Compare commits

...

49 Commits

Author SHA1 Message Date
T. Zehetbauer
5c5aaabae5 refactor: remove obsolete member management API module
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
2026-04-03 14:08:31 +02:00
T. Zehetbauer
124c6a632a feat: update Docker configuration for improved performance and add local environment example 2026-04-03 09:37:36 +02:00
T. Zehetbauer
d4acc3ba22 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
2026-04-02 23:37:19 +02:00
Zaid Marzguioui
28188bb3a6 fix(billing): wire up Stripe checkout with real price IDs and env vars
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m3s
Workflow / ⚫️ Test (push) Has been skipped
- Replace 8 placeholder price IDs (price_starter_monthly, etc.) with real
  Stripe test-mode price IDs created via API
- Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_BILLING_PROVIDER,
  and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to docker-compose.yml (build args + runtime)
- Add NEXT_PUBLIC_BILLING_PROVIDER ARG/ENV to Dockerfile
- Enable team and personal account billing (was 'false')
- Created Stripe webhook endpoint for production URL
- Created 4 Stripe products (Starter/Pro/Verband/Enterprise) with monthly+yearly prices

Checkout was crashing because:
1. STRIPE_SECRET_KEY was missing → Zod validation failed at createStripeClient()
2. STRIPE_WEBHOOK_SECRET was missing → same Zod schema rejection
3. NEXT_PUBLIC_BILLING_PROVIDER was unset → BillingProviderSchema.parse() failed
4. Price IDs were placeholders, not real Stripe price_xxx IDs
2026-04-02 23:34:30 +02:00
Zaid Marzguioui
f10a34c505 fix: comprehensive CMS QA fixes — i18n, UI labels, breadcrumbs
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
- Fix i18n dotted permission keys causing INVALID_KEY console spam (en/de cms.json)
- Fix member detail breadcrumb showing UUID instead of member name
- Fix bookings stats card showing 'of' instead of 'Total'
- Fix events/registrations table 'status' column header resolving to object
- Fix finance search placeholder showing 'Show All' instead of search text
- Fix finance status filter default showing 'No data' instead of 'All'
- Fix applications page German pluralization 'Antrage' → 'Anträge'
- Add breadcrumbValues prop to CmsPageShell for UUID→name overrides
2026-04-02 22:59:42 +02:00
T. Zehetbauer
f43770999f feat: enhance member management features; add quick stats and search capabilities 2026-04-02 22:56:04 +02:00
Zaid Marzguioui
0932c57fa1 fix: revert ctx→_ctx rename — ctx IS used for auth in server actions
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m13s
Workflow / ⚫️ Test (push) Has been skipped
The previous lint fix incorrectly renamed ctx to _ctx in server actions
that actually USE ctx.user.id for authorization. This caused runtime
'ctx is not defined' errors when creating pages, modules, etc.

Reverted all 13 action files back to using ctx properly.
2026-04-02 19:30:59 +02:00
Zaid Marzguioui
a1aa1bee86 fix: rename unused ctx params to _ctx, remove unused imports
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m38s
Workflow / ⚫️ Test (push) Has been skipped
- Fix 17 unused ctx params in module-builder, verbandsverwaltung, sitzungsprotokolle actions
- Remove unused CardDescription/CardHeader/CardTitle from pricing calculator
- Remove unused FileSignature import from layout
2026-04-02 19:25:01 +02:00
Zaid Marzguioui
a3be926f6f fix(carousel): replace emoji lock with Lucide icon, widen container, fix tab overflow
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
- Replace 🔒 emoji with Lucide <Lock> icon (3x3, muted) for cleaner browser chrome
- Widen carousel from max-w-4xl to max-w-5xl so tabs have room
- Bottom tabs: shrink-0 + scrollbar-none + centered flex layout
- Labels show on lg+ screens, icons-only on smaller screens
- Slightly larger tab hit targets (px-3 py-1.5, text-[11px])
2026-04-02 19:09:46 +02:00
Zaid Marzguioui
4f40abdce4 fix: override text-right alignment on Why Choose Us section items
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
The EcosystemShowcase component forces text-right on children when
textPosition="right", causing WhyItem content to be right-aligned.
Added text-left on the wrapper div to fix alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:08:13 +02:00
Zaid Marzguioui
d87fbb050f feat: add feature carousel hero + enable Stripe billing
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m5s
Workflow / ⚫️ Test (push) Has been skipped
- Replace static dashboard screenshot with interactive feature carousel
  9 slides: Dashboard, Mitglieder, Kurse, Finanzen, Veranstaltungen,
  Newsletter, Website, Buchungen, Dokumente
  Auto-advances every 6s, clickable sidebar + bottom tabs
  Virtual app UI rendered with shadcn components (no images needed)

- Enable Stripe test mode billing
  Add publishable key to .env.development, .env.production, docker-compose
  Add secret key to .env.development and docker-compose
  Add NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to Dockerfile build args
2026-04-02 18:54:58 +02:00
Zaid Marzguioui
a6c9537195 feat(billing): update pricing tiers to MyEasyCMS plans (EUR)
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m2s
Workflow / ⚫️ Test (push) Has been skipped
Replace MakerKit sample billing config with actual MyEasyCMS tiers:
- Starter: 29€/mo (250 members) — Mitgliederverwaltung, SEPA, Website, Newsletter
- Pro: 59€/mo (1.000 members) — Kursverwaltung, Buchungen, unbegrenzte Benutzer
- Verband: 199€/mo (10.000 members) — Mehrebenen-Hierarchie, Verbandssuche
- Enterprise: 349€/mo (unlimited) — dedizierte Infrastruktur, SLA, API

All plans: EUR currency, German feature labels, yearly = ~2 months free.
Also update marketing i18n subtitles.
2026-04-02 16:13:54 +02:00
Zaid Marzguioui
da862f2194 feat(pricing): add competitor price comparison calculator
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m5s
Workflow / ⚫️ Test (push) Has been skipped
- Interactive slider for 50–10,000 members
- Real prices from SEWOBE, easyVerein, ClubDesk, WISO MeinVerein
- Bar chart comparing monthly/yearly costs
- Savings callout showing annual savings vs most expensive competitor
- Feature comparison table (Verband, Website, Kurse, SEPA, Support, etc.)
- SEWOBE Verband warning for 1000+ members
- Uses shadcn/ui components (Card, Badge, Button, Table)
- CTA linking to sign-up
- 4 tiers: Starter (29€), Pro (59€), Verband (199€), Enterprise (349€)
2026-04-02 15:32:55 +02:00
Zaid Marzguioui
0bd5d0cf42 fix: QA audit — lint cleanup, i18n fixes, module visibility, sidebar UX
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m40s
Workflow / ⚫️ Test (push) Has been skipped
- Fix 97 lint errors → 0 (unused imports, params, variables across 40+ files)
- Fix i18n key format: colon → dot notation for next-intl compatibility
- Add missing i18n keys (routes.application, routes.home, confirm)
- Fix module visibility: sidebar now respects per-account DB features
- Fix inject function: use dot-notation keys, add collapsed:true defaults
- Fix ConfirmDialog: use useTranslations instead of hardcoded German defaults
- Fix events page: replace placeholder 'Beschreibung' with proper description
- Fix Dockerfile: add NEXT_PUBLIC_CI ARG for Docker builds
- Collapse secondary sidebar sections by default for cleaner UX
2026-04-02 14:39:20 +02:00
T. Zehetbauer
c6d564836f fix: add missing newlines at the end of JSON files; clean up formatting in page components
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 17m4s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-02 11:02:58 +02:00
Zaid Marzguioui
b26e5aaafa feat: pre-existing local changes — fischerei, verband, modules, members, packages
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m20s
Workflow / ⚫️ Test (push) Has been skipped
Commits all remaining uncommitted local work:

- apps/web: fischerei, verband, modules, members-cms, documents,
  newsletter, meetings, site-builder, courses, bookings, events,
  finance pages and components
- apps/web: marketing page updates, layout, paths config,
  next.config.mjs, styles/makerkit.css
- apps/web/i18n: documents, fischerei, marketing, verband (de+en)
- packages/features: finance, fischerei, member-management,
  module-builder, newsletter, sitzungsprotokolle, verbandsverwaltung
  server APIs and components
- packages/ui: button.tsx updates
- pnpm-lock.yaml
2026-04-02 01:19:54 +02:00
Zaid Marzguioui
a1719671df fix: QA remediation — all 19 audit fixes (C+ → A-)
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
## Summary
Fixes all 31  FAILs and most ⚠️ WARNs from the QA audit (113/33⚠️/31).

## Changes

### FIX 1 — Loading Skeleton
- Replace full-screen GlobalLoader with PageBody-scoped animate-pulse skeleton
- Sidebar stays visible during page transitions

### FIX 2 — Status Badges i18n (15 files, 12 label maps)
- Add *_LABEL_KEYS maps to lib/status-badges.ts (i18n keys instead of German)
- Update all 15 consumer files to use t(*_LABEL_KEYS[status])
- Add status namespace to finance.json (de+en)
- Add registration_open to events.json status (de+en)
- Add status block to cms.json events section (de+en)
- Add missing pending/bounced keys to newsletter.json (de+en)
- Add active key to courses.json status (de+en)

### FIX 3 — Error Page i18n
- Replace 4 hardcoded German strings with useTranslations('common')
- Add error.* keys to common.json (de+en)

### FIX 4 — Account Not Found i18n
- Convert AccountNotFound to async Server Component
- Resolve default props from getTranslations('common')
- Add accountNotFoundCard.* keys to common.json (de+en)

### FIX 5 — Publish Toggle Button (6 strings + 2 bugs)
- Add useTranslations('siteBuilder'), replace 6 German strings
- Fix: add response.ok check before router.refresh()
- Fix: add disabled={isPending} to AlertDialogAction
- Fix: use Base UI render= prop pattern (not asChild)
- Add pages.hide/publish/hideTitle/publishTitle/hideDesc/publishDesc/
  toggleError/cancelAction to siteBuilder.json (de+en)

### FIX 6 — Cancel Booking Button (7 strings + bugs)
- Add useTranslations('bookings'), replace all strings
- Fix: use render= prop pattern, add disabled={isPending}
- Add cancel.* and calendar.* keys to bookings.json (de+en)

### FIX 7 — Portal Pages i18n (5 files, ~40 strings)
- Create i18n/messages/de/portal.json and en/portal.json
- Add 'portal' to i18n/request.ts namespace list
- Rewrite portal/page.tsx, invite/page.tsx, profile/page.tsx,
  documents/page.tsx with getTranslations('portal')
- Fix portal-linked-accounts.tsx: add useTranslations, replace
  hardcoded strings, fix AlertDialogTrigger render= pattern

### FIX 8 — Invitations View (1 string)
- Replace hardcoded string with t('invitations.emptyDescription')
- Add key to members.json (de+en)

### FIX 9 — Dead Navigation Link
- Comment out memberPortal nav entry (page does not exist)

### FIX 10 — Calendar Button Accessibility
- Add aria-label + aria-hidden to all icon buttons in bookings/calendar
- Add aria-label + aria-hidden to all icon buttons in courses/calendar
- Add previousMonth/nextMonth/backToBookings/backToCourses to
  bookings.json and courses.json (de+en)

### FIX 11 — Pagination Aria Labels
- Add aria-label to icon-only pagination buttons in finance/page.tsx
- Fix Link/Button nesting in newsletter/page.tsx, add aria-labels
- Add pagination.* to common.json (de+en)
- Add common.previous/next to newsletter.json (de+en)

### FIX 12 — Site Builder Type Safety
- Add SitePage interface, replace Record<string,unknown> in page.tsx
- Add SitePost interface, replace Record<string,unknown> in posts/page.tsx
- Remove String() casts on typed properties

### FIX 14 — EmptyState Heading Level
- Change <h3> to <h2> in empty-state.tsx (WCAG heading sequence)

### FIX 16 — CmsPageShell Nullish Coalescing
- Change description ?? <AppBreadcrumbs /> to !== undefined check

### FIX 17 — Meetings Protocol Hardcoded Strings
- Replace 5 hardcoded German strings with t() in protocol detail page
- Add notFound/back/backToList/statusPublished/statusDraft to meetings.json

### FIX 18 — Finance Toolbar Hardcoded Strings
- Replace toolbar filter labels with t() calls in finance/page.tsx

### FIX 19 — Admin Audit Hardcoded Strings
- Add getTranslations('cms.audit') to audit page
- Replace title, description, column headers, pagination labels
- Add description/timestamp/paginationPrevious/paginationNext to cms.json

## Verification
- tsc --noEmit: 0 errors
- Turbopack: Compiled successfully in 9.3s
- Lint: 0 new errors introduced
- All 8 audit verification checks pass
2026-04-02 01:18:15 +02:00
Zaid Marzguioui
a5bbf42901 Replace all marketing placeholder content with real MYeasyCMS content
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m12s
Workflow / ⚫️ Test (push) Has been skipped
- Logo: Replace generic Makerkit SVG with MYeasyCMS branded logo (grid icon + styled text)
- Blog: Replace 3 SaaS placeholder posts with 5 real articles (Vereinsverwaltung, SEPA, Website, DSGVO, Mitglieder-Tipps)
- Changelog: Replace 6 generic entries with real feature announcements (Verbandsverwaltung, Fischerei, Dateien, Kurse, Einladungen, i18n)
- Documentation: Rewrite all 20 docs from Makerkit references to MYeasyCMS content
- FAQ: Replace 6 generic SaaS questions with 10 real MYeasyCMS questions
- Navigation: Replace Changelog link with Contact in main nav
- Footer: Reorganize into Product/Company/Legal sections
- Translations: Update all EN marketing strings to match real Com.BISS content
2026-04-01 21:09:06 +02:00
T. Zehetbauer
bbb33aa63d feat: add file upload and management features; enhance pagination and permissions handling
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m43s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 20:13:15 +02:00
T. Zehetbauer
db4e19c3af feat: add invitations management and import wizard; enhance audit logging and member detail fetching
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 19:02:55 +02:00
T. Zehetbauer
080ec1cb47 feat: add delete functionality for leases, catch books, and permits; implement newsletter update feature
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 17:53:39 +02:00
T. Zehetbauer
c6b2824da8 feat: add update and delete functionality for courses, events, and species; enhance attendance tracking and category creation
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 16:03:50 +02:00
T. Zehetbauer
7b078f298b feat: enhance API response handling and add new components for module management
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m50s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 15:18:24 +02:00
T. Zehetbauer
f82a366a52 Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m51s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 14:05:13 +02:00
Zaid Marzguioui
49fd6b65b9 fix(supabase): use internal URL for all server-side clients with cookie name matching
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m38s
Workflow / ⚫️ Test (push) Has been skipped
ROOT CAUSE FIX: All server-side Supabase clients (server-client, middleware-client,
server-admin-client) now use SUPABASE_INTERNAL_URL (http://supabase-kong:8000)
when available, with cookieOptions.name set to match the external URL's cookie key
(e.g. sb-myeasycms-auth-token). This gives us:
- Reliable Docker-internal networking (no hairpin NAT through Traefik)
- Correct session cookie matching between browser and server
- No more 500 errors on SSR pages that query Supabase

Reverted per-page try/catch workarounds since root cause is now fixed.
2026-04-01 13:53:59 +02:00
T. Zehetbauer
5e1976f07b Merge remote-tracking branch 'origin/main' 2026-04-01 13:48:10 +02:00
Zaid Marzguioui
4aa11cd408 fix: add error handling to meetings + verband dashboard pages
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m47s
Workflow / ⚫️ Test (push) Has been skipped
SSR pages crash with 500 when Supabase queries fail (expired session,
network issues). Now catch errors and render with empty data instead
of crashing the entire page.
2026-04-01 13:40:02 +02:00
T. Zehetbauer
a5baaae12f Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 13:33:58 +02:00
T. Zehetbauer
c98cada7f6 refactor: improve code readability and consistency in api.ts and common.json 2026-04-01 13:33:43 +02:00
Zaid Marzguioui
9484ba91f8 fix(db): add explicit GRANT permissions for all CMS module tables
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
The REVOKE+GRANT pattern in migrations can fail if a previous migration run
partially succeeded. Adding explicit GRANTs to dev-bootstrap.sh ensures all
tables have correct permissions on every deploy. Fixes 500 error on
Sitzungsprotokolle (meeting_protocol_items permission denied).
2026-04-01 13:32:32 +02:00
T. Zehetbauer
2a9d543ee4 Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
# Conflicts:
#	docker-compose.yml
2026-04-01 13:30:00 +02:00
Zaid Marzguioui
5294cfab61 feat: enable Fischerei, Sitzungsprotokolle, Verbandsverwaltung modules
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m44s
Workflow / ⚫️ Test (push) Has been skipped
- Enable all 3 modules via NEXT_PUBLIC_ENABLE_* build args + runtime env
- Fix empty-string-to-null for date/optional columns in all module APIs:
  fischerei (24 fixes), verbandsverwaltung (15 fixes), sitzungsprotokolle (2 fixes)
- CACHE_BUST=12 for full rebuild with new feature flags
2026-04-01 13:23:57 +02:00
T. Zehetbauer
98afe6aa5f feat: enhance accessibility and testing with data-test attributes and improve error handling
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
2026-04-01 13:22:23 +02:00
T. Zehetbauer
da8a43a3b0 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx
#	packages/features/course-management/src/server/api.ts
#	packages/features/event-management/src/server/api.ts
#	packages/supabase/src/get-supabase-client-keys.ts
#	pnpm-lock.yaml
2026-04-01 13:22:17 +02:00
Zaid Marzguioui
8d8f4e94ee fix(api): convert empty strings to null for date/optional DB columns
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m37s
Workflow / ⚫️ Test (push) Has been skipped
Course and event creation Server Actions were failing with 'Something went
wrong' because empty form strings ('') were being inserted into date/uuid
columns which reject empty strings. Now converts '' to null for all
optional fields (dates, descriptions, IDs, contact info).
2026-04-01 13:14:53 +02:00
Zaid Marzguioui
72227b5aab fix(auth): revert SUPABASE_INTERNAL_URL — cookie name mismatch
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m17s
Workflow / ⚫️ Test (push) Has been skipped
Browser creates cookies keyed by the external hostname (sb-myeasycms-*),
but server was using SUPABASE_INTERNAL_URL (sb-supabase-kong-*) — different
keys = server can't find the session = infinite 'please wait' after login.

Both client and server now use the same NEXT_PUBLIC_SUPABASE_URL (external
domain). The SSR reaches Supabase via Traefik → Kong which works fine.
2026-04-01 11:42:00 +02:00
Zaid Marzguioui
0aa2773086 fix(docker): fix EACCES on .next/cache/images — chown for nextjs user
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m35s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 11:33:06 +02:00
Zaid Marzguioui
08357f568e fix(docker): actually use CACHE_BUST ARG to bust Docker layer cache
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m29s
Workflow / ⚫️ Test (push) Has been skipped
The ARG was defined but never used in a RUN/ENV command, so Docker
ignored value changes and kept using cached COPY layers from the
very first build. Adding 'RUN echo' forces cache invalidation.
2026-04-01 11:22:24 +02:00
Zaid Marzguioui
b2c9503749 fix(proxy): graceful error handling when Supabase is unreachable
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
Wrap getUser() calls in proxy.ts with try/catch so the proxy doesn't
crash when the Supabase client can't connect. Without this, the proxy
fails silently and Next.js returns 404 for all locale-dependent routes
(/auth/sign-in, /join, etc.) because the locale rewrite never happens.
2026-04-01 11:18:44 +02:00
Zaid Marzguioui
1687735de0 fix: merge upstream, fix locale duplicate, add missing catalog deps
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 12m20s
Workflow / ⚫️ Test (push) Has been skipped
- Merged upstream/main (MakerKit latest fixes)
- Fixed locales.tsx: removed hardcoded 'de' duplicate (defaultLocale already = 'de')
- Added missing pnpm catalog entries for custom packages:
  @measured/puck, @react-pdf/renderer, @tiptap/*, exceljs, iban, papaparse
- CACHE_BUST=7 for full rebuild
2026-04-01 11:02:06 +02:00
Zaid Marzguioui
8d9d62ca56 merge: upstream/main — latest MakerKit fixes and dependency updates 2026-04-01 10:56:45 +02:00
T. Zehetbauer
abac22feb1 feat: enhance accessibility and testing with data-test attributes and improve error handling
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
2026-04-01 10:46:44 +02:00
T. Zehetbauer
3bcc5c70a3 feat: add data-test attributes for improved testing in various components 2026-04-01 10:23:35 +02:00
T. Zehetbauer
fd8c2cc32a feat: add cross-organization member search and template cloning functionality 2026-04-01 10:15:35 +02:00
T. Zehetbauer
d3db316a68 Merge remote-tracking branch 'origin/main' 2026-04-01 09:31:25 +02:00
T. Zehetbauer
2f80d5cc4a Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docker-compose.yml
2026-03-31 22:19:42 +02:00
T. Zehetbauer
59546ad6d2 Add account hierarchy framework with migrations, RLS policies, and UI components 2026-03-31 22:18:04 +02:00
Giancarlo Buomprisco
c837d4f592 chore: bump version to 3.1.1 in package.json; refactor mobile navigation components and improve layout structure (#472)
- Updated version to 3.1.1 in package.json.
- Refactored mobile navigation components to enhance structure and usability.
- Adjusted layout components to improve responsiveness and visual consistency.
- Introduced shared mobile navigation components for better code reuse.
2026-03-31 21:24:37 +08:00
Giancarlo Buomprisco
6268d1bab0 Updated dependencies, Added Hosted mode for Stripe checkout
* chore: bump version to 3.1.0 and update dependencies in package.json, pnpm-lock.yaml, and pnpm-workspace.yaml; enhance billing services with support for hosted checkout page in Stripe integration

* Enhance error handling in billing services to log error messages instead of objects; update documentation for Stripe integration to clarify publishable key requirements based on UI mode.
2026-03-31 12:44:30 +08:00
560 changed files with 53806 additions and 13596 deletions

1
.bg-shell/manifest.json Normal file
View File

@@ -0,0 +1 @@
[]

View File

@@ -3,8 +3,9 @@ node_modules
.turbo
**/.turbo
.git
*.md
.env*
!.env.example
!.env.local.example
.DS_Store
apps/e2e
apps/dev-tool
@@ -16,3 +17,6 @@ apps/dev-tool
.github
docs
**/*.tsbuildinfo
**/*.md
!**/AGENTS.md
!**/CLAUDE.md

19
.env.local.example Normal file
View File

@@ -0,0 +1,19 @@
# =====================================================
# MyEasyCMS v2 — Local Development Environment
# Copy to .env and run: docker compose -f docker-compose.local.yml up -d
# =====================================================
# --- Database ---
POSTGRES_PASSWORD=postgres
# --- Supabase Auth ---
JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
# --- Supabase Keys (demo keys — safe for local dev only) ---
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
# --- Stripe (test keys) ---
# Get your own test keys from https://dashboard.stripe.com/test/apikeys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
STRIPE_SECRET_KEY=sk_test_YOUR_KEY

View File

@@ -44,6 +44,12 @@ ADDITIONAL_REDIRECT_URLS=
# --- Webhooks ---
DB_WEBHOOK_SECRET=your-webhook-secret
# --- Monitoring (Sentry) ---
NEXT_PUBLIC_MONITORING_PROVIDER=sentry
NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123456.ingest.sentry.io/123456
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
# SENTRY_AUTH_TOKEN=your-auth-token-for-source-maps
# --- Feature Flags ---
# All default to true, set to false to disable
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true

View File

@@ -72,7 +72,7 @@ After implementation, always run:
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -3,7 +3,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,35 +1,50 @@
FROM node:22-alpine AS base
# node:22-slim (Debian/glibc) is ~2x faster for Next.js builds vs Alpine/musl
FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# --- Install + Build in one stage ---
# --- Install + Build ---
FROM base AS builder
ARG CACHE_BUST=6
COPY . .
RUN pnpm install --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile --prefer-offline
ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* vars are baked into the Next.js build at compile time.
# Pass them as build args so the same Dockerfile works for any environment.
ARG NEXT_PUBLIC_CI=false
ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de
ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
ARG NEXT_PUBLIC_SUPABASE_PUBLIC_KEY
ARG NEXT_PUBLIC_DEFAULT_LOCALE=de
ARG NEXT_PUBLIC_ENABLE_FISCHEREI=true
ARG NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=true
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=${NEXT_PUBLIC_SUPABASE_PUBLIC_KEY}
ENV NEXT_PUBLIC_DEFAULT_LOCALE=${NEXT_PUBLIC_DEFAULT_LOCALE}
ENV NEXT_PUBLIC_ENABLE_FISCHEREI=${NEXT_PUBLIC_ENABLE_FISCHEREI}
ENV NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=${NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS}
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
RUN pnpm --filter web build
# --- Run ---
FROM base AS runner
# --- Run (slim for smaller image than full Debian) ---
FROM node:22-slim AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/ ./
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --create-home nextjs
RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache
RUN mkdir -p /home/nextjs/.cache/node/corepack && chown -R nextjs:nodejs /home/nextjs/.cache
USER nextjs
EXPOSE 3000

View File

@@ -1,23 +1,25 @@
/**
* E2E Test: Course Enrollment
*/
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';
test.describe('Course Management', () => {
test('create course, enroll participant, check capacity, waitlist', async ({ page }) => {
test('create course, enroll participant, check capacity, waitlist', async ({
page: _page,
}) => {
// Create course with capacity 2
// Enroll participant 1 → status: enrolled
// Enroll participant 2 → status: enrolled
// Enroll participant 3 → status: waitlisted (capacity full)
});
test('course calendar view shows sessions', async ({ page }) => {
test('course calendar view shows sessions', async ({ page: _page }) => {
// Create course with sessions
// Navigate to calendar
// Verify sessions visible
});
test('attendance tracking', async ({ page }) => {
test('attendance tracking', async ({ page: _page }) => {
// Create course + session + participants
// Mark attendance
// Verify attendance persists

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test';
test.describe('Member Management', () => {
test('create member, edit, search, filter by status', async ({ page }) => {
test('create member, edit, search, filter by status', async ({
page: _page,
}) => {
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'testpassword123');
@@ -15,13 +17,15 @@ test.describe('Member Management', () => {
await expect(page.locator('h1')).toContainText('Mitglieder');
});
test('application workflow: submit → review → approve → member created', async ({ page }) => {
test('application workflow: submit → review → approve → member created', async ({
page: _page,
}) => {
// Submit application
// Review application
// Approve → verify member auto-created
});
test('SEPA mandate management', async ({ page }) => {
test('SEPA mandate management', async ({ page: _page }) => {
// Create member with IBAN
// Verify IBAN validation
// Create SEPA batch from dues

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test';
test.describe('Module Builder', () => {
test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => {
test('create module, add fields, insert record, query, update, soft-delete', async ({
page: _page,
}) => {
// Login
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
@@ -22,7 +24,7 @@ test.describe('Module Builder', () => {
});
test.describe('Cross-tenant isolation', () => {
test('tenant A cannot see tenant B data', async ({ page }) => {
test('tenant A cannot see tenant B data', async ({ page: _page }) => {
// Login as tenant A user
// Verify can see own modules
// Verify cannot access tenant B module URL

View File

@@ -1,10 +1,12 @@
/**
* E2E Test: Newsletter
*/
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';
test.describe('Newsletter', () => {
test('create campaign, select recipients from members, preview, send', async ({ page }) => {
test('create campaign, select recipients from members, preview, send', async ({
page: _page,
}) => {
// Create newsletter
// Add recipients from member filter (status=active, hasEmail=true)
// Preview with variable substitution
@@ -12,7 +14,7 @@ test.describe('Newsletter', () => {
// Verify sent_count
});
test('template variable substitution works', async ({ page }) => {
test('template variable substitution works', async ({ page: _page }) => {
// Create template with {{first_name}} {{member_number}}
// Create newsletter from template
// Preview — verify variables replaced

View File

@@ -1,10 +1,12 @@
/**
* E2E Test: SEPA Batch Processing
*/
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';
test.describe('SEPA / Finance', () => {
test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => {
test('create SEPA direct debit batch, add items, generate XML', async ({
page: _page,
}) => {
// Create batch
// Add items with valid IBANs
// Generate XML
@@ -12,12 +14,12 @@ test.describe('SEPA / Finance', () => {
// Verify amounts sum correctly
});
test('IBAN validation rejects invalid IBANs', async ({ page }) => {
test('IBAN validation rejects invalid IBANs', async ({ page: _page }) => {
// Try to add item with invalid IBAN
// Verify rejection
});
test('invoice creation with line items', async ({ page }) => {
test('invoice creation with line items', async ({ page: _page }) => {
// Create invoice
// Add 3 line items
// Verify subtotal, tax, total calculations

View File

@@ -0,0 +1 @@
[]

View File

@@ -21,7 +21,9 @@ EMAIL_PASSWORD=password
CONTACT_EMAIL=test@makerkit.dev
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51SMbesKttnWb7SsFOR7cJ1jshdEaZiHmCflWndgLtL3gx1Cu8N4p5qxSJY8PHmpEJL8gf4VrqqX2Fr7pxJtQILUS00yYQ7Tx8V
# MAILER
MAILER_PROVIDER=nodemailer
MAILER_PROVIDER=nodemailer
# STRIPE SECRET KEY
STRIPE_SECRET_KEY=sk_test_51SMbesKttnWb7SsFTjCsPZMlxVe3WjrsDnpLDAQehVdzSoDaWMFmc3hiZOTp2IAKB1cleMPIOW9GmEJHEkhazJsq00FEfTr6BI

View File

@@ -9,4 +9,4 @@
NEXT_PUBLIC_SUPABASE_URL=
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51SMbesKttnWb7SsFOR7cJ1jshdEaZiHmCflWndgLtL3gx1Cu8N4p5qxSJY8PHmpEJL8gf4VrqqX2Fr7pxJtQILUS00yYQ7Tx8V

View File

@@ -18,6 +18,7 @@ EMAIL_PASSWORD=password
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
STRIPE_UI_MODE=embedded_page # TESTS ONLY SUPPORT THIS MODE, KEEP AS IS
CONTACT_EMAIL=test@makerkit.dev

View File

@@ -0,0 +1,37 @@
'use client';
import { useEffect, useRef } from 'react';
export function AnimateOnScroll(props: {
children: React.ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const reveals = el.querySelectorAll('.reveal');
reveals.forEach((r) => r.setAttribute('data-visible', 'true'));
observer.disconnect();
}
}
},
{ threshold: 0.15 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className={props.className}>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,582 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
BedDouble,
CalendarDays,
ChevronLeft,
ChevronRight,
FileText,
Globe,
GraduationCap,
LayoutDashboard,
Lock,
Mail,
Users,
Wallet,
} from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function MiniStat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background rounded-lg border p-3 text-center">
<div className="text-foreground text-lg font-bold">{value}</div>
<div className="text-muted-foreground text-[10px]">{label}</div>
</div>
);
}
function MiniRow({
cells,
highlighted,
}: {
cells: string[];
highlighted?: boolean;
}) {
return (
<div
className={cn(
'grid gap-2 border-b px-3 py-2 text-xs',
highlighted && 'bg-primary/5',
)}
style={{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` }}
>
{cells.map((c, i) => (
<span
key={i}
className={i === 0 ? 'font-medium' : 'text-muted-foreground'}
>
{c}
</span>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Slide data
// ---------------------------------------------------------------------------
interface Slide {
id: string;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
}
const IC = 'h-4 w-4';
const SLIDES: Slide[] = [
{
id: 'dashboard',
label: 'Dashboard',
icon: <LayoutDashboard className={IC} />,
content: (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<MiniStat label="Mitglieder" value="1.247" />
<MiniStat label="Aktive Kurse" value="18" />
<MiniStat label="Offene Rechnungen" value="3.420 €" />
<MiniStat label="Newsletter" value="12" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-background space-y-2 rounded-lg border p-3">
<div className="text-xs font-semibold">Letzte Aktivität</div>
{[
'Max Müller — Beitritt',
'SEPA-Einzug #42 — erstellt',
'Schwimmkurs — 3 neue Teilnehmer',
].map((t) => (
<div
key={t}
className="text-muted-foreground flex items-center gap-2 text-[10px]"
>
<div className="bg-primary/20 h-1.5 w-1.5 rounded-full" />
{t}
</div>
))}
</div>
<div className="bg-background space-y-2 rounded-lg border p-3">
<div className="text-xs font-semibold">Schnellaktionen</div>
{['Neues Mitglied', 'Neuer Kurs', 'Newsletter erstellen'].map(
(t) => (
<div
key={t}
className="bg-muted flex items-center justify-between rounded px-2 py-1 text-[10px]"
>
{t} <span className="text-muted-foreground"></span>
</div>
),
)}
</div>
</div>
</div>
),
},
{
id: 'members',
label: 'Mitglieder',
icon: <Users className={IC} />,
content: (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-[10px]">
🔍 Mitglied suchen
</div>
<div className="flex gap-2">
<Badge variant="outline" className="text-[9px]">
CSV
</Badge>
<Badge variant="outline" className="text-[9px]">
Excel
</Badge>
<Badge variant="default" className="text-[9px]">
+ Neues Mitglied
</Badge>
</div>
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-5 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Nr</span>
<span>Name</span>
<span>E-Mail</span>
<span>Status</span>
<span>Eintritt</span>
</div>
{(
[
[
'M-001',
'Müller, Hans',
'h.mueller@web.de',
'Aktiv',
'15.01.2024',
],
[
'M-002',
'Schmidt, Anna',
'a.schmidt@gmx.de',
'Aktiv',
'01.03.2024',
],
[
'M-003',
'Weber, Thomas',
'weber@t-online.de',
'Passiv',
'22.06.2023',
],
[
'M-004',
'Fischer, Maria',
'm.fischer@mail.de',
'Aktiv',
'08.09.2024',
],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} highlighted={i === 0} />
))}
</div>
<div className="text-muted-foreground text-center text-[10px]">
Zurück &nbsp; Seite 1 von 52 &nbsp; Weiter
</div>
</div>
),
},
{
id: 'courses',
label: 'Kurse',
icon: <GraduationCap className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-3">
<MiniStat label="Gesamt" value="24" />
<MiniStat label="Aktiv" value="18" />
<MiniStat label="Teilnehmer" value="342" />
<MiniStat label="Auslastung" value="78%" />
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-5 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Kurs-Nr</span>
<span>Name</span>
<span>Beginn</span>
<span>Status</span>
<span>Gebühr</span>
</div>
{(
[
['SK-001', 'Schwimmkurs Anfänger', '01.05.2026', 'Aktiv', '50 €'],
['YG-003', 'Yoga für Senioren', '15.03.2026', 'Geplant', '35 €'],
['TN-012', 'Tenniskurs Jugend', '01.06.2026', 'Aktiv', '80 €'],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} />
))}
</div>
</div>
),
},
{
id: 'finance',
label: 'Finanzen',
icon: <Wallet className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="SEPA-Einzüge" value="8" />
<MiniStat label="Rechnungen" value="147" />
<MiniStat label="Offene Forderungen" value="3.420 €" />
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Rechnungs-Nr</span>
<span>Mitglied</span>
<span>Betrag</span>
<span>Status</span>
</div>
{(
[
['RE-2026-001', 'Hans Müller', '120,00 €', 'Bezahlt'],
['RE-2026-002', 'Anna Schmidt', '85,00 €', 'Offen'],
['RE-2026-003', 'Thomas Weber', '120,00 €', 'Überfällig'],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} />
))}
</div>
</div>
),
},
{
id: 'events',
label: 'Veranstaltungen',
icon: <CalendarDays className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Veranstaltungen" value="12" />
<MiniStat label="Anmeldungen" value="284" />
<MiniStat label="Kapazität" value="500" />
</div>
<div className="space-y-2">
{[
{
name: 'Sommerfest 2026',
date: '21.06.2026',
spots: '84/120',
badge: 'Offen' as const,
},
{
name: 'Jahreshauptversammlung',
date: '15.03.2026',
spots: '200/200',
badge: 'Ausgebucht' as const,
},
{
name: 'Weihnachtsfeier',
date: '20.12.2026',
spots: '0/80',
badge: 'Geplant' as const,
},
].map((e) => (
<div
key={e.name}
className="bg-background flex items-center justify-between rounded-lg border px-3 py-2"
>
<div>
<div className="text-xs font-medium">{e.name}</div>
<div className="text-muted-foreground text-[10px]">
{e.date} · {e.spots} Plätze
</div>
</div>
<Badge
variant={
e.badge === 'Ausgebucht'
? 'destructive'
: e.badge === 'Offen'
? 'default'
: 'outline'
}
className="text-[9px]"
>
{e.badge}
</Badge>
</div>
))}
</div>
</div>
),
},
{
id: 'newsletter',
label: 'Newsletter',
icon: <Mail className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Kampagnen" value="12" />
<MiniStat label="Gesendet" value="8" />
<MiniStat label="Empfänger" value="1.180" />
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Betreff</span>
<span>Empfänger</span>
<span>Datum</span>
<span>Status</span>
</div>
{(
[
['Frühjahrsnewsletter', '1.180', '01.03.2026', 'Gesendet'],
['Einladung Sommerfest', '1.180', '15.05.2026', 'Entwurf'],
['Beitragsinfo 2026', '1.180', '15.01.2026', 'Gesendet'],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} />
))}
</div>
</div>
),
},
{
id: 'website',
label: 'Website',
icon: <Globe className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Seiten" value="7" />
<MiniStat label="Veröffentlicht" value="5" />
<MiniStat label="Status" value="● Online" />
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Titel</span>
<span>URL</span>
<span>Status</span>
<span>Aktualisiert</span>
</div>
{(
[
['Startseite', '/home', 'Veröffentlicht', '30.03.2026'],
['Über uns', '/ueber-uns', 'Entwurf', '28.03.2026'],
['Kurse', '/kurse', 'Veröffentlicht', '25.03.2026'],
['Kontakt', '/kontakt', 'Veröffentlicht', '20.03.2026'],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} />
))}
</div>
</div>
),
},
{
id: 'bookings',
label: 'Buchungen',
icon: <BedDouble className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Buchungen" value="36" />
<MiniStat label="Räume" value="8" />
<MiniStat label="Auslastung" value="64%" />
</div>
<div className="rounded-lg border">
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
<span>Raum</span>
<span>Gast</span>
<span>Check-in</span>
<span>Status</span>
</div>
{(
[
['Seminarraum A', 'TV Musterstadt', '15.04.2026', 'Bestätigt'],
['Vereinsheim', 'Karin Bauer', '18.04.2026', 'Ausstehend'],
['Turnhalle', 'TSV Neustadt', '20.04.2026', 'Bestätigt'],
] as string[][]
).map((row, i) => (
<MiniRow key={i} cells={row} />
))}
</div>
</div>
),
},
{
id: 'documents',
label: 'Dokumente',
icon: <FileText className={IC} />,
content: (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Vorlagen" value="5" />
<MiniStat label="Generiert" value="324" />
<MiniStat label="Zuletzt" value="Heute" />
</div>
<div className="space-y-2">
{[
{ name: 'Mitgliedsausweis', count: '1.247 generiert', type: 'PDF' },
{ name: 'Beitragsrechnung', count: '324 generiert', type: 'PDF' },
{ name: 'SEPA-Mandat', count: '890 generiert', type: 'PDF' },
{ name: 'Mitgliederliste', count: '12 Exporte', type: 'Excel' },
].map((d) => (
<div
key={d.name}
className="bg-background flex items-center justify-between rounded-lg border px-3 py-2"
>
<div>
<div className="text-xs font-medium">{d.name}</div>
<div className="text-muted-foreground text-[10px]">
{d.count}
</div>
</div>
<Badge variant="outline" className="text-[9px]">
{d.type}
</Badge>
</div>
))}
</div>
</div>
),
},
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function FeatureCarousel() {
const [active, setActive] = useState(0);
const slide = SLIDES[active]!;
const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
const prev = useCallback(
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
[],
);
useEffect(() => {
const id = setInterval(next, 6000);
return () => clearInterval(id);
}, [next]);
return (
<div className="relative mx-auto w-full max-w-5xl">
<div
className="bg-primary/10 absolute inset-0 -z-10 mx-auto max-w-3xl rounded-full blur-3xl"
aria-hidden="true"
/>
<div className="dark:border-primary/10 overflow-hidden rounded-2xl border border-gray-200 shadow-2xl">
{/* Browser chrome */}
<div className="bg-muted/80 flex items-center gap-2 border-b px-4 py-2.5">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-400" />
<div className="h-3 w-3 rounded-full bg-amber-400" />
<div className="h-3 w-3 rounded-full bg-green-400" />
</div>
<div className="bg-background mx-auto flex items-center gap-1.5 rounded-md px-4 py-1 text-xs">
<Lock className="text-muted-foreground/40 h-3 w-3" />
<span className="text-muted-foreground">
myeasycms.de/home/mein-verein
</span>
</div>
</div>
{/* Layout */}
<div className="bg-background flex" style={{ minHeight: 380 }}>
{/* Sidebar */}
<div className="bg-muted/30 hidden w-40 shrink-0 border-r p-2 md:block">
<div className="text-foreground mb-3 px-2 text-[10px] font-bold">
MYeasyCMS
</div>
{SLIDES.map((s, i) => (
<button
key={s.id}
onClick={() => setActive(i)}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[10px] transition-colors',
i === active
? 'bg-primary/10 text-primary font-semibold'
: 'text-muted-foreground hover:bg-muted',
)}
>
{s.icon}
<span className="truncate">{s.label}</span>
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-5">
<div className="text-muted-foreground mb-4 flex items-center gap-1 text-[10px]">
<span>Home</span>
<span></span>
<span>Mein Verein</span>
<span></span>
<span className="text-foreground font-medium">{slide.label}</span>
</div>
<div
className="animate-in fade-in slide-in-from-right-2 duration-300"
key={slide.id}
>
{slide.content}
</div>
</div>
</div>
{/* Bottom tabs */}
<div className="border-t">
<div className="flex items-center gap-2 px-3 py-2">
<Button
variant="ghost"
size="icon"
onClick={prev}
className="h-7 w-7 shrink-0"
aria-label="Vorheriges Feature"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="scrollbar-none flex min-w-0 flex-1 items-center justify-center gap-1.5 overflow-x-auto">
{SLIDES.map((s, i) => (
<button
key={s.id}
onClick={() => setActive(i)}
className={cn(
'flex shrink-0 items-center gap-1 rounded-full px-3 py-1.5 text-[11px] transition-colors',
i === active
? 'bg-primary text-primary-foreground font-semibold'
: 'text-muted-foreground hover:bg-muted',
)}
>
{s.icon}
<span className="hidden lg:inline">{s.label}</span>
</button>
))}
</div>
<Button
variant="ghost"
size="icon"
onClick={next}
className="h-7 w-7 shrink-0"
aria-label="Nächstes Feature"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,37 +2,44 @@ import { Footer } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
export function SiteFooter() {
return (
<Footer
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
logo={<AppLogo className="w-[120px] md:w-[140px]" />}
description={<Trans i18nKey="marketing.footerDescription" />}
copyright={
<Trans
i18nKey="marketing.copyright"
values={{
product: appConfig.name,
year: new Date().getFullYear(),
}}
/>
}
sections={[
{
heading: <Trans i18nKey="marketing.about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
],
},
{
heading: <Trans i18nKey="marketing.product" />,
links: [
{
href: '/pricing',
label: <Trans i18nKey="marketing.pricing" />,
},
{
href: '/docs',
label: <Trans i18nKey="marketing.documentation" />,
},
{
href: '/changelog',
label: <Trans i18nKey="marketing.changelog" />,
},
],
},
{
heading: <Trans i18nKey="marketing.about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
{ href: '/faq', label: <Trans i18nKey="marketing.faq" /> },
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
],
},
{

View File

@@ -18,10 +18,6 @@ const links = {
label: 'marketing.blog',
path: '/blog',
},
Changelog: {
label: 'marketing.changelog',
path: '/changelog',
},
Docs: {
label: 'marketing.documentation',
path: '/docs',
@@ -34,6 +30,10 @@ const links = {
label: 'marketing.faq',
path: '/faq',
},
Contact: {
label: 'marketing.contact',
path: '/contact',
},
};
export function SiteNavigation() {

View File

@@ -19,33 +19,56 @@ export const generateMetadata = async () => {
async function FAQPage() {
const t = await getTranslations('marketing');
// replace this content with translations
const faqItems = [
{
// or: t('faq.question1')
question: `Do you offer a free trial?`,
// or: t('faq.answer1')
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
question: 'Was ist MYeasyCMS?',
answer:
'MYeasyCMS ist eine webbasierte Vereins- und Verbandsverwaltung. Sie verwalten Mitglieder, Beiträge, Kurse, Veranstaltungen, Finanzen und mehr — alles über den Browser, ohne Software-Installation.',
},
{
question: `Can I cancel my subscription?`,
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
question: 'Für welche Vereine ist MYeasyCMS geeignet?',
answer:
'MYeasyCMS eignet sich für alle Arten von Vereinen: Sportvereine, Fischereivereine, Kulturvereine, Bildungseinrichtungen, Verbände und kommunale Organisationen. Über 90 Vereine in Bayern arbeiten bereits mit der Plattform.',
},
{
question: `Where can I find my invoices?`,
answer: `You can find your invoices in your account settings.`,
question: 'Gibt es einen kostenlosen Testzugang?',
answer:
'Ja, wir richten Ihnen einen kostenlosen Testzugang ein und führen Sie persönlich durch die Plattform. Rufen Sie uns an unter 09451 9499-09 oder nutzen Sie das Kontaktformular.',
},
{
question: `What payment methods do you accept?`,
answer: `We accept all major credit cards and PayPal.`,
question: 'Muss ich Software installieren?',
answer:
'Nein. MYeasyCMS ist vollständig webbasiert und läuft in jedem modernen Browser (Chrome, Firefox, Safari, Edge). Es gibt keinen Download, keine Installation und keine manuellen Updates.',
},
{
question: `Can I upgrade or downgrade my plan?`,
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
question: 'Wie viele Benutzer kann ich anlegen?',
answer:
'Es gibt keine Begrenzung der Benutzeranzahl. Alle Tarife enthalten unbegrenzte Zugänge für Vorstandsmitglieder, Kassenwarte, Kursleiter und andere Mitarbeiter.',
},
{
question: `Do you offer discounts for non-profits?`,
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
question: 'Ist MYeasyCMS DSGVO-konform?',
answer:
'Ja. Alle Daten liegen auf Servern in Deutschland. Rollenbasierte Zugriffsrechte, verschlüsselte Datenübertragung und Funktionen für Auskunfts- und Löschungsanfragen sind integriert. Ein Auftragsverarbeitungsvertrag (AVV) wird bereitgestellt.',
},
{
question: 'Kann ich bestehende Mitgliederlisten importieren?',
answer:
'Ja. Der Import-Assistent unterstützt Excel- und CSV-Dateien. Sie laden Ihre bestehende Mitgliederliste hoch, ordnen die Spalten zu und importieren die Daten. Bei Bedarf unterstützt Sie das Com.BISS-Team persönlich.',
},
{
question: 'Wie funktioniert der SEPA-Beitragseinzug?',
answer:
'MYeasyCMS erzeugt SEPA-XML-Dateien (pain.008) aus den hinterlegten Mandaten und offenen Beiträgen. Sie laden die Datei herunter und reichen sie im Online-Banking Ihrer Bank ein. Mandatsverwaltung und IBAN-Prüfung sind integriert.',
},
{
question: 'Wie erreiche ich den Support?',
answer:
'Direkt und persönlich — kein anonymes Ticketsystem. Telefon: 09451 9499-09, E-Mail: info@combiss.de. Sie sprechen mit den Menschen, die Ihre Software entwickeln.',
},
{
question: 'Was kostet MYeasyCMS?',
answer:
'Die Preise richten sich nach der Vereinsgröße (Mitgliederzahl). Alle Module und Funktionen sind in jedem Tarif enthalten. Für ein individuelles Angebot kontaktieren Sie uns — wir beraten Sie gerne.',
},
];

View File

@@ -1,9 +1,7 @@
import Image from 'next/image';
import Link from 'next/link';
import {
ArrowRightIcon,
BookOpenIcon,
CalendarIcon,
FileTextIcon,
GraduationCapIcon,
@@ -27,15 +25,20 @@ import {
EcosystemShowcase,
FeatureShowcase,
FeatureShowcaseIconContainer,
GradientText,
Hero,
Pill,
SecondaryHero,
} from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { AnimateOnScroll } from './_components/animate-on-scroll';
import { FeatureCarousel } from './_components/feature-carousel';
function Home() {
return (
<div className={'mt-4 flex flex-col space-y-24 py-14 lg:space-y-36'}>
@@ -51,7 +54,10 @@ function Home() {
}
title={
<span className="text-secondary-foreground">
<Trans i18nKey={'marketing.heroTitle'} />
<Trans i18nKey={'marketing.heroTitleLine1'} />{' '}
<GradientText className="from-primary to-primary/60">
<Trans i18nKey={'marketing.heroTitleLine2'} />
</GradientText>
</span>
}
subtitle={
@@ -61,288 +67,334 @@ function Home() {
}
cta={<MainCallToActionButton />}
image={
<Image
priority
className={
'dark:border-primary/10 w-full rounded-2xl border border-gray-200 shadow-2xl'
}
width={3558}
height={2222}
src={`/images/dashboard.webp`}
alt={`MyEasyCMS Dashboard`}
/>
<div className="relative">
<div
className="bg-primary/10 absolute inset-0 -z-10 mx-auto max-w-3xl rounded-full blur-3xl"
aria-hidden="true"
/>
<FeatureCarousel />
</div>
}
/>
</div>
{/* Trust Indicators */}
<div className={'container mx-auto'}>
<div className="flex flex-col items-center gap-8">
<p className="text-muted-foreground text-sm font-medium uppercase tracking-widest">
<Trans i18nKey={'marketing.trustedBy'} />
</p>
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
<TrustItem icon={UsersIcon} label="marketing.trustAssociations" />
<TrustItem
icon={GraduationCapIcon}
label="marketing.trustSchools"
/>
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
<TrustItem
icon={GlobeIcon}
label="marketing.trustOrganizations"
/>
{/* Stats Bar */}
<AnimateOnScroll>
<div className={'container mx-auto'}>
<div className="border-border border-y py-8">
<p className="text-muted-foreground mb-6 text-center text-sm font-medium tracking-widest uppercase">
<Trans i18nKey={'marketing.trustedBy'} />
</p>
<div className="reveal-stagger divide-border flex flex-wrap items-center justify-center divide-x">
<StatItem value="69,000+" labelKey="marketing.statMembers" />
<StatItem value="90+" labelKey="marketing.statOrganizations" />
<StatItem value="22" labelKey="marketing.statYears" />
<StatItem value="3" labelKey="marketing.statFederations" />
</div>
</div>
</div>
</div>
</AnimateOnScroll>
{/* Core Modules Feature Grid */}
<div className={'container mx-auto'}>
<div className={'py-4 xl:py-8'}>
<FeatureShowcase
heading={
<>
<b className="font-medium tracking-tight dark:text-white">
<Trans i18nKey={'marketing.featuresHeading'} />
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans i18nKey={'marketing.featuresSubheading'} />
</span>
</>
}
icon={
<FeatureShowcaseIconContainer>
<LayoutDashboardIcon className="h-4 w-4" />
<span>
<Trans i18nKey={'marketing.featuresLabel'} />
</span>
</FeatureShowcaseIconContainer>
}
>
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
<IconFeatureCard
icon={UsersIcon}
titleKey="marketing.featureMembersTitle"
descKey="marketing.featureMembersDesc"
<AnimateOnScroll>
<div className={'container mx-auto'}>
<div className={'py-4 xl:py-8'}>
<FeatureShowcase
heading={
<>
<b className="font-medium tracking-tight dark:text-white">
<Trans i18nKey={'marketing.featuresHeading'} />
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans i18nKey={'marketing.featuresSubheading'} />
</span>
</>
}
icon={
<FeatureShowcaseIconContainer>
<LayoutDashboardIcon className="h-4 w-4" />
<span>
<Trans i18nKey={'marketing.featuresLabel'} />
</span>
</FeatureShowcaseIconContainer>
}
>
<div className="reveal-stagger mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
<IconFeatureCard
icon={UsersIcon}
titleKey="marketing.featureMembersTitle"
descKey="marketing.featureMembersDesc"
/>
<IconFeatureCard
icon={GraduationCapIcon}
titleKey="marketing.featureCoursesTitle"
descKey="marketing.featureCoursesDesc"
accentBg="bg-chart-1/10"
accentText="text-chart-1"
/>
<IconFeatureCard
icon={BedDoubleIcon}
titleKey="marketing.featureBookingsTitle"
descKey="marketing.featureBookingsDesc"
accentBg="bg-chart-2/10"
accentText="text-chart-2"
/>
<IconFeatureCard
icon={CalendarIcon}
titleKey="marketing.featureEventsTitle"
descKey="marketing.featureEventsDesc"
accentBg="bg-chart-3/10"
accentText="text-chart-3"
/>
<IconFeatureCard
icon={WalletIcon}
titleKey="marketing.featureFinanceTitle"
descKey="marketing.featureFinanceDesc"
accentBg="bg-chart-4/10"
accentText="text-chart-4"
/>
<IconFeatureCard
icon={MailIcon}
titleKey="marketing.featureNewsletterTitle"
descKey="marketing.featureNewsletterDesc"
accentBg="bg-chart-5/10"
accentText="text-chart-5"
/>
</div>
</FeatureShowcase>
</div>
</div>
</AnimateOnScroll>
{/* Testimonials */}
<AnimateOnScroll>
<div className="container mx-auto">
<div className="flex flex-col items-center gap-12">
<div className="text-center">
<h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
<Trans i18nKey={'marketing.testimonialsHeading'} />
</h2>
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
<Trans i18nKey={'marketing.testimonialsSubheading'} />
</p>
</div>
<div className="reveal-stagger grid w-full grid-cols-1 gap-6 md:grid-cols-3">
<TestimonialCard
quoteKey="marketing.testimonial1Quote"
nameKey="marketing.testimonial1Name"
roleKey="marketing.testimonial1Role"
/>
<IconFeatureCard
icon={GraduationCapIcon}
titleKey="marketing.featureCoursesTitle"
descKey="marketing.featureCoursesDesc"
<TestimonialCard
quoteKey="marketing.testimonial2Quote"
nameKey="marketing.testimonial2Name"
roleKey="marketing.testimonial2Role"
/>
<IconFeatureCard
icon={BedDoubleIcon}
titleKey="marketing.featureBookingsTitle"
descKey="marketing.featureBookingsDesc"
/>
<IconFeatureCard
icon={CalendarIcon}
titleKey="marketing.featureEventsTitle"
descKey="marketing.featureEventsDesc"
/>
<IconFeatureCard
icon={WalletIcon}
titleKey="marketing.featureFinanceTitle"
descKey="marketing.featureFinanceDesc"
/>
<IconFeatureCard
icon={MailIcon}
titleKey="marketing.featureNewsletterTitle"
descKey="marketing.featureNewsletterDesc"
<TestimonialCard
quoteKey="marketing.testimonial3Quote"
nameKey="marketing.testimonial3Name"
roleKey="marketing.testimonial3Role"
/>
</div>
</FeatureShowcase>
</div>
</div>
</div>
{/* Dashboard Showcase */}
<div className={'container mx-auto'}>
<EcosystemShowcase
heading={<Trans i18nKey={'marketing.showcaseHeading'} />}
description={<Trans i18nKey={'marketing.showcaseDescription'} />}
>
<Image
className="rounded-lg shadow-lg"
src={'/images/dashboard.webp'}
alt="MyEasyCMS Dashboard"
width={1200}
height={800}
/>
</EcosystemShowcase>
</div>
</AnimateOnScroll>
{/* Additional Features Row */}
<div className={'container mx-auto'}>
<div className={'py-4 xl:py-8'}>
<FeatureShowcase
heading={
<>
<b className="font-medium tracking-tight dark:text-white">
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans
i18nKey={'marketing.additionalFeaturesSubheading'}
/>
</span>
</>
}
icon={
<FeatureShowcaseIconContainer>
<ZapIcon className="h-4 w-4" />
<span>
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
</span>
</FeatureShowcaseIconContainer>
}
>
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
<IconFeatureCard
icon={FileTextIcon}
titleKey="marketing.featureDocumentsTitle"
descKey="marketing.featureDocumentsDesc"
/>
<IconFeatureCard
icon={GlobeIcon}
titleKey="marketing.featureSiteBuilderTitle"
descKey="marketing.featureSiteBuilderDesc"
/>
<IconFeatureCard
icon={LayoutDashboardIcon}
titleKey="marketing.featureModulesTitle"
descKey="marketing.featureModulesDesc"
/>
</div>
</FeatureShowcase>
<AnimateOnScroll>
<div className={'container mx-auto'}>
<div className={'py-4 xl:py-8'}>
<FeatureShowcase
heading={
<>
<b className="font-medium tracking-tight dark:text-white">
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans i18nKey={'marketing.additionalFeaturesSubheading'} />
</span>
</>
}
icon={
<FeatureShowcaseIconContainer>
<ZapIcon className="h-4 w-4" />
<span>
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
</span>
</FeatureShowcaseIconContainer>
}
>
<div className="reveal-stagger mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
<IconFeatureCard
icon={FileTextIcon}
titleKey="marketing.featureDocumentsTitle"
descKey="marketing.featureDocumentsDesc"
accentBg="bg-chart-1/10"
accentText="text-chart-1"
/>
<IconFeatureCard
icon={GlobeIcon}
titleKey="marketing.featureSiteBuilderTitle"
descKey="marketing.featureSiteBuilderDesc"
accentBg="bg-chart-2/10"
accentText="text-chart-2"
/>
<IconFeatureCard
icon={LayoutDashboardIcon}
titleKey="marketing.featureModulesTitle"
descKey="marketing.featureModulesDesc"
accentBg="bg-chart-3/10"
accentText="text-chart-3"
/>
</div>
</FeatureShowcase>
</div>
</div>
</div>
</AnimateOnScroll>
{/* Why Choose Us Section */}
<div className={'container mx-auto'}>
<EcosystemShowcase
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
textPosition="right"
>
<div className="flex flex-col gap-6">
<WhyItem
icon={SmartphoneIcon}
titleKey="marketing.whyResponsiveTitle"
descKey="marketing.whyResponsiveDesc"
/>
<WhyItem
icon={LockIcon}
titleKey="marketing.whySecureTitle"
descKey="marketing.whySecureDesc"
/>
<WhyItem
icon={HeadsetIcon}
titleKey="marketing.whySupportTitle"
descKey="marketing.whySupportDesc"
/>
<WhyItem
icon={ShieldCheckIcon}
titleKey="marketing.whyGdprTitle"
descKey="marketing.whyGdprDesc"
/>
</div>
</EcosystemShowcase>
</div>
<AnimateOnScroll>
<div className={'container mx-auto'}>
<EcosystemShowcase
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
textPosition="right"
className="border-primary/10 rounded-xl border"
>
<div className="flex flex-col gap-6 text-left">
<WhyItem
icon={SmartphoneIcon}
titleKey="marketing.whyResponsiveTitle"
descKey="marketing.whyResponsiveDesc"
/>
<WhyItem
icon={LockIcon}
titleKey="marketing.whySecureTitle"
descKey="marketing.whySecureDesc"
/>
<WhyItem
icon={HeadsetIcon}
titleKey="marketing.whySupportTitle"
descKey="marketing.whySupportDesc"
/>
<WhyItem
icon={ShieldCheckIcon}
titleKey="marketing.whyGdprTitle"
descKey="marketing.whyGdprDesc"
/>
</div>
</EcosystemShowcase>
</div>
</AnimateOnScroll>
{/* How It Works */}
<div className="container mx-auto">
<div className="flex flex-col items-center gap-12">
<div className="text-center">
<h2 className="text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
<Trans i18nKey={'marketing.howItWorksHeading'} />
</h2>
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
<Trans i18nKey={'marketing.howItWorksSubheading'} />
</p>
</div>
<AnimateOnScroll>
<div className="container mx-auto">
<div className="flex flex-col items-center gap-12">
<div className="text-center">
<h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
<Trans i18nKey={'marketing.howItWorksHeading'} />
</h2>
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
<Trans i18nKey={'marketing.howItWorksSubheading'} />
</p>
</div>
<div className="grid w-full grid-cols-1 gap-8 md:grid-cols-3">
<StepCard
step="01"
titleKey="marketing.howStep1Title"
descKey="marketing.howStep1Desc"
/>
<StepCard
step="02"
titleKey="marketing.howStep2Title"
descKey="marketing.howStep2Desc"
/>
<StepCard
step="03"
titleKey="marketing.howStep3Title"
descKey="marketing.howStep3Desc"
/>
<div className="relative grid w-full grid-cols-1 gap-8 md:grid-cols-3">
<div
className="border-primary/30 absolute top-10 right-[16.67%] left-[16.67%] hidden h-px border-t border-dashed md:block"
aria-hidden="true"
/>
<StepCard
step="01"
titleKey="marketing.howStep1Title"
descKey="marketing.howStep1Desc"
/>
<StepCard
step="02"
titleKey="marketing.howStep2Title"
descKey="marketing.howStep2Desc"
/>
<StepCard
step="03"
titleKey="marketing.howStep3Title"
descKey="marketing.howStep3Desc"
/>
</div>
</div>
</div>
</div>
</AnimateOnScroll>
{/* Pricing Section */}
<div className={'container mx-auto'}>
<div
className={
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
}
>
<SecondaryHero
pill={
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
<Trans i18nKey={'marketing.pricingPillText'} />
</Pill>
<AnimateOnScroll>
<div className={'container mx-auto'}>
<div
className={
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
}
heading={<Trans i18nKey={'marketing.pricingHeading'} />}
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
/>
<div className={'w-full'}>
<PricingTable
config={billingConfig}
paths={{
signUp: pathsConfig.auth.signUp,
return: pathsConfig.app.home,
}}
>
<SecondaryHero
pill={
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
<Trans i18nKey={'marketing.pricingPillText'} />
</Pill>
}
heading={
<GradientText className="from-primary to-primary/60">
<Trans i18nKey={'marketing.pricingHeading'} />
</GradientText>
}
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
/>
<div className={'w-full'}>
<PricingTable
config={billingConfig}
paths={{
signUp: pathsConfig.auth.signUp,
return: pathsConfig.app.home,
}}
/>
</div>
</div>
</div>
</div>
</AnimateOnScroll>
{/* Final CTA */}
<div className="container mx-auto">
<div className="bg-primary/5 flex flex-col items-center gap-8 rounded-2xl border p-12 text-center lg:p-16">
<h2 className="max-w-3xl text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
<Trans i18nKey={'marketing.ctaHeading'} />
</h2>
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
<Trans i18nKey={'marketing.ctaDescription'} />
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<CtaButton className="h-12 px-8 text-base">
<Link href={'/auth/sign-up'}>
<span className="flex items-center gap-2">
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
<ArrowRightIcon className="h-4 w-4" />
</span>
</Link>
</CtaButton>
<CtaButton variant={'outline'} className="h-12 px-8 text-base">
<Link href={'/contact'}>
<Trans i18nKey={'marketing.ctaButtonSecondary'} />
</Link>
</CtaButton>
<AnimateOnScroll>
<div className="container mx-auto">
<div className="ring-primary/10 from-primary/10 via-background to-primary/5 flex flex-col items-center gap-8 rounded-2xl border bg-gradient-to-br p-12 text-center ring-1 lg:p-16">
<h2 className="max-w-3xl text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
<GradientText className="from-primary to-primary/60">
<Trans i18nKey={'marketing.ctaHeading'} />
</GradientText>
</h2>
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
<Trans i18nKey={'marketing.ctaDescription'} />
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<CtaButton className="h-14 px-10 text-lg">
<Link href={'/auth/sign-up'}>
<span className="flex items-center gap-2">
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
<ArrowRightIcon className="h-5 w-5" />
</span>
</Link>
</CtaButton>
<CtaButton variant={'outline'} className="h-14 px-10 text-lg">
<Link href={'/contact'}>
<Trans i18nKey={'marketing.ctaButtonSecondary'} />
</Link>
</CtaButton>
</div>
<p className="text-muted-foreground flex items-center gap-2 text-sm">
<CheckIcon className="h-4 w-4" />
<Trans i18nKey={'marketing.ctaNote'} />
</p>
</div>
<p className="text-muted-foreground flex items-center gap-2 text-sm">
<CheckIcon className="h-4 w-4" />
<Trans i18nKey={'marketing.ctaNote'} />
</p>
</div>
</div>
</AnimateOnScroll>
</div>
);
}
@@ -352,7 +404,7 @@ export default Home;
function MainCallToActionButton() {
return (
<div className={'flex space-x-2.5'}>
<CtaButton className="h-10 text-sm">
<CtaButton className="h-12 px-8 text-base shadow-lg">
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-0.5'}>
<span>
@@ -369,7 +421,7 @@ function MainCallToActionButton() {
</Link>
</CtaButton>
<CtaButton variant={'link'} className="h-10 text-sm">
<CtaButton variant={'link'} className="h-12 px-8 text-base">
<Link href={'/pricing'}>
<Trans i18nKey={'common.pricing'} />
</Link>
@@ -382,11 +434,20 @@ function IconFeatureCard(props: {
icon: React.ComponentType<{ className?: string }>;
titleKey: string;
descKey: string;
accentBg?: string;
accentText?: string;
}) {
return (
<div className="bg-muted/50 flex flex-col gap-3 rounded p-6">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<props.icon className="text-primary h-5 w-5" />
<div className="reveal bg-muted/50 hover:border-primary/20 flex flex-col gap-3 rounded-xl border border-transparent p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-md">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
props.accentBg ?? 'bg-primary/10',
)}
>
<props.icon
className={cn('h-5 w-5', props.accentText ?? 'text-primary')}
/>
</div>
<h4 className="text-lg font-medium">
<Trans i18nKey={props.titleKey} />
@@ -398,16 +459,39 @@ function IconFeatureCard(props: {
);
}
function TrustItem(props: {
icon: React.ComponentType<{ className?: string }>;
label: string;
function StatItem(props: { value: string; labelKey: string }) {
return (
<div className="reveal flex flex-col items-center gap-1 px-6 py-4">
<span className="text-primary text-3xl font-bold tracking-tight lg:text-4xl">
{props.value}
</span>
<span className="text-muted-foreground text-sm font-medium">
<Trans i18nKey={props.labelKey} />
</span>
</div>
);
}
function TestimonialCard(props: {
quoteKey: string;
nameKey: string;
roleKey: string;
}) {
return (
<div className="text-muted-foreground flex items-center gap-2.5">
<props.icon className="h-5 w-5" />
<span className="text-sm font-medium">
<Trans i18nKey={props.label} />
</span>
<div className="reveal border-border bg-card flex flex-col gap-4 rounded-xl border p-6 shadow-sm">
<p className="text-secondary-foreground text-sm leading-relaxed italic">
&ldquo;
<Trans i18nKey={props.quoteKey} />
&rdquo;
</p>
<div className="border-border border-t pt-4">
<p className="text-sm font-medium">
<Trans i18nKey={props.nameKey} />
</p>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={props.roleKey} />
</p>
</div>
</div>
);
}
@@ -419,7 +503,7 @@ function WhyItem(props: {
}) {
return (
<div className="flex gap-4">
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<div className="ring-primary/20 bg-primary/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-xl ring-1">
<props.icon className="text-primary h-5 w-5" />
</div>
<div>
@@ -436,8 +520,10 @@ function WhyItem(props: {
function StepCard(props: { step: string; titleKey: string; descKey: string }) {
return (
<div className="bg-muted/50 relative flex flex-col gap-4 rounded-lg p-6">
<span className="text-primary/20 text-6xl font-bold">{props.step}</span>
<div className="reveal border-border bg-card relative flex flex-col items-center gap-4 rounded-xl border p-8 text-center shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-md">
<div className="bg-primary text-primary-foreground shadow-primary/20 relative z-10 flex h-14 w-14 items-center justify-center rounded-full text-xl font-bold shadow-lg">
{props.step}
</div>
<h3 className="text-secondary-foreground text-xl font-medium">
<Trans i18nKey={props.titleKey} />
</h3>

View File

@@ -0,0 +1,569 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { Check, ExternalLink, X } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent } from '@kit/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { cn } from '@kit/ui/utils';
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
interface Competitor {
id: string;
name: string;
note: string;
getPrice: (m: number) => number | null;
color: string;
maxMembers?: number;
verbandExtra: string | null;
}
const COMPETITORS: Competitor[] = [
{
id: 'sewobe',
name: 'SEWOBE VereinsManager',
note: 'VerbandsMANAGER separat',
getPrice: (m) => {
if (m <= 500) return 30;
if (m <= 1000) return 49;
if (m <= 2000) return 99;
if (m <= 3000) return 159;
if (m <= 5000) return 269;
if (m <= 7500) return 369;
return 469;
},
color: 'bg-red-500',
verbandExtra:
'VerbandsMANAGER: separates Produkt, Preis auf Anfrage (deutlich teurer)',
},
{
id: 'easyverein',
name: 'easyVerein Professional',
note: 'Dachverbandslösung extra',
getPrice: (m) => {
if (m <= 100) return 20;
if (m <= 250) return 31;
if (m <= 500) return 49;
if (m <= 1000) return 79;
if (m <= 2000) return 129;
if (m <= 5000) return 249;
return 399;
},
color: 'bg-orange-500',
verbandExtra:
'Dachverbandslösung: jede Instanz eigene kostenpflichtige Lizenz',
},
{
id: 'clubdesk',
name: 'ClubDesk',
note: 'Server in der Schweiz',
getPrice: (m) => {
if (m <= 50) return 0;
if (m <= 100) return 10;
if (m <= 250) return 15;
if (m <= 500) return 25;
if (m <= 1000) return 33;
return null;
},
color: 'bg-blue-500',
maxMembers: 1000,
verbandExtra: null,
},
{
id: 'wiso',
name: 'WISO MeinVerein Web',
note: 'Buhl / ZDF-WISO Marke',
getPrice: (m) => {
if (m <= 100) return 10;
if (m <= 250) return 15;
if (m <= 500) return 25;
if (m <= 1000) return 35;
return null;
},
color: 'bg-violet-500',
maxMembers: 1000,
verbandExtra: null,
},
];
const TIERS = [
{ name: 'Starter', price: 29, maxMembers: 250 },
{ name: 'Pro', price: 59, maxMembers: 1000 },
{ name: 'Verband', price: 199, maxMembers: 10000 },
{ name: 'Enterprise', price: 349, maxMembers: 99999 },
] as const;
function getTier(m: number) {
return TIERS.find((t) => m <= t.maxMembers) ?? TIERS[TIERS.length - 1]!;
}
function fmt(n: number) {
return n.toLocaleString('de-DE');
}
// ---------------------------------------------------------------------------
// Feature comparison data
// ---------------------------------------------------------------------------
type FeatureValue = boolean | string;
interface FeatureRow {
label: string;
mcms: FeatureValue;
sewobe: FeatureValue;
easy: FeatureValue;
club: FeatureValue;
wiso: FeatureValue;
}
const USP_FEATURES: FeatureRow[] = [
{
label: 'Verbandsmodul (Mehrebenen-Hierarchie)',
mcms: true,
sewobe: 'Separates Produkt',
easy: 'Extra Lösung',
club: false,
wiso: false,
},
{
label: 'Vereins-Website inklusive',
mcms: true,
sewobe: false,
easy: false,
club: true,
wiso: false,
},
{
label: 'Kursverwaltung (Dozenten, Räume)',
mcms: true,
sewobe: false,
easy: false,
club: false,
wiso: false,
},
{
label: 'SEPA-Lastschrift',
mcms: true,
sewobe: true,
easy: true,
club: true,
wiso: true,
},
{
label: 'Persönlicher Telefon-Support',
mcms: true,
sewobe: 'Kostenpflichtig',
easy: false,
club: false,
wiso: false,
},
{
label: 'Unbegrenzte Benutzer (ab Pro)',
mcms: true,
sewobe: '5 inkl., +6€/User',
easy: '310 inkl.',
club: false,
wiso: false,
},
{
label: 'Server in Deutschland',
mcms: true,
sewobe: true,
easy: true,
club: 'Schweiz',
wiso: true,
},
{
label: 'Individuelle Module',
mcms: true,
sewobe: 'Aufpreis',
easy: false,
club: false,
wiso: false,
},
];
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function FeatureCell({ value }: { value: FeatureValue }) {
if (value === true) {
return <Check className="text-primary mx-auto h-4 w-4" />;
}
if (value === false) {
return <X className="text-destructive mx-auto h-3.5 w-3.5" />;
}
return (
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
{value}
</span>
);
}
function PriceBar({
label,
note,
value,
maxValue,
color,
available,
maxMembers,
period,
}: {
label: string;
note?: string;
value: number;
maxValue: number;
color: string;
available: boolean;
maxMembers?: number;
period: 'month' | 'year';
}) {
const mult = period === 'year' ? 12 : 1;
const pct = available ? Math.min((value / (maxValue * 1.1)) * 100, 100) : 0;
return (
<div className="space-y-1">
<div className="text-muted-foreground flex items-baseline justify-between text-sm">
<span className="font-medium">
{label}{' '}
{note && (
<span className="text-muted-foreground/60 text-xs">({note})</span>
)}
</span>
<span className="font-mono font-bold">
{available ? (
<span className="text-destructive">{fmt(value * mult)} </span>
) : (
<span className="text-muted-foreground/50 text-xs">
nicht verfügbar ab {fmt(maxMembers ?? 0)}
</span>
)}
</span>
</div>
<div className="bg-muted h-6 overflow-hidden rounded-md">
{available ? (
<div
className={cn(
'h-full rounded-md transition-all duration-500',
color,
)}
style={{
width: `${pct}%`,
minWidth: pct > 0 ? 4 : 0,
opacity: 0.7,
}}
/>
) : (
<div
className="bg-muted-foreground/5 h-full rounded-md"
style={{
backgroundImage:
'repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(0,0,0,.03) 8px, rgba(0,0,0,.03) 16px)',
}}
/>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Calculator
// ---------------------------------------------------------------------------
export function PricingCalculator() {
const [members, setMembers] = useState(500);
const [period, setPeriod] = useState<'month' | 'year'>('year');
const tier = useMemo(() => getTier(members), [members]);
const mult = period === 'year' ? 12 : 1;
const compPrices = useMemo(
() => COMPETITORS.map((c) => ({ ...c, p: c.getPrice(members) })),
[members],
);
const maxBar = useMemo(
() =>
Math.max(
tier.price,
...compPrices.filter((c) => c.p !== null).map((c) => c.p!),
),
[tier.price, compPrices],
);
const bestSaving = useMemo(
() =>
compPrices
.filter((c) => c.p !== null && c.p > tier.price)
.sort((a, b) => b.p! - a.p!)[0] ?? null,
[compPrices, tier.price],
);
return (
<div className="mx-auto w-full max-w-4xl space-y-0">
{/* ── Header ── */}
<div className="bg-primary rounded-t-2xl px-8 py-7">
<Badge
variant="outline"
className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] tracking-widest uppercase"
>
Preisvergleich
</Badge>
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
MYeasyCMS vs. Markt was sparen Sie wirklich?
</h2>
<p className="text-primary-foreground/50 mt-1 text-sm">
Echte Preise von SEWOBE, easyVerein, ClubDesk und WISO MeinVerein.
Alle Preise netto.
</p>
</div>
{/* ── Body ── */}
<Card className="rounded-t-none border-t-0">
<CardContent className="space-y-6 pt-7">
{/* Slider */}
<div>
<div className="mb-2 flex items-baseline justify-between">
<span className="text-muted-foreground text-sm font-semibold">
Vereinsgröße
</span>
<div>
<span className="text-primary font-mono text-2xl font-bold">
{fmt(members)}
</span>
<span className="text-muted-foreground/60 ml-1 text-sm">
Mitglieder
</span>
</div>
</div>
<input
type="range"
min={50}
max={10000}
step={50}
value={members}
onChange={(e) => setMembers(+e.target.value)}
className="accent-primary w-full"
/>
<div className="text-muted-foreground/40 flex justify-between text-[10px]">
<span>50</span>
<span>500</span>
<span>1.000</span>
<span>2.500</span>
<span>5.000</span>
<span>10.000</span>
</div>
</div>
{/* Tier + Period toggle */}
<div className="flex flex-wrap items-center gap-3">
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
<div>
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
Ihr MYeasyCMS-Tarif
</div>
<div className="font-heading text-primary text-xl font-bold">
{tier.name}
</div>
</div>
<div className="text-right">
<span className="text-primary font-mono text-3xl font-bold">
{tier.price}
</span>
<div className="text-muted-foreground text-xs">
/ Monat netto
</div>
</div>
</Card>
<div className="border-border flex overflow-hidden rounded-lg border">
{(['month', 'year'] as const).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={cn(
'px-4 py-2 text-sm font-semibold transition-colors',
period === p
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:bg-muted',
)}
>
{p === 'month' ? 'Monatlich' : 'Jährlich'}
</button>
))}
</div>
</div>
{/* Bar chart */}
<div>
<h3 className="text-foreground mb-3 text-sm font-semibold">
Preisvergleich bei {fmt(members)} Mitgliedern (
{period === 'year' ? 'pro Jahr' : 'pro Monat'})
</h3>
<div className="space-y-3">
{compPrices.map((c) => (
<PriceBar
key={c.id}
label={c.name}
note={c.note}
value={c.p ?? 0}
maxValue={maxBar}
color={c.color}
available={c.p !== null}
maxMembers={c.maxMembers}
period={period}
/>
))}
{/* MYeasyCMS bar */}
<div className="space-y-1">
<div className="flex items-baseline justify-between text-sm">
<span className="text-primary font-bold">
MYeasyCMS {tier.name}
</span>
<span className="text-primary font-mono font-bold">
{fmt(tier.price * mult)}
</span>
</div>
<div className="bg-muted h-7 overflow-hidden rounded-md">
<div
className="from-primary/80 to-primary h-full rounded-md bg-gradient-to-r transition-all duration-500"
style={{
width: `${Math.min((tier.price / (maxBar * 1.1)) * 100, 100)}%`,
minWidth: 4,
}}
/>
</div>
</div>
</div>
</div>
{/* Savings callout */}
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
Ersparnis vs. {bestSaving.name.split(' ')[0]}
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
{fmt((bestSaving.p - tier.price) * 12)}
</div>
<div className="text-muted-foreground text-sm">
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
günstiger)
</div>
</Card>
<Card className="bg-muted/50 p-5 text-center">
<div className="text-muted-foreground text-[10px] font-bold tracking-wider uppercase">
Preis pro Mitglied
</div>
<div className="font-heading text-primary mt-1 text-3xl font-bold">
{((tier.price / members) * 100).toFixed(1)} ct
</div>
<div className="text-muted-foreground text-sm">
pro Mitglied / Monat
</div>
</Card>
</div>
)}
{/* SEWOBE Verband note */}
{members >= 1000 && (
<div className="bg-destructive/5 border-destructive/10 text-destructive rounded-xl border p-4 text-sm">
<strong>Hinweis zu SEWOBE:</strong> Ab Verbandsebene benötigt
SEWOBE den separaten <strong>VerbandsMANAGER</strong> ein
eigenes Produkt mit eigener Preisliste (deutlich teurer als der
VereinsMANAGER). MYeasyCMS enthält das Verbandsmodul mit
Mehrebenen-Hierarchie bereits im Verband-Tarif.
</div>
)}
{/* Feature comparison table */}
<div>
<h3 className="text-foreground mb-3 text-sm font-bold">
Funktionsvergleich
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">Funktion</TableHead>
<TableHead className="bg-primary/5 text-primary text-center font-bold">
MYeasyCMS
</TableHead>
<TableHead className="text-center">SEWOBE</TableHead>
<TableHead className="text-center">easyVerein</TableHead>
<TableHead className="text-center">ClubDesk</TableHead>
<TableHead className="text-center">WISO</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{USP_FEATURES.map((f, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{f.label}</TableCell>
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
(col) => (
<TableCell
key={col}
className={cn(
'text-center',
col === 'mcms' && 'bg-primary/5',
)}
>
<FeatureCell value={f[col]} />
</TableCell>
),
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* ── CTA footer ── */}
<div className="bg-primary flex flex-wrap items-center justify-between gap-4 rounded-b-2xl px-8 py-5">
<div>
<p className="text-primary-foreground text-sm font-semibold">
{members >= 1000
? `${Math.round((1 - tier.price / (compPrices[0]?.p ?? tier.price)) * 100)}% günstiger als SEWOBE — mit dem Verbandsmodul inklusive.`
: 'Alle Funktionen inklusive. Keine versteckten Kosten.'}
</p>
<p className="text-primary-foreground/40 text-xs">
14 Tage kostenlos testen. Persönliche Einrichtung inklusive.
</p>
</div>
<Button variant="secondary" size="lg" asChild>
<Link href="/auth/sign-up">
Kostenlos testen
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { PricingCalculator } from './_components/pricing-calculator';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
@@ -23,12 +25,18 @@ async function PricingPage() {
const t = await getTranslations('marketing');
return (
<div className={'flex flex-col space-y-8'}>
<div className={'flex flex-col space-y-16'}>
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
<div className={'container mx-auto pb-8 xl:pb-16'}>
{/* Pricing tiers */}
<div className={'container mx-auto'}>
<PricingTable paths={paths} config={billingConfig} />
</div>
{/* Price comparison calculator */}
<div className={'container mx-auto pb-8 xl:pb-16'}>
<PricingCalculator />
</div>
</div>
);
}

View File

@@ -1,20 +1,224 @@
export default async function AdminAuditPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Protokoll</h1>
<p className="text-muted-foreground">
Mandantenübergreifendes Änderungsprotokoll
</p>
</div>
import { getTranslations } from 'next-intl/server';
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren)
über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer,
Tabelle und Aktion.
</p>
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { formatDateTime } from '@kit/shared/dates';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
interface SearchParams {
action?: string;
table?: string;
page?: string;
}
interface AdminAuditPageProps {
searchParams: Promise<SearchParams>;
}
const ACTION_LABELS: Record<string, string> = {
insert: 'Erstellen',
update: 'Ändern',
delete: 'Löschen',
lock: 'Sperren',
};
const ACTION_COLORS: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
insert: 'default',
update: 'secondary',
delete: 'destructive',
lock: 'outline',
};
async function AuditPage(props: AdminAuditPageProps) {
const searchParams = await props.searchParams;
const client = getSupabaseServerAdminClient();
const api = createModuleBuilderApi(client);
const t = await getTranslations('cms.audit');
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
const result = await api.audit.query({
action: searchParams.action || undefined,
tableName: searchParams.table || undefined,
page,
pageSize: 50,
});
const totalPages = Math.ceil(result.total / result.pageSize);
return (
<PageBody>
<PageHeader title={t('title')} description={t('description')} />
<div className="space-y-4">
{/* Filters */}
<AuditFilters
currentAction={searchParams.action}
currentTable={searchParams.table}
/>
{/* Results table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('timestamp')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('action')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('table')}
</th>
<th scope="col" className="p-3 text-left font-medium">
Datensatz-ID
</th>
<th scope="col" className="p-3 text-left font-medium">
Benutzer-ID
</th>
</tr>
</thead>
<tbody>
{result.data.length === 0 ? (
<tr>
<td
colSpan={5}
className="text-muted-foreground p-8 text-center"
>
Keine Einträge gefunden.
</td>
</tr>
) : (
result.data.map((entry) => (
<tr key={entry.id} className="border-b">
<td className="p-3 text-xs">
{formatDateTime(entry.created_at)}
</td>
<td className="p-3">
<Badge
variant={
ACTION_COLORS[entry.action as string] ?? 'secondary'
}
>
{ACTION_LABELS[entry.action as string] ??
String(entry.action)}
</Badge>
</td>
<td className="p-3 font-mono text-xs">
{String(entry.table_name)}
</td>
<td className="p-3 font-mono text-xs">
{String(entry.record_id).slice(0, 8)}...
</td>
<td className="p-3 font-mono text-xs">
{String(entry.user_id).slice(0, 8)}...
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({result.total} Einträge)
</p>
<div className="flex gap-2">
{page > 1 && (
<PaginationLink
page={page - 1}
action={searchParams.action}
table={searchParams.table}
label={t('paginationPrevious')}
/>
)}
{page < totalPages && (
<PaginationLink
page={page + 1}
action={searchParams.action}
table={searchParams.table}
label={t('paginationNext')}
/>
)}
</div>
</div>
)}
</div>
</PageBody>
);
}
function AuditFilters({
currentAction,
currentTable,
}: {
currentAction?: string;
currentTable?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3">
<form className="flex items-center gap-3">
<select
name="action"
defaultValue={currentAction ?? ''}
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
<option value="">Alle Aktionen</option>
<option value="insert">Erstellen</option>
<option value="update">Ändern</option>
<option value="delete">Löschen</option>
<option value="lock">Sperren</option>
</select>
<input
name="table"
type="text"
placeholder="Tabelle filtern..."
defaultValue={currentTable ?? ''}
className="border-input bg-background flex h-9 w-48 rounded-md border px-3 py-1 text-sm shadow-sm"
/>
<Button type="submit" variant="outline" size="sm">
Filtern
</Button>
</form>
</div>
);
}
function PaginationLink({
page,
action,
table,
label,
}: {
page: number;
action?: string;
table?: string;
label: string;
}) {
const params = new URLSearchParams();
params.set('page', String(page));
if (action) params.set('action', action);
if (table) params.set('table', table);
return (
<a href={`?${params.toString()}`}>
<Button variant="outline" size="sm">
{label}
</Button>
</a>
);
}
export default AdminGuard(AuditPage);

View File

@@ -9,10 +9,11 @@ export default async function AdminGdprPage() {
</div>
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten
gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien,
Aufbewahrungsfristen und technisch-organisatorische Maßnahmen.
<p className="text-muted-foreground text-sm">
Mandantenübergreifende Übersicht aller registrierten
Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Umfasst Zweck,
Rechtsgrundlage, Datenkategorien, Aufbewahrungsfristen und
technisch-organisatorische Maßnahmen.
</p>
</div>
</div>

View File

@@ -8,23 +8,27 @@ export default async function AdminMigrationPage() {
</p>
</div>
<div className="rounded-lg border p-6 space-y-4">
<div className="space-y-4 rounded-lg border p-6">
<h2 className="text-lg font-semibold">Migrationsschritte</h2>
<ol className="list-decimal list-inside space-y-2 text-sm">
<ol className="list-inside list-decimal space-y-2 text-sm">
<li>MySQL-Verbindung konfigurieren</li>
<li>Mandanten (user_profile team accounts) zuordnen</li>
<li>Benutzer (cms_user auth.users) migrieren</li>
<li>Module (m_module/m_modulfeld modules/module_fields) übertragen</li>
<li>
Module (m_module/m_modulfeld modules/module_fields) übertragen
</li>
<li>Mitglieder (ve_mitglieder members) importieren</li>
<li>Kurse (ve_kurse courses) importieren</li>
<li>Dateien (cms_files Supabase Storage) hochladen</li>
<li>Daten verifizieren und bereinigen</li>
</ol>
<div className="rounded-md bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 p-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Hinweis:</strong> Die Migration erfordert eine MySQL-Verbindung zum Legacy-System.
Stellen Sie sicher, dass <code>mysql2</code> installiert ist und die Verbindungsdaten korrekt konfiguriert sind.
<strong>Hinweis:</strong> Die Migration erfordert eine
MySQL-Verbindung zum Legacy-System. Stellen Sie sicher, dass{' '}
<code>mysql2</code> installiert ist und die Verbindungsdaten korrekt
konfiguriert sind.
</p>
</div>
</div>

View File

@@ -9,9 +9,10 @@ export default async function AdminModulesPage() {
</div>
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Hier werden alle Module über alle Mandanten hinweg angezeigt.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und
-konfigurationen.
</p>
</div>
</div>

View File

@@ -1,9 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string; page: string[] }> }
interface Props {
params: Promise<{ slug: string; page: string[] }>;
}
export default async function ClubSubPage({ params }: Props) {
const { slug, page: pagePath } = await params;
@@ -14,36 +18,73 @@ export default async function ClubSubPage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
const { data: account } = await supabase
.from('accounts')
.select('id')
.eq('slug', slug)
.single();
if (!account) notFound();
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
const { data: settings } = await supabase
.from('site_settings')
.select('*')
.eq('account_id', account.id)
.eq('is_public', true)
.maybeSingle();
if (!settings) notFound();
const { data: sitePageData } = await supabase.from('site_pages').select('*')
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
const { data: sitePageData } = await supabase
.from('site_pages')
.select('*')
.eq('account_id', account.id)
.eq('slug', pageSlug)
.eq('is_published', true)
.maybeSingle();
if (!sitePageData) notFound();
// Pre-fetch CMS data for Puck components
const [eventsRes, coursesRes, postsRes] = await Promise.all([
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
supabase
.from('events')
.select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id)
.order('event_date', { ascending: true })
.limit(20),
supabase
.from('courses')
.select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id)
.order('start_date', { ascending: true })
.limit(20),
supabase
.from('cms_posts')
.select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(20),
]);
const siteData: SiteData = {
accountId: account.id,
events: eventsRes.data ?? [],
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
posts: postsRes.data ?? [],
};
return (
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
<div
style={
{
'--primary': settings.primary_color,
fontFamily: settings.font_family,
} as React.CSSProperties
}
>
<SiteRenderer
data={(sitePageData.puck_data ?? {}) as Record<string, unknown>}
siteData={siteData}
/>
</div>
);
}

View File

@@ -1,23 +1,28 @@
import { Mail } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Button } from '@kit/ui/button';
import { Mail } from 'lucide-react';
interface Props { params: Promise<{ slug: string }> }
interface Props {
params: Promise<{ slug: string }>;
}
export default async function NewsletterSubscribePage({ params }: Props) {
const { slug } = await params;
const { slug: _slug } = await params;
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-6 w-6 text-primary" />
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Mail className="text-primary h-6 w-6" />
</div>
<CardTitle>Newsletter abonnieren</CardTitle>
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
<p className="text-muted-foreground text-sm">
Bleiben Sie über Neuigkeiten informiert.
</p>
</CardHeader>
<CardContent>
<form className="space-y-4">
@@ -27,11 +32,19 @@ export default async function NewsletterSubscribePage({ params }: Props) {
</div>
<div className="space-y-2">
<Label>E-Mail-Adresse *</Label>
<Input name="email" type="email" placeholder="ihre@email.de" required />
<Input
name="email"
type="email"
placeholder="ihre@email.de"
required
/>
</div>
<Button type="submit" className="w-full">Abonnieren</Button>
<p className="text-xs text-center text-muted-foreground">
Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail.
<Button type="submit" className="w-full">
Abonnieren
</Button>
<p className="text-muted-foreground text-center text-xs">
Sie können sich jederzeit abmelden. Wir senden Ihnen eine
Bestätigungs-E-Mail.
</p>
</form>
</CardContent>

View File

@@ -1,34 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { MailX } from 'lucide-react';
import Link from 'next/link';
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
import { MailX } from 'lucide-react';
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>;
}
export default async function NewsletterUnsubscribePage({
params,
searchParams,
}: Props) {
const { slug } = await params;
const { token } = await searchParams;
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<MailX className="h-6 w-6 text-destructive" />
<div className="bg-destructive/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<MailX className="text-destructive h-6 w-6" />
</div>
<CardTitle>Newsletter abbestellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{token ? (
<>
<p className="text-sm text-muted-foreground">Möchten Sie den Newsletter wirklich abbestellen?</p>
<Button variant="destructive" className="w-full">Abbestellen bestätigen</Button>
<p className="text-muted-foreground text-sm">
Möchten Sie den Newsletter wirklich abbestellen?
</p>
<Button variant="destructive" className="w-full">
Abbestellen bestätigen
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
<p className="text-muted-foreground text-sm">
Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der
Newsletter-E-Mail.
</p>
)}
<Link href={`/club/${slug}`}>
<Button variant="outline" size="sm"> Zurück zur Website</Button>
<Button variant="outline" size="sm">
Zurück zur Website
</Button>
</Link>
</CardContent>
</Card>

View File

@@ -1,9 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string }> }
interface Props {
params: Promise<{ slug: string }>;
}
export default async function ClubHomePage({ params }: Props) {
const { slug } = await params;
@@ -13,36 +17,74 @@ export default async function ClubHomePage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account) notFound();
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
const { data: settings } = await supabase
.from('site_settings')
.select('*')
.eq('account_id', account.id)
.eq('is_public', true)
.maybeSingle();
if (!settings) notFound();
const { data: page } = await supabase.from('site_pages').select('*')
.eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
const { data: page } = await supabase
.from('site_pages')
.select('*')
.eq('account_id', account.id)
.eq('is_homepage', true)
.eq('is_published', true)
.maybeSingle();
if (!page) notFound();
// Pre-fetch CMS data for Puck components
const [eventsRes, coursesRes, postsRes] = await Promise.all([
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
supabase
.from('events')
.select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id)
.order('event_date', { ascending: true })
.limit(20),
supabase
.from('courses')
.select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id)
.order('start_date', { ascending: true })
.limit(20),
supabase
.from('cms_posts')
.select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(20),
]);
const siteData: SiteData = {
accountId: account.id,
events: eventsRes.data ?? [],
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
posts: postsRes.data ?? [],
};
return (
<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>} siteData={siteData} />
<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>}
siteData={siteData}
/>
</div>
);
}

View File

@@ -1,91 +1,134 @@
import { createClient } from '@supabase/supabase-js';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { Badge } from '@kit/ui/badge';
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
import Link from 'next/link';
import { createClient } from '@supabase/supabase-js';
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
}
export default async function PortalDocumentsPage({ params }: Props) {
const { slug } = await params;
const t = await getTranslations('portal');
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
process.env.SUPABASE_SERVICE_ROLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
// Demo documents (in production: query invoices + cms_files for this member)
const documents = [
{ id: '1', title: 'Mitgliedsbeitrag 2026', type: 'Rechnung', date: '2026-01-15', status: 'paid' },
{ id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' },
{ id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' },
{
id: '1',
title: 'Mitgliedsbeitrag 2026',
type: t('documents.typeInvoice'),
date: '2026-01-15',
status: 'paid',
},
{
id: '2',
title: 'Mitgliedsbeitrag 2025',
type: t('documents.typeInvoice'),
date: '2025-01-10',
status: 'paid',
},
{
id: '3',
title: 'Beitrittserklärung',
type: t('documents.typeDocument'),
date: '2020-01-15',
status: 'signed',
},
];
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid': return <Badge variant="default">Bezahlt</Badge>;
case 'open': return <Badge variant="secondary">Offen</Badge>;
case 'signed': return <Badge variant="outline">Unterschrieben</Badge>;
default: return <Badge variant="secondary">{status}</Badge>;
case 'paid':
return <Badge variant="default">{t('documents.statusPaid')}</Badge>;
case 'open':
return <Badge variant="secondary">{t('documents.statusOpen')}</Badge>;
case 'signed':
return <Badge variant="outline">{t('documents.statusSigned')}</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getIcon = (type: string) => {
switch (type) {
case 'Rechnung': return <Receipt className="h-5 w-5 text-primary" />;
case 'Dokument': return <FileCheck className="h-5 w-5 text-primary" />;
default: return <FileText className="h-5 w-5 text-primary" />;
if (type === t('documents.typeInvoice')) {
return <Receipt className="text-primary h-5 w-5" />;
}
if (type === t('documents.typeDocument')) {
return <FileCheck className="text-primary h-5 w-5" />;
}
return <FileText className="text-primary h-5 w-5" />;
};
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold">Meine Dokumente</h1>
<Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">{t('documents.title')}</h1>
</div>
<Link href={`/club/${slug}/portal`}>
<Button variant="ghost" size="sm"> Zurück zum Portal</Button>
</Link>
<Button variant="ghost" size="sm" asChild>
<Link href={`/club/${slug}/portal`}>{t('home.backToPortal')}</Link>
</Button>
</div>
</header>
<main className="max-w-3xl mx-auto py-8 px-6">
<main className="mx-auto max-w-3xl px-6 py-8">
<Card>
<CardHeader>
<CardTitle>Verfügbare Dokumente</CardTitle>
<p className="text-sm text-muted-foreground">{String(account.name)} Dokumente und Rechnungen</p>
<CardTitle>{t('documents.available')}</CardTitle>
<p className="text-muted-foreground text-sm">
{String(account.name)} {t('documents.subtitle')}
</p>
</CardHeader>
<CardContent>
{documents.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="mx-auto h-10 w-10 mb-3" />
<p>Keine Dokumente vorhanden</p>
<div className="text-muted-foreground py-8 text-center">
<FileText className="mx-auto mb-3 h-10 w-10" />
<p>{t('documents.empty')}</p>
</div>
) : (
<div className="space-y-3">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/30 transition-colors">
<div
key={doc.id}
className="hover:bg-muted/30 flex items-center justify-between rounded-lg border p-4 transition-colors"
>
<div className="flex items-center gap-3">
{getIcon(doc.type)}
<div>
<p className="font-medium text-sm">{doc.title}</p>
<p className="text-xs text-muted-foreground">{doc.type} {new Date(doc.date).toLocaleDateString('de-DE')}</p>
<p className="text-sm font-medium">{doc.title}</p>
<p className="text-muted-foreground text-xs">
{doc.type} {formatDate(doc.date)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(doc.status)}
<Button size="sm" variant="outline">
<Download className="h-3 w-3 mr-1" />
PDF
<Download className="mr-1 h-3 w-3" />
{t('documents.downloadPdf')}
</Button>
</div>
</div>

View File

@@ -1,20 +1,29 @@
import { createClient } from '@supabase/supabase-js';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createClient } from '@supabase/supabase-js';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import Link from 'next/link';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>;
}
export default async function PortalInvitePage({ params, searchParams }: Props) {
export default async function PortalInvitePage({
params,
searchParams,
}: Props) {
const { slug } = await params;
const { token } = await searchParams;
const t = await getTranslations('portal');
if (!token) notFound();
@@ -24,29 +33,33 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
);
// Resolve account
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account) notFound();
// Look up invitation
const { data: invitation } = await supabase.from('member_portal_invitations')
const { data: invitation } = await supabase
.from('member_portal_invitations')
.select('id, email, status, expires_at, member_id')
.eq('invite_token', token)
.maybeSingle();
if (!invitation || invitation.status !== 'pending') {
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="max-w-md text-center">
<CardContent className="p-8">
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
<h2 className="text-lg font-bold">Einladung ungültig</h2>
<p className="text-sm text-muted-foreground mt-2">
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
Bitte wenden Sie sich an Ihren Vereinsadministrator.
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
<h2 className="text-lg font-bold">{t('invite.invalidTitle')}</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t('invite.invalidDesc')}
</p>
<Link href={`/club/${slug}`}>
<Button variant="outline" className="mt-4"> Zur Website</Button>
</Link>
<Button variant="outline" className="mt-4" asChild>
<Link href={`/club/${slug}`}>{t('invite.backToWebsite')}</Link>
</Button>
</CardContent>
</Card>
</div>
@@ -56,14 +69,15 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
const expired = new Date(invitation.expires_at) < new Date();
if (expired) {
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="max-w-md text-center">
<CardContent className="p-8">
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" />
<h2 className="text-lg font-bold">Einladung abgelaufen</h2>
<p className="text-sm text-muted-foreground mt-2">
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
Bitte fordern Sie eine neue Einladung an.
<Shield className="mx-auto mb-4 h-10 w-10 text-amber-500" />
<h2 className="text-lg font-bold">{t('invite.expiredTitle')}</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t('invite.expiredDesc', {
date: formatDate(invitation.expires_at),
})}
</p>
</CardContent>
</Card>
@@ -72,51 +86,79 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
}
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<UserPlus className="h-6 w-6 text-primary" />
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<UserPlus className="text-primary h-6 w-6" />
</div>
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
<CardTitle>{t('invite.title')}</CardTitle>
<p className="text-muted-foreground text-sm">
{String(account.name)}
</p>
</CardHeader>
<CardContent>
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6">
<p className="text-sm">
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen.
Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten.
</p>
<div className="bg-primary/5 border-primary/20 mb-6 rounded-md border p-4">
<p className="text-sm">{t('invite.invitedDesc')}</p>
</div>
<form className="space-y-4" action={`/api/club/accept-invite`} method="POST">
<form
className="space-y-4"
action={`/api/club/accept-invite`}
method="POST"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="slug" value={slug} />
<div className="space-y-2">
<Label>E-Mail-Adresse</Label>
<Input type="email" value={invitation.email} readOnly className="bg-muted" />
<p className="text-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p>
<Label>{t('invite.emailLabel')}</Label>
<Input
type="email"
value={invitation.email}
readOnly
className="bg-muted"
/>
<p className="text-muted-foreground text-xs">
{t('invite.emailNote')}
</p>
</div>
<div className="space-y-2">
<Label>Passwort festlegen *</Label>
<Input type="password" name="password" placeholder="Mindestens 8 Zeichen" required minLength={8} />
<Label>{t('invite.passwordLabel')}</Label>
<Input
type="password"
name="password"
placeholder={t('invite.passwordPlaceholder')}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label>Passwort wiederholen *</Label>
<Input type="password" name="passwordConfirm" placeholder="Passwort bestätigen" required minLength={8} />
<Label>{t('invite.passwordConfirmLabel')}</Label>
<Input
type="password"
name="passwordConfirm"
placeholder={t('invite.passwordConfirmPlaceholder')}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full">
<CheckCircle className="mr-2 h-4 w-4" />
Konto erstellen & Einladung annehmen
{t('invite.submit')}
</Button>
</form>
<p className="mt-4 text-xs text-center text-muted-foreground">
Bereits ein Konto? <Link href={`/club/${slug}/portal`} className="text-primary underline">Anmelden</Link>
<p className="text-muted-foreground mt-4 text-center text-xs">
{t('invite.hasAccount')}{' '}
<Link
href={`/club/${slug}/portal`}
className="text-primary underline"
>
{t('invite.login')}
</Link>
</p>
</CardContent>
</Card>

View File

@@ -1,10 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
import Link from 'next/link';
import { createClient } from '@supabase/supabase-js';
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { PortalLoginForm } from '@kit/site-builder/components';
import { Button } from '@kit/ui/button';
import { Card, CardContent } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
@@ -12,21 +15,30 @@ interface Props {
export default async function MemberPortalPage({ params }: Props) {
const { slug } = await params;
const t = await getTranslations('portal');
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
// Check if user is already logged in
const { data: { user } } = await supabase.auth.getUser();
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
// Check if this user is a member of this club
const { data: member } = await supabase.from('members')
const { data: member } = await supabase
.from('members')
.select('id, first_name, last_name, status')
.eq('account_id', account.id)
.eq('user_id', user.id)
@@ -35,45 +47,59 @@ export default async function MemberPortalPage({ params }: Props) {
if (member) {
// Logged in member — show portal dashboard
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold">Mitgliederbereich {String(account.name)}</h1>
<Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">
{t('home.membersArea')} {String(account.name)}
</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Website</Button></Link>
<span className="text-muted-foreground text-sm">
{String(member.first_name)} {String(member.last_name)}
</span>
<Button variant="ghost" size="sm" asChild>
<Link href={`/club/${slug}`}>{t('home.backToWebsite')}</Link>
</Button>
</div>
</div>
</header>
<main className="max-w-4xl mx-auto py-12 px-6">
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
<main className="mx-auto max-w-4xl px-6 py-12">
<h2 className="mb-6 text-2xl font-bold">
{t('home.welcomeUser', { name: String(member.first_name) })}
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Link href={`/club/${slug}/portal/profile`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<Card className="hover:border-primary cursor-pointer transition-colors">
<CardContent className="p-6 text-center">
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Mein Profil</h3>
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
<UserCircle className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">{t('home.profile')}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t('home.profileDesc')}
</p>
</CardContent>
</Card>
</Link>
<Link href={`/club/${slug}/portal/documents`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<Card className="hover:border-primary cursor-pointer transition-colors">
<CardContent className="p-6 text-center">
<FileText className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Dokumente</h3>
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
<FileText className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">{t('home.documents')}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t('home.documentsDesc')}
</p>
</CardContent>
</Card>
</Link>
<Card>
<CardContent className="p-6 text-center">
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Mitgliedsausweis</h3>
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
<CreditCard className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">{t('home.memberCard')}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t('home.memberCardDesc')}
</p>
</CardContent>
</Card>
</div>
@@ -85,14 +111,16 @@ export default async function MemberPortalPage({ params }: Props) {
// Not logged in or not a member — show login form
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<h1 className="text-lg font-bold">Mitgliederbereich</h1>
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Zurück zur Website</Button></Link>
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<h1 className="text-lg font-bold">{t('home.membersArea')}</h1>
<Button variant="ghost" size="sm" asChild>
<Link href={`/club/${slug}`}>{t('home.backToWebsiteFull')}</Link>
</Button>
</div>
</header>
<main className="max-w-4xl mx-auto py-12 px-6">
<main className="mx-auto max-w-4xl px-6 py-12">
<PortalLoginForm slug={slug} accountName={String(account.name)} />
</main>
</div>

View File

@@ -0,0 +1,246 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { Provider, UserIdentity } from '@supabase/supabase-js';
import { createClient } from '@supabase/supabase-js';
import { Link2Off, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { toast } from '@kit/ui/sonner';
const PROVIDERS: Provider[] = ['google', 'apple', 'azure', 'github'];
const PROVIDER_LABELS: Record<string, string> = {
google: 'Google',
apple: 'Apple',
azure: 'Microsoft',
github: 'GitHub',
};
function getSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
}
export function PortalLinkedAccounts({ slug }: { slug: string }) {
const t = useTranslations('portal');
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const loadIdentities = useCallback(async () => {
const supabase = getSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user?.identities) {
setIdentities(user.identities);
}
setLoading(false);
}, []);
useEffect(() => {
void loadIdentities();
}, [loadIdentities]);
const handleLink = async (provider: Provider) => {
setActionLoading(provider);
try {
const supabase = getSupabaseClient();
const redirectTo = `${window.location.origin}/club/${slug}/portal/profile`;
const { error } = await supabase.auth.linkIdentity({
provider,
options: { redirectTo },
});
if (error) {
toast.error(`Verknüpfung fehlgeschlagen: ${error.message}`);
setActionLoading(null);
}
} catch {
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
setActionLoading(null);
}
};
const handleUnlink = async (identity: UserIdentity) => {
if (identities.length <= 1) {
toast.error('Sie benötigen mindestens eine Anmeldemethode.');
return;
}
setActionLoading(identity.id);
try {
const supabase = getSupabaseClient();
const { error } = await supabase.auth.unlinkIdentity(identity);
if (error) {
toast.error(`Trennung fehlgeschlagen: ${error.message}`);
} else {
toast.success(
`${PROVIDER_LABELS[identity.provider] ?? identity.provider} wurde getrennt.`,
);
await loadIdentities();
}
} catch {
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
);
}
const connectedProviders = identities
.filter((i) => i.provider !== 'email')
.map((i) => i.provider);
const availableProviders = PROVIDERS.filter(
(p) => !connectedProviders.includes(p),
);
return (
<div className="space-y-4">
{/* Connected accounts */}
{identities.filter((i) => i.provider !== 'email').length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Verknüpfte Konten
</p>
{identities
.filter((i) => i.provider !== 'email')
.map((identity) => (
<div
key={identity.id}
className="bg-muted/50 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center">
<OauthProviderLogoImage providerId={identity.provider} />
</div>
<div>
<p className="text-sm font-medium capitalize">
{PROVIDER_LABELS[identity.provider] ?? identity.provider}
</p>
{identity.identity_data?.email && (
<p className="text-muted-foreground text-xs">
{identity.identity_data.email as string}
</p>
)}
</div>
</div>
{identities.length > 1 && (
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
disabled={actionLoading === identity.id}
>
{actionLoading === identity.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2Off className="h-4 w-4" />
)}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('linkedAccounts.title')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('linkedAccounts.disconnectDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t('linkedAccounts.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlink(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('linkedAccounts.disconnect')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
))}
</div>
)}
{/* Available providers to link */}
{availableProviders.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
{t('linkedAccounts.connect')}
</p>
<div className="flex flex-wrap gap-2">
{availableProviders.map((provider) => (
<Button
key={provider}
variant="outline"
size="sm"
className="gap-2"
disabled={actionLoading === provider}
onClick={() => handleLink(provider)}
>
{actionLoading === provider ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<OauthProviderLogoImage providerId={provider} />
)}
{PROVIDER_LABELS[provider] ?? provider}
</Button>
))}
</div>
</div>
)}
{/* Info text when email-only */}
{identities.length <= 1 && availableProviders.length > 0 && (
<p className="text-muted-foreground text-xs">
Verknüpfen Sie ein Konto, um sich zukünftig schneller und ohne
Passwort anmelden zu können.
</p>
)}
</div>
);
}

View File

@@ -1,11 +1,18 @@
import { createClient } from '@supabase/supabase-js';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createClient } from '@supabase/supabase-js';
import { UserCircle, Mail, MapPin, Shield, Link2 } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
import Link from 'next/link';
import { PortalLinkedAccounts } from './_components/portal-linked-accounts';
interface Props {
params: Promise<{ slug: string }>;
@@ -13,21 +20,30 @@ interface Props {
export default async function PortalProfilePage({ params }: Props) {
const { slug } = await params;
const t = await getTranslations('portal');
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
// Get current user
const { data: { user } } = await supabase.auth.getUser();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect(`/club/${slug}/portal`);
// Find member linked to this user
const { data: member } = await supabase.from('members')
const { data: member } = await supabase
.from('members')
.select('*')
.eq('account_id', account.id)
.eq('user_id', user.id)
@@ -35,18 +51,17 @@ export default async function PortalProfilePage({ params }: Props) {
if (!member) {
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
<div className="bg-muted/30 flex min-h-screen items-center justify-center">
<Card className="max-w-md">
<CardContent className="p-8 text-center">
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
<h2 className="text-lg font-bold">Kein Mitglied</h2>
<p className="text-sm text-muted-foreground mt-2">
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
Bitte wenden Sie sich an Ihren Vereinsadministrator.
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
<h2 className="text-lg font-bold">{t('profile.noMemberTitle')}</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t('profile.noMemberDesc')}
</p>
<Link href={`/club/${slug}/portal`}>
<Button variant="outline" className="mt-4"> Zurück</Button>
</Link>
<Button variant="outline" className="mt-4" asChild>
<Link href={`/club/${slug}/portal`}>{t('profile.back')}</Link>
</Button>
</CardContent>
</Card>
</div>
@@ -56,28 +71,35 @@ export default async function PortalProfilePage({ params }: Props) {
const m = member;
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold">Mein Profil</h1>
<Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">{t('profile.title')}</h1>
</div>
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm"> Zurück zum Portal</Button></Link>
<Button variant="ghost" size="sm" asChild>
<Link href={`/club/${slug}/portal`}>{t('home.backToPortal')}</Link>
</Button>
</div>
</header>
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6">
<main className="mx-auto max-w-3xl space-y-6 px-6 py-8">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
<div className="bg-primary/10 text-primary flex h-16 w-16 items-center justify-center rounded-full">
<UserCircle className="h-8 w-8" />
</div>
<div>
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
<p className="text-sm text-muted-foreground">
Nr. {String(m.member_number ?? '—')} Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
<h2 className="text-xl font-bold">
{String(m.first_name)} {String(m.last_name)}
</h2>
<p className="text-muted-foreground text-sm">
{t('profile.memberSince', {
number: String(m.member_number ?? '—'),
date: formatDate(m.entry_date),
})}
</p>
</div>
</div>
@@ -85,37 +107,111 @@ export default async function PortalProfilePage({ params }: Props) {
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
{t('profile.contactData')}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label>Vorname</Label><Input defaultValue={String(m.first_name)} readOnly /></div>
<div className="space-y-2"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div>
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div>
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div>
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div>
<div className="space-y-2">
<Label>{t('profile.firstName')}</Label>
<Input defaultValue={String(m.first_name)} readOnly />
</div>
<div className="space-y-2">
<Label>{t('profile.lastName')}</Label>
<Input defaultValue={String(m.last_name)} readOnly />
</div>
<div className="space-y-2">
<Label>{t('profile.email')}</Label>
<Input defaultValue={String(m.email ?? '')} />
</div>
<div className="space-y-2">
<Label>{t('profile.phone')}</Label>
<Input defaultValue={String(m.phone ?? '')} />
</div>
<div className="space-y-2">
<Label>{t('profile.mobile')}</Label>
<Input defaultValue={String(m.mobile ?? '')} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
{t('profile.address')}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label>Straße</Label><Input defaultValue={String(m.street ?? '')} /></div>
<div className="space-y-2"><Label>Hausnummer</Label><Input defaultValue={String(m.house_number ?? '')} /></div>
<div className="space-y-2"><Label>PLZ</Label><Input defaultValue={String(m.postal_code ?? '')} /></div>
<div className="space-y-2"><Label>Ort</Label><Input defaultValue={String(m.city ?? '')} /></div>
<div className="space-y-2">
<Label>{t('profile.street')}</Label>
<Input defaultValue={String(m.street ?? '')} />
</div>
<div className="space-y-2">
<Label>{t('profile.houseNumber')}</Label>
<Input defaultValue={String(m.house_number ?? '')} />
</div>
<div className="space-y-2">
<Label>{t('profile.postalCode')}</Label>
<Input defaultValue={String(m.postal_code ?? '')} />
</div>
<div className="space-y-2">
<Label>{t('profile.city')}</Label>
<Input defaultValue={String(m.city ?? '')} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
{t('profile.loginMethods')}
</CardTitle>
</CardHeader>
<CardContent>
<PortalLinkedAccounts slug={slug} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t('profile.privacy')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{[
{ key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter },
{ key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet },
{ key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print },
{ key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info },
{
key: 'gdpr_newsletter',
label: t('profile.gdprNewsletter'),
value: m.gdpr_newsletter,
},
{
key: 'gdpr_internet',
label: t('profile.gdprInternet'),
value: m.gdpr_internet,
},
{
key: 'gdpr_print',
label: t('profile.gdprPrint'),
value: m.gdpr_print,
},
{
key: 'gdpr_birthday_info',
label: t('profile.gdprBirthday'),
value: m.gdpr_birthday_info,
},
].map(({ key, label, value }) => (
<label key={key} className="flex items-center gap-3 text-sm">
<input type="checkbox" defaultChecked={Boolean(value)} className="h-4 w-4 rounded border-input" />
<input
type="checkbox"
defaultChecked={Boolean(value)}
className="border-input h-4 w-4 rounded"
/>
{label}
</label>
))}
@@ -123,7 +219,7 @@ export default async function PortalProfilePage({ params }: Props) {
</Card>
<div className="flex justify-end">
<Button>Änderungen speichern</Button>
<Button>{t('profile.saveChanges')}</Button>
</div>
</main>
</div>

View File

@@ -22,13 +22,15 @@ export function HomeAccountSelector(props: {
}>;
userId: string;
collapsed?: boolean;
}) {
const router = useRouter();
const context = useContext(SidebarContext);
const collapsed = props.collapsed ?? !context?.open;
return (
<AccountSelector
collapsed={!context?.open}
collapsed={collapsed}
accounts={props.accounts}
features={features}
userId={props.userId}

View File

@@ -39,7 +39,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
return (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<div>
<AppLogo />
</div>
<BorderedNavigationMenu>
{routes.map((route) => (
@@ -54,7 +56,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
</If>
<If condition={featuresFlagConfig.enableTeamAccounts}>
<HomeAccountSelector userId={user.id} accounts={accounts} />
<div>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</div>
</If>
<div>

View File

@@ -1,15 +1,12 @@
'use client';
import Link from 'next/link';
import { LogOut, Menu } from 'lucide-react';
import { Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -17,6 +14,10 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
@@ -27,25 +28,6 @@ import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
const signOut = useSignOut();
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -60,6 +42,7 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
</DropdownMenuLabel>
<HomeAccountSelector
collapsed={false}
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
/>
@@ -68,57 +51,16 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<DropdownMenuGroup>
<MobileNavRouteLinks
routes={personalAccountNavigationConfig.routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
<MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
key={props.path}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -1,12 +1,26 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page';
export function HomeLayoutPageHeader(
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
export async function HomeLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
}>,
) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const displaySidebarTrigger =
(layoutStyleCookie ?? personalAccountNavigationConfig.style) === 'sidebar';
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
<PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
);
}

View File

@@ -1,4 +1,6 @@
import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
@@ -81,9 +83,12 @@ class UserBillingService {
`User requested a personal account checkout session. Contacting provider...`,
);
let checkoutToken: string | null | undefined;
let url: string | null | undefined;
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
const checkout = await service.createCheckoutSession({
returnUrl,
accountId,
customerEmail: user.email,
@@ -93,32 +98,55 @@ class UserBillingService {
enableDiscountField: product.enableDiscountField,
});
logger.info(
{
userId: user.id,
},
`Checkout session created. Returning checkout token to client...`,
);
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
checkoutToken = checkout.checkoutToken;
url = checkout.url;
} catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error,
error: message,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
if (!url && !checkoutToken) {
throw new Error(
'Checkout session returned neither a URL nor a checkout token',
);
}
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
{
userId: user.id,
},
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
logger.info(
{
userId: user.id,
},
`Checkout session created. Returning checkout token to client...`,
);
return {
checkoutToken,
};
}
/**

View File

@@ -52,7 +52,7 @@ async function SidebarLayout({ children }: React.PropsWithChildren) {
<HomeSidebar workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<PageMobileNavigation>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
@@ -75,7 +75,7 @@ async function HeaderLayout({ children }: React.PropsWithChildren) {
<HomeMenuNavigation workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<PageMobileNavigation>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
@@ -92,7 +92,9 @@ function MobileNavigation({
}) {
return (
<>
<AppLogo />
<div>
<AppLogo />
</div>
<HomeMobileNavigation workspace={workspace} />
</>

View File

@@ -1,9 +1,11 @@
'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSidebar } from '@kit/ui/sidebar';
import { SidebarContext } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
@@ -23,7 +25,7 @@ export function TeamAccountAccountsSelector(params: {
}>;
}) {
const router = useRouter();
const ctx = useSidebar();
const ctx = useContext(SidebarContext);
return (
<AccountSelector

View File

@@ -1,32 +1,28 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react';
import * as z from 'zod';
import { Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{
label: string | null;
@@ -35,7 +31,6 @@ type Accounts = Array<{
}>;
const features = {
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
@@ -44,131 +39,23 @@ export const TeamAccountLayoutMobileNavigation = (
account: string;
userId: string;
accounts: Accounts;
config: z.output<typeof NavigationConfigSchema>;
}>,
) => {
const router = useRouter();
const signOut = useSignOut();
const Links = props.config.routes.map(
(item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
},
);
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<TeamAccountsModal
userId={props.userId}
accounts={props.accounts}
account={props.account}
/>
{Links}
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common.signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;
userId: string;
account: string;
}) {
const router = useRouter();
return (
<Dialog>
<DialogTrigger
render={
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onSelect={(e) => e.preventDefault()}
>
<Home className={'h-4'} />
<span>
<Trans i18nKey={'common.yourAccounts'} />
</span>
</DropdownMenuItem>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<DropdownMenuContent className={'w-screen rounded-none'}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common.yourAccounts'} />
</DialogTitle>
</DialogHeader>
</DropdownMenuLabel>
<div className={'py-6'}>
<AccountSelector
className={'w-full max-w-full'}
userId={props.userId}
@@ -187,8 +74,20 @@ function TeamAccountsModal(props: {
router.replace(path);
}}
/>
</div>
</DialogContent>
</Dialog>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<MobileNavRouteLinks
routes={getTeamAccountSidebarConfig(props.account).routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}
};

View File

@@ -1,13 +1,28 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page';
export function TeamAccountLayoutPageHeader(
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
export async function TeamAccountLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
account: string;
}>,
) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const defaultStyle = getTeamAccountSidebarConfig(props.account).style;
const displaySidebarTrigger =
(layoutStyleCookie ?? defaultStyle) === 'sidebar';
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
<PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
);
}

View File

@@ -44,7 +44,9 @@ export function TeamAccountNavigationMenu(props: {
return (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<div>
<AppLogo />
</div>
<BorderedNavigationMenu>
{routes.map((route) => (
@@ -53,20 +55,22 @@ export function TeamAccountNavigationMenu(props: {
</BorderedNavigationMenu>
</div>
<div className={'flex items-center justify-end space-x-2.5'}>
<div className={'flex items-center justify-end space-x-1'}>
<If condition={featureFlagsConfig.enableNotifications}>
<TeamAccountNotifications accountId={account.id} userId={user.id} />
</If>
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
<div>
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
</div>
<div>
<ProfileAccountDropdownContainer

View File

@@ -1,4 +1,6 @@
import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
@@ -106,9 +108,12 @@ class TeamBillingService {
`Creating checkout session...`,
);
let checkoutToken: string | null = null;
let url: string | null | undefined;
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
const checkout = await service.createCheckoutSession({
accountId,
plan,
returnUrl,
@@ -118,22 +123,37 @@ class TeamBillingService {
enableDiscountField: product.enableDiscountField,
});
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
checkoutToken = checkout.checkoutToken;
url = checkout.url;
} catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error(
{
...ctx,
error,
error: message,
},
`Error creating the checkout session`,
);
throw new Error(`Checkout not created`, { cause: error });
}
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
ctx,
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
}
/**

View File

@@ -9,7 +9,9 @@ import {
XCircle,
User,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -21,37 +23,21 @@ import {
CardTitle,
} from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import {
BOOKING_STATUS_VARIANT as STATUS_BADGE_VARIANT,
BOOKING_STATUS_LABEL_KEYS as STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string; bookingId: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
confirmed: 'default',
checked_in: 'info',
checked_out: 'outline',
cancelled: 'destructive',
no_show: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
checked_in: 'Eingecheckt',
checked_out: 'Ausgecheckt',
cancelled: 'Storniert',
no_show: 'Nicht erschienen',
};
export default async function BookingDetailPage({ params }: PageProps) {
const { account, bookingId } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('bookings');
const { data: acct } = await client
.from('accounts')
@@ -61,7 +47,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
if (!acct) {
return (
<CmsPageShell account={account} title="Buchungsdetails">
<CmsPageShell account={account} title={t('detail.title')}>
<AccountNotFound />
</CmsPageShell>
);
@@ -77,17 +63,17 @@ export default async function BookingDetailPage({ params }: PageProps) {
if (!booking) {
return (
<CmsPageShell account={account} title="Buchung nicht gefunden">
<CmsPageShell account={account} title={t('detail.notFound')}>
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-muted-foreground">
Buchung mit ID &quot;{bookingId}&quot; wurde nicht gefunden.
{t('detail.notFoundDesc', { id: bookingId })}
</p>
<Link href={`/home/${account}/bookings`}>
<Button variant="outline">
<Button variant="outline" asChild>
<Link href={`/home/${account}/bookings`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück zu Buchungen
</Button>
</Link>
{t('detail.backToBookings')}
</Link>
</Button>
</div>
</CmsPageShell>
);
@@ -108,44 +94,47 @@ export default async function BookingDetailPage({ params }: PageProps) {
const status = String(booking.status ?? 'pending');
return (
<CmsPageShell account={account} title="Buchungsdetails">
<CmsPageShell account={account} title={t('detail.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('detail.backToBookings')}
>
<Link href={`/home/${account}/bookings`}>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<div>
<div className="flex items-center gap-3">
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
{t(STATUS_LABEL_KEYS[status] ?? status)}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
ID: {bookingId}
</p>
<p className="text-muted-foreground text-sm">ID: {bookingId}</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Zimmer */}
{/* Room */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BedDouble className="h-5 w-5" />
Zimmer
{t('detail.room')}
</CardTitle>
</CardHeader>
<CardContent>
{room ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Zimmernummer
<span className="text-muted-foreground text-sm">
{t('detail.roomNumber')}
</span>
<span className="font-medium">
{String(room.room_number)}
@@ -153,14 +142,16 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div>
{room.name && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Name
<span className="text-muted-foreground text-sm">
{t('rooms.name')}
</span>
<span className="font-medium">{String(room.name)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Typ</span>
<span className="text-muted-foreground text-sm">
{t('detail.type')}
</span>
<span className="font-medium">
{String(room.room_type ?? '—')}
</span>
@@ -168,145 +159,119 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div>
) : (
<p className="text-muted-foreground text-sm">
Kein Zimmer zugewiesen
{t('detail.noRoom')}
</p>
)}
</CardContent>
</Card>
{/* Gast */}
{/* Guest */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Gast
{t('detail.guest')}
</CardTitle>
</CardHeader>
<CardContent>
{guest ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="text-muted-foreground text-sm">
{t('guests.name')}
</span>
<span className="font-medium">
{String(guest.first_name)} {String(guest.last_name)}
</span>
</div>
{guest.email && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
E-Mail
</span>
<span className="font-medium">
{String(guest.email)}
<span className="text-muted-foreground text-sm">
{t('detail.email')}
</span>
<span className="font-medium">{String(guest.email)}</span>
</div>
)}
{guest.phone && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Telefon
</span>
<span className="font-medium">
{String(guest.phone)}
<span className="text-muted-foreground text-sm">
{t('detail.phone')}
</span>
<span className="font-medium">{String(guest.phone)}</span>
</div>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
Kein Gast zugewiesen
{t('detail.noGuest')}
</p>
)}
</CardContent>
</Card>
{/* Aufenthalt */}
{/* Stay */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-5 w-5" />
Aufenthalt
{t('detail.stay')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Check-in
<span className="text-muted-foreground text-sm">
{t('list.checkIn')}
</span>
<span className="font-medium">
{booking.check_in
? new Date(String(booking.check_in)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
{formatDate(booking.check_in)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Check-out
<span className="text-muted-foreground text-sm">
{t('list.checkOut')}
</span>
<span className="font-medium">
{booking.check_out
? new Date(String(booking.check_out)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
{formatDate(booking.check_out)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Erwachsene
</span>
<span className="font-medium">
{booking.adults ?? '—'}
<span className="text-muted-foreground text-sm">
{t('detail.adults')}
</span>
<span className="font-medium">{booking.adults ?? '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Kinder
</span>
<span className="font-medium">
{booking.children ?? 0}
<span className="text-muted-foreground text-sm">
{t('detail.children')}
</span>
<span className="font-medium">{booking.children ?? 0}</span>
</div>
</div>
</CardContent>
</Card>
{/* Betrag */}
{/* Amount */}
<Card>
<CardHeader>
<CardTitle>Betrag</CardTitle>
<CardTitle>{t('detail.amount')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Gesamtpreis
<span className="text-muted-foreground text-sm">
{t('detail.totalPrice')}
</span>
<span className="text-2xl font-bold">
{booking.total_price != null
? `${Number(booking.total_price).toFixed(2)}`
? formatCurrencyAmount(booking.total_price as number)
: '—'}
</span>
</div>
{booking.notes && (
<div className="border-t pt-2">
<span className="text-sm text-muted-foreground">
Notizen
<span className="text-muted-foreground text-sm">
{t('detail.notes')}
</span>
<p className="mt-1 text-sm">{String(booking.notes)}</p>
</div>
@@ -319,24 +284,22 @@ export default async function BookingDetailPage({ params }: PageProps) {
{/* Status Workflow */}
<Card>
<CardHeader>
<CardTitle>Aktionen</CardTitle>
<CardDescription>
Status der Buchung ändern
</CardDescription>
<CardTitle>{t('detail.actions')}</CardTitle>
<CardDescription>{t('detail.changeStatus')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{(status === 'pending' || status === 'confirmed') && (
<Button variant="default">
<LogIn className="mr-2 h-4 w-4" />
Einchecken
{t('detail.checkIn')}
</Button>
)}
{status === 'checked_in' && (
<Button variant="default">
<LogOut className="mr-2 h-4 w-4" />
Auschecken
{t('detail.checkOut')}
</Button>
)}
@@ -345,15 +308,18 @@ export default async function BookingDetailPage({ params }: PageProps) {
status !== 'no_show' && (
<Button variant="destructive">
<XCircle className="mr-2 h-4 w-4" />
Stornieren
{t('detail.cancel')}
</Button>
)}
{status === 'cancelled' || status === 'checked_out' ? (
<p className="text-sm text-muted-foreground py-2">
Diese Buchung ist{' '}
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} keine
weiteren Aktionen verfügbar.
<p className="text-muted-foreground py-2 text-sm">
{t('detail.noMoreActions', {
statusLabel:
status === 'cancelled'
? t('detail.cancelledStatus')
: t('detail.completedStatus'),
})}
</p>
) : null}
</div>

View File

@@ -1,16 +1,16 @@
import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
@@ -43,12 +43,17 @@ function getFirstWeekday(year: number, month: number): number {
return day === 0 ? 6 : day - 1;
}
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
function isDateInRange(
date: string,
checkIn: string,
checkOut: string,
): boolean {
return date >= checkIn && date < checkOut;
}
export default async function BookingCalendarPage({ params }: PageProps) {
const { account } = await params;
const t = await getTranslations('bookings');
const client = getSupabaseServerClient();
const { data: acct } = await client
@@ -59,7 +64,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
if (!acct) {
return (
<CmsPageShell account={account} title="Belegungskalender">
<CmsPageShell account={account} title={t('calendar.title')}>
<AccountNotFound />
</CmsPageShell>
);
@@ -77,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const bookings = await api.listBookings(acct.id, {
const bookings = await api.bookings.list(acct.id, {
from: monthStart,
to: monthEnd,
page: 1,
@@ -101,7 +106,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
}
// Build calendar grid cells
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
const cells: Array<{
day: number | null;
occupied: boolean;
isToday: boolean;
}> = [];
// Empty cells before first day
for (let i = 0; i < firstWeekday; i++) {
@@ -125,19 +134,22 @@ export default async function BookingCalendarPage({ params }: PageProps) {
}
return (
<CmsPageShell account={account} title="Belegungskalender">
<CmsPageShell account={account} title={t('calendar.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<p className="text-muted-foreground">
Zimmerauslastung im Überblick
</p>
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.backToBookings')}
>
<Link href={`/home/${account}/bookings`}>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<p className="text-muted-foreground">{t('calendar.subtitle')}</p>
</div>
</div>
@@ -145,24 +157,34 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
disabled
aria-label={t('calendar.previousMonth')}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
</CardTitle>
<Button variant="ghost" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
disabled
aria-label={t('calendar.nextMonth')}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1">
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
className="text-muted-foreground py-2 text-center text-xs font-medium"
>
{day}
</div>
@@ -180,13 +202,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
: cell.occupied
? 'bg-primary/15 text-primary font-semibold'
: 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
>
{cell.day !== null && (
<>
<span>{cell.day}</span>
{cell.occupied && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
<span className="bg-primary absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full" />
)}
</>
)}
@@ -195,18 +217,18 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
Belegt
<span className="bg-primary/15 inline-block h-3 w-3 rounded-sm" />
{t('calendar.occupied')}
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
{t('calendar.free')}
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
{t('calendar.today')}
</div>
</div>
</CardContent>
@@ -217,13 +239,16 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Buchungen in diesem Monat
<p className="text-muted-foreground text-sm">
{t('calendar.bookingsThisMonth')}
</p>
<p className="text-2xl font-bold">{bookings.data.length}</p>
</div>
<Badge variant="outline">
{occupiedDates.size} von {daysInMonth} Tagen belegt
{t('calendar.daysOccupied', {
occupied: occupiedDates.size,
total: daysInMonth,
})}
</Badge>
</div>
</CardContent>

View File

@@ -0,0 +1,84 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { XCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
interface CancelBookingButtonProps {
bookingId: string;
accountId: string;
}
export function CancelBookingButton({
bookingId,
accountId,
}: CancelBookingButtonProps) {
const router = useRouter();
const t = useTranslations('bookings');
const [isPending, startTransition] = useTransition();
const handleCancel = () => {
startTransition(async () => {
try {
const response = await fetch(`/api/bookings/${bookingId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId }),
});
if (response.ok) {
router.refresh();
}
} catch (error) {
console.error('Failed to cancel booking:', error);
}
});
};
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button variant="destructive" disabled={isPending}>
<XCircle className="mr-2 h-4 w-4" aria-hidden="true" />
{t('cancel.confirm')}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('cancel.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cancel.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancel}
variant="destructive"
disabled={isPending}
>
{isPending ? t('cancel.cancelling') : t('cancel.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,14 +1,14 @@
import { UserCircle, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,6 +17,7 @@ interface PageProps {
export default async function GuestsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('bookings');
const { data: acct } = await client
.from('accounts')
@@ -26,55 +27,71 @@ export default async function GuestsPage({ params }: PageProps) {
if (!acct) {
return (
<CmsPageShell account={account} title="Gäste">
<CmsPageShell account={account} title={t('guests.title')}>
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client);
const guests = await api.listGuests(acct.id);
const guests = await api.guests.list(acct.id);
return (
<CmsPageShell account={account} title="Gäste">
<CmsPageShell account={account} title={t('guests.title')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Gästeverwaltung</p>
<Button>
<p className="text-muted-foreground">{t('guests.manage')}</p>
<Button data-test="guests-new-btn">
<Plus className="mr-2 h-4 w-4" />
Neuer Gast
{t('guests.newGuest')}
</Button>
</div>
{guests.length === 0 ? (
<EmptyState
icon={<UserCircle className="h-8 w-8" />}
title="Keine Gäste vorhanden"
description="Legen Sie Ihren ersten Gast an."
actionLabel="Neuer Gast"
title={t('guests.noGuests')}
description={t('guests.addFirst')}
actionLabel={t('guests.newGuest')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Gäste ({guests.length})</CardTitle>
<CardTitle>
{t('guests.allGuests', { count: guests.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Stadt</th>
<th className="p-3 text-left font-medium">Land</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('guests.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('guests.email')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('guests.phone')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('guests.city')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('guests.country')}
</th>
</tr>
</thead>
<tbody>
{guests.map((guest: Record<string, unknown>) => (
<tr key={String(guest.id)} className="border-b hover:bg-muted/30">
<tr
key={String(guest.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(guest.last_name ?? '')}, {String(guest.first_name ?? '')}
{String(guest.last_name ?? '')},{' '}
{String(guest.first_name ?? '')}
</td>
<td className="p-3">{String(guest.email ?? '—')}</td>
<td className="p-3">{String(guest.phone ?? '—')}</td>

View File

@@ -1,34 +1,51 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateBookingForm } from '@kit/booking-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
interface Props { params: Promise<{ account: string }> }
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewBookingPage({ params }: Props) {
const { account } = await params;
const t = await getTranslations('bookings');
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 (
<CmsPageShell account={account} title="Neue Buchung">
<CmsPageShell account={account} title={t('nav.newBooking')}>
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
return (
<CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen">
<CreateBookingForm
accountId={acct.id}
account={account}
<CmsPageShell
account={account}
title={t('newBooking.title')}
description={t('newBooking.description')}
>
<CreateBookingForm
accountId={acct.id}
account={account}
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0)
}))}
id: String(r.id),
roomNumber: String(r.room_number),
name: String(r.name ?? ''),
pricePerNight: Number(r.price_per_night ?? 0),
}))}
/>
</CmsPageShell>
);

View File

@@ -1,19 +1,24 @@
import Link from 'next/link';
import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import {
BOOKING_STATUS_VARIANT as STATUS_BADGE_VARIANT,
BOOKING_STATUS_LABEL_KEYS as STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string }>;
@@ -22,30 +27,14 @@ interface PageProps {
const PAGE_SIZE = 25;
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
confirmed: 'default',
checked_in: 'info',
checked_out: 'outline',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
checked_in: 'Eingecheckt',
checked_out: 'Ausgecheckt',
cancelled: 'Storniert',
no_show: 'Nicht erschienen',
};
export default async function BookingsPage({ params, searchParams }: PageProps) {
export default async function BookingsPage({
params,
searchParams,
}: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('bookings');
const { data: acct } = await client
.from('accounts')
@@ -55,7 +44,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
if (!acct) {
return (
<CmsPageShell account={account} title="Buchungen">
<CmsPageShell account={account} title={t('list.title')}>
<AccountNotFound />
</CmsPageShell>
);
@@ -65,7 +54,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
const page = Number(search.page) || 1;
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
const bookingsQuery = client
@@ -80,16 +69,15 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
const { data: bookingsRaw, count: bookingsTotal } = await bookingsQuery;
/* eslint-disable @typescript-eslint/no-explicit-any */
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, any>>;
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, unknown>>;
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;
bookingsData = bookingsData.filter((booking) => {
const room = booking.room as Record<string, string> | null;
const guest = booking.guest as Record<string, string> | null;
const roomName = (room?.name ?? '').toLowerCase();
const roomNumber = (room?.room_number ?? '').toLowerCase();
const guestFirst = (guest?.first_name ?? '').toLowerCase();
@@ -104,42 +92,41 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
}
const activeBookings = bookingsData.filter(
(b) => b.status === 'confirmed' || b.status === 'checked_in',
(booking) =>
booking.status === 'confirmed' || booking.status === 'checked_in',
);
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<CmsPageShell account={account} title="Buchungen">
<CmsPageShell account={account} title={t('list.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Zimmer und Buchungen verwalten
</p>
<p className="text-muted-foreground">{t('list.manage')}</p>
<Link href={`/home/${account}/bookings/new`}>
<Button>
<Button data-test="bookings-new-btn" asChild>
<Link href={`/home/${account}/bookings/new`}>
<Plus className="mr-2 h-4 w-4" />
Neue Buchung
</Button>
</Link>
{t('list.newBooking')}
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatsCard
title="Zimmer"
title={t('rooms.title')}
value={rooms.length}
icon={<BedDouble className="h-5 w-5" />}
/>
<StatsCard
title="Aktive Buchungen"
title={t('list.activeBookings')}
value={activeBookings.length}
icon={<CalendarCheck className="h-5 w-5" />}
/>
<StatsCard
title="Gesamt"
title={t('list.total')}
value={total}
icon={<Euro className="h-5 w-5" />}
/>
@@ -148,23 +135,25 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
{/* 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" />
<Search
className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
name="q"
defaultValue={searchQuery}
placeholder="Gast oder Zimmer suchen…"
placeholder={t('list.searchPlaceholder')}
aria-label={t('list.searchPlaceholder')}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary" size="sm">
Suchen
{t('list.search')}
</Button>
{searchQuery && (
<Link href={`/home/${account}/bookings`}>
<Button type="button" variant="ghost" size="sm">
Zurücksetzen
</Button>
</Link>
<Button type="button" variant="ghost" size="sm" asChild>
<Link href={`/home/${account}/bookings`}>{t('list.reset')}</Link>
</Button>
)}
</form>
@@ -172,17 +161,13 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
{bookingsData.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
title={
searchQuery
? 'Keine Buchungen gefunden'
: 'Keine Buchungen vorhanden'
}
title={searchQuery ? t('list.noResults') : t('list.noBookings')}
description={
searchQuery
? `Keine Ergebnisse für „${searchQuery}".`
: 'Erstellen Sie Ihre erste Buchung, um loszulegen.'
? t('list.noResultsFor', { query: searchQuery })
: t('list.createFirst')
}
actionLabel={searchQuery ? undefined : 'Neue Buchung'}
actionLabel={searchQuery ? undefined : t('list.newBooking')}
actionHref={
searchQuery ? undefined : `/home/${account}/bookings/new`
}
@@ -192,32 +177,50 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
<CardHeader>
<CardTitle>
{searchQuery
? `Ergebnisse (${bookingsData.length})`
: `Alle Buchungen (${total})`}
? t('list.searchResults', { count: bookingsData.length })
: t('list.allBookings', { count: total })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmer</th>
<th className="p-3 text-left font-medium">Gast</th>
<th className="p-3 text-left font-medium">Anreise</th>
<th className="p-3 text-left font-medium">Abreise</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Betrag</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('list.room')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.guest')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.checkIn')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.checkOut')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.status')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('list.amount')}
</th>
</tr>
</thead>
<tbody>
{bookingsData.map((booking) => {
const room = booking.room as Record<string, string> | null;
const guest = booking.guest as Record<string, string> | null;
const room = booking.room as Record<
string,
string
> | null;
const guest = booking.guest as Record<
string,
string
> | null;
return (
<tr
key={String(booking.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
<Link
@@ -235,18 +238,10 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
: '—'}
</td>
<td className="p-3">
{booking.check_in
? new Date(
String(booking.check_in),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(booking.check_in as string)}
</td>
<td className="p-3">
{booking.check_out
? new Date(
String(booking.check_out),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(booking.check_out as string)}
</td>
<td className="p-3">
<Badge
@@ -255,13 +250,17 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
'secondary'
}
>
{STATUS_LABEL[String(booking.status)] ??
String(booking.status)}
{t(
STATUS_LABEL_KEYS[String(booking.status)] ??
String(booking.status),
)}
</Badge>
</td>
<td className="p-3 text-right">
{booking.total_price != null
? `${Number(booking.total_price).toFixed(2)}`
? formatCurrencyAmount(
booking.total_price as number,
)
: '—'}
</td>
</tr>
@@ -274,35 +273,36 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
{/* 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 className="text-muted-foreground text-sm">
{t('common.page')} {page} {t('common.of')} {totalPages} (
{total} {t('common.entries')})
</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" asChild>
<Link
href={`/home/${account}/bookings?page=${page - 1}`}
>
{t('common.previous')}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
Zurück
{t('common.previous')}
</Button>
)}
{page < totalPages ? (
<Link
href={`/home/${account}/bookings?page=${page + 1}`}
>
<Button variant="outline" size="sm">
Weiter
</Button>
</Link>
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/bookings?page=${page + 1}`}
>
{t('common.next')}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
Weiter
{t('common.next')}
</Button>
)}
</div>

View File

@@ -1,15 +1,16 @@
import { BedDouble, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -18,6 +19,7 @@ interface PageProps {
export default async function RoomsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('bookings');
const { data: acct } = await client
.from('accounts')
@@ -27,65 +29,90 @@ export default async function RoomsPage({ params }: PageProps) {
if (!acct) {
return (
<CmsPageShell account={account} title="Zimmer">
<CmsPageShell account={account} title={t('rooms.title')}>
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
return (
<CmsPageShell account={account} title="Zimmer">
<CmsPageShell account={account} title={t('rooms.title')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Zimmerverwaltung</p>
<Button>
<p className="text-muted-foreground">{t('rooms.manage')}</p>
<Button data-test="rooms-new-btn">
<Plus className="mr-2 h-4 w-4" />
Neues Zimmer
{t('rooms.newRoom')}
</Button>
</div>
{rooms.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
title="Keine Zimmer vorhanden"
description="Fügen Sie Ihr erstes Zimmer hinzu."
actionLabel="Neues Zimmer"
title={t('rooms.noRooms')}
description={t('rooms.addFirst')}
actionLabel={t('rooms.newRoom')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Zimmer ({rooms.length})</CardTitle>
<CardTitle>
{t('rooms.allRooms', { count: rooms.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmernr.</th>
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-right font-medium">Preis/Nacht</th>
<th className="p-3 text-center font-medium">Aktiv</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('rooms.roomNumber')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('rooms.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('rooms.type')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('rooms.capacity')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('rooms.price')}
</th>
<th scope="col" className="p-3 text-center font-medium">
{t('rooms.active')}
</th>
</tr>
</thead>
<tbody>
{rooms.map((room: Record<string, unknown>) => (
<tr key={String(room.id)} className="border-b hover:bg-muted/30">
<tr
key={String(room.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs">
{String(room.room_number ?? '—')}
</td>
<td className="p-3 font-medium">{String(room.name ?? '—')}</td>
<td className="p-3">
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
<td className="p-3 font-medium">
{String(room.name ?? '—')}
</td>
<td className="p-3">
<Badge variant="outline">
{String(room.room_type ?? '—')}
</Badge>
</td>
<td className="p-3 text-right">
{String(room.capacity ?? '—')}
</td>
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
<td className="p-3 text-right">
{room.price_per_night != null
? `${Number(room.price_per_night).toFixed(2)}`
? formatCurrencyAmount(
room.price_per_night as number,
)
: '—'}
</td>
<td className="p-3 text-center">

View File

@@ -0,0 +1,119 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Save } from 'lucide-react';
import { markAttendance } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
interface Participant {
id: string;
firstName: string;
lastName: string;
}
interface AttendanceGridProps {
sessionId: string;
participants: Participant[];
initialAttendance: Map<string, boolean>;
}
export function AttendanceGrid({
sessionId,
participants,
initialAttendance,
}: AttendanceGridProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [attendance, setAttendance] = useState<Map<string, boolean>>(
() => new Map(initialAttendance),
);
const [isSaving, setIsSaving] = useState(false);
const toggle = (participantId: string) => {
setAttendance((prev) => {
const next = new Map(prev);
next.set(participantId, !prev.get(participantId));
return next;
});
};
const handleSave = async () => {
setIsSaving(true);
try {
const promises = participants.map((p) =>
markAttendance({
sessionId,
participantId: p.id,
present: attendance.get(p.id) ?? false,
}),
);
await Promise.all(promises);
toast.success('Anwesenheit gespeichert');
startTransition(() => router.refresh());
} catch {
toast.error('Fehler beim Speichern der Anwesenheit');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-4">
{participants.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Keine Teilnehmer in diesem Kurs
</p>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
Teilnehmer
</th>
<th scope="col" className="p-3 text-center font-medium">
Anwesend
</th>
</tr>
</thead>
<tbody>
{participants.map((p) => (
<tr
key={p.id}
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() => toggle(p.id)}
>
<td className="p-3 font-medium">
{p.lastName}, {p.firstName}
</td>
<td className="p-3 text-center">
<input
type="checkbox"
checked={attendance.get(p.id) ?? false}
onChange={() => toggle(p.id)}
className="h-4 w-4 rounded border-gray-300"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{participants.length > 0 && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving || isPending}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? 'Wird gespeichert...' : 'Anwesenheit speichern'}
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,65 +1,84 @@
import { ClipboardCheck, Calendar } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AttendanceGrid } from './attendance-grid';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function AttendancePage({ params, searchParams }: PageProps) {
export default async function AttendancePage({
params,
searchParams,
}: PageProps) {
const { account, courseId } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const t = await getTranslations('courses');
const [course, sessions, participants] = await Promise.all([
api.getCourse(courseId),
api.getSessions(courseId),
api.getParticipants(courseId),
api.courses.getById(courseId),
api.sessions.list(courseId),
api.enrollment.listParticipants(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
if (!course) return <AccountNotFound />;
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
const selectedSessionId =
(search.session as string) ??
(sessions.length > 0
? String((sessions[0] as Record<string, unknown>).id)
: null);
const attendance = selectedSessionId
? await api.getAttendance(selectedSessionId)
? await api.attendance.getBySession(selectedSessionId)
: [];
const attendanceMap = new Map(
attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]),
attendance.map((a: Record<string, unknown>) => [
String(a.participant_id),
Boolean(a.present),
]),
);
const participantList = participants.map((p: Record<string, unknown>) => ({
id: String(p.id),
firstName: String(p.first_name ?? ''),
lastName: String(p.last_name ?? ''),
}));
return (
<CmsPageShell account={account} title="Anwesenheit">
<CmsPageShell account={account} title={t('attendance.title')}>
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Anwesenheit</h1>
<p className="text-muted-foreground">
{String((course as Record<string, unknown>).name)}
</p>
</div>
{/* Session Selector */}
{sessions.length === 0 ? (
<EmptyState
icon={<Calendar className="h-8 w-8" />}
title="Keine Termine vorhanden"
description="Erstellen Sie zuerst Termine für diesen Kurs."
title={t('attendance.noSessions')}
description={t('attendance.noSessionsDescription')}
/>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Termin auswählen</CardTitle>
<CardTitle>{t('attendance.selectSession')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
@@ -70,9 +89,12 @@ export default async function AttendancePage({ params, searchParams }: PageProps
key={String(s.id)}
href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`}
>
<Badge variant={isSelected ? 'default' : 'outline'} className="cursor-pointer px-3 py-1">
<Badge
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer px-3 py-1"
>
{s.session_date
? new Date(String(s.session_date)).toLocaleDateString('de-DE')
? formatDate(s.session_date as string)
: String(s.id)}
</Badge>
</a>
@@ -82,47 +104,24 @@ export default async function AttendancePage({ params, searchParams }: PageProps
</CardContent>
</Card>
{/* Attendance Grid */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5" />
Anwesenheitsliste
{t('attendance.attendanceList')}
</CardTitle>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Keine Teilnehmer in diesem Kurs
</p>
{selectedSessionId ? (
<AttendanceGrid
sessionId={selectedSessionId}
participants={participantList}
initialAttendance={attendanceMap}
/>
) : (
<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">Teilnehmer</th>
<th className="p-3 text-center font-medium">Anwesend</th>
</tr>
</thead>
<tbody>
{participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
</td>
<td className="p-3 text-center">
<input
type="checkbox"
defaultChecked={attendanceMap.get(String(p.id)) ?? false}
className="h-4 w-4 rounded border-gray-300"
aria-label={`Anwesenheit ${String(p.last_name)}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-muted-foreground py-6 text-center text-sm">
{t('attendance.selectSessionPrompt')}
</p>
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,100 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { createSession } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface Props {
courseId: string;
}
export function CreateSessionDialog({ courseId }: Props) {
const router = useRouter();
const [open, setOpen] = useState(false);
const { execute, isPending } = useActionWithToast(createSession, {
successMessage: 'Termin erstellt',
errorMessage: 'Fehler beim Erstellen',
onSuccess: () => {
setOpen(false);
router.refresh();
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Neuer Termin
</Button>
}
/>
<DialogContent>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
execute({
courseId,
sessionDate: fd.get('sessionDate') as string,
startTime: fd.get('startTime') as string,
endTime: fd.get('endTime') as string,
notes: (fd.get('notes') as string) || undefined,
});
}}
>
<DialogHeader>
<DialogTitle>Neuen Termin erstellen</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="sessionDate">Datum *</Label>
<Input id="sessionDate" name="sessionDate" type="date" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Beginn *</Label>
<Input id="startTime" name="startTime" type="time" required />
</div>
<div className="space-y-2">
<Label htmlFor="endTime">Ende *</Label>
<Input id="endTime" name="endTime" type="time" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notizen</Label>
<Input id="notes" name="notes" />
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={isPending}
data-test="session-submit-btn"
>
{isPending ? 'Wird erstellt...' : 'Termin erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useRouter } from 'next/navigation';
import { Trash2 } from 'lucide-react';
import { deleteCourse } from '@kit/course-management/actions/course-actions';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface Props {
courseId: string;
accountSlug: string;
}
export function DeleteCourseButton({ courseId, accountSlug }: Props) {
const router = useRouter();
const { execute, isPending } = useActionWithToast(deleteCourse, {
successMessage: 'Kurs wurde abgesagt',
errorMessage: 'Fehler beim Absagen',
onSuccess: () => router.push(`/home/${accountSlug}/courses`),
});
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="destructive"
size="sm"
disabled={isPending}
data-test="course-cancel-btn"
>
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Kurs absagen
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kurs absagen?</AlertDialogTitle>
<AlertDialogDescription>
Der Kurs wird auf den Status &quot;Abgesagt&quot; gesetzt. Diese
Aktion kann rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-test="course-cancel-dismiss-btn">
Abbrechen
</AlertDialogCancel>
<AlertDialogAction
data-test="course-cancel-confirm-btn"
onClick={() => execute({ courseId })}
>
Absagen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,59 @@
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CreateCourseForm } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
export default async function EditCoursePage({ params }: PageProps) {
const { account, courseId } = await params;
const t = await getTranslations('courses');
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const course = await api.courses.getById(courseId);
if (!course) return <AccountNotFound />;
const c = course as Record<string, unknown>;
return (
<CmsPageShell
account={account}
title={`${String(c.name)}${t('pages.editCourseTitle')}`}
>
<CreateCourseForm
accountId={acct.id}
account={account}
courseId={courseId}
initialData={{
courseNumber: String(c.course_number ?? ''),
name: String(c.name ?? ''),
description: String(c.description ?? ''),
startDate: String(c.start_date ?? ''),
endDate: String(c.end_date ?? ''),
fee: Number(c.fee ?? 0),
reducedFee: Number(c.reduced_fee ?? 0),
capacity: Number(c.capacity ?? 20),
minParticipants: Number(c.min_participants ?? 5),
status: String(c.status ?? 'planned'),
registrationDeadline: String(c.registration_deadline ?? ''),
notes: String(c.notes ?? ''),
}}
/>
</CmsPageShell>
);
}

View File

@@ -1,182 +1,289 @@
import Link from 'next/link';
import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react';
import {
GraduationCap,
Users,
Calendar,
Euro,
User,
Clock,
Pencil,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import {
COURSE_STATUS_VARIANT,
COURSE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
import { CreateSessionDialog } from './create-session-dialog';
import { DeleteCourseButton } from './delete-course-button';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
const STATUS_LABEL: Record<string, string> = {
planned: 'Geplant', open: 'Offen', running: 'Laufend',
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
};
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
planned: 'secondary', open: 'default', running: 'info',
completed: 'outline', cancelled: 'destructive',
};
export default async function CourseDetailPage({ params }: PageProps) {
const { account, courseId } = await params;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const t = await getTranslations('courses');
const [course, participants, sessions] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.getSessions(courseId),
api.courses.getById(courseId),
api.enrollment.listParticipants(courseId),
api.sessions.list(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
if (!course) return <AccountNotFound />;
const c = course as Record<string, unknown>;
const courseData = course as Record<string, unknown>;
return (
<CmsPageShell account={account} title={String(c.name)}>
<CmsPageShell account={account} title={String(courseData.name)}>
<div className="flex w-full flex-col gap-6">
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/courses/${courseId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
{t('detail.edit')}
</Link>
</Button>
<DeleteCourseButton courseId={courseId} accountSlug={account} />
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<GraduationCap className="h-5 w-5 text-primary" />
<GraduationCap className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Name</p>
<p className="font-semibold">{String(c.name)}</p>
<p className="text-muted-foreground text-xs">
{t('detail.name')}
</p>
<p className="font-semibold">{String(courseData.name)}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="h-5 w-5 text-primary" />
<Clock className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Status</p>
<Badge variant={STATUS_VARIANT[String(c.status)] ?? 'secondary'}>
{STATUS_LABEL[String(c.status)] ?? String(c.status)}
<p className="text-muted-foreground text-xs">
{t('common.status')}
</p>
<Badge
variant={
COURSE_STATUS_VARIANT[String(courseData.status)] ??
'secondary'
}
>
{t(
COURSE_STATUS_LABEL_KEYS[String(courseData.status)] ??
String(courseData.status),
)}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<User className="h-5 w-5 text-primary" />
<User className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Dozent</p>
<p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
<p className="text-muted-foreground text-xs">
{t('detail.instructor')}
</p>
<p className="font-semibold">
{String(courseData.instructor_id ?? '—')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Calendar className="h-5 w-5 text-primary" />
<Calendar className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Beginn Ende</p>
<p className="text-muted-foreground text-xs">
{t('detail.dateRange')}
</p>
<p className="font-semibold">
{c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
{formatDate(courseData.start_date as string)}
{' '}
{c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'}
{formatDate(courseData.end_date as string)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Euro className="h-5 w-5 text-primary" />
<Euro className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Gebühr</p>
<p className="text-muted-foreground text-xs">{t('list.fee')}</p>
<p className="font-semibold">
{c.fee != null ? `${Number(c.fee).toFixed(2)}` : '—'}
{courseData.fee != null
? formatCurrencyAmount(courseData.fee as number)
: '—'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="h-5 w-5 text-primary" />
<Users className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Teilnehmer</p>
<p className="text-muted-foreground text-xs">
{t('detail.participants')}
</p>
<p className="font-semibold">
{participants.length} / {String(c.capacity ?? '∞')}
{participants.length} / {String(courseData.capacity ?? '∞')}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Teilnehmer Section */}
{/* Participants Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Teilnehmer</CardTitle>
<Link href={`/home/${account}/courses/${courseId}/participants`}>
<Button variant="outline" size="sm">Alle anzeigen</Button>
</Link>
<CardTitle>{t('detail.participants')}</CardTitle>
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/courses/${courseId}/participants`}>
{t('detail.viewAll')}
</Link>
</Button>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">E-Mail</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Datum</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('detail.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.email')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.date')}
</th>
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
) : participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
<td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
<tr>
<td
colSpan={4}
className="text-muted-foreground p-6 text-center"
>
{t('detail.noParticipants')}
</td>
</tr>
))}
) : (
participants.map((p: Record<string, unknown>) => (
<tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3">
<Badge variant="outline">
{String(p.status ?? '—')}
</Badge>
</td>
<td className="p-3">
{formatDate(p.enrolled_at as string)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Termine Section */}
{/* Sessions Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Termine</CardTitle>
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
<Button variant="outline" size="sm">Anwesenheit</Button>
</Link>
<CardTitle>{t('detail.sessions')}</CardTitle>
<div className="flex gap-2">
<CreateSessionDialog courseId={courseId} />
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
{t('detail.attendance')}
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Datum</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">Abgesagt?</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('detail.date')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.startDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.endDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.cancelled')}
</th>
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
) : sessions.map((s: Record<string, unknown>) => (
<tr key={String(s.id)} className="border-b hover:bg-muted/30">
<td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
<td className="p-3">{String(s.start_time ?? '—')}</td>
<td className="p-3">{String(s.end_time ?? '—')}</td>
<td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
<tr>
<td
colSpan={4}
className="text-muted-foreground p-6 text-center"
>
{t('detail.noSessions')}
</td>
</tr>
))}
) : (
sessions.map((s: Record<string, unknown>) => (
<tr
key={String(s.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
{formatDate(s.session_date as string)}
</td>
<td className="p-3">{String(s.start_time ?? '—')}</td>
<td className="p-3">{String(s.end_time ?? '—')}</td>
<td className="p-3">
{s.cancelled ? (
<Badge variant="destructive">
{t('common.yes')}
</Badge>
) : (
'—'
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>

View File

@@ -1,14 +1,14 @@
import Link from 'next/link';
import { Plus, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
@@ -16,17 +16,20 @@ interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
enrolled: 'default',
waitlisted: 'secondary',
cancelled: 'destructive',
completed: 'outline',
};
const STATUS_LABEL: Record<string, string> = {
enrolled: 'Angemeldet',
const ENROLLMENT_STATUS_LABEL: Record<string, string> = {
enrolled: 'Eingeschrieben',
waitlisted: 'Warteliste',
cancelled: 'Abgemeldet',
cancelled: 'Storniert',
completed: 'Abgeschlossen',
};
@@ -34,71 +37,91 @@ export default async function ParticipantsPage({ params }: PageProps) {
const { account, courseId } = await params;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const t = await getTranslations('courses');
const [course, participants] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.courses.getById(courseId),
api.enrollment.listParticipants(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
if (!course) return <AccountNotFound />;
return (
<CmsPageShell account={account} title="Teilnehmer">
<CmsPageShell account={account} title={t('participants.title')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Teilnehmer</h1>
<p className="text-muted-foreground">
{String((course as Record<string, unknown>).name)} {participants.length} Teilnehmer
{String((course as Record<string, unknown>).name)} {' '}
{participants.length} {t('participants.title')}
</p>
</div>
<Button>
<Button data-test="participants-add-btn">
<Plus className="mr-2 h-4 w-4" />
Teilnehmer anmelden
{t('participants.add')}
</Button>
</div>
{participants.length === 0 ? (
<EmptyState
icon={<Users className="h-8 w-8" />}
title="Keine Teilnehmer"
description="Melden Sie den ersten Teilnehmer für diesen Kurs an."
actionLabel="Teilnehmer anmelden"
title={t('participants.none')}
description={t('participants.noneDescription')}
actionLabel={t('participants.add')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Teilnehmer ({participants.length})</CardTitle>
<CardTitle>
{t('participants.allTitle', { count: participants.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Anmeldedatum</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.email')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.phone')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('enrollment.registrationDate')}
</th>
</tr>
</thead>
<tbody>
{participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
{String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3">{String(p.phone ?? '—')}</td>
<td className="p-3">
<Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}>
{STATUS_LABEL[String(p.status)] ?? String(p.status)}
<Badge
variant={
STATUS_VARIANT[String(p.status)] ?? 'secondary'
}
>
{ENROLLMENT_STATUS_LABEL[String(p.status)] ??
String(p.status)}
</Badge>
</td>
<td className="p-3">
{p.enrolled_at
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
: '—'}
{formatDate(p.enrolled_at as string)}
</td>
</tr>
))}

View File

@@ -1,38 +1,23 @@
import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_NAMES = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
@@ -42,9 +27,14 @@ function getFirstWeekday(year: number, month: number): number {
return day === 0 ? 6 : day - 1;
}
export default async function CourseCalendarPage({ params }: PageProps) {
export default async function CourseCalendarPage({
params,
searchParams,
}: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
@@ -55,11 +45,20 @@ export default async function CourseCalendarPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
const courses = await api.courses.list(acct.id, { page: 1, pageSize: 100 });
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const monthParam = search.month as string | undefined;
let year: number;
let month: number;
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
const [y, m] = monthParam.split('-').map(Number);
year = y!;
month = m! - 1;
} else {
year = now.getFullYear();
month = now.getMonth();
}
const daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month);
@@ -67,10 +66,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
const courseDates = new Set<number>();
for (const course of courses.data) {
const c = course as Record<string, unknown>;
if (c.status === 'cancelled') continue;
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
const courseItem = course as Record<string, unknown>;
if (courseItem.status === 'cancelled') continue;
const startDate = courseItem.start_date
? new Date(String(courseItem.start_date))
: null;
const endDate = courseItem.end_date
? new Date(String(courseItem.end_date))
: null;
if (!startDate) continue;
@@ -86,7 +89,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
}
// Build calendar grid
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
const cells: Array<{
day: number | null;
hasCourse: boolean;
isToday: boolean;
}> = [];
for (let i = 0; i < firstWeekday; i++) {
cells.push({ day: null, hasCourse: false, isToday: false });
@@ -96,7 +103,10 @@ export default async function CourseCalendarPage({ params }: PageProps) {
cells.push({
day: d,
hasCourse: courseDates.has(d),
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
isToday:
d === now.getDate() &&
month === now.getMonth() &&
year === now.getFullYear(),
});
}
@@ -105,24 +115,31 @@ export default async function CourseCalendarPage({ params }: PageProps) {
}
const activeCourses = courses.data.filter(
(c: Record<string, unknown>) =>
c.status === 'open' || c.status === 'running',
(courseItem: Record<string, unknown>) =>
courseItem.status === 'open' || courseItem.status === 'running',
);
// Use translation arrays for weekdays and months
const WEEKDAYS = t.raw('calendar.weekdays') as string[];
const MONTH_NAMES = t.raw('calendar.months') as string[];
return (
<CmsPageShell account={account} title="Kurskalender">
<CmsPageShell account={account} title={t('pages.calendarTitle')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/courses`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<p className="text-muted-foreground">
Kurstermine im Überblick
</p>
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.backToCourses')}
>
<Link href={`/home/${account}/courses`}>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<p className="text-muted-foreground">{t('calendar.overview')}</p>
</div>
</div>
@@ -130,24 +147,50 @@ export default async function CourseCalendarPage({ params }: PageProps) {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.previousMonth')}
>
<Link
href={`/home/${account}/courses/calendar?month=${
month === 0
? `${year - 1}-12`
: `${year}-${String(month).padStart(2, '0')}`
}`}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
</CardTitle>
<Button variant="ghost" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.nextMonth')}
>
<Link
href={`/home/${account}/courses/calendar?month=${
month === 11
? `${year + 1}-01`
: `${year}-${String(month + 2).padStart(2, '0')}`
}`}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1">
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
className="text-muted-foreground py-2 text-center text-xs font-medium"
>
{day}
</div>
@@ -163,15 +206,15 @@ export default async function CourseCalendarPage({ params }: PageProps) {
cell.day === null
? 'bg-transparent'
: cell.hasCourse
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
? 'bg-emerald-500/15 font-semibold text-emerald-700 dark:text-emerald-400'
: 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
>
{cell.day !== null && (
<>
<span>{cell.day}</span>
{cell.hasCourse && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
<span className="absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" />
)}
</>
)}
@@ -180,18 +223,18 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
Kurstag
{t('calendar.courseDay')}
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
{t('calendar.free')}
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
{t('calendar.today')}
</div>
</div>
</CardContent>
@@ -200,12 +243,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
{/* Active Courses this Month */}
<Card>
<CardHeader>
<CardTitle>Aktive Kurse ({activeCourses.length})</CardTitle>
<CardTitle>
{t('calendar.activeCourses', { count: activeCourses.length })}
</CardTitle>
</CardHeader>
<CardContent>
{activeCourses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine aktiven Kurse in diesem Monat.
<p className="text-muted-foreground text-sm">
{t('calendar.noActiveCourses')}
</p>
) : (
<div className="space-y-3">
@@ -221,18 +266,19 @@ export default async function CourseCalendarPage({ params }: PageProps) {
>
{String(course.name)}
</Link>
<p className="text-xs text-muted-foreground">
{course.start_date
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}{' '}
{' '}
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
<p className="text-muted-foreground text-xs">
{formatDate(course.start_date as string)} {' '}
{formatDate(course.end_date as string)}
</p>
</div>
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
{String(course.status) === 'running' ? 'Laufend' : 'Offen'}
<Badge
variant={
String(course.status) === 'running' ? 'info' : 'default'
}
>
{String(course.status) === 'running'
? t('status.running')
: t('status.open')}
</Badge>
</div>
))}

View File

@@ -0,0 +1,106 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { createCategory } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface CreateCategoryDialogProps {
accountId: string;
}
export function CreateCategoryDialog({ accountId }: CreateCategoryDialogProps) {
const t = useTranslations('courses');
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const { execute, isPending } = useActionWithToast(createCategory, {
successMessage: 'Kategorie erstellt',
errorMessage: 'Fehler beim Erstellen der Kategorie',
onSuccess: () => {
setOpen(false);
setName('');
setDescription('');
router.refresh();
},
});
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 Kategorie
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogDescription>
Erstellen Sie eine neue Kurskategorie.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="cat-name">Name</Label>
<Input
id="cat-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('categories.namePlaceholder')}
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cat-description">Beschreibung (optional)</Label>
<Input
id="cat-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('categories.descriptionPlaceholder')}
/>
</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,14 +1,15 @@
import { FolderTree, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { FolderTree } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import { CreateCategoryDialog } from './create-category-dialog';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,6 +18,7 @@ interface PageProps {
export default async function CategoriesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
@@ -27,46 +29,54 @@ export default async function CategoriesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const categories = await api.listCategories(acct.id);
const categories = await api.referenceData.listCategories(acct.id);
return (
<CmsPageShell account={account} title="Kategorien">
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Kurskategorien verwalten</p>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Kategorie
</Button>
<p className="text-muted-foreground">{t('categories.manage')}</p>
<CreateCategoryDialog accountId={acct.id} />
</div>
{categories.length === 0 ? (
<EmptyState
icon={<FolderTree className="h-8 w-8" />}
title="Keine Kategorien vorhanden"
description="Erstellen Sie Ihre erste Kurskategorie."
actionLabel="Neue Kategorie"
title={t('categories.noCategories')}
description={t('categories.manage')}
actionLabel={t('categories.newCategory')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
<CardTitle>
{t('categories.allTitle', { count: categories.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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>
<th className="p-3 text-left font-medium">Übergeordnet</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.description')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.parent')}
</th>
</tr>
</thead>
<tbody>
{categories.map((cat: Record<string, unknown>) => (
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
<tr
key={String(cat.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(cat.name)}</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{String(cat.description ?? '—')}
</td>
<td className="p-3">{String(cat.parent_id ?? '—')}</td>

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { createInstructor } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface CreateInstructorDialogProps {
accountId: string;
}
export function CreateInstructorDialog({
accountId,
}: CreateInstructorDialogProps) {
const t = useTranslations('courses');
const router = useRouter();
const [open, setOpen] = useState(false);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [qualifications, setQualifications] = useState('');
const [hourlyRate, setHourlyRate] = useState('');
const { execute, isPending } = useActionWithToast(createInstructor, {
successMessage: 'Dozent erstellt',
errorMessage: 'Fehler beim Erstellen des Dozenten',
onSuccess: () => {
setOpen(false);
setFirstName('');
setLastName('');
setEmail('');
setPhone('');
setQualifications('');
setHourlyRate('');
router.refresh();
},
});
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!firstName.trim() || !lastName.trim()) return;
execute({
accountId,
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim() || undefined,
phone: phone.trim() || undefined,
qualifications: qualifications.trim() || undefined,
hourlyRate: hourlyRate ? Number(hourlyRate) : undefined,
});
},
[
execute,
accountId,
firstName,
lastName,
email,
phone,
qualifications,
hourlyRate,
],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neuer Dozent
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neuer Dozent</DialogTitle>
<DialogDescription>
Einen neuen Dozenten zum Dozentenpool hinzufuegen.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inst-first-name">Vorname</Label>
<Input
id="inst-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t('instructors.firstNamePlaceholder')}
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-last-name">Nachname</Label>
<Input
id="inst-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={t('instructors.lastNamePlaceholder')}
required
minLength={1}
maxLength={128}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inst-email">E-Mail (optional)</Label>
<Input
id="inst-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="dozent@beispiel.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-phone">Telefon (optional)</Label>
<Input
id="inst-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 123 456789"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inst-qualifications">
Qualifikationen (optional)
</Label>
<Textarea
id="inst-qualifications"
value={qualifications}
onChange={(e) => setQualifications(e.target.value)}
placeholder={t('instructors.qualificationsPlaceholder')}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-hourly-rate">Stundensatz (optional)</Label>
<Input
id="inst-hourly-rate"
type="number"
min={0}
step={0.01}
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button
type="submit"
disabled={isPending || !firstName.trim() || !lastName.trim()}
>
{isPending ? 'Wird erstellt...' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,14 +1,16 @@
import { GraduationCap, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { GraduationCap } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import { CreateInstructorDialog } from './create-instructor-dialog';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,6 +19,7 @@ interface PageProps {
export default async function InstructorsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
@@ -27,55 +30,70 @@ export default async function InstructorsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const instructors = await api.listInstructors(acct.id);
const instructors = await api.referenceData.listInstructors(acct.id);
return (
<CmsPageShell account={account} title="Dozenten">
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Dozentenpool verwalten</p>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Dozent
</Button>
<p className="text-muted-foreground">{t('instructors.manage')}</p>
<CreateInstructorDialog accountId={acct.id} />
</div>
{instructors.length === 0 ? (
<EmptyState
icon={<GraduationCap className="h-8 w-8" />}
title="Keine Dozenten vorhanden"
description="Fügen Sie Ihren ersten Dozenten hinzu."
actionLabel="Neuer Dozent"
title={t('instructors.noInstructors')}
description={t('instructors.manage')}
actionLabel={t('instructors.newInstructor')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Dozenten ({instructors.length})</CardTitle>
<CardTitle>
{t('instructors.allTitle', { count: instructors.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Qualifikation</th>
<th className="p-3 text-right font-medium">Stundensatz</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.email')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.phone')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('instructors.qualification')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('instructors.hourlyRate')}
</th>
</tr>
</thead>
<tbody>
{instructors.map((inst: Record<string, unknown>) => (
<tr key={String(inst.id)} className="border-b hover:bg-muted/30">
<tr
key={String(inst.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(inst.last_name ?? '')}, {String(inst.first_name ?? '')}
{String(inst.last_name ?? '')},{' '}
{String(inst.first_name ?? '')}
</td>
<td className="p-3">{String(inst.email ?? '—')}</td>
<td className="p-3">{String(inst.phone ?? '—')}</td>
<td className="p-3">{String(inst.qualification ?? '—')}</td>
<td className="p-3">
{String(inst.qualification ?? '—')}
</td>
<td className="p-3 text-right">
{inst.hourly_rate != null
? `${Number(inst.hourly_rate).toFixed(2)}`
? formatCurrencyAmount(inst.hourly_rate as number)
: '—'}
</td>
</tr>

View File

@@ -0,0 +1,134 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { createLocation } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface CreateLocationDialogProps {
accountId: string;
}
export function CreateLocationDialog({ accountId }: CreateLocationDialogProps) {
const t = useTranslations('courses');
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [room, setRoom] = useState('');
const [capacity, setCapacity] = useState('');
const { execute, isPending } = useActionWithToast(createLocation, {
successMessage: 'Ort erstellt',
errorMessage: 'Fehler beim Erstellen des Ortes',
onSuccess: () => {
setOpen(false);
setName('');
setAddress('');
setRoom('');
setCapacity('');
router.refresh();
},
});
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
execute({
accountId,
name: name.trim(),
address: address.trim() || undefined,
room: room.trim() || undefined,
capacity: capacity ? Number(capacity) : undefined,
});
},
[execute, accountId, name, address, room, capacity],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neuer Ort
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neuer Ort</DialogTitle>
<DialogDescription>
Einen neuen Kurs- oder Veranstaltungsort hinzufuegen.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="loc-name">Name</Label>
<Input
id="loc-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('locations.namePlaceholder')}
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="loc-address">Adresse (optional)</Label>
<Input
id="loc-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder={t('locations.addressPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="loc-room">Raum (optional)</Label>
<Input
id="loc-room"
value={room}
onChange={(e) => setRoom(e.target.value)}
placeholder={t('locations.roomPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="loc-capacity">Kapazitaet (optional)</Label>
<Input
id="loc-capacity"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="z. B. 30"
/>
</div>
</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,14 +1,15 @@
import { MapPin, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { MapPin } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import { CreateLocationDialog } from './create-location-dialog';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,6 +18,7 @@ interface PageProps {
export default async function LocationsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
@@ -27,45 +29,55 @@ export default async function LocationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const locations = await api.listLocations(acct.id);
const locations = await api.referenceData.listLocations(acct.id);
return (
<CmsPageShell account={account} title="Orte">
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Ort
</Button>
<p className="text-muted-foreground">{t('locations.manage')}</p>
<CreateLocationDialog accountId={acct.id} />
</div>
{locations.length === 0 ? (
<EmptyState
icon={<MapPin className="h-8 w-8" />}
title="Keine Orte vorhanden"
description="Fügen Sie Ihren ersten Veranstaltungsort hinzu."
actionLabel="Neuer Ort"
title={t('locations.noLocations')}
description={t('locations.noLocationsDescription')}
actionLabel={t('locations.newLocationLabel')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Orte ({locations.length})</CardTitle>
<CardTitle>
{t('locations.allTitle', { count: locations.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">Adresse</th>
<th className="p-3 text-left font-medium">Raum</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.address')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.room')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('list.capacity')}
</th>
</tr>
</thead>
<tbody>
{locations.map((loc: Record<string, unknown>) => (
<tr key={String(loc.id)} className="border-b hover:bg-muted/30">
<tr
key={String(loc.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(loc.name)}</td>
<td className="p-3">
{[loc.street, loc.postal_code, loc.city]
@@ -74,7 +86,9 @@ export default async function LocationsPage({ params }: PageProps) {
.join(', ') || '—'}
</td>
<td className="p-3">{String(loc.room ?? '—')}</td>
<td className="p-3 text-right">{String(loc.capacity ?? '—')}</td>
<td className="p-3 text-right">
{String(loc.capacity ?? '—')}
</td>
</tr>
))}
</tbody>

View File

@@ -1,18 +1,33 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateCourseForm } from '@kit/course-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { getTranslations } from 'next-intl/server';
interface Props { params: Promise<{ account: string }> }
import { CreateCourseForm } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewCoursePage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
return (
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
<CmsPageShell
account={account}
title={t('pages.newCourseTitle')}
description={t('pages.newCourseDescription')}
>
<CreateCourseForm accountId={acct.id} account={account} />
</CmsPageShell>
);

View File

@@ -1,19 +1,31 @@
import Link from 'next/link';
import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
GraduationCap,
Plus,
Users,
Calendar,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { createCourseManagementApi } from '@kit/course-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
import {
COURSE_STATUS_VARIANT,
COURSE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string }>;
@@ -26,6 +38,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
@@ -39,84 +52,125 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
const page = Number(search.page) || 1;
const [courses, stats] = await Promise.all([
api.listCourses(acct.id, { page, pageSize: PAGE_SIZE }),
api.getStatistics(acct.id),
api.courses.list(acct.id, {
search: search.q as string,
status: search.status as string,
page,
pageSize: PAGE_SIZE,
}),
api.statistics.getQuickStats(acct.id),
]);
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
return (
<CmsPageShell account={account} title="Kurse">
<CmsPageShell account={account} title={t('pages.coursesTitle')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Kursangebot verwalten
{t('pages.coursesDescription')}
</p>
<Link href={`/home/${account}/courses/new`}>
<Button>
<Button data-test="courses-new-btn" asChild>
<Link href={`/home/${account}/courses/new`}>
<Plus className="mr-2 h-4 w-4" />
Neuer Kurs
</Button>
</Link>
{t('nav.newCourse')}
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Gesamt"
title={t('stats.total')}
value={stats.totalCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Aktiv"
title={t('stats.active')}
value={stats.openCourses}
icon={<Calendar className="h-5 w-5" />}
/>
<StatsCard
title="Abgeschlossen"
title={t('stats.completed')}
value={stats.completedCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Teilnehmer"
title={t('stats.participants')}
value={stats.totalParticipants}
icon={<Users className="h-5 w-5" />}
/>
</div>
{/* Search & Filters */}
<ListToolbar
searchPlaceholder={t('list.searchPlaceholder')}
filters={[
{
param: 'status',
label: t('common.status'),
options: [
{ value: '', label: t('common.all') },
{ value: 'planned', label: t('status.planned') },
{ value: 'open', label: t('status.open') },
{ value: 'running', label: t('status.running') },
{ value: 'completed', label: t('status.completed') },
{ value: 'cancelled', label: t('status.cancelled') },
],
},
]}
/>
{/* Table or Empty State */}
{courses.data.length === 0 ? (
<EmptyState
icon={<GraduationCap className="h-8 w-8" />}
title="Keine Kurse vorhanden"
description="Erstellen Sie Ihren ersten Kurs, um loszulegen."
actionLabel="Neuer Kurs"
title={t('list.noCourses')}
description={t('list.createFirst')}
actionLabel={t('nav.newCourse')}
actionHref={`/home/${account}/courses/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Kurse ({courses.total})</CardTitle>
<CardTitle>{t('list.title', { count: courses.total })}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Kursnr.</th>
<th className="p-3 text-left font-medium">Name</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">Status</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-right font-medium">Gebühr</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('list.courseNumber')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.courseName')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.startDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.endDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.status')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('list.capacity')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('list.fee')}
</th>
</tr>
</thead>
<tbody>
{courses.data.map((course: Record<string, unknown>) => (
<tr key={String(course.id)} className="border-b hover:bg-muted/30">
<tr
key={String(course.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs">
{String(course.course_number ?? '—')}
</td>
@@ -129,20 +183,22 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
</Link>
</td>
<td className="p-3">
{course.start_date
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(course.start_date as string)}
</td>
<td className="p-3">
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(course.end_date as string)}
</td>
<td className="p-3">
<Badge
variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'}
variant={
COURSE_STATUS_VARIANT[String(course.status)] ??
'secondary'
}
>
{COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)}
{t(
COURSE_STATUS_LABEL_KEYS[String(course.status)] ??
String(course.status),
)}
</Badge>
</td>
<td className="p-3 text-right">
@@ -152,7 +208,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
</td>
<td className="p-3 text-right">
{course.fee != null
? `${Number(course.fee).toFixed(2)}`
? formatCurrencyAmount(course.fee as number)
: '—'}
</td>
</tr>
@@ -164,34 +220,39 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
{/* 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 className="text-muted-foreground text-sm">
{t('common.page')} {page} {t('common.of')} {totalPages} (
{courses.total} {t('common.entries')})
</p>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link href={`/home/${account}/courses?page=${page - 1}`}>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/courses?page=${page - 1}`}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Zurück
</Button>
</Link>
{t('common.previous')}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
<ChevronLeft className="mr-1 h-4 w-4" />
Zurück
{t('common.previous')}
</Button>
)}
{page < totalPages ? (
<Link href={`/home/${account}/courses?page=${page + 1}`}>
<Button variant="outline" size="sm">
Weiter
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/courses?page=${page + 1}`}
>
{t('common.next')}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
Weiter
{t('common.next')}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
)}

View File

@@ -1,14 +1,20 @@
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
import {
GraduationCap,
Users,
Calendar,
TrendingUp,
BarChart3,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,27 +23,48 @@ interface PageProps {
export default async function CourseStatisticsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
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 <AccountNotFound />;
const api = createCourseManagementApi(client);
const stats = await api.getStatistics(acct.id);
const stats = await api.statistics.getQuickStats(acct.id);
const statusChartData = [
{ name: 'Aktiv', value: stats.openCourses },
{ name: 'Abgeschlossen', value: stats.completedCourses },
{ name: 'Gesamt', value: stats.totalCourses },
{ name: t('stats.active'), value: stats.openCourses },
{ name: t('stats.completed'), value: stats.completedCourses },
{ name: t('stats.total'), value: stats.totalCourses },
];
return (
<CmsPageShell account={account} title="Kurs-Statistiken">
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<div className="flex w-full flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard title="Kurse gesamt" value={stats.totalCourses} icon={<GraduationCap className="h-5 w-5" />} />
<StatsCard title="Aktive Kurse" value={stats.openCourses} icon={<Calendar className="h-5 w-5" />} />
<StatsCard title="Teilnehmer" value={stats.totalParticipants} icon={<Users className="h-5 w-5" />} />
<StatsCard title="Abgeschlossen" value={stats.completedCourses} icon={<TrendingUp className="h-5 w-5" />} />
<StatsCard
title={t('stats.totalCourses')}
value={stats.totalCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title={t('stats.activeCourses')}
value={stats.openCourses}
icon={<Calendar className="h-5 w-5" />}
/>
<StatsCard
title={t('stats.participants')}
value={stats.totalParticipants}
icon={<Users className="h-5 w-5" />}
/>
<StatsCard
title={t('stats.completed')}
value={stats.completedCourses}
icon={<TrendingUp className="h-5 w-5" />}
/>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -45,7 +72,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Kursauslastung
{t('stats.utilization')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -57,7 +84,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Verteilung
{t('stats.distribution')}
</CardTitle>
</CardHeader>
<CardContent>

View File

@@ -68,12 +68,13 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<select
id="documentType"
name="documentType"
data-test="document-type-select"
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
@@ -92,7 +93,8 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<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.
befindet sich noch in Entwicklung und wird in Kürze verfügbar
sein.
</p>
</div>
</div>
@@ -118,7 +120,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
@@ -131,7 +133,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
@@ -140,7 +142,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
</div>
{/* Hint */}
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
<div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm">
<p>
<strong>Hinweis:</strong>{' '}
{selectedType === 'member-card'
@@ -189,7 +191,11 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
{/* Submit button */}
<div className="flex justify-end">
<Button type="submit" disabled={isPending || isComingSoon}>
<Button
type="submit"
data-test="document-generate-btn"
disabled={isPending || isComingSoon}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -211,11 +217,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
* 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,
) {
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++) {

View File

@@ -2,8 +2,13 @@
import React from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { SupabaseClient } from '@supabase/supabase-js';
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
import { formatDate } from '@kit/shared/dates';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type GenerateDocumentInput = {
accountSlug: string;
@@ -55,7 +60,11 @@ export async function generateDocumentAction(
return { success: false, error: 'Unbekannter Dokumenttyp.' };
}
} catch (err) {
console.error('Document generation error:', err);
const logger = await getLogger();
logger.error(
{ error: err, context: 'document-generation' },
'Document generation error',
);
return {
success: false,
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
@@ -73,31 +82,42 @@ const LABELS: Record<string, string> = {
};
function fmtDate(d: string | null): string {
if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
return formatDate(d);
}
// ═══════════════════════════════════════════════════════════════════════════
// Member Card PDF — premium design with color accent bar, structured layout
// ═══════════════════════════════════════════════════════════════════════════
async function generateMemberCards(
client: ReturnType<typeof getSupabaseServerClient>,
client: SupabaseClient<Database>,
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')
.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.' };
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');
const {
Document,
Page,
View,
Text,
StyleSheet,
renderToBuffer,
Svg: _Svg,
Rect: _Rect,
Circle: _Circle,
} = await import('@react-pdf/renderer');
// — Brand colors (configurable later via account settings) —
const PRIMARY = '#1e40af';
@@ -107,7 +127,13 @@ async function generateMemberCards(
const LIGHT_GRAY = '#f1f5f9';
const s = StyleSheet.create({
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
page: {
padding: 24,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
fontFamily: 'Helvetica',
},
// ── Card shell ──
card: {
@@ -138,10 +164,22 @@ async function generateMemberCards(
paddingHorizontal: 6,
paddingVertical: 2,
},
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
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 },
body: {
flexDirection: 'row',
paddingHorizontal: 14,
paddingTop: 8,
gap: 12,
flex: 1,
},
// Photo column
photoCol: { width: 64, alignItems: 'center' },
@@ -165,11 +203,22 @@ async function generateMemberCards(
// Info column
infoCol: { flex: 1, justifyContent: 'center' },
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
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 },
fieldLabel: {
fontSize: 6,
color: GRAY,
textTransform: 'uppercase' as const,
letterSpacing: 0.6,
marginBottom: 1,
},
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
// ── Footer ──
@@ -184,10 +233,16 @@ async function generateMemberCards(
},
footerLeft: { fontSize: 6, color: GRAY },
footerRight: { fontSize: 6, color: GRAY },
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
validDot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: '#22c55e',
marginRight: 3,
},
});
const today = new Date().toLocaleDateString('de-DE');
const today = formatDate(new Date());
const year = new Date().getFullYear();
const cardsPerPage = 4;
const pages: React.ReactElement[] = [];
@@ -198,52 +253,122 @@ async function generateMemberCards(
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 },
{
key: `p${i}`,
size:
input.format === 'letter'
? 'LETTER'
: (input.format.toUpperCase() as 'A4' | 'A5'),
orientation: input.orientation,
style: s.page,
},
...batch.map((memberItem) =>
React.createElement(
View,
{ key: memberItem.id, style: s.card },
// Accent bar
React.createElement(View, { style: s.accentBar }),
// Header
React.createElement(View, { style: s.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'),
React.createElement(
View,
{ style: s.badge },
React.createElement(
Text,
{ style: s.badgeText },
'Mitgliedsausweis',
),
),
),
// Body: photo + info
React.createElement(View, { style: s.body },
React.createElement(
View,
{ style: s.body },
// Photo column
React.createElement(View, { style: s.photoCol },
React.createElement(View, { style: s.photoFrame },
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 ?? ''}`),
React.createElement(
Text,
{ style: s.memberNumber },
`Nr. ${memberItem.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 },
React.createElement(
View,
{ style: s.infoCol },
React.createElement(
Text,
{ style: s.memberName },
`${memberItem.first_name} ${memberItem.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)),
React.createElement(
View,
{ style: s.field },
React.createElement(
Text,
{ style: s.fieldLabel },
'Mitglied seit',
),
React.createElement(
Text,
{ style: s.fieldValue },
fmtDate(memberItem.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)),
React.createElement(
View,
{ style: s.field },
React.createElement(
Text,
{ style: s.fieldLabel },
'Geb.-Datum',
),
React.createElement(
Text,
{ style: s.fieldValue },
fmtDate(memberItem.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(
View,
{ style: { ...s.field, width: '100%' } },
React.createElement(
Text,
{ style: s.fieldLabel },
'Adresse',
),
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
[m.postal_code, m.city].filter(Boolean).join(' ') || '',
React.createElement(
Text,
{ style: s.fieldValue },
[memberItem.street, memberItem.house_number]
.filter(Boolean)
.join(' ') || '',
),
React.createElement(
Text,
{ style: { ...s.fieldValue, marginTop: 1 } },
[memberItem.postal_code, memberItem.city]
.filter(Boolean)
.join(' ') || '',
),
),
),
@@ -251,12 +376,24 @@ async function generateMemberCards(
),
// Footer
React.createElement(View, { style: s.footer },
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
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.footerLeft },
`Gültig ${year}/${year + 1}`,
),
),
React.createElement(
Text,
{ style: s.footerRight },
`Ausgestellt ${today}`,
),
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
),
),
),
@@ -279,25 +416,38 @@ async function generateMemberCards(
// Address Labels (HTML — Avery L7163)
// ═══════════════════════════════════════════════════════════════════════════
async function generateLabels(
client: ReturnType<typeof getSupabaseServerClient>,
client: SupabaseClient<Database>,
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')
.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.' };
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 records = members.map((record) => ({
line1: [
record.salutation,
record.title,
record.first_name,
record.last_name,
]
.filter(Boolean)
.join(' '),
line2:
[record.street, record.house_number].filter(Boolean).join(' ') ||
undefined,
line3:
[record.postal_code, record.city].filter(Boolean).join(' ') || undefined,
}));
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
@@ -314,13 +464,15 @@ async function generateLabels(
// Member Report (Excel)
// ═══════════════════════════════════════════════════════════════════════════
async function generateMemberReport(
client: ReturnType<typeof getSupabaseServerClient>,
client: SupabaseClient<Database>,
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')
.select(
'member_number, last_name, first_name, email, postal_code, city, status, entry_date',
)
.eq('account_id', accountId)
.order('last_name');
@@ -346,27 +498,42 @@ async function generateMemberReport(
const hdr = ws.getRow(1);
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
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' };
const SL: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
};
for (const m of members) {
for (const member 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') : '',
nr: member.member_number ?? '',
name: member.last_name,
vorname: member.first_name,
email: member.email ?? '',
plz: member.postal_code ?? '',
ort: member.city ?? '',
status: SL[member.status] ?? member.status,
eintritt: member.entry_date ? formatDate(member.entry_date) : '',
});
}
ws.eachRow((row, n) => {
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
if (n > 1 && n % 2 === 0)
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF1F5F9' },
};
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
});
@@ -379,7 +546,8 @@ async function generateMemberReport(
return {
success: true,
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
};
}

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
@@ -13,25 +14,16 @@ import {
CardTitle,
} from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { GenerateDocumentForm } from '../_components/generate-document-form';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<{ type?: string }>;
}
const DOCUMENT_LABELS: Record<string, string> = {
'member-card': 'Mitgliedsausweis',
invoice: 'Rechnung',
labels: 'Etiketten',
report: 'Bericht',
letter: 'Brief',
certificate: 'Zertifikat',
};
export default async function GenerateDocumentPage({
params,
searchParams,
@@ -39,6 +31,7 @@ export default async function GenerateDocumentPage({
const { account } = await params;
const { type } = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('documents');
const { data: acct } = await client
.from('accounts')
@@ -49,10 +42,13 @@ export default async function GenerateDocumentPage({
if (!acct) return <AccountNotFound />;
const selectedType = type ?? 'member-card';
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
// Resolve the label from translations; fall back to generic 'Document'
const selectedLabel =
(t.raw(`types.${selectedType}`) as string | undefined) ??
t('generate.document');
return (
<CmsPageShell account={account} title="Dokument generieren">
<CmsPageShell account={account} title={t('generate.title')}>
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
@@ -61,16 +57,16 @@ export default async function GenerateDocumentPage({
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Dokumente
{t('generate.backToDocuments')}
</Link>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{selectedLabel} generieren</CardTitle>
<CardDescription>
Wählen Sie den Dokumenttyp und die gewünschten Optionen.
</CardDescription>
<CardTitle>
{t('generate.generateLabel', { label: selectedLabel })}
</CardTitle>
<CardDescription>{t('generate.chooseOptions')}</CardDescription>
</CardHeader>
<CardContent>
@@ -82,7 +78,7 @@ export default async function GenerateDocumentPage({
<CardFooter>
<Link href={`/home/${account}/documents`}>
<Button variant="outline">Abbrechen</Button>
<Button variant="outline">{t('generate.cancel')}</Button>
</Link>
</CardFooter>
</Card>

View File

@@ -8,13 +8,14 @@ import {
Mail,
Award,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
@@ -23,49 +24,31 @@ interface PageProps {
const DOCUMENT_TYPES = [
{
id: 'member-card',
title: 'Mitgliedsausweis',
description:
'Mitgliedsausweise mit Foto, Name und Mitgliedsnummer generieren.',
icon: CreditCard,
color: 'text-blue-600 bg-blue-50',
},
{
id: 'invoice',
title: 'Rechnung',
description:
'Professionelle Rechnungen im PDF-Format mit Logo und Positionen.',
icon: FileText,
color: 'text-green-600 bg-green-50',
},
{
id: 'labels',
title: 'Etiketten',
description:
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
icon: Tag,
color: 'text-orange-600 bg-orange-50',
},
{
id: 'report',
title: 'Bericht',
description:
'Statistische Auswertungen und Berichte als PDF oder Excel.',
icon: BarChart3,
color: 'text-purple-600 bg-purple-50',
},
{
id: 'letter',
title: 'Brief',
description:
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
icon: Mail,
color: 'text-rose-600 bg-rose-50',
},
{
id: 'certificate',
title: 'Zertifikat',
description:
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
icon: Award,
color: 'text-amber-600 bg-amber-50',
},
@@ -74,6 +57,7 @@ const DOCUMENT_TYPES = [
export default async function DocumentsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('documents');
const { data: acct } = await client
.from('accounts')
@@ -84,12 +68,16 @@ export default async function DocumentsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
return (
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
<CmsPageShell
account={account}
title={t('overview.title')}
description={t('overview.subtitle')}
>
<div className="flex w-full flex-col gap-6">
{/* Actions */}
<div className="flex items-center justify-end">
<Link href={`/home/${account}/documents/templates`}>
<Button variant="outline">Vorlagen verwalten</Button>
<Button variant="outline">{t('overview.manageTemplates')}</Button>
</Link>
</div>
@@ -104,18 +92,20 @@ export default async function DocumentsPage({ params }: PageProps) {
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<CardTitle className="text-base">{docType.title}</CardTitle>
<CardTitle className="text-base">
{t(`types.${docType.id}`)}
</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<p className="text-sm text-muted-foreground">
{docType.description}
<p className="text-muted-foreground text-sm">
{t(`typeDescriptions.${docType.id}`)}
</p>
<Link
href={`/home/${account}/documents/generate?type=${docType.id}`}
>
<Button variant="outline" size="sm" className="w-full">
Erstellen
{t('overview.generate')}
</Button>
</Link>
</CardContent>

View File

@@ -1,14 +1,13 @@
import Link from 'next/link';
import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -17,6 +16,7 @@ interface PageProps {
export default async function DocumentTemplatesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('documents');
const { data: acct } = await client
.from('accounts')
@@ -35,20 +35,17 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
}> = [];
return (
<CmsPageShell account={account} title="Dokumentvorlagen">
<CmsPageShell account={account} title={t('templates.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dokumentvorlagen</h1>
<p className="text-muted-foreground">
Vorlagen für Mitgliedsausweise, Rechnungen, Etiketten und mehr
</p>
<p className="text-muted-foreground">{t('templates.subtitle')}</p>
</div>
<Button>
<Button data-test="document-templates-new-btn">
<Plus className="mr-2 h-4 w-4" />
Neue Vorlage
{t('templates.newTemplate')}
</Button>
</div>
@@ -56,24 +53,30 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
{templates.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Vorlagen vorhanden"
description="Erstellen Sie Ihre erste Dokumentvorlage, um Mitgliedsausweise, Rechnungen und mehr zu generieren."
actionLabel="Neue Vorlage"
title={t('templates.noTemplates')}
description={t('templates.createFirstLong')}
actionLabel={t('templates.newTemplate')}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
<CardTitle>
{t('templates.allTemplates', { count: templates.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">Typ</th>
<th className="p-3 text-left font-medium">
Beschreibung
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('templates.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('templates.type')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('templates.description')}
</th>
</tr>
</thead>
@@ -81,11 +84,11 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
{templates.map((template) => (
<tr
key={template.id}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{template.name}</td>
<td className="p-3">{template.type}</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{template.description}
</td>
</tr>

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { AlertTriangle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@kit/ui/button';
export default function AccountError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations('common');
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="bg-destructive/10 mb-4 rounded-full p-4">
<AlertTriangle className="text-destructive h-8 w-8" />
</div>
<h2 className="text-xl font-semibold">{t('error.title')}</h2>
<p className="text-muted-foreground mt-2 max-w-md text-sm">
{t('error.description')}
</p>
<div className="mt-6 flex gap-2">
<Button onClick={reset}>{t('error.retry')}</Button>
<Button variant="outline" asChild>
<Link href="/home">{t('error.toDashboard')}</Link>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useRouter } from 'next/navigation';
import { Trash2 } from 'lucide-react';
import { deleteEvent } from '@kit/event-management/actions/event-actions';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface Props {
eventId: string;
accountSlug: string;
}
export function DeleteEventButton({ eventId, accountSlug }: Props) {
const router = useRouter();
const { execute, isPending } = useActionWithToast(deleteEvent, {
successMessage: 'Veranstaltung wurde abgesagt',
errorMessage: 'Fehler beim Absagen',
onSuccess: () => router.push(`/home/${accountSlug}/events`),
});
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button variant="destructive" size="sm" disabled={isPending}>
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Absagen
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Veranstaltung absagen?</AlertDialogTitle>
<AlertDialogDescription>
Die Veranstaltung wird auf den Status &quot;Abgesagt&quot; gesetzt.
Diese Aktion kann rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={() => execute({ eventId })}>
Absagen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,62 @@
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { CreateEventForm } from '@kit/event-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; eventId: string }>;
}
export default async function EditEventPage({ params }: PageProps) {
const { account, eventId } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('cms.events');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const event = await api.events.getById(eventId);
if (!event) return <AccountNotFound />;
const e = event as Record<string, unknown>;
return (
<CmsPageShell
account={account}
title={`${String(e.name)}${t('editTitle')}`}
>
<CreateEventForm
accountId={acct.id}
account={account}
eventId={eventId}
initialData={{
name: String(e.name ?? ''),
description: String(e.description ?? ''),
eventDate: String(e.event_date ?? ''),
eventTime: String(e.event_time ?? ''),
endDate: String(e.end_date ?? ''),
location: String(e.location ?? ''),
capacity: e.capacity != null ? Number(e.capacity) : undefined,
minAge: e.min_age != null ? Number(e.min_age) : undefined,
maxAge: e.max_age != null ? Number(e.max_age) : undefined,
fee: Number(e.fee ?? 0),
status: String(e.status ?? 'planned'),
registrationDeadline: String(e.registration_deadline ?? ''),
contactName: String(e.contact_name ?? ''),
contactEmail: String(e.contact_email ?? ''),
contactPhone: String(e.contact_phone ?? ''),
}}
/>
</CmsPageShell>
);
}

View File

@@ -4,71 +4,78 @@ import {
CalendarDays,
MapPin,
Users,
Euro,
Clock,
Pencil,
UserPlus,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import {
EVENT_STATUS_LABEL_KEYS,
EVENT_STATUS_VARIANT,
} from '~/lib/status-badges';
import { DeleteEventButton } from './delete-event-button';
interface PageProps {
params: Promise<{ account: string; eventId: string }>;
}
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
registration_open: 'Anmeldung offen',
registration_closed: 'Anmeldung geschlossen',
cancelled: 'Abgesagt',
completed: 'Abgeschlossen',
};
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
draft: 'secondary',
published: 'default',
registration_open: 'info',
registration_closed: 'outline',
cancelled: 'destructive',
completed: 'outline',
};
export default async function EventDetailPage({ params }: PageProps) {
const { account, eventId } = await params;
const client = getSupabaseServerClient();
const api = createEventManagementApi(client);
const t = await getTranslations('cms.events');
const [event, registrations] = await Promise.all([
api.getEvent(eventId),
api.getRegistrations(eventId),
api.events.getById(eventId),
api.registrations.list(eventId),
]);
if (!event) return <div>Veranstaltung nicht gefunden</div>;
if (!event) return <div>{t('notFound')}</div>;
const e = event as Record<string, unknown>;
const eventData = event as Record<string, unknown>;
return (
<CmsPageShell account={account} title={String(e.name)}>
<CmsPageShell account={account} title={String(eventData.name)}>
<div className="flex w-full flex-col gap-6">
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/events/${eventId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Link>
</Button>
<DeleteEventButton eventId={eventId} accountSlug={account} />
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{String(e.name)}</h1>
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
{STATUS_LABEL[String(e.status)] ?? String(e.status)}
<Badge
variant={
EVENT_STATUS_VARIANT[String(eventData.status)] ?? 'secondary'
}
className="mt-1"
>
{t(
EVENT_STATUS_LABEL_KEYS[String(eventData.status)] ??
String(eventData.status),
)}
</Badge>
</div>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Anmelden
{t('register')}
</Button>
</div>
@@ -76,44 +83,47 @@ export default async function EventDetailPage({ params }: PageProps) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<CalendarDays className="h-5 w-5 text-primary" />
<CalendarDays className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Datum</p>
<p className="text-muted-foreground text-xs">{t('date')}</p>
<p className="font-semibold">
{e.event_date
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(eventData.event_date as string)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="h-5 w-5 text-primary" />
<Clock className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Uhrzeit</p>
<p className="text-muted-foreground text-xs">{t('time')}</p>
<p className="font-semibold">
{String(e.start_time ?? '—')} {String(e.end_time ?? '—')}
{String(eventData.start_time ?? '—')} {' '}
{String(eventData.end_time ?? '—')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<MapPin className="h-5 w-5 text-primary" />
<MapPin className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Ort</p>
<p className="font-semibold">{String(e.location ?? '—')}</p>
<p className="text-muted-foreground text-xs">{t('location')}</p>
<p className="font-semibold">
{String(eventData.location ?? '—')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="h-5 w-5 text-primary" />
<Users className="text-primary h-5 w-5" />
<div>
<p className="text-xs text-muted-foreground">Anmeldungen</p>
<p className="text-muted-foreground text-xs">
{t('registrations')}
</p>
<p className="font-semibold">
{registrations.length} / {String(e.capacity ?? '∞')}
{registrations.length} / {String(eventData.capacity ?? '∞')}
</p>
</div>
</CardContent>
@@ -121,14 +131,14 @@ export default async function EventDetailPage({ params }: PageProps) {
</div>
{/* Description */}
{e.description ? (
{eventData.description ? (
<Card>
<CardHeader>
<CardTitle>Beschreibung</CardTitle>
<CardTitle>{t('description')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{String(e.description)}
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
{String(eventData.description)}
</p>
</CardContent>
</Card>
@@ -137,36 +147,50 @@ export default async function EventDetailPage({ params }: PageProps) {
{/* Registrations Table */}
<Card>
<CardHeader>
<CardTitle>Anmeldungen ({registrations.length})</CardTitle>
<CardTitle>
{t('registrationsCount', { count: registrations.length })}
</CardTitle>
</CardHeader>
<CardContent>
{registrations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Anmeldungen
<p className="text-muted-foreground py-6 text-center text-sm">
{t('noRegistrations')}
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">E-Mail</th>
<th className="p-3 text-left font-medium">Elternteil</th>
<th className="p-3 text-left font-medium">Datum</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
E-Mail
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('parentName')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('date')}
</th>
</tr>
</thead>
<tbody>
{registrations.map((reg: Record<string, unknown>) => (
<tr key={String(reg.id)} className="border-b hover:bg-muted/30">
<tr
key={String(reg.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(reg.last_name ?? '')}, {String(reg.first_name ?? '')}
{String(reg.last_name ?? '')},{' '}
{String(reg.first_name ?? '')}
</td>
<td className="p-3">{String(reg.email ?? '—')}</td>
<td className="p-3">{String(reg.parent_name ?? '—')}</td>
<td className="p-3">
{reg.created_at
? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
: '—'}
{String(reg.parent_name ?? '—')}
</td>
<td className="p-3">
{formatDate(reg.created_at as string)}
</td>
</tr>
))}

View File

@@ -1,15 +1,15 @@
import { Ticket, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -29,15 +29,16 @@ export default async function HolidayPassesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const passes = await api.listHolidayPasses(acct.id);
const passes = await api.holidayPasses.list(acct.id);
return (
<CmsPageShell account={account} title={t('holidayPasses')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t('holidayPasses')}</h1>
<p className="text-muted-foreground">{t('holidayPassesDescription')}</p>
<p className="text-muted-foreground">
{t('holidayPassesDescription')}
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
@@ -55,39 +56,50 @@ export default async function HolidayPassesPage({ params }: PageProps) {
) : (
<Card>
<CardHeader>
<CardTitle>{t('allHolidayPasses')} ({passes.length})</CardTitle>
<CardTitle>
{t('allHolidayPasses')} ({passes.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">{t('year')}</th>
<th className="p-3 text-right font-medium">{t('price')}</th>
<th className="p-3 text-left font-medium">{t('validFrom')}</th>
<th className="p-3 text-left font-medium">{t('validUntil')}</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('year')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('price')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('validFrom')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('validUntil')}
</th>
</tr>
</thead>
<tbody>
{passes.map((pass: Record<string, unknown>) => (
<tr key={String(pass.id)} className="border-b hover:bg-muted/30">
<tr
key={String(pass.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(pass.name)}</td>
<td className="p-3">{String(pass.year ?? '—')}</td>
<td className="p-3 text-right">
{pass.price != null
? `${Number(pass.price).toFixed(2)}`
? formatCurrencyAmount(pass.price as number)
: '—'}
</td>
<td className="p-3">
{pass.valid_from
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
: '—'}
{formatDate(pass.valid_from as string)}
</td>
<td className="p-3">
{pass.valid_until
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
: '—'}
{formatDate(pass.valid_until as string)}
</td>
</tr>
))}

View File

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

View File

@@ -1,20 +1,30 @@
import Link from 'next/link';
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react';
import {
CalendarDays,
ChevronLeft,
ChevronRight,
MapPin,
Plus,
Users,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
import {
EVENT_STATUS_VARIANT,
EVENT_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string }>;
@@ -37,22 +47,24 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
const page = Number(search.page) || 1;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page });
const events = await api.events.list(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);
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
String(eventItem.id),
);
const registrationCounts = await api.events.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering
const uniqueLocationCount = new Set(
events.data
.map((e: Record<string, unknown>) => e.location)
.map((eventItem: Record<string, unknown>) => eventItem.location)
.filter(Boolean),
).size;
const totalCapacity = events.data.reduce(
(sum: number, e: Record<string, unknown>) =>
sum + (Number(e.capacity) || 0),
(sum: number, eventItem: Record<string, unknown>) =>
sum + (Number(eventItem.capacity) || 0),
0,
);
@@ -62,18 +74,15 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
{t('description')}
</p>
<p className="text-muted-foreground">{t('description')}</p>
</div>
<Link href={`/home/${account}/events/new`}>
<Button>
<Button data-test="events-new-btn" asChild>
<Link href={`/home/${account}/events/new`}>
<Plus className="mr-2 h-4 w-4" />
{t('newEvent')}
</Button>
</Link>
</Link>
</Button>
</div>
{/* Stats */}
@@ -107,19 +116,33 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
) : (
<Card>
<CardHeader>
<CardTitle>{t('allEvents')} ({events.total})</CardTitle>
<CardTitle>
{t('allEvents')} ({events.total})
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
<th className="p-3 text-left font-medium">{t('eventLocation')}</th>
<th className="p-3 text-right font-medium">{t('capacity')}</th>
<th className="p-3 text-left font-medium">{t('status')}</th>
<th className="p-3 text-right font-medium">{t('registrations')}</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('eventDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('eventLocation')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('capacity')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('statusLabel')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('registrations')}
</th>
</tr>
</thead>
<tbody>
@@ -130,7 +153,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
return (
<tr
key={eventId}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
<Link
@@ -141,9 +164,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
</Link>
</td>
<td className="p-3">
{event.event_date
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(event.event_date as string)}
</td>
<td className="p-3">
{String(event.location ?? '—')}
@@ -156,10 +177,14 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
<td className="p-3">
<Badge
variant={
EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary'
EVENT_STATUS_VARIANT[String(event.status)] ??
'secondary'
}
>
{EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)}
{t(
EVENT_STATUS_LABEL_KEYS[String(event.status)] ??
String(event.status),
)}
</Badge>
</td>
<td className="p-3 text-right font-medium">
@@ -175,25 +200,32 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
{/* 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 className="text-muted-foreground text-sm">
{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">
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/events?page=${events.page - 1}`}
>
<ChevronLeft className="mr-1 h-4 w-4" />
{t('paginationPrevious')}
</Button>
</Link>
</Link>
</Button>
)}
{events.page < events.totalPages && (
<Link href={`/home/${account}/events?page=${events.page + 1}`}>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/events?page=${events.page + 1}`}
>
{t('paginationNext')}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
)}
</div>
</div>

View File

@@ -1,19 +1,22 @@
import Link from 'next/link';
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
import {
EVENT_STATUS_VARIANT,
EVENT_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string }>;
@@ -33,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 });
const events = await api.events.list(acct.id, { page: 1 });
// Load registrations for each event in parallel
const eventsWithRegistrations = await Promise.all(
events.data.map(async (event: Record<string, unknown>) => {
const registrations = await api.getRegistrations(String(event.id));
const registrations = await api.registrations.list(String(event.id));
return {
id: String(event.id),
name: String(event.name),
@@ -63,10 +66,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">{t('registrations')}</h1>
<p className="text-muted-foreground">
{t('registrationsOverview')}
</p>
<p className="text-muted-foreground">{t('registrationsOverview')}</p>
</div>
{/* Stats */}
@@ -105,20 +105,28 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('event')}
</th>
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
<th className="p-3 text-left font-medium">{t('status')}</th>
<th className="p-3 text-right font-medium">{t('capacity')}</th>
<th className="p-3 text-right font-medium">
<th scope="col" className="p-3 text-left font-medium">
{t('eventDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('statusLabel')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('capacity')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('registrations')}
</th>
<th className="p-3 text-right font-medium">{t('utilization')}</th>
<th scope="col" className="p-3 text-right font-medium">
{t('utilization')}
</th>
</tr>
</thead>
<tbody>
@@ -133,7 +141,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
return (
<tr
key={event.id}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
<Link
@@ -143,20 +151,18 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{event.name}
</Link>
</td>
<td className="p-3">
{event.eventDate
? new Date(event.eventDate).toLocaleDateString(
'de-DE',
)
: '—'}
</td>
<td className="p-3">{formatDate(event.eventDate)}</td>
<td className="p-3">
<Badge
variant={
EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
EVENT_STATUS_VARIANT[event.status] ??
'secondary'
}
>
{EVENT_STATUS_LABEL[event.status] ?? event.status}
{t(
EVENT_STATUS_LABEL_KEYS[event.status] ??
event.status,
)}
</Badge>
</td>
<td className="p-3 text-right">

View File

@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
interface DeleteConfirmButtonProps {
title: string;
description: string;
isPending?: boolean;
onConfirm: () => void;
}
export function DeleteConfirmButton({
title,
description,
isPending,
onConfirm,
}: DeleteConfirmButtonProps) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
data-test="file-delete-btn"
disabled={isPending}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
{isPending ? 'Wird gelöscht...' : 'Löschen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Upload } from 'lucide-react';
import { uploadFile } from '@kit/module-builder/actions/file-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface FileUploadDialogProps {
accountId: string;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ACCEPTED_TYPES = [
'image/*',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
'text/plain',
'application/zip',
].join(',');
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
export function FileUploadDialog({ accountId }: FileUploadDialogProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<{
name: string;
type: string;
size: number;
base64: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const { execute, isPending } = useActionWithToast(uploadFile, {
successMessage: 'Datei hochgeladen',
onSuccess: () => {
setOpen(false);
setSelectedFile(null);
setError(null);
router.refresh();
},
});
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (!file) {
setSelectedFile(null);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError('Die Datei darf maximal 10 MB groß sein.');
setSelectedFile(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove the data:...;base64, prefix
const base64 = result.split(',')[1] ?? '';
setSelectedFile({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
base64,
});
};
reader.onerror = () => {
setError('Fehler beim Lesen der Datei.');
setSelectedFile(null);
};
reader.readAsDataURL(file);
},
[],
);
const handleUpload = useCallback(() => {
if (!selectedFile) return;
execute({
accountId,
fileName: selectedFile.name,
fileType: selectedFile.type,
fileSize: selectedFile.size,
base64: selectedFile.base64,
});
}, [accountId, execute, selectedFile]);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (!isPending) {
setOpen(isOpen);
if (!isOpen) {
setSelectedFile(null);
setError(null);
}
}
},
[isPending],
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={
<Button size="sm" data-test="file-upload-btn">
<Upload className="mr-2 h-4 w-4" />
Datei hochladen
</Button>
}
/>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>Datei hochladen</DialogTitle>
<DialogDescription>
Wählen Sie eine Datei aus (max. 10 MB).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileSelect}
className="file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-medium"
data-test="file-upload-input"
/>
{error && <p className="text-destructive text-sm">{error}</p>}
{selectedFile && (
<div className="bg-muted rounded-md p-3">
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-muted-foreground text-xs">
{selectedFile.type} &middot; {formatFileSize(selectedFile.size)}
</p>
</div>
)}
<Button
onClick={handleUpload}
disabled={!selectedFile || isPending}
data-test="file-upload-submit"
>
{isPending ? 'Wird hochgeladen...' : 'Hochladen'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Download, FileIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { deleteFile } from '@kit/module-builder/actions/file-actions';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { DeleteConfirmButton } from './delete-confirm-button';
interface FileRecord {
id: string;
file_name: string;
original_name: string;
mime_type: string;
file_size: number;
created_at: string;
storage_path: string;
publicUrl: string;
}
interface FilesTableProps {
files: FileRecord[];
pagination: { total: number; page: number; pageSize: number };
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getMimeLabel(mimeType: string): string {
const map: Record<string, string> = {
'application/pdf': 'PDF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/gif': 'GIF',
'image/webp': 'WebP',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
'DOCX',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
'text/csv': 'CSV',
'text/plain': 'TXT',
'application/zip': 'ZIP',
};
return map[mimeType] ?? mimeType.split('/').pop()?.toUpperCase() ?? 'Datei';
}
export function FilesTable({ files, pagination }: FilesTableProps) {
const t = useTranslations('common');
const router = useRouter();
const searchParams = useSearchParams();
const { total, page, pageSize } = pagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteFile,
{
successMessage: 'Datei gelöscht',
onSuccess: () => router.refresh(),
},
);
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(newPage));
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
return (
<Card>
<CardHeader>
<CardTitle>Dateien ({total})</CardTitle>
</CardHeader>
<CardContent>
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<FileIcon className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-semibold">Keine Dateien vorhanden</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Laden Sie Ihre erste Datei hoch.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
Dateiname
</th>
<th scope="col" className="p-3 text-left font-medium">
Typ
</th>
<th scope="col" className="p-3 text-right font-medium">
Größe
</th>
<th scope="col" className="p-3 text-left font-medium">
Hochgeladen
</th>
<th scope="col" className="p-3 text-right font-medium">
Aktionen
</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="hover:bg-muted/30 border-b">
<td className="max-w-[300px] truncate p-3 font-medium">
{file.original_name}
</td>
<td className="p-3">
<Badge variant="secondary">
{getMimeLabel(file.mime_type)}
</Badge>
</td>
<td className="text-muted-foreground p-3 text-right">
{formatFileSize(file.file_size)}
</td>
<td className="text-muted-foreground p-3">
{formatDate(file.created_at)}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<a
href={file.publicUrl}
target="_blank"
rel="noopener noreferrer"
download={file.original_name}
>
<Button
variant="ghost"
size="sm"
data-test="file-download-btn"
>
<Download className="h-4 w-4" />
</Button>
</a>
<DeleteConfirmButton
title={t('deleteFile')}
description={t('deleteFileConfirm')}
isPending={isDeleting}
onConfirm={() => executeDelete({ fileId: file.id })}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
data-test="files-prev-page"
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
data-test="files-next-page"
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
import { getTranslations } from 'next-intl/server';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { FileUploadDialog } from './file-upload-dialog';
import { FilesTable } from './files-table';
interface Props {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function FilesPage({ params, searchParams }: Props) {
const { account } = await params;
const t = await getTranslations('common');
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 = createModuleBuilderApi(client);
const page = Number(search.page) || 1;
const pageSize = 25;
const result = await api.files.listFiles(acct.id, {
search: search.q as string,
page,
pageSize,
});
// Resolve public URLs for each file
const filesWithUrls = result.data.map((file) => ({
id: String(file.id),
file_name: String(file.file_name),
original_name: String(file.original_name),
mime_type: String(file.mime_type),
file_size: Number(file.file_size),
created_at: String(file.created_at),
storage_path: String(file.storage_path),
publicUrl: api.files.getPublicUrl(String(file.storage_path)),
}));
return (
<CmsPageShell
account={account}
title={t('filesTitle')}
description={t('filesSubtitle')}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<ListToolbar searchPlaceholder={t('filesSearch')} />
<FileUploadDialog accountId={acct.id} />
</div>
<FilesTable
files={filesWithUrls}
pagination={{ total: result.total, page, pageSize }}
/>
</div>
</CmsPageShell>
);
}

View File

@@ -1,40 +1,27 @@
import Link from 'next/link';
import { ArrowLeft, Send, CheckCircle } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ArrowLeft } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import {
INVOICE_STATUS_VARIANT,
INVOICE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
import { MarkPaidButton, SendInvoiceButton } from '../invoice-action-buttons';
interface PageProps {
params: Promise<{ account: string; id: 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) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
@@ -42,6 +29,7 @@ const formatCurrency = (amount: unknown) =>
export default async function InvoiceDetailPage({ params }: PageProps) {
const { account, id } = await params;
const t = await getTranslations('finance');
const client = getSupabaseServerClient();
const { data: acct } = await client
@@ -55,13 +43,13 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
const api = createFinanceApi(client);
const invoice = await api.getInvoiceWithItems(id);
if (!invoice) return <div>Rechnung nicht gefunden</div>;
if (!invoice) return <AccountNotFound />;
const status = String(invoice.status);
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
return (
<CmsPageShell account={account} title="Rechnungsdetails">
<CmsPageShell account={account} title={t('invoices.detailTitle')}>
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
@@ -70,7 +58,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Rechnungen
{t('invoices.backToList')}
</Link>
</div>
@@ -78,49 +66,43 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
Rechnung {String(invoice.invoice_number ?? '')}
{t('invoices.invoiceLabel', {
number: String(invoice.invoice_number ?? ''),
})}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
<Badge variant={INVOICE_STATUS_VARIANT[status] ?? 'secondary'}>
{t(INVOICE_STATUS_LABEL_KEYS[status] ?? status)}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Empfänger
<dt className="text-muted-foreground text-sm font-medium">
{t('invoices.recipient')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{String(invoice.recipient_name ?? '—')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Rechnungsdatum
<dt className="text-muted-foreground text-sm font-medium">
{t('invoices.issueDate')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(invoice.issue_date)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Fälligkeitsdatum
<dt className="text-muted-foreground text-sm font-medium">
{t('invoices.dueDate')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.due_date
? new Date(String(invoice.due_date)).toLocaleDateString(
'de-DE',
)
: '—'}
{formatDate(invoice.due_date)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Gesamtbetrag
<dt className="text-muted-foreground text-sm font-medium">
{t('invoices.amount')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.total_amount != null
@@ -133,16 +115,10 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
{/* Actions */}
<div className="mt-6 flex gap-3">
{status === 'draft' && (
<Button>
<Send className="mr-2 h-4 w-4" />
Senden
</Button>
<SendInvoiceButton invoiceId={id} accountId={acct.id} />
)}
{(status === 'sent' || status === 'overdue') && (
<Button variant="outline">
<CheckCircle className="mr-2 h-4 w-4" />
Bezahlt markieren
</Button>
<MarkPaidButton invoiceId={id} accountId={acct.id} />
)}
</div>
</CardContent>
@@ -151,33 +127,39 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
{/* Line Items */}
<Card>
<CardHeader>
<CardTitle>Positionen ({items.length})</CardTitle>
<CardTitle>
{t('invoiceForm.lineItems')} ({items.length})
</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Positionen vorhanden.
<p className="text-muted-foreground py-8 text-center text-sm">
{t('invoices.noItems')}
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
Beschreibung
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('invoiceForm.itemDescription')}
</th>
<th className="p-3 text-right font-medium">Menge</th>
<th className="p-3 text-right font-medium">
Einzelpreis
<th scope="col" className="p-3 text-right font-medium">
{t('invoiceForm.quantity')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('invoices.unitPriceCol')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('invoices.totalCol')}
</th>
<th className="p-3 text-right font-medium">Gesamt</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={String(item.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
{String(item.description ?? '—')}
@@ -199,9 +181,9 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
))}
</tbody>
<tfoot>
<tr className="border-t bg-muted/30">
<tr className="bg-muted/30 border-t">
<td colSpan={3} className="p-3 text-right font-medium">
Zwischensumme
{t('invoiceForm.subtotal')}
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.subtotal ?? 0)}
@@ -209,7 +191,9 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</tr>
<tr>
<td colSpan={3} className="p-3 text-right font-medium">
MwSt. ({Number(invoice.tax_rate ?? 19)}%)
{t('invoiceForm.tax', {
rate: Number(invoice.tax_rate ?? 19),
})}
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.tax_amount ?? 0)}
@@ -217,7 +201,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</tr>
<tr className="border-t font-semibold">
<td colSpan={3} className="p-3 text-right">
Gesamtbetrag
{t('invoiceForm.total')}
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.total_amount ?? 0)}

View File

@@ -0,0 +1,141 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { CheckCircle, Send } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
interface SendInvoiceButtonProps {
invoiceId: string;
accountId: string;
}
export function SendInvoiceButton({
invoiceId,
accountId,
}: SendInvoiceButtonProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSend = () => {
startTransition(async () => {
try {
const response = await fetch(
`/api/finance/invoices/${invoiceId}/send`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId }),
},
);
if (response.ok) {
router.refresh();
}
} catch (error) {
console.error('Failed to send invoice:', error);
}
});
};
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button disabled={isPending}>
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
Senden
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Rechnung senden?</AlertDialogTitle>
<AlertDialogDescription>
Diese Aktion kann nicht rückgängig gemacht werden. Die Rechnung wird
an den Empfänger gesendet und der Status auf Versendet" gesetzt.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={handleSend}>
{isPending ? 'Wird gesendet...' : 'Senden'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
interface MarkPaidButtonProps {
invoiceId: string;
accountId: string;
}
export function MarkPaidButton({ invoiceId, accountId }: MarkPaidButtonProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleMarkPaid = () => {
startTransition(async () => {
try {
const response = await fetch(
`/api/finance/invoices/${invoiceId}/mark-paid`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId }),
},
);
if (response.ok) {
router.refresh();
}
} catch (error) {
console.error('Failed to mark invoice as paid:', error);
}
});
};
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button variant="outline" disabled={isPending}>
<CheckCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Bezahlt markieren
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Als bezahlt markieren?</AlertDialogTitle>
<AlertDialogDescription>
Der Status der Rechnung wird auf „Bezahlt" gesetzt. Diese Aktion
bestätigt den Zahlungseingang.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={handleMarkPaid}>
{isPending ? 'Wird gespeichert...' : 'Bestätigen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,18 +1,32 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateInvoiceForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { getTranslations } from 'next-intl/server';
interface Props { params: Promise<{ account: string }> }
import { CreateInvoiceForm } from '@kit/finance/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewInvoicePage({ params }: Props) {
const { account } = await params;
const t = await getTranslations('finance');
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 <AccountNotFound />;
return (
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
<CmsPageShell
account={account}
title={t('invoices.newInvoice')}
description={t('invoices.newInvoiceDesc')}
>
<CreateInvoiceForm accountId={acct.id} account={account} />
</CmsPageShell>
);

View File

@@ -1,20 +1,21 @@
import Link from 'next/link';
import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import {
INVOICE_STATUS_VARIANT,
INVOICE_STATUS_LABEL,
INVOICE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
@@ -29,6 +30,7 @@ const formatCurrency = (amount: unknown) =>
export default async function InvoicesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('finance');
const { data: acct } = await client
.from('accounts')
@@ -39,51 +41,65 @@ export default async function InvoicesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client);
const invoices = await api.listInvoices(acct.id);
const invoicesResult = await api.listInvoices(acct.id);
const invoices = invoicesResult.data;
return (
<CmsPageShell account={account} title="Rechnungen">
<CmsPageShell account={account} title={t('invoices.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Rechnungen</h1>
<p className="text-muted-foreground">Rechnungen verwalten</p>
</div>
<Link href={`/home/${account}/finance/invoices/new`}>
<Button>
<Button asChild>
<Link href={`/home/${account}/finance/invoices/new`}>
<Plus className="mr-2 h-4 w-4" />
Neue Rechnung
</Button>
</Link>
{t('invoices.newInvoice')}
</Link>
</Button>
</div>
{/* Table or Empty State */}
{invoices.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Rechnungen vorhanden"
description="Erstellen Sie Ihre erste Rechnung."
actionLabel="Neue Rechnung"
title={t('invoices.noInvoices')}
description={t('invoices.createFirst')}
actionLabel={t('invoices.newInvoice')}
actionHref={`/home/${account}/finance/invoices/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Rechnungen ({invoices.length})</CardTitle>
<CardTitle>
{t('invoices.title')} ({invoices.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th>
<th className="p-3 text-left font-medium">Empfänger</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Fällig</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.invoiceNumber')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.recipient')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.issueDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.dueDate')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('common.amount')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
</tr>
</thead>
<tbody>
@@ -92,7 +108,7 @@ export default async function InvoicesPage({ params }: PageProps) {
return (
<tr
key={String(invoice.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs">
<Link
@@ -106,18 +122,10 @@ export default async function InvoicesPage({ params }: PageProps) {
{String(invoice.recipient_name ?? '—')}
</td>
<td className="p-3">
{invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(invoice.issue_date as string | null)}
</td>
<td className="p-3">
{invoice.due_date
? new Date(
String(invoice.due_date),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(invoice.due_date as string | null)}
</td>
<td className="p-3 text-right">
{invoice.total_amount != null
@@ -130,7 +138,7 @@ export default async function InvoicesPage({ params }: PageProps) {
INVOICE_STATUS_VARIANT[status] ?? 'secondary'
}
>
{INVOICE_STATUS_LABEL[status] ?? status}
{t(INVOICE_STATUS_LABEL_KEYS[status] ?? status)}
</Badge>
</td>
</tr>

View File

@@ -1,32 +1,61 @@
import Link from 'next/link';
import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react';
import {
Landmark,
FileText,
Euro,
ArrowRight,
Plus,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { createFinanceApi } from '@kit/finance/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL,
BATCH_STATUS_LABEL_KEYS,
INVOICE_STATUS_VARIANT,
INVOICE_STATUS_LABEL,
INVOICE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
const PAGE_SIZE = 25;
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function FinancePage({ params }: PageProps) {
function buildQuery(
base: Record<string, string | undefined>,
overrides: Record<string, string | number | undefined>,
): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries({ ...base, ...overrides })) {
if (value !== undefined && value !== '') {
params.set(key, String(value));
}
}
const qs = params.toString();
return qs ? `?${qs}` : '';
}
export default async function FinancePage({ params, searchParams }: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('finance');
const { data: acct } = await client
.from('accounts')
@@ -36,13 +65,20 @@ export default async function FinancePage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const q = typeof search.q === 'string' ? search.q : undefined;
const status = typeof search.status === 'string' ? search.status : undefined;
const page = Math.max(1, Number(search.page) || 1);
const api = createFinanceApi(client);
const [batches, invoices] = await Promise.all([
api.listBatches(acct.id),
api.listInvoices(acct.id),
const [batchesResult, invoicesResult] = await Promise.all([
api.listBatches(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }),
api.listInvoices(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }),
]);
const batches = batchesResult.data;
const invoices = invoicesResult.data;
const openAmount = invoices
.filter(
(inv: Record<string, unknown>) =>
@@ -54,114 +90,154 @@ export default async function FinancePage({ params }: PageProps) {
0,
);
// Use the larger of the two totals for pagination
const totalPages = Math.max(
batchesResult.totalPages,
invoicesResult.totalPages,
);
const safePage = page;
const queryBase = { q, status };
return (
<CmsPageShell account={account} title="Finanzen">
<CmsPageShell account={account} title={t('dashboard.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Finanzen</h1>
<p className="text-muted-foreground">
SEPA-Einzüge und Rechnungen
</p>
<p className="text-muted-foreground">{t('dashboard.subtitle')}</p>
</div>
<div className="flex gap-2">
<Link href={`/home/${account}/finance/invoices/new`}>
<Button variant="outline">
<Button variant="outline" asChild>
<Link href={`/home/${account}/finance/invoices/new`}>
<Plus className="mr-2 h-4 w-4" />
Neue Rechnung
</Button>
</Link>
<Link href={`/home/${account}/finance/sepa/new`}>
<Button>
{t('invoices.newInvoice')}
</Link>
</Button>
<Button asChild>
<Link href={`/home/${account}/finance/sepa/new`}>
<Plus className="mr-2 h-4 w-4" />
Neuer SEPA-Einzug
</Button>
</Link>
{t('nav.newBatch')}
</Link>
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="SEPA-Einzüge"
value={batches.length}
title={t('dashboard.sepaBatches')}
value={batchesResult.total}
icon={<Landmark className="h-5 w-5" />}
/>
<StatsCard
title="Rechnungen"
value={invoices.length}
title={t('invoices.title')}
value={invoicesResult.total}
icon={<FileText className="h-5 w-5" />}
/>
<StatsCard
title="Offener Betrag"
value={`${openAmount.toFixed(2)}`}
title={t('dashboard.openInvoices')}
value={formatCurrencyAmount(openAmount)}
icon={<Euro className="h-5 w-5" />}
/>
</div>
{/* Toolbar */}
<ListToolbar
searchPlaceholder={t('common.searchPlaceholder')}
filters={[
{
param: 'status',
label: t('common.status'),
options: [
{ value: '', label: t('common.all') },
{ value: 'draft', label: t('status.draft') },
{ value: 'ready', label: t('sepa.newBatch') },
{ value: 'sent', label: t('status.sent') },
{ value: 'paid', label: t('status.paid') },
{ value: 'overdue', label: t('status.overdue') },
{ value: 'cancelled', label: t('status.cancelled') },
],
},
]}
/>
{/* SEPA Batches */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Letzte SEPA-Einzüge</CardTitle>
<Link href={`/home/${account}/finance/sepa`}>
<Button variant="ghost" size="sm">
Alle anzeigen
<CardTitle>
{t('sepa.title')} ({batchesResult.total})
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/home/${account}/finance/sepa`}>
{t('common.showAll')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
</CardHeader>
<CardContent>
{batches.length === 0 ? (
<EmptyState
icon={<Landmark className="h-8 w-8" />}
title="Keine SEPA-Einzüge"
description="Erstellen Sie Ihren ersten SEPA-Einzug."
actionLabel="Neuer SEPA-Einzug"
title={t('sepa.noBatches')}
description={t('sepa.createFirst')}
actionLabel={t('nav.newBatch')}
actionHref={`/home/${account}/finance/sepa/new`}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Datum</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.type')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('common.amount')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.date')}
</th>
</tr>
</thead>
<tbody>
{batches.map((batch: Record<string, unknown>) => (
<tr
key={String(batch.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
<Badge
variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
}
>
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
{t(
BATCH_STATUS_LABEL_KEYS[String(batch.status)] ??
String(batch.status),
)}
</Badge>
</td>
<td className="p-3">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
? t('sepa.directDebit')
: t('sepa.creditTransfer')}
</td>
<td className="p-3 text-right">
{batch.total_amount != null
? `${Number(batch.total_amount).toFixed(2)}`
? formatCurrencyAmount(batch.total_amount as number)
: '—'}
</td>
<td className="p-3">
{batch.execution_date
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
: batch.created_at
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
: '—'}
{formatDate(
(batch.execution_date ?? batch.created_at) as
| string
| null,
)}
</td>
</tr>
))}
@@ -175,39 +251,49 @@ export default async function FinancePage({ params }: PageProps) {
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Letzte Rechnungen</CardTitle>
<Link href={`/home/${account}/finance/invoices`}>
<Button variant="ghost" size="sm">
Alle anzeigen
<CardTitle>
{t('invoices.title')} ({invoicesResult.total})
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/home/${account}/finance/invoices`}>
{t('common.showAll')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Rechnungen"
description="Erstellen Sie Ihre erste Rechnung."
actionLabel="Neue Rechnung"
title={t('invoices.noInvoices')}
description={t('invoices.createFirst')}
actionLabel={t('invoices.newInvoice')}
actionHref={`/home/${account}/finance/invoices/new`}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th>
<th className="p-3 text-left font-medium">Empfänger</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.invoiceNumber')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('invoices.recipient')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('common.amount')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice: Record<string, unknown>) => (
<tr
key={String(invoice.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs">
<Link
@@ -222,7 +308,9 @@ export default async function FinancePage({ params }: PageProps) {
</td>
<td className="p-3 text-right">
{invoice.total_amount != null
? `${Number(invoice.total_amount).toFixed(2)}`
? formatCurrencyAmount(
invoice.total_amount as number,
)
: '—'}
</td>
<td className="p-3">
@@ -232,8 +320,11 @@ export default async function FinancePage({ params }: PageProps) {
'secondary'
}
>
{INVOICE_STATUS_LABEL[String(invoice.status)] ??
String(invoice.status)}
{t(
INVOICE_STATUS_LABEL_KEYS[
String(invoice.status)
] ?? String(invoice.status),
)}
</Badge>
</td>
</tr>
@@ -244,6 +335,60 @@ export default async function FinancePage({ params }: PageProps) {
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{t('common.page')} {safePage} {t('common.of')} {totalPages}
</p>
<div className="flex items-center gap-1">
{safePage > 1 ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/finance${buildQuery(queryBase, { page: safePage - 1 })}`}
aria-label={t('common.previous')}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled
aria-label={t('common.previous')}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<span className="px-3 text-sm font-medium">
{safePage} / {totalPages}
</span>
{safePage < totalPages ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/finance${buildQuery(queryBase, { page: safePage + 1 })}`}
aria-label={t('common.next')}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled
aria-label={t('common.next')}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
)}
</div>
</CmsPageShell>
);

View File

@@ -1,17 +1,17 @@
import Link from 'next/link';
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -24,6 +24,7 @@ const formatCurrency = (amount: number) =>
export default async function PaymentsPage({ params }: PageProps) {
const { account } = await params;
const t = await getTranslations('finance');
const client = getSupabaseServerClient();
const { data: acct } = await client
@@ -36,10 +37,12 @@ export default async function PaymentsPage({ params }: PageProps) {
const api = createFinanceApi(client);
const [batches, invoices] = await Promise.all([
const [batchesResult, invoicesResult] = await Promise.all([
api.listBatches(acct.id),
api.listInvoices(acct.id),
]);
const batches = batchesResult.data;
const invoices = invoicesResult.data;
const paidInvoices = invoices.filter(
(inv: Record<string, unknown>) => inv.status === 'paid',
@@ -77,41 +80,38 @@ export default async function PaymentsPage({ params }: PageProps) {
);
return (
<CmsPageShell account={account} title="Zahlungen">
<CmsPageShell account={account} title={t('payments.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Zahlungsübersicht</h1>
<p className="text-muted-foreground">
Zusammenfassung aller Zahlungen und offenen Beträge
</p>
<p className="text-muted-foreground">{t('payments.subtitle')}</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Bezahlt"
title={t('payments.statPaid')}
value={formatCurrency(paidTotal)}
icon={<Euro className="h-5 w-5" />}
description={`${paidInvoices.length} Rechnungen`}
description={`${paidInvoices.length} ${t('payments.paidInvoices')}`}
/>
<StatsCard
title="Offen"
title={t('payments.statOpen')}
value={formatCurrency(openTotal)}
icon={<CreditCard className="h-5 w-5" />}
description={`${openInvoices.length} Rechnungen`}
description={`${openInvoices.length} ${t('invoices.title')}`}
/>
<StatsCard
title="Überfällig"
title={t('payments.statOverdue')}
value={formatCurrency(overdueTotal)}
icon={<TrendingUp className="h-5 w-5" />}
description={`${overdueInvoices.length} Rechnungen`}
description={`${overdueInvoices.length} ${t('invoices.title')}`}
/>
<StatsCard
title="SEPA-Einzüge"
title={t('payments.sepaBatches')}
value={formatCurrency(sepaTotal)}
icon={<Euro className="h-5 w-5" />}
description={`${batches.length} Einzüge`}
description={`${batches.length} ${t('payments.batchUnit')}`}
/>
</div>
@@ -119,45 +119,57 @@ export default async function PaymentsPage({ params }: PageProps) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Offene Rechnungen</CardTitle>
<Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}>
<CardTitle className="text-base">
{t('payments.openInvoices')}
</CardTitle>
<Badge
variant={openInvoices.length > 0 ? 'default' : 'secondary'}
>
{openInvoices.length}
</Badge>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
<p className="text-muted-foreground mb-4 text-sm">
{openInvoices.length > 0
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
: 'Keine offenen Rechnungen vorhanden.'}
? t('payments.invoicesOpenSummary', {
count: openInvoices.length,
total: formatCurrency(openTotal),
})
: t('payments.noOpenInvoices')}
</p>
<Link href={`/home/${account}/finance/invoices`}>
<Button variant="outline" size="sm">
Rechnungen anzeigen
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/finance/invoices`}>
{t('payments.viewInvoices')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">SEPA-Einzüge</CardTitle>
<CardTitle className="text-base">
{t('payments.sepaBatches')}
</CardTitle>
<Badge variant={batches.length > 0 ? 'default' : 'secondary'}>
{batches.length}
</Badge>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
<p className="text-muted-foreground mb-4 text-sm">
{batches.length > 0
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
: 'Keine SEPA-Einzüge vorhanden.'}
? t('payments.batchSummary', {
count: batches.length,
total: formatCurrency(sepaTotal),
})
: t('payments.noBatchesFound')}
</p>
<Link href={`/home/${account}/finance/sepa`}>
<Button variant="outline" size="sm">
Einzüge anzeigen
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/finance/sepa`}>
{t('payments.viewBatches')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</Link>
</Button>
</CardContent>
</Card>
</div>

View File

@@ -1,40 +1,26 @@
import Link from 'next/link';
import { ArrowLeft, Download } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string; batchId: 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 ITEM_STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
@@ -44,12 +30,6 @@ const ITEM_STATUS_VARIANT: Record<
failed: 'destructive',
};
const ITEM_STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
processed: 'Verarbeitet',
failed: 'Fehlgeschlagen',
};
const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
@@ -57,6 +37,7 @@ const formatCurrency = (amount: unknown) =>
export default async function SepaBatchDetailPage({ params }: PageProps) {
const { account, batchId } = await params;
const t = await getTranslations('finance');
const client = getSupabaseServerClient();
const { data: acct } = await client
@@ -74,12 +55,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
api.getBatchItems(batchId),
]);
if (!batch) return <div>Einzug nicht gefunden</div>;
if (!batch) return <AccountNotFound />;
const status = String(batch.status);
return (
<CmsPageShell account={account} title="SEPA-Einzug Details">
<CmsPageShell account={account} title={t('sepa.detailTitle')}>
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
@@ -88,7 +69,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu SEPA-Lastschriften
{t('sepa.backToList')}
</Link>
</div>
@@ -96,27 +77,27 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{String(batch.description ?? 'SEPA-Einzug')}
{String(batch.description ?? t('sepa.batchFallbackName'))}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
<Badge variant={BATCH_STATUS_VARIANT[status] ?? 'secondary'}>
{t(BATCH_STATUS_LABEL_KEYS[status] ?? status)}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Typ
<dt className="text-muted-foreground text-sm font-medium">
{t('common.type')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
? t('sepa.directDebit')
: t('sepa.creditTransfer')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Betrag
<dt className="text-muted-foreground text-sm font-medium">
{t('common.amount')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.total_amount != null
@@ -125,23 +106,19 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Anzahl
<dt className="text-muted-foreground text-sm font-medium">
{t('sepa.itemCountLabel')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{String(batch.item_count ?? items.length)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Ausführungsdatum
<dt className="text-muted-foreground text-sm font-medium">
{t('sepa.executionDate')}
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(batch.execution_date)}
</dd>
</div>
</dl>
@@ -149,7 +126,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
<div className="mt-6">
<Button disabled variant="outline">
<Download className="mr-2 h-4 w-4" />
XML herunterladen
{t('sepa.downloadXml')}
</Button>
</div>
</CardContent>
@@ -158,22 +135,32 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
{/* Items Table */}
<Card>
<CardHeader>
<CardTitle>Positionen ({items.length})</CardTitle>
<CardTitle>
{t('sepa.itemCount')} ({items.length})
</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Positionen vorhanden.
<p className="text-muted-foreground py-8 text-center text-sm">
{t('sepa.noItems')}
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] 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">IBAN</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
Name
</th>
<th scope="col" className="p-3 text-left font-medium">
IBAN
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('common.amount')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
</tr>
</thead>
<tbody>
@@ -182,7 +169,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
return (
<tr
key={String(item.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(item.debtor_name ?? '—')}
@@ -201,7 +188,11 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
ITEM_STATUS_VARIANT[itemStatus] ?? 'secondary'
}
>
{ITEM_STATUS_LABEL[itemStatus] ?? itemStatus}
{t(
`sepaItemStatus.${itemStatus}` as Parameters<
typeof t
>[0],
) ?? itemStatus}
</Badge>
</td>
</tr>

View File

@@ -1,8 +1,10 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getTranslations } from 'next-intl/server';
import { CreateSepaBatchForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -10,6 +12,7 @@ interface Props {
export default async function NewSepaBatchPage({ params }: Props) {
const { account } = await params;
const t = await getTranslations('finance');
const client = getSupabaseServerClient();
const { data: acct } = await client
@@ -21,7 +24,11 @@ export default async function NewSepaBatchPage({ params }: Props) {
if (!acct) return <AccountNotFound />;
return (
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
<CmsPageShell
account={account}
title={t('sepa.newBatch')}
description={t('sepa.newBatchDesc')}
>
<CreateSepaBatchForm accountId={acct.id} account={account} />
</CmsPageShell>
);

View File

@@ -1,20 +1,21 @@
import Link from 'next/link';
import { Landmark, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL,
BATCH_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
@@ -29,6 +30,7 @@ const formatCurrency = (amount: unknown) =>
export default async function SepaPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('finance');
const { data: acct } = await client
.from('accounts')
@@ -39,56 +41,66 @@ export default async function SepaPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client);
const batches = await api.listBatches(acct.id);
const batchesResult = await api.listBatches(acct.id);
const batches = batchesResult.data;
return (
<CmsPageShell account={account} title="SEPA-Lastschriften">
<CmsPageShell account={account} title={t('sepa.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">SEPA-Lastschriften</h1>
<p className="text-muted-foreground">
Lastschrifteinzüge verwalten
</p>
</div>
<Link href={`/home/${account}/finance/sepa/new`}>
<Button>
<Button asChild>
<Link href={`/home/${account}/finance/sepa/new`}>
<Plus className="mr-2 h-4 w-4" />
Neuer Einzug
</Button>
</Link>
{t('nav.newBatch')}
</Link>
</Button>
</div>
{/* Table or Empty State */}
{batches.length === 0 ? (
<EmptyState
icon={<Landmark className="h-8 w-8" />}
title="Keine SEPA-Einzüge"
description="Erstellen Sie Ihren ersten SEPA-Einzug."
actionLabel="Neuer Einzug"
title={t('sepa.noBatches')}
description={t('sepa.createFirst')}
actionLabel={t('nav.newBatch')}
actionHref={`/home/${account}/finance/sepa/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Einzüge ({batches.length})</CardTitle>
<CardTitle>
{t('sepa.title')} ({batches.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-left font-medium">
Beschreibung
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-right font-medium">Anzahl</th>
<th className="p-3 text-left font-medium">
Ausführungsdatum
<th scope="col" className="p-3 text-left font-medium">
{t('common.type')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.description')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('sepa.totalAmount')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('sepa.itemCount')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('sepa.executionDate')}
</th>
</tr>
</thead>
@@ -96,22 +108,25 @@ export default async function SepaPage({ params }: PageProps) {
{batches.map((batch: Record<string, unknown>) => (
<tr
key={String(batch.id)}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
<Badge
variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
}
>
{BATCH_STATUS_LABEL[String(batch.status)] ??
String(batch.status)}
{t(
BATCH_STATUS_LABEL_KEYS[String(batch.status)] ??
String(batch.status),
)}
</Badge>
</td>
<td className="p-3">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
? t('sepa.directDebit')
: t('sepa.creditTransfer')}
</td>
<td className="p-3">
<Link
@@ -130,11 +145,7 @@ export default async function SepaPage({ params }: PageProps) {
{String(batch.item_count ?? 0)}
</td>
<td className="p-3">
{batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
{formatDate(batch.execution_date as string | null)}
</td>
</tr>
))}

View File

@@ -1,9 +1,15 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components';
import { getTranslations } from 'next-intl/server';
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CatchBooksDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -14,6 +20,7 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('fischerei');
const { data: acct } = await client
.from('accounts')
@@ -25,7 +32,18 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
const api = createFischereiApi(client);
const page = Number(search.page) || 1;
const currentYear = new Date().getFullYear();
const yearOptions = [
{ value: '', label: 'Alle Jahre' },
...Array.from({ length: 4 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
})),
];
const result = await api.listCatchBooks(acct.id, {
search: search.q as string,
year: search.year ? Number(search.year) : undefined,
status: search.status as string,
page,
@@ -33,14 +51,31 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
});
return (
<CmsPageShell account={account} title="Fischerei - Fangbücher">
<CmsPageShell account={account} title={t('pages.catchBooksTitle')}>
<FischereiTabNavigation account={account} activeTab="catch-books" />
<ListToolbar
searchPlaceholder="Mitglied suchen..."
filters={[
{ param: 'year', label: 'Jahr', options: yearOptions },
{
param: 'status',
label: 'Status',
options: [
{ value: '', label: 'Alle' },
{ value: 'open', label: 'Offen' },
{ value: 'submitted', label: 'Eingereicht' },
{ value: 'checked', label: 'Geprüft' },
],
},
]}
/>
<CatchBooksDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
accountId={acct.id}
/>
</CmsPageShell>
);

View File

@@ -0,0 +1,48 @@
import { getTranslations } from 'next-intl/server';
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateCompetitionForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewCompetitionPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('fischerei');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
return (
<CmsPageShell account={account} title={t('pages.newCompetitionTitle')}>
<FischereiTabNavigation account={account} activeTab="competitions" />
<CreateCompetitionForm
accountId={acct.id}
account={account}
waters={waters}
/>
</CmsPageShell>
);
}

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