9 Commits

Author SHA1 Message Date
Zaid Marzguioui
9cbe6652a1 fix: close all remaining known gaps across modules
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
Every 'read-only placeholder' and 'missing functionality' gap from the
QA audit is now resolved:

COURSES — categories/instructors/locations can now be deleted:
- Added update/delete methods to course-reference-data.service.ts
- Added deleteCategory/deleteInstructor/deleteLocation server actions
- Created DeleteRefDataButton client component with confirmation dialog
- Wired delete buttons into all three table pages

BOOKINGS — calendar month navigation now works:
- Calendar was hardcoded to current month with disabled prev/next
- Added year/month search params for server-side month rendering
- Replaced disabled buttons with Link-based navigation
- Verified: clicking next/prev correctly renders different months

DOCUMENTS — templates page now reads from database:
- Was hardcoded empty array; now queries document_templates table
- Table exists since migration 20260414000006_shared_templates.sql

FISCHEREI — statistics page shows real data:
- Replaced dashed-border placeholder with 6 real stat cards
- Queries waters, species, stocking, catch_books, leases, permits
- Shows counts + stocking costs + pending catch books
- Falls back to helpful message when no data exists

VERBAND — statistics page shows real KPIs:
- Added server-side data fetching (clubs, members, fees)
- Passes activeClubs, totalMembers, openFees as props
- Added 4 KPI cards: Aktive Vereine, Gesamtmitglieder,
  ∅ Mitglieder/Verein, Offene Beiträge
- Kept existing trend charts below KPI cards
2026-04-03 23:52:25 +02:00
Zaid Marzguioui
ad01ecb8b9 feat: wire 10 dead buttons across 6 modules to their server actions
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped
Every module had buttons that rendered visually but did nothing when
clicked. Server actions existed for all of them. Created client
components with dialogs/forms and wired them in.

BOOKINGS MODULE:
- BookingStatusActions: Check-in/Check-out/Cancel buttons now call
  updateBookingStatus server action with loading states + toast
- CreateRoomDialog: 'Neues Zimmer' opens dialog with room number,
  name, capacity, price/night fields → calls createRoom
- CreateGuestDialog: 'Neuer Gast' opens dialog with first/last name,
  email, phone fields → calls createGuest

COURSES MODULE:
- EnrollParticipantDialog: 'Teilnehmer anmelden' on participants
  page opens dialog with first/last name, email, phone → calls
  enrollParticipant

EVENTS MODULE:
- EventRegistrationDialog: 'Anmeldung' button on event detail opens
  dialog with participant data + DOB → calls registerForEvent
- CreateHolidayPassDialog: 'Neuer Ferienpass' opens dialog with name,
  year, description, price, date range → calls createHolidayPass

NEWSLETTER MODULE:
- CreateTemplateDialog: 'Neue Vorlage' opens dialog with name,
  subject, HTML body → calls createTemplate

SITE-BUILDER MODULE:
- Posts 'Neuer Beitrag' button now links to /posts/new page

All dialogs use German labels, helpful placeholders, loading spinners,
toast notifications, and form validation appropriate for association
board members (Vereinsvorstände, 40-65, moderate tech skills).
2026-04-03 23:33:42 +02:00
Zaid Marzguioui
7cfd88f1c3 feat(finance): add SEPA batch actions — populate, manual add, XML download
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m54s
Workflow / ⚫️ Test (push) Has been skipped
The SEPA batch detail page was a dead end: users could create a batch
but had no way to add payment positions or generate the XML file.

Added SepaBatchActions client component with three key workflows:

1. 'Mitglieder hinzufügen' — auto-populates batch from all active
   members who have a SEPA mandate and dues category (calls existing
   populateBatchFromMembers server action)

2. 'Einzelposition' — dialog to manually add a single debit item
   with Name, IBAN, Amount, and Verwendungszweck fields

3. 'XML herunterladen' — dialog for creditor info (Gläubiger-Name,
   IBAN, BIC, Gläubiger-ID) then generates and triggers download of
   the SEPA pain.008 XML file. Disabled when batch has 0 positions.

Also fixed: SEPA list page crashed because a Server Component had an
onClick handler on a <tr> — removed the invalid event handler.

Target demographic: German association treasurers (Kassenwarte) who
need a straightforward workflow for annual membership fee collection
via SEPA Lastschrift.
2026-04-03 22:47:35 +02:00
Zaid Marzguioui
1215e351c1 fix: UX improvements for German association users
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
- fix(member-detail): display gender in German (Männlich/Weiblich/Divers)
  instead of raw English enum values (male/female/diverse)

- fix(member-detail): display country names in German (Österreich, Deutschland)
  instead of raw ISO codes (AT, DE)

- fix(member-statistics): total member count was always 0
  getStatistics() returns per-status counts without a total key;
  now computes total by summing all status counts

- fix(i18n): add 56 breadcrumb segment translations for DE and EN
  Breadcrumbs were showing English path segments (Courses, Calendar,
  Registrations) because translation keys for URL path segments were
  missing. Added all segment-level route translations so breadcrumbs
  now display in German throughout the app.
2026-04-03 22:10:02 +02:00
Zaid Marzguioui
9f83b5cc75 Merge branch 'main' of https://gitea.frontieralgorithmics.de/zaid.marzguioui/myeasycms-v2
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m40s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-03 18:42:10 +02:00
Zaid Marzguioui
5b169a381f fix: resolve 4 QA bugs found in Docker production build
- fix(member-management): Zod v4 .partial() on refined schema crash
  Separated CreateMemberBaseSchema from superRefine so .partial()
  works for UpdateMemberSchema. Fixes members-cms page crash.

- fix(course-management): snake_case→camelCase stats normalization
  getQuickStats RPC returns snake_case keys but templates expect
  camelCase. Added normalization layer so stats cards display values.

- fix(blog): add missing cover images for 5 German blog posts
  Posts referenced /images/posts/*.webp that didn't exist.

- fix(docker): remove non-existent catch_entries table from bootstrap
  dev-bootstrap.sh granted permissions on catch_entries which has no
  migration. Removed the stale reference.

- docs: add qa-checklist.md with full test report
2026-04-03 18:41:51 +02:00
T. Zehetbauer
9d5fe58ee3 feat: add shared notification, communication, and export services for bookings, courses, and events; introduce btree_gist extension and new booking atomic function
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m42s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-03 17:03:34 +02:00
4d538a5668 Merge pull request 'feat/members-redesign' (#1) from feat/members-redesign into main
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m44s
Workflow / ⚫️ Test (push) Has been skipped
Reviewed-on: #1
2026-04-03 12:11:03 +00:00
Zaid Marzguioui
b6092adc3e fix(billing): add billing feature flags as Dockerfile build args
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 16m1s
Workflow / ⚫️ Test (push) Has been skipped
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING and
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING must be set at build time
(ARG/ENV in Dockerfile + build args in docker-compose) because Next.js
bakes NEXT_PUBLIC_* into the bundle during 'next build'. Setting them
only as runtime environment vars has no effect.
2026-04-03 11:51:02 +02:00
79 changed files with 6272 additions and 376 deletions

View File

@@ -20,6 +20,8 @@ ARG NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=true
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
ARG NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
ARG NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI} ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
@@ -30,6 +32,8 @@ ENV NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=${NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS}
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG} ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER} ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
ENV NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=${NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING}
ENV NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=${NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING}
RUN pnpm --filter web build RUN pnpm --filter web build
# --- Run (slim for smaller image than full Debian) --- # --- Run (slim for smaller image than full Debian) ---

View File

@@ -4,13 +4,11 @@ import {
ArrowLeft, ArrowLeft,
BedDouble, BedDouble,
CalendarDays, CalendarDays,
LogIn,
LogOut,
XCircle,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { BookingStatusActions } from '@kit/booking-management/components';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates'; import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -288,41 +286,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardDescription>{t('detail.changeStatus')}</CardDescription> <CardDescription>{t('detail.changeStatus')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-3"> <BookingStatusActions
{(status === 'pending' || status === 'confirmed') && ( bookingId={bookingId}
<Button variant="default"> status={status}
<LogIn className="mr-2 h-4 w-4" /> />
{t('detail.checkIn')}
</Button>
)}
{status === 'checked_in' && (
<Button variant="default">
<LogOut className="mr-2 h-4 w-4" />
{t('detail.checkOut')}
</Button>
)}
{status !== 'cancelled' &&
status !== 'checked_out' &&
status !== 'no_show' && (
<Button variant="destructive">
<XCircle className="mr-2 h-4 w-4" />
{t('detail.cancel')}
</Button>
)}
{status === 'cancelled' || status === 'checked_out' ? (
<p className="text-muted-foreground py-2 text-sm">
{t('detail.noMoreActions', {
statusLabel:
status === 'cancelled'
? t('detail.cancelledStatus')
: t('detail.completedStatus'),
})}
</p>
) : null}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -51,8 +52,9 @@ function isDateInRange(
return date >= checkIn && date < checkOut; return date >= checkIn && date < checkOut;
} }
export default async function BookingCalendarPage({ params }: PageProps) { export default async function BookingCalendarPage({ params, searchParams }: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams;
const t = await getTranslations('bookings'); const t = await getTranslations('bookings');
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) {
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = Number(search.year) || now.getFullYear();
const month = now.getMonth(); const month = search.month != null ? Number(search.month) - 1 : now.getMonth();
// Compute prev/next month for navigation links
const prevMonth = month === 0 ? 12 : month;
const prevYear = month === 0 ? year - 1 : year;
const nextMonth = month === 11 ? 1 : month + 2;
const nextYear = month === 11 ? year + 1 : year;
const daysInMonth = getDaysInMonth(year, month); const daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month); const firstWeekday = getFirstWeekday(year, month);
@@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled asChild
aria-label={t('calendar.previousMonth')} aria-label={t('calendar.previousMonth')}
> >
<ChevronLeft className="h-4 w-4" aria-hidden="true" /> <Link href={`/home/${account}/bookings/calendar?year=${prevYear}&month=${prevMonth}`}>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button> </Button>
<CardTitle> <CardTitle>
{MONTH_NAMES[month]} {year} {MONTH_NAMES[month]} {year}
@@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled asChild
aria-label={t('calendar.nextMonth')} aria-label={t('calendar.nextMonth')}
> >
<ChevronRight className="h-4 w-4" aria-hidden="true" /> <Link href={`/home/${account}/bookings/calendar?year=${nextYear}&month=${nextMonth}`}>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -2,6 +2,7 @@ import { UserCircle, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateGuestDialog } from '@kit/booking-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -41,10 +42,7 @@ export default async function GuestsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">{t('guests.manage')}</p> <p className="text-muted-foreground">{t('guests.manage')}</p>
<Button data-test="guests-new-btn"> <CreateGuestDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
{t('guests.newGuest')}
</Button>
</div> </div>
{guests.length === 0 ? ( {guests.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { BedDouble, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateRoomDialog } from '@kit/booking-management/components';
import { formatCurrencyAmount } from '@kit/shared/dates'; import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -43,10 +44,7 @@ export default async function RoomsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">{t('rooms.manage')}</p> <p className="text-muted-foreground">{t('rooms.manage')}</p>
<Button data-test="rooms-new-btn"> <CreateRoomDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
{t('rooms.newRoom')}
</Button>
</div> </div>
{rooms.length === 0 ? ( {rooms.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { Plus, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { EnrollParticipantDialog } from '@kit/course-management/components';
import { formatDate } from '@kit/shared/dates'; import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -56,10 +57,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
{participants.length} {t('participants.title')} {participants.length} {t('participants.title')}
</p> </p>
</div> </div>
<Button data-test="participants-add-btn"> <EnrollParticipantDialog courseId={courseId} />
<Plus className="mr-2 h-4 w-4" />
{t('participants.add')}
</Button>
</div> </div>
{participants.length === 0 ? ( {participants.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -67,6 +68,7 @@ export default async function CategoriesPage({ params }: PageProps) {
<th scope="col" className="p-3 text-left font-medium"> <th scope="col" className="p-3 text-left font-medium">
{t('common.parent')} {t('common.parent')}
</th> </th>
<th scope="col" className="w-16 p-3" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) {
{String(cat.description ?? '—')} {String(cat.description ?? '—')}
</td> </td>
<td className="p-3">{String(cat.parent_id ?? '—')}</td> <td className="p-3">{String(cat.parent_id ?? '—')}</td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(cat.id)}
type="category"
itemName={String(cat.name)}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -2,6 +2,7 @@ import { GraduationCap } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { formatCurrencyAmount } from '@kit/shared/dates'; import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -74,6 +75,7 @@ export default async function InstructorsPage({ params }: PageProps) {
<th scope="col" className="p-3 text-right font-medium"> <th scope="col" className="p-3 text-right font-medium">
{t('instructors.hourlyRate')} {t('instructors.hourlyRate')}
</th> </th>
<th scope="col" className="w-16 p-3" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -96,6 +98,13 @@ export default async function InstructorsPage({ params }: PageProps) {
? formatCurrencyAmount(inst.hourly_rate as number) ? formatCurrencyAmount(inst.hourly_rate as number)
: '—'} : '—'}
</td> </td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(inst.id)}
type="instructor"
itemName={`${inst.first_name} ${inst.last_name}`}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -2,6 +2,7 @@ import { MapPin } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -70,6 +71,7 @@ export default async function LocationsPage({ params }: PageProps) {
<th scope="col" className="p-3 text-right font-medium"> <th scope="col" className="p-3 text-right font-medium">
{t('list.capacity')} {t('list.capacity')}
</th> </th>
<th scope="col" className="w-16 p-3" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -89,6 +91,13 @@ export default async function LocationsPage({ params }: PageProps) {
<td className="p-3 text-right"> <td className="p-3 text-right">
{String(loc.capacity ?? '—')} {String(loc.capacity ?? '—')}
</td> </td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(loc.id)}
type="location"
itemName={String(loc.name)}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -26,13 +26,19 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
// Document templates are stored locally for now — placeholder for future DB integration // Fetch document templates from DB
const templates: Array<{ const { data: templates } = await client
id: string; .from('document_templates')
name: string; .select('id, name, template_type, description')
type: string; .eq('account_id', acct.id)
description: string; .order('name');
}> = [];
const templatesList = (templates ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
type: String(t.template_type ?? '—'),
description: String(t.description ?? ''),
}));
return ( return (
<CmsPageShell account={account} title={t('templates.title')}> <CmsPageShell account={account} title={t('templates.title')}>
@@ -50,7 +56,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
</div> </div>
{/* Table or Empty State */} {/* Table or Empty State */}
{templates.length === 0 ? ( {templatesList.length === 0 ? (
<EmptyState <EmptyState
icon={<FileText className="h-8 w-8" />} icon={<FileText className="h-8 w-8" />}
title={t('templates.noTemplates')} title={t('templates.noTemplates')}
@@ -61,7 +67,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{t('templates.allTemplates', { count: templates.length })} {t('templates.allTemplates', { count: templatesList.length })}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -81,7 +87,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{templates.map((template) => ( {templatesList.map((template) => (
<tr <tr
key={template.id} key={template.id}
className="hover:bg-muted/30 border-b" className="hover:bg-muted/30 border-b"

View File

@@ -11,6 +11,7 @@ import {
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api'; import { createEventManagementApi } from '@kit/event-management/api';
import { EventRegistrationDialog } from '@kit/event-management/components';
import { formatDate } from '@kit/shared/dates'; import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -73,10 +74,7 @@ export default async function EventDetailPage({ params }: PageProps) {
)} )}
</Badge> </Badge>
</div> </div>
<Button> <EventRegistrationDialog eventId={eventId} eventName={String((event as any).name ?? '')} />
<UserPlus className="mr-2 h-4 w-4" />
{t('register')}
</Button>
</div> </div>
{/* Detail Cards */} {/* Detail Cards */}

View File

@@ -2,6 +2,7 @@ import { Ticket, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api'; import { createEventManagementApi } from '@kit/event-management/api';
import { CreateHolidayPassDialog } from '@kit/event-management/components';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates'; import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -40,10 +41,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
{t('holidayPassesDescription')} {t('holidayPassesDescription')}
</p> </p>
</div> </div>
<Button> <CreateHolidayPassDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
{t('newHolidayPass')}
</Button>
</div> </div>
{passes.length === 0 ? ( {passes.length === 0 ? (

View File

@@ -1,13 +1,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Download } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api'; import { createFinanceApi } from '@kit/finance/api';
import { SepaBatchActions } from '@kit/finance/components';
import { formatDate } from '@kit/shared/dates'; import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
@@ -124,10 +124,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dl> </dl>
<div className="mt-6"> <div className="mt-6">
<Button disabled variant="outline"> <SepaBatchActions
<Download className="mr-2 h-4 w-4" /> batchId={batchId}
{t('sepa.downloadXml')} accountId={acct.id}
</Button> batchStatus={status}
itemCount={items.length}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -108,7 +108,7 @@ export default async function SepaPage({ params }: PageProps) {
{batches.map((batch: Record<string, unknown>) => ( {batches.map((batch: Record<string, unknown>) => (
<tr <tr
key={String(batch.id)} key={String(batch.id)}
className="hover:bg-muted/30 border-b" className="hover:bg-muted/30 cursor-pointer border-b"
> >
<td className="p-3"> <td className="p-3">
<Badge <Badge
@@ -131,7 +131,7 @@ export default async function SepaPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Link <Link
href={`/home/${account}/finance/sepa/${String(batch.id)}`} href={`/home/${account}/finance/sepa/${String(batch.id)}`}
className="hover:underline" className="font-medium hover:underline"
> >
{String(batch.description ?? '—')} {String(batch.description ?? '—')}
</Link> </Link>

View File

@@ -24,6 +24,29 @@ export default async function StatisticsPage({ params }: Props) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
// Fetch actual statistics from existing tables
const [watersResult, speciesResult, stockingResult, catchBooksResult, leasesResult, permitsResult] = await Promise.allSettled([
client.from('waters').select('id', { count: 'exact' }).eq('account_id', acct.id),
client.from('fish_species').select('id', { count: 'exact' }).eq('account_id', acct.id),
client.from('fish_stocking').select('id, quantity, cost_total', { count: 'exact' }).eq('account_id', acct.id),
client.from('catch_books').select('id, status', { count: 'exact' }).eq('account_id', acct.id),
client.from('fishing_leases').select('id', { count: 'exact' }).eq('account_id', acct.id).eq('status', 'active'),
client.from('fishing_permits').select('id', { count: 'exact' }).eq('account_id', acct.id),
]);
const waterCount = watersResult.status === 'fulfilled' ? (watersResult.value.count ?? 0) : 0;
const speciesCount = speciesResult.status === 'fulfilled' ? (speciesResult.value.count ?? 0) : 0;
const stockingData = stockingResult.status === 'fulfilled' ? (stockingResult.value.data ?? []) : [];
const stockingCount = stockingData.length;
const stockingCost = stockingData.reduce((sum: number, s: any) => sum + (Number(s.cost_total) || 0), 0);
const catchBookCount = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.count ?? 0) : 0;
const catchBookData = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.data ?? []) : [];
const pendingCatchBooks = catchBookData.filter((cb: any) => cb.status === 'submitted').length;
const leaseCount = leasesResult.status === 'fulfilled' ? (leasesResult.value.count ?? 0) : 0;
const permitCount = permitsResult.status === 'fulfilled' ? (permitsResult.value.count ?? 0) : 0;
const formatCurrency = (v: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
return ( return (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}> <CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<FischereiTabNavigation account={account} activeTab="statistics" /> <FischereiTabNavigation account={account} activeTab="statistics" />
@@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) {
Fangstatistiken und Auswertungen Fangstatistiken und Auswertungen
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Gewässer</p><p className="text-2xl font-bold">{waterCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fischarten</p><p className="text-2xl font-bold">{speciesCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Besatzaktionen</p><p className="text-2xl font-bold">{stockingCount}</p><p className="text-muted-foreground text-xs">{formatCurrency(stockingCost)} Gesamtkosten</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fangbücher</p><p className="text-2xl font-bold">{catchBookCount}</p>{pendingCatchBooks > 0 && <p className="text-xs text-amber-600">{pendingCatchBooks} zur Prüfung</p>}</CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Aktive Pachten</p><p className="text-2xl font-bold">{leaseCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Erlaubnisscheine</p><p className="text-2xl font-bold">{permitCount}</p></CardContent></Card>
</div>
{waterCount === 0 && speciesCount === 0 && (
<Card> <Card>
<CardHeader> <CardContent className="p-6">
<CardTitle>Fangstatistiken</CardTitle> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
</CardHeader> <h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">
Noch keine Daten vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm"> <p className="text-muted-foreground mt-1 max-w-sm text-sm">
Sobald Fangbücher eingereicht und geprüft werden, erscheinen Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.
hier Statistiken und Auswertungen.
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -45,40 +45,54 @@ export default async function MembersPage({ params, searchParams }: Props) {
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}); });
// Fetch categories, departments, and tags in parallel // Fetch categories and departments (always available)
const [duesCategories, departments, tagsResult, tagAssignmentsResult] = const [duesCategories, departments] = await Promise.all([
await Promise.all([ organization.listDuesCategories(acct.id),
organization.listDuesCategories(acct.id), organization.listDepartmentsWithCounts(acct.id),
organization.listDepartmentsWithCounts(acct.id), ]);
(client.from as any)('member_tags')
.select('id, name, color')
.eq('account_id', acct.id)
.order('sort_order'),
(client.from as any)('member_tag_assignments')
.select('member_id, tag_id, member_tags(id, name, color)')
.in(
'member_id',
result.data.map((m: any) => m.id),
),
]);
// Build memberTags lookup: { memberId: [{ id, name, color }] } // Fetch tags gracefully (tables may not exist if migration hasn't run)
let accountTags: Array<{ id: string; name: string; color: string }> = [];
const memberTags: Record< const memberTags: Record<
string, string,
Array<{ id: string; name: string; color: string }> Array<{ id: string; name: string; color: string }>
> = {}; > = {};
for (const a of tagAssignmentsResult.data ?? []) { try {
const memberId = String(a.member_id); const memberIds = result.data.map((m: any) => m.id);
const tag = a.member_tags;
if (!tag) continue;
if (!memberTags[memberId]) memberTags[memberId] = []; const [tagsResult, tagAssignmentsResult] = await Promise.all([
memberTags[memberId]!.push({ (client.from as any)('member_tags')
id: String(tag.id), .select('id, name, color')
name: String(tag.name), .eq('account_id', acct.id)
color: String(tag.color), .order('sort_order'),
}); memberIds.length > 0
? (client.from as any)('member_tag_assignments')
.select('member_id, tag_id, member_tags(id, name, color)')
.in('member_id', memberIds)
: { data: [] },
]);
accountTags = (tagsResult.data ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
color: String(t.color),
}));
for (const a of tagAssignmentsResult.data ?? []) {
const memberId = String(a.member_id);
const tag = a.member_tags;
if (!tag) continue;
if (!memberTags[memberId]) memberTags[memberId] = [];
memberTags[memberId]!.push({
id: String(tag.id),
name: String(tag.name),
color: String(tag.color),
});
}
} catch {
// Tags tables may not exist yet — gracefully degrade
} }
return ( return (
@@ -100,11 +114,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
name: String(d.name), name: String(d.name),
memberCount: d.memberCount, memberCount: d.memberCount,
}))} }))}
tags={(tagsResult.data ?? []).map((t: any) => ({ tags={accountTags}
id: String(t.id),
name: String(t.name),
color: String(t.color),
}))}
memberTags={memberTags} memberTags={memberTags}
/> />
); );

View File

@@ -35,7 +35,11 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const { query } = createMemberServices(client); const { query } = createMemberServices(client);
const stats = await query.getStatistics(acct.id); const statsRaw = await query.getStatistics(acct.id);
// Compute total from individual status counts
const total = Object.values(statsRaw).reduce((a, b) => a + b, 0);
const stats = { ...statsRaw, total };
const statusChartData = [ const statusChartData = [
{ name: t('status.active'), value: stats.active ?? 0 }, { name: t('status.active'), value: stats.active ?? 0 },

View File

@@ -2,6 +2,7 @@ import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createNewsletterApi } from '@kit/newsletter/api'; import { createNewsletterApi } from '@kit/newsletter/api';
import { CreateTemplateDialog } from '@kit/newsletter/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -41,10 +42,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
<p className="text-muted-foreground">{t('templates.subtitle')}</p> <p className="text-muted-foreground">{t('templates.subtitle')}</p>
</div> </div>
<Button data-test="newsletter-templates-new-btn"> <CreateTemplateDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
{t('templates.newTemplate')}
</Button>
</div> </div>
{/* Table or Empty State */} {/* Table or Empty State */}

View File

@@ -1,3 +1,5 @@
import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
@@ -45,9 +47,11 @@ export default async function PostsManagerPage({ params }: Props) {
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-end"> <div className="flex justify-end">
<Button data-test="site-new-post-btn"> <Button data-test="site-new-post-btn" asChild>
<Plus className="mr-2 h-4 w-4" /> <Link href={`/home/${account}/site-builder/posts/new`}>
{t('posts.newPost')} <Plus className="mr-2 h-4 w-4" />
{t('posts.newPost')}
</Link>
</Button> </Button>
</div> </div>
{posts.length === 0 ? ( {posts.length === 0 ? (

View File

@@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [
{ year: '2025', vereine: 19, mitglieder: 1200 }, { year: '2025', vereine: 19, mitglieder: 1200 },
]; ];
export default function StatisticsContent() { export default function StatisticsContent({
activeClubs = 0,
totalClubs = 0,
totalMembers = 0,
openFees = 0,
}: {
activeClubs?: number;
totalClubs?: number;
totalMembers?: number;
openFees?: number;
}) {
const formatCurrency = (v: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf Aktuelle Kennzahlen des Verbands
</p> </p>
</div> </div>
{/* KPI Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Aktive Vereine</p>
<p className="text-2xl font-bold">{activeClubs}</p>
<p className="text-muted-foreground text-xs">{totalClubs} gesamt</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Gesamtmitglieder</p>
<p className="text-2xl font-bold">{totalMembers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm"> Mitglieder/Verein</p>
<p className="text-2xl font-bold">
{activeClubs > 0 ? Math.round(totalMembers / activeClubs) : 0}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Offene Beiträge</p>
<p className="text-2xl font-bold">{formatCurrency(openFees)}</p>
</CardContent>
</Card>
</div>
{/* Charts (keep existing placeholder data as trend visualization) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -1,7 +1,9 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components'; import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import StatisticsContent from './_components/statistics-content'; import StatisticsContent from './_components/statistics-content';
@@ -13,11 +15,50 @@ interface Props {
export default async function StatisticsPage({ params }: Props) { export default async function StatisticsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const t = await getTranslations('verband'); const t = await getTranslations('verband');
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
// Fetch real verband stats
const [clubsResult, membersResult, feesResult] = await Promise.allSettled([
client
.from('member_clubs')
.select('id, status, member_count', { count: 'exact' })
.eq('account_id', acct.id),
client
.from('members')
.select('id', { count: 'exact' })
.eq('account_id', acct.id)
.eq('status', 'active'),
(client.from as any)('club_fees')
.select('amount, status')
.eq('account_id', acct.id),
]);
const clubs = clubsResult.status === 'fulfilled' ? (clubsResult.value.data ?? []) : [];
const activeClubs = clubs.filter((c: any) => c.status !== 'archived').length;
const totalMembers = clubsResult.status === 'fulfilled'
? clubs.reduce((sum: number, c: any) => sum + (Number(c.member_count) || 0), 0)
: 0;
const directMembers = membersResult.status === 'fulfilled' ? (membersResult.value.count ?? 0) : 0;
const fees = feesResult.status === 'fulfilled' ? (feesResult.value.data ?? []) : [];
const openFees = fees.filter((f: any) => f.status !== 'paid').reduce((s: number, f: any) => s + (Number(f.amount) || 0), 0);
return ( return (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}> <CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<VerbandTabNavigation account={account} activeTab="statistics" /> <VerbandTabNavigation account={account} activeTab="statistics" />
<StatisticsContent /> <StatisticsContent
activeClubs={activeClubs}
totalClubs={clubs.length}
totalMembers={totalMembers || directMembers}
openFees={openFees}
/>
</CmsPageShell> </CmsPageShell>
); );
} }

View File

@@ -139,7 +139,58 @@
"administration": "Administration", "administration": "Administration",
"accountSettings": "Kontoeinstellungen", "accountSettings": "Kontoeinstellungen",
"application": "Anwendung", "application": "Anwendung",
"home": "Startseite" "home": "Startseite",
"courses": "Kurse",
"calendar": "Kalender",
"instructors": "Kursleiter",
"locations": "Standorte",
"categories": "Kategorien",
"statistics": "Statistiken",
"events": "Veranstaltungen",
"registrations": "Anmeldungen",
"holiday passes": "Ferienpässe",
"bookings": "Buchungen",
"rooms": "Zimmer",
"guests": "Gäste",
"finance": "Finanzen",
"invoices": "Rechnungen",
"sepa": "SEPA-Einzüge",
"payments": "Zahlungen",
"documents": "Dokumente",
"generate": "Generieren",
"templates": "Vorlagen",
"newsletter": "Newsletter",
"new": "Neu",
"edit": "Bearbeiten",
"members": "Mitglieder",
"members cms": "Vereinsmitglieder",
"site builder": "Website",
"posts": "Beiträge",
"fischerei": "Fischerei",
"waters": "Gewässer",
"species": "Fischarten",
"stocking": "Besatz",
"leases": "Pachten",
"catch books": "Fangbücher",
"permits": "Erlaubnisscheine",
"competitions": "Wettbewerbe",
"meetings": "Sitzungen",
"protocols": "Protokolle",
"tasks": "Aufgaben",
"verband": "Verband",
"clubs": "Vereine",
"hierarchy": "Organisationsstruktur",
"reporting": "Berichte",
"modules": "Module",
"import": "Import",
"applications": "Aufnahmeanträge",
"departments": "Abteilungen",
"dues": "Beiträge",
"tags": "Tags",
"cards": "Mitgliedsausweise",
"invitations": "Einladungen",
"attendance": "Anwesenheit",
"participants": "Teilnehmer"
}, },
"roles": { "roles": {
"owner": { "owner": {

View File

@@ -139,7 +139,58 @@
"associationTemplates": "Shared Templates", "associationTemplates": "Shared Templates",
"administration": "Administration", "administration": "Administration",
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"application": "Application" "application": "Application",
"courses": "Courses",
"calendar": "Calendar",
"instructors": "Instructors",
"locations": "Locations",
"categories": "Categories",
"statistics": "Statistics",
"events": "Events",
"registrations": "Registrations",
"holiday passes": "Holiday Passes",
"bookings": "Bookings",
"rooms": "Rooms",
"guests": "Guests",
"finance": "Finance",
"invoices": "Invoices",
"sepa": "SEPA",
"payments": "Payments",
"documents": "Documents",
"generate": "Generate",
"templates": "Templates",
"newsletter": "Newsletter",
"new": "New",
"edit": "Edit",
"members": "Members",
"members cms": "Members",
"site builder": "Site Builder",
"posts": "Posts",
"fischerei": "Fisheries",
"waters": "Waters",
"species": "Species",
"stocking": "Stocking",
"leases": "Leases",
"catch books": "Catch Books",
"permits": "Permits",
"competitions": "Competitions",
"meetings": "Meetings",
"protocols": "Protocols",
"tasks": "Tasks",
"verband": "Federation",
"clubs": "Clubs",
"hierarchy": "Hierarchy",
"reporting": "Reporting",
"modules": "Modules",
"import": "Import",
"applications": "Applications",
"departments": "Departments",
"dues": "Dues",
"tags": "Tags",
"cards": "Cards",
"invitations": "Invitations",
"attendance": "Attendance",
"participants": "Participants"
}, },
"roles": { "roles": {
"owner": { "owner": {

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -17,7 +17,11 @@ UPDATE public.members SET exit_date = entry_date
UPDATE public.members SET entry_date = current_date UPDATE public.members SET entry_date = current_date
WHERE entry_date IS NOT NULL AND entry_date > current_date; WHERE entry_date IS NOT NULL AND entry_date > current_date;
-- Normalize IBANs in sepa_mandates to uppercase, strip spaces -- Normalize IBANs to uppercase, strip spaces (both tables)
UPDATE public.members
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
WHERE iban IS NOT NULL AND iban != '';
UPDATE public.sepa_mandates UPDATE public.sepa_mandates
SET iban = upper(regexp_replace(iban, '\s', '', 'g')) SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
WHERE iban IS NOT NULL AND iban != ''; WHERE iban IS NOT NULL AND iban != '';

View File

@@ -21,12 +21,12 @@ CREATE INDEX IF NOT EXISTS ix_event_registrations_member
-- Backfill: match existing registrations to members by email within the same account -- Backfill: match existing registrations to members by email within the same account
UPDATE public.event_registrations er UPDATE public.event_registrations er
SET member_id = m.id SET member_id = m.id
FROM public.events e FROM public.events e, public.members m
JOIN public.members m ON m.account_id = e.account_id WHERE e.id = er.event_id
AND m.account_id = e.account_id
AND lower(m.email) = lower(er.email) AND lower(m.email) = lower(er.email)
AND m.email IS NOT NULL AND m.email != '' AND m.email IS NOT NULL AND m.email != ''
AND m.status IN ('active', 'inactive', 'pending') AND m.status IN ('active', 'inactive', 'pending')
WHERE e.id = er.event_id
AND er.member_id IS NULL AND er.member_id IS NULL
AND er.email IS NOT NULL AND er.email != ''; AND er.email IS NOT NULL AND er.email != '';
@@ -35,7 +35,7 @@ CREATE OR REPLACE FUNCTION public.transfer_member(
p_member_id uuid, p_member_id uuid,
p_target_account_id uuid, p_target_account_id uuid,
p_reason text DEFAULT NULL, p_reason text DEFAULT NULL,
p_keep_sepa boolean DEFAULT false p_keep_sepa boolean DEFAULT true
) )
RETURNS uuid RETURNS uuid
LANGUAGE plpgsql LANGUAGE plpgsql

View File

@@ -0,0 +1,3 @@
-- Enable btree_gist extension (required by booking overlap exclusion constraint)
-- Separated into own migration to avoid "multiple commands in prepared statement" error
CREATE EXTENSION IF NOT EXISTS btree_gist;

View File

@@ -0,0 +1,54 @@
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
p_account_id uuid,
p_room_id uuid,
p_guest_id uuid DEFAULT NULL,
p_check_in date DEFAULT NULL,
p_check_out date DEFAULT NULL,
p_adults integer DEFAULT 1,
p_children integer DEFAULT 0,
p_status text DEFAULT 'confirmed',
p_total_price numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $fn$
DECLARE
v_room record;
v_computed_price numeric(10,2);
v_booking_id uuid;
BEGIN
SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE;
IF v_room IS NULL THEN
RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002';
END IF;
IF p_check_in IS NULL OR p_check_out IS NULL THEN
RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001';
END IF;
IF p_check_out <= p_check_in THEN
RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001';
END IF;
IF (p_adults + p_children) > v_room.capacity THEN
RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001';
END IF;
IF p_total_price IS NOT NULL THEN
v_computed_price := p_total_price;
ELSE
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
END IF;
INSERT INTO public.bookings (
account_id, room_id, guest_id, check_in, check_out,
adults, children, status, total_price, notes
) VALUES (
p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out,
p_adults, p_children, p_status, v_computed_price, p_notes
)
RETURNING id INTO v_booking_id;
RETURN v_booking_id;
END;
$fn$;

View File

@@ -0,0 +1 @@
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;

View File

@@ -1,25 +1,4 @@
-- ===================================================== DO $excl$
-- Atomic Booking Creation with Overlap Prevention
--
-- Problem: Creating a booking requires checking room
-- availability, validating capacity, and inserting — all
-- as separate queries. Race conditions can double-book
-- a room for overlapping dates.
--
-- Fix:
-- A) Enable btree_gist extension for exclusion constraints.
-- B) Add GiST exclusion constraint to prevent overlapping
-- bookings for the same room (non-cancelled/no_show).
-- C) Single transactional PG function that locks the room,
-- validates inputs, calculates price, and inserts. The
-- exclusion constraint provides a final safety net.
-- =====================================================
-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types)
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- B) Add exclusion constraint to prevent overlapping bookings (idempotent)
DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates' SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
@@ -32,97 +11,4 @@ BEGIN
) WHERE (status NOT IN ('cancelled', 'no_show')); ) WHERE (status NOT IN ('cancelled', 'no_show'));
END IF; END IF;
END; END;
$$; $excl$;
-- C) Atomic booking creation function
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
p_account_id uuid,
p_room_id uuid,
p_guest_id uuid DEFAULT NULL,
p_check_in date DEFAULT NULL,
p_check_out date DEFAULT NULL,
p_adults integer DEFAULT 1,
p_children integer DEFAULT 0,
p_status text DEFAULT 'confirmed',
p_total_price numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_room record;
v_computed_price numeric(10,2);
v_booking_id uuid;
BEGIN
-- 1. Lock the room row to serialize booking attempts
SELECT * INTO v_room
FROM public.rooms
WHERE id = p_room_id
FOR UPDATE;
-- 2. Validate room exists
IF v_room IS NULL THEN
RAISE EXCEPTION 'Room % not found', p_room_id
USING ERRCODE = 'P0002';
END IF;
-- 3. Validate check_out > check_in
IF p_check_in IS NULL OR p_check_out IS NULL THEN
RAISE EXCEPTION 'check_in and check_out dates are required'
USING ERRCODE = 'P0001';
END IF;
IF p_check_out <= p_check_in THEN
RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in
USING ERRCODE = 'P0001';
END IF;
-- 4. Validate total guests do not exceed room capacity
IF (p_adults + p_children) > v_room.capacity THEN
RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity
USING ERRCODE = 'P0001';
END IF;
-- 5. Calculate price if not provided
IF p_total_price IS NOT NULL THEN
v_computed_price := p_total_price;
ELSE
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
END IF;
-- 6. Insert the booking (exclusion constraint prevents double-booking)
INSERT INTO public.bookings (
account_id,
room_id,
guest_id,
check_in,
check_out,
adults,
children,
status,
total_price,
notes
) VALUES (
p_account_id,
p_room_id,
p_guest_id,
p_check_in,
p_check_out,
p_adults,
p_children,
p_status,
v_computed_price,
p_notes
)
RETURNING id INTO v_booking_id;
-- 7. Return the new booking id
RETURN v_booking_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role;

View File

@@ -0,0 +1,93 @@
-- =====================================================
-- Module Notification Rules & Queue
-- Shared notification infrastructure for courses, events, bookings.
-- =====================================================
-- Notification rules: define what triggers notifications
CREATE TABLE IF NOT EXISTS public.module_notification_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
trigger_event text NOT NULL CHECK (trigger_event IN (
'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted',
'course.participant_cancelled', 'course.status_changed', 'course.session_reminder',
'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted',
'event.registration_cancelled', 'event.status_changed', 'event.reminder',
'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in',
'booking.checked_out', 'booking.cancelled'
)),
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')),
recipient_config jsonb NOT NULL DEFAULT '{}',
subject_template text,
message_template text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup
ON public.module_notification_rules(account_id, module, trigger_event)
WHERE is_active = true;
ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated;
GRANT ALL ON public.module_notification_rules TO service_role;
CREATE POLICY module_notification_rules_select ON public.module_notification_rules
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules
FOR ALL TO authenticated USING (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
) WITH CHECK (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
-- Pending notifications queue
CREATE TABLE IF NOT EXISTS public.pending_module_notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
trigger_event text NOT NULL,
entity_id uuid NOT NULL,
context jsonb NOT NULL DEFAULT '{}',
processed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed
ON public.pending_module_notifications(created_at)
WHERE processed_at IS NULL;
ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role;
GRANT SELECT ON public.pending_module_notifications TO authenticated;
GRANT ALL ON public.pending_module_notifications TO service_role;
CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
-- Enqueue helper
CREATE OR REPLACE FUNCTION public.enqueue_module_notification(
p_account_id uuid,
p_module text,
p_trigger_event text,
p_entity_id uuid,
p_context jsonb DEFAULT '{}'
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.pending_module_notifications
(account_id, module, trigger_event, entity_id, context)
VALUES
(p_account_id, p_module, p_trigger_event, p_entity_id, p_context);
END;
$$;
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated;
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role;

View File

@@ -0,0 +1,40 @@
/*
* -------------------------------------------------------
* Shared Communications Table for Courses, Events, Bookings
* Tracks email, phone, letter, meeting, note, sms entries
* -------------------------------------------------------
*/
CREATE TABLE IF NOT EXISTS public.module_communications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
entity_id uuid NOT NULL,
type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')),
subject text,
body text,
email_to text,
email_cc text,
attachment_paths text[],
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_module_communications_entity
ON public.module_communications(module, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_module_communications_account
ON public.module_communications(account_id, module, created_at DESC);
ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.module_communications FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated;
GRANT ALL ON public.module_communications TO service_role;
CREATE POLICY module_communications_select ON public.module_communications
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
CREATE POLICY module_communications_mutate ON public.module_communications
FOR ALL TO authenticated USING (public.has_role_on_account(account_id))
WITH CHECK (public.has_role_on_account(account_id));

View File

@@ -0,0 +1,121 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Audit Trigger & Version Tests
-- Verifies triggers fire correctly on member changes
-- =====================================================
-- Setup
select tests.create_supabase_user('audit_owner', 'audit_owner@test.com');
select makerkit.set_identifier('audit_owner', 'audit_owner@test.com');
set local role service_role;
select public.create_team_account('Audit Verein', tests.get_supabase_uid('audit_owner'));
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- Get account ID
select makerkit.authenticate_as('audit_owner');
-- Insert a member (triggers audit INSERT)
set local role service_role;
insert into public.members (
account_id, first_name, last_name, status, entry_date, member_number,
created_by, updated_by
) values (
(select id from public.accounts where slug = 'audit-verein' limit 1),
'Audit', 'Test', 'active', current_date, '0001',
tests.get_supabase_uid('audit_owner'),
tests.get_supabase_uid('audit_owner')
);
-- -------------------------------------------------------
-- Test: INSERT creates audit entry
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'Audit' limit 1)
and action = 'created' $$,
'Member INSERT creates audit entry with action=created'
);
-- -------------------------------------------------------
-- Test: Version starts at 1
-- -------------------------------------------------------
select is(
(select version from public.members where first_name = 'Audit' limit 1),
1,
'Initial version is 1'
);
-- -------------------------------------------------------
-- Test: UPDATE increments version
-- -------------------------------------------------------
update public.members
set first_name = 'AuditUpdated'
where first_name = 'Audit';
select is(
(select version from public.members where first_name = 'AuditUpdated' limit 1),
2,
'Version incremented to 2 after update'
);
-- -------------------------------------------------------
-- Test: UPDATE creates audit entry with field diff
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'updated'
and changes ? 'first_name' $$,
'Member UPDATE creates audit entry with first_name change diff'
);
-- -------------------------------------------------------
-- Test: Status change creates status_changed audit entry
-- -------------------------------------------------------
update public.members
set status = 'inactive'
where first_name = 'AuditUpdated';
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'status_changed' $$,
'Status change creates audit entry with action=status_changed'
);
-- -------------------------------------------------------
-- Test: Archive creates archived audit entry
-- -------------------------------------------------------
update public.members
set is_archived = true
where first_name = 'AuditUpdated';
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'archived' $$,
'Archive creates audit entry with action=archived'
);
-- -------------------------------------------------------
-- Test: Multiple updates increment version correctly
-- -------------------------------------------------------
select is(
(select version from public.members where first_name = 'AuditUpdated' limit 1),
4,
'Version is 4 after 3 updates (initial insert + 3 updates)'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,186 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- CHECK Constraint Tests
-- =====================================================
-- Setup
select tests.create_supabase_user('constraint_owner', 'constraint_owner@test.com');
select makerkit.set_identifier('constraint_owner', 'constraint_owner@test.com');
set local role service_role;
select public.create_team_account('Constraint Verein', tests.get_supabase_uid('constraint_owner'));
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- -------------------------------------------------------
-- Test: DOB in future rejected
-- -------------------------------------------------------
set local role service_role;
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, date_of_birth, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Future', 'Baby', current_date + interval '1 day', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_dob_not_future"',
'Future date of birth is rejected'
);
-- -------------------------------------------------------
-- Test: Exit date before entry date rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date, exit_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Wrong', 'Dates', 'resigned', '2024-06-01', '2024-01-01',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_exit_after_entry"',
'Exit date before entry date is rejected'
);
-- -------------------------------------------------------
-- Test: Entry date in future rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Future', 'Entry', 'active', current_date + interval '2 days',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_entry_not_future"',
'Future entry date is rejected'
);
-- -------------------------------------------------------
-- Test: Valid member insert succeeds
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date,
date_of_birth, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Valid', 'Member', 'active', '2024-01-15', '1990-05-20',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'Valid member with correct dates succeeds'
);
-- -------------------------------------------------------
-- Test: Duplicate email in same account rejected
-- -------------------------------------------------------
insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'First', 'Email', 'duplicate@test.com', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
);
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Second', 'Email', 'duplicate@test.com', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'duplicate key value violates unique constraint "uix_members_email_per_account"',
'Duplicate email in same account is rejected'
);
-- -------------------------------------------------------
-- Test: NULL emails allowed (multiple)
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'No', 'Email1', null, 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'NULL email is allowed'
);
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'No', 'Email2', null, 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'Multiple NULL emails allowed'
);
-- -------------------------------------------------------
-- Test: Invalid IBAN rejected on sepa_mandates
-- -------------------------------------------------------
insert into public.members (
account_id, first_name, last_name, status, entry_date, member_number, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'SEPA', 'Test', 'active', current_date, 'SEPA01',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
);
select throws_ok(
$test$ insert into public.sepa_mandates (
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
) values (
(select id from public.members where first_name = 'SEPA' limit 1),
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'MANDATE-001', 'invalid-iban', 'Test Holder', current_date, 'active'
) $test$,
'new row for relation "sepa_mandates" violates check constraint "chk_sepa_iban_format"',
'Invalid IBAN format is rejected'
);
-- -------------------------------------------------------
-- Test: Valid IBAN accepted
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.sepa_mandates (
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
) values (
(select id from public.members where first_name = 'SEPA' limit 1),
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'MANDATE-002', 'DE89370400440532013000', 'Test Holder', current_date, 'active'
) $test$,
'Valid German IBAN is accepted'
);
-- -------------------------------------------------------
-- Test: Negative dues amount rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.dues_categories (
account_id, name, amount
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Negative Fee', -50
) $test$,
'new row for relation "dues_categories" violates check constraint "chk_dues_amount_non_negative"',
'Negative dues amount is rejected'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,211 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Member Management Function Tests
-- Tests PG functions for correctness, auth, atomicity
-- =====================================================
-- Setup: create test users and account
select tests.create_supabase_user('owner', 'owner@test.com');
select tests.create_supabase_user('member_user', 'member@test.com');
select tests.create_supabase_user('outsider', 'outsider@test.com');
select makerkit.set_identifier('owner', 'owner@test.com');
select makerkit.set_identifier('member_user', 'member@test.com');
select makerkit.set_identifier('outsider', 'outsider@test.com');
-- Create a team account owned by 'owner'
set local role service_role;
select public.create_team_account('Test Verein', tests.get_supabase_uid('owner'));
-- Get account ID
select makerkit.authenticate_as('owner');
\set test_account_id '(select id from public.accounts where slug = ''test-verein'' limit 1)'
-- Grant members.write permission to owner
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- -------------------------------------------------------
-- Test: get_next_member_number
-- -------------------------------------------------------
select makerkit.authenticate_as('owner');
select is(
public.get_next_member_number(:test_account_id),
'0001',
'First member number should be 0001'
);
-- Insert a member to test incrementing
set local role service_role;
insert into public.members (account_id, first_name, last_name, member_number, status, entry_date, created_by, updated_by)
values (:test_account_id, 'Max', 'Mustermann', '0001', 'active', current_date,
tests.get_supabase_uid('owner'), tests.get_supabase_uid('owner'));
select makerkit.authenticate_as('owner');
select is(
public.get_next_member_number(:test_account_id),
'0002',
'Second member number should be 0002'
);
-- -------------------------------------------------------
-- Test: get_member_quick_stats
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.get_member_quick_stats((select id from public.accounts where slug = 'test-verein' limit 1)) $$,
'Quick stats returns data for account with members'
);
-- -------------------------------------------------------
-- Test: check_duplicate_member
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.check_duplicate_member(
(select id from public.accounts where slug = 'test-verein' limit 1),
'Max', 'Mustermann', null
) $$,
'Duplicate check finds existing member by name'
);
select is_empty(
$$ select * from public.check_duplicate_member(
(select id from public.accounts where slug = 'test-verein' limit 1),
'Nonexistent', 'Person', null
) $$,
'Duplicate check returns empty for non-matching name'
);
-- -------------------------------------------------------
-- Test: approve_application
-- -------------------------------------------------------
-- Create a test application
set local role service_role;
insert into public.membership_applications (
account_id, first_name, last_name, email, status
) values (
:test_account_id, 'Anna', 'Bewerberin', 'anna@test.com', 'submitted'
);
select makerkit.authenticate_as('owner');
-- Approve it
select lives_ok(
$$ select public.approve_application(
(select id from public.membership_applications where email = 'anna@test.com'),
tests.get_supabase_uid('owner')
) $$,
'Owner can approve application'
);
-- Verify member was created
select isnt_empty(
$$ select * from public.members where first_name = 'Anna' and last_name = 'Bewerberin' $$,
'Approved application creates a member'
);
-- Verify application status changed
select is(
(select status from public.membership_applications where email = 'anna@test.com'),
'approved'::public.application_status,
'Application status is approved'
);
-- -------------------------------------------------------
-- Test: reject_application
-- -------------------------------------------------------
set local role service_role;
insert into public.membership_applications (
account_id, first_name, last_name, email, status
) values (
:test_account_id, 'Bob', 'Abgelehnt', 'bob@test.com', 'submitted'
);
select makerkit.authenticate_as('owner');
select lives_ok(
$$ select public.reject_application(
(select id from public.membership_applications where email = 'bob@test.com'),
tests.get_supabase_uid('owner'),
'Nicht qualifiziert'
) $$,
'Owner can reject application'
);
select is(
(select status from public.membership_applications where email = 'bob@test.com'),
'rejected'::public.application_status,
'Application status is rejected'
);
-- -------------------------------------------------------
-- Test: approve_application — already approved should fail
-- -------------------------------------------------------
-- Verify the re-approval throws with status message
prepare approve_again as select public.approve_application(
(select id from public.membership_applications where email = 'anna@test.com'),
tests.get_supabase_uid('owner')
);
select throws_ok(
'approve_again',
'P0001',
'Application is not in a reviewable state (current: approved)',
'Cannot approve already-approved application'
);
-- -------------------------------------------------------
-- Test: get_member_timeline
-- -------------------------------------------------------
-- The member creation via approve_application should have generated an audit entry
select isnt_empty(
$$ select * from public.get_member_timeline(
(select id from public.members where first_name = 'Anna' limit 1),
1, 50, null
) $$,
'Member timeline has entries after creation'
);
-- -------------------------------------------------------
-- Test: log_member_audit_event
-- -------------------------------------------------------
select makerkit.authenticate_as('owner');
select lives_ok(
$$ select public.log_member_audit_event(
(select id from public.members where first_name = 'Max' limit 1),
(select id from public.accounts where slug = 'test-verein' limit 1),
'note_added',
'{"note": "Test note"}'::jsonb,
'{}'::jsonb
) $$,
'Owner can log audit event for member'
);
-- -------------------------------------------------------
-- Test: outsider cannot access functions
-- -------------------------------------------------------
select makerkit.authenticate_as('outsider');
-- Outsider should get an error when calling get_next_member_number
prepare outsider_member_number as select public.get_next_member_number(
(select id from public.accounts where slug = 'test-verein' limit 1)
);
select throws_ok(
'outsider_member_number',
'P0001',
null,
'Outsider cannot call get_next_member_number'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,105 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Member Management Schema Tests
-- Verifies all tables, columns, and RLS settings
-- =====================================================
-- 1. Core tables exist
select has_table('public', 'members', 'members table exists');
select has_table('public', 'dues_categories', 'dues_categories table exists');
select has_table('public', 'membership_applications', 'membership_applications table exists');
select has_table('public', 'member_cards', 'member_cards table exists');
select has_table('public', 'member_departments', 'member_departments table exists');
select has_table('public', 'member_department_assignments', 'member_department_assignments table exists');
select has_table('public', 'member_roles', 'member_roles table exists');
select has_table('public', 'member_honors', 'member_honors table exists');
select has_table('public', 'sepa_mandates', 'sepa_mandates table exists');
select has_table('public', 'member_portal_invitations', 'member_portal_invitations table exists');
select has_table('public', 'member_transfers', 'member_transfers table exists');
-- 2. New Phase 1-4 tables exist
select has_table('public', 'member_audit_log', 'member_audit_log table exists');
select has_table('public', 'member_communications', 'member_communications table exists');
select has_table('public', 'member_tags', 'member_tags table exists');
select has_table('public', 'member_tag_assignments', 'member_tag_assignments table exists');
select has_table('public', 'member_merges', 'member_merges table exists');
select has_table('public', 'gdpr_retention_policies', 'gdpr_retention_policies table exists');
select has_table('public', 'member_notification_rules', 'member_notification_rules table exists');
select has_table('public', 'scheduled_job_configs', 'scheduled_job_configs table exists');
select has_table('public', 'scheduled_job_runs', 'scheduled_job_runs table exists');
select has_table('public', 'pending_member_notifications', 'pending_member_notifications table exists');
-- 3. New columns on members table
select has_column('public', 'members', 'primary_mandate_id', 'members has primary_mandate_id column');
select has_column('public', 'members', 'version', 'members has version column');
-- 4. New column on event_registrations
select has_column('public', 'event_registrations', 'member_id', 'event_registrations has member_id FK');
-- 5. RLS enabled on all member tables
select is(
(select relrowsecurity from pg_class where relname = 'members' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on members'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_audit_log' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_audit_log'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_communications' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_communications'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_tags' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_tags'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_tag_assignments' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_tag_assignments'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_notification_rules' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_notification_rules'
);
select is(
(select relrowsecurity from pg_class where relname = 'scheduled_job_configs' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on scheduled_job_configs'
);
-- 6. Key indexes exist
select is(
(select count(*) > 0 from pg_indexes where tablename = 'members' and indexname = 'ix_members_active_account_status'),
true, 'Active members composite index exists'
);
select is(
(select count(*) > 0 from pg_indexes where tablename = 'member_audit_log' and indexname = 'ix_member_audit_member'),
true, 'Audit log member index exists'
);
-- 7. Check constraints exist on members
select is(
(select count(*) > 0 from information_schema.check_constraints
where constraint_name = 'chk_members_dob_not_future'),
true, 'DOB not-future constraint exists'
);
select is(
(select count(*) > 0 from information_schema.check_constraints
where constraint_name = 'chk_members_exit_after_entry'),
true, 'Exit-after-entry constraint exists'
);
-- 8. Version column has correct default
select is(
(select column_default from information_schema.columns
where table_name = 'members' and column_name = 'version'),
'1', 'Version column defaults to 1'
);
select * from finish();
rollback;

View File

@@ -333,6 +333,8 @@ services:
# Stripe (build-time) # Stripe (build-time)
NEXT_PUBLIC_BILLING_PROVIDER: stripe NEXT_PUBLIC_BILLING_PROVIDER: stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: 'true'
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: 'true'
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
supabase-kong: supabase-kong:

View File

@@ -59,7 +59,6 @@ $PSQL -c "
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_entries TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;

View File

@@ -35,5 +35,8 @@
"react": "catalog:", "react": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"lucide-react": "catalog:"
} }
} }

View File

@@ -0,0 +1,99 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { LogIn, LogOut, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import { updateBookingStatus } from '../server/actions/booking-actions';
interface BookingStatusActionsProps {
bookingId: string;
status: string;
}
export function BookingStatusActions({
bookingId,
status,
}: BookingStatusActionsProps) {
const router = useRouter();
const action = useAction(updateBookingStatus, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Status aktualisiert');
router.refresh();
} else {
toast.error(data?.error ?? 'Fehler beim Aktualisieren');
}
},
onError: () => {
toast.error('Fehler beim Aktualisieren des Status');
},
});
const execute = (newStatus: string) =>
action.execute({ bookingId, status: newStatus as any });
if (status === 'cancelled' || status === 'checked_out') {
return (
<p className="text-muted-foreground py-2 text-sm">
{status === 'cancelled'
? 'Diese Buchung wurde storniert.'
: 'Diese Buchung ist abgeschlossen.'}
</p>
);
}
return (
<div className="flex flex-wrap gap-3">
{(status === 'pending' || status === 'confirmed') && (
<Button
onClick={() => execute('checked_in')}
disabled={action.isPending}
>
{action.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogIn className="mr-2 h-4 w-4" />
)}
Einchecken
</Button>
)}
{status === 'checked_in' && (
<Button
onClick={() => execute('checked_out')}
disabled={action.isPending}
>
{action.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
Auschecken
</Button>
)}
{status !== 'cancelled' &&
status !== 'checked_out' &&
status !== 'no_show' && (
<Button
variant="destructive"
onClick={() => execute('cancelled')}
disabled={action.isPending}
>
{action.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<XCircle className="mr-2 h-4 w-4" />
)}
Stornieren
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { createGuest } from '../server/actions/booking-actions';
interface CreateGuestDialogProps {
accountId: string;
}
export function CreateGuestDialog({ accountId }: CreateGuestDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
const action = useAction(createGuest, {
onSuccess: () => {
toast.success('Gast erstellt');
setOpen(false);
setForm({ firstName: '', lastName: '', email: '', phone: '' });
router.refresh();
},
onError: () => toast.error('Fehler beim Erstellen'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Neuer Gast</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Gast anlegen</DialogTitle></DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Vorname *</Label>
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Nachname *</Label>
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
</div>
</div>
<div className="grid gap-2">
<Label>E-Mail</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Telefon</Label>
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={() => action.execute({ accountId, firstName: form.firstName, lastName: form.lastName, email: form.email || undefined, phone: form.phone || undefined })} disabled={action.isPending || !form.firstName || !form.lastName}>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { createRoom } from '../server/actions/booking-actions';
interface CreateRoomDialogProps {
accountId: string;
}
export function CreateRoomDialog({ accountId }: CreateRoomDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
roomNumber: '',
name: '',
roomType: 'single',
capacity: '2',
pricePerNight: '0',
});
const action = useAction(createRoom, {
onSuccess: () => {
toast.success('Zimmer erstellt');
setOpen(false);
setForm({ roomNumber: '', name: '', roomType: 'single', capacity: '2', pricePerNight: '0' });
router.refresh();
},
onError: () => toast.error('Fehler beim Erstellen'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Neues Zimmer</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Zimmer anlegen</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Zimmernummer *</Label>
<Input placeholder="z.B. 101" value={form.roomNumber} onChange={(e) => setForm(s => ({ ...s, roomNumber: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Bezeichnung</Label>
<Input placeholder="z.B. Doppelzimmer Süd" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Kapazität</Label>
<Input type="number" min="1" value={form.capacity} onChange={(e) => setForm(s => ({ ...s, capacity: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Preis/Nacht ()</Label>
<Input type="number" step="0.01" min="0" value={form.pricePerNight} onChange={(e) => setForm(s => ({ ...s, pricePerNight: e.target.value }))} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={() => action.execute({ accountId, roomNumber: form.roomNumber, name: form.name || undefined, roomType: form.roomType as any, capacity: Number(form.capacity) || 2, pricePerNight: Number(form.pricePerNight) || 0 })} disabled={action.isPending || !form.roomNumber}>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1 +1,4 @@
export { CreateBookingForm } from './create-booking-form'; export { CreateBookingForm } from './create-booking-form';
export { BookingStatusActions } from './booking-status-actions';
export { CreateRoomDialog } from './create-room-dialog';
export { CreateGuestDialog } from './create-guest-dialog';

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createBookingCommunicationService(
client: SupabaseClient<Database>,
) {
return new BookingCommunicationService(client);
}
class BookingCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
bookingId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'bookings')
.eq('entity_id', bookingId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'bookings',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'bookings');
if (error) throw error;
}
}

View File

@@ -0,0 +1,76 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createBookingExportService(client: SupabaseClient<Database>) {
return new BookingExportService(client);
}
class BookingExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportBookingsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('bookings')
.select('*, rooms(room_number, name), guests(first_name, last_name)')
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: bookings, error } = await query;
if (error) throw error;
if (!bookings?.length) return '';
const headers = ['Zimmer', 'Gast', 'Anreise', 'Abreise', 'Status', 'Preis'];
const rows = bookings.map((b) => {
const room = (b as any).rooms;
const guest = (b as any).guests;
const roomLabel = room
? `${room.room_number}${room.name ? ` (${room.name})` : ''}`
: '';
const guestLabel = guest ? `${guest.first_name} ${guest.last_name}` : '';
return [
roomLabel,
guestLabel,
b.check_in ?? '',
b.check_out ?? '',
b.status,
b.total_price?.toString() ?? '0',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';');
});
return [headers.join(';'), ...rows].join('\n');
}
async exportGuestsCsv(accountId: string): Promise<string> {
const { data: guests, error } = await this.client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
if (!guests?.length) return '';
const headers = ['Vorname', 'Nachname', 'E-Mail', 'Telefon', 'Ort'];
const rows = guests.map((g) =>
[g.first_name, g.last_name, g.email ?? '', g.phone ?? '', g.city ?? '']
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'booking-notification';
const MODULE = 'bookings';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createBookingNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

View File

@@ -35,5 +35,8 @@
"react": "catalog:", "react": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"lucide-react": "catalog:"
} }
} }

View File

@@ -0,0 +1,113 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Loader2, 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';
import { toast } from '@kit/ui/sonner';
import {
deleteCategory,
deleteInstructor,
deleteLocation,
} from '../server/actions/course-actions';
type RefDataType = 'category' | 'instructor' | 'location';
const actions = {
category: deleteCategory,
instructor: deleteInstructor,
location: deleteLocation,
};
const labels: Record<RefDataType, { name: string; confirm: string }> = {
category: {
name: 'Kategorie',
confirm: 'Möchten Sie diese Kategorie wirklich löschen?',
},
instructor: {
name: 'Kursleiter',
confirm: 'Möchten Sie diesen Kursleiter wirklich löschen?',
},
location: {
name: 'Standort',
confirm: 'Möchten Sie diesen Standort wirklich löschen?',
},
};
interface DeleteRefDataButtonProps {
id: string;
type: RefDataType;
itemName: string;
}
export function DeleteRefDataButton({
id,
type,
itemName,
}: DeleteRefDataButtonProps) {
const router = useRouter();
const label = labels[type];
const action = useAction(actions[type], {
onSuccess: () => {
toast.success(`${label.name}${itemName}" gelöscht`);
router.refresh();
},
onError: () =>
toast.error(`Fehler beim Löschen`, {
description:
'Möglicherweise wird der Eintrag noch von Kursen verwendet.',
}),
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-8 w-8"
aria-label={`${label.name} löschen`}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{label.name} löschen</AlertDialogTitle>
<AlertDialogDescription>
{label.confirm} <strong>{itemName}</strong>" wird unwiderruflich
entfernt.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => action.execute({ id })}
disabled={action.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{action.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { enrollParticipant } from '../server/actions/course-actions';
interface EnrollParticipantDialogProps {
courseId: string;
}
export function EnrollParticipantDialog({ courseId }: EnrollParticipantDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
const action = useAction(enrollParticipant, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Teilnehmer angemeldet');
setOpen(false);
setForm({ firstName: '', lastName: '', email: '', phone: '' });
router.refresh();
} else {
toast.error(data?.error ?? 'Fehler bei der Anmeldung');
}
},
onError: () => toast.error('Fehler bei der Anmeldung'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Teilnehmer anmelden</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Teilnehmer anmelden</DialogTitle>
<DialogDescription>Melden Sie einen Teilnehmer für diesen Kurs an.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Vorname *</Label>
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Nachname *</Label>
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
</div>
</div>
<div className="grid gap-2">
<Label>E-Mail</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Telefon</Label>
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button
onClick={() => action.execute({
courseId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email || undefined,
phone: form.phone || undefined,
})}
disabled={action.isPending || !form.firstName || !form.lastName}
>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Anmelden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1 +1,3 @@
export { CreateCourseForm } from './create-course-form'; export { CreateCourseForm } from './create-course-form';
export { EnrollParticipantDialog } from './enroll-participant-dialog';
export { DeleteRefDataButton } from './delete-ref-data-button';

View File

@@ -188,3 +188,32 @@ export const createSession = authActionClient
logger.info({ name: 'course.createSession' }, 'Session created'); logger.info({ name: 'course.createSession' }, 'Session created');
return { success: true, data: result }; return { success: true, data: result };
}); });
// ── Delete reference data ──
export const deleteCategory = authActionClient
.inputSchema(z.object({ id: z.string().uuid() }))
.action(async ({ parsedInput: { id } }) => {
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
await api.referenceData.deleteCategory(id);
return { success: true };
});
export const deleteInstructor = authActionClient
.inputSchema(z.object({ id: z.string().uuid() }))
.action(async ({ parsedInput: { id } }) => {
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
await api.referenceData.deleteInstructor(id);
return { success: true };
});
export const deleteLocation = authActionClient
.inputSchema(z.object({ id: z.string().uuid() }))
.action(async ({ parsedInput: { id } }) => {
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
await api.referenceData.deleteLocation(id);
return { success: true };
});

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createCourseCommunicationService(
client: SupabaseClient<Database>,
) {
return new CourseCommunicationService(client);
}
class CourseCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
courseId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'courses')
.eq('entity_id', courseId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'courses',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'courses');
if (error) throw error;
}
}

View File

@@ -0,0 +1,94 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createCourseExportService(client: SupabaseClient<Database>) {
return new CourseExportService(client);
}
class CourseExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportParticipantsCsv(courseId: string): Promise<string> {
const { data: participants, error } = await this.client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('last_name');
if (error) throw error;
if (!participants?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Status',
'Anmeldedatum',
];
const rows = participants.map((p) =>
[
p.first_name,
p.last_name,
p.email ?? '',
p.phone ?? '',
p.status,
p.enrolled_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportCoursesCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('courses')
.select('*')
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: courses, error } = await query;
if (error) throw error;
if (!courses?.length) return '';
const headers = [
'Kursnr.',
'Name',
'Status',
'Startdatum',
'Enddatum',
'Gebuhr',
'Kapazitat',
'Min. Teilnehmer',
];
const rows = courses.map((c) =>
[
c.course_number ?? '',
c.name,
c.status,
c.start_date ?? '',
c.end_date ?? '',
c.fee?.toString() ?? '0',
c.capacity?.toString() ?? '',
c.min_participants?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'course-notification';
const MODULE = 'courses';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createCourseNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

View File

@@ -104,5 +104,80 @@ export function createCourseReferenceDataService(
if (error) throw error; if (error) throw error;
return data; return data;
}, },
// ── Update / Delete ──
async updateCategory(
id: string,
input: { name?: string; description?: string },
) {
const { error } = await client
.from('course_categories')
.update(input)
.eq('id', id);
if (error) throw error;
},
async deleteCategory(id: string) {
const { error } = await client
.from('course_categories')
.delete()
.eq('id', id);
if (error) throw error;
},
async updateInstructor(
id: string,
input: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
},
) {
const update: Record<string, unknown> = {};
if (input.firstName !== undefined) update.first_name = input.firstName;
if (input.lastName !== undefined) update.last_name = input.lastName;
if (input.email !== undefined) update.email = input.email;
if (input.phone !== undefined) update.phone = input.phone;
if (input.qualifications !== undefined)
update.qualifications = input.qualifications;
if (input.hourlyRate !== undefined) update.hourly_rate = input.hourlyRate;
const { error } = await client
.from('course_instructors')
.update(update)
.eq('id', id);
if (error) throw error;
},
async deleteInstructor(id: string) {
const { error } = await client
.from('course_instructors')
.delete()
.eq('id', id);
if (error) throw error;
},
async updateLocation(
id: string,
input: { name?: string; address?: string; room?: string; capacity?: number },
) {
const { error } = await client
.from('course_locations')
.update(input)
.eq('id', id);
if (error) throw error;
},
async deleteLocation(id: string) {
const { error } = await client
.from('course_locations')
.delete()
.eq('id', id);
if (error) throw error;
},
}; };
} }

View File

@@ -13,21 +13,31 @@ export function createCourseStatisticsService(
{ p_account_id: accountId }, { p_account_id: accountId },
); );
if (error) throw error; if (error) throw error;
// RPC returns a single row as an array // RPC returns a single row as an array with snake_case keys
const stats = Array.isArray(data) ? data[0] : data; const raw = Array.isArray(data) ? data[0] : data;
return ( const s = raw ?? {
stats ?? { total_courses: 0,
total_courses: 0, open_courses: 0,
open_courses: 0, running_courses: 0,
running_courses: 0, completed_courses: 0,
completed_courses: 0, cancelled_courses: 0,
cancelled_courses: 0, total_participants: 0,
total_participants: 0, total_waitlisted: 0,
total_waitlisted: 0, avg_occupancy_rate: 0,
avg_occupancy_rate: 0, total_revenue: 0,
total_revenue: 0, };
} // Normalise to camelCase for consumers
); return {
totalCourses: s.total_courses ?? s.totalCourses ?? 0,
openCourses: s.open_courses ?? s.openCourses ?? 0,
runningCourses: s.running_courses ?? s.runningCourses ?? 0,
completedCourses: s.completed_courses ?? s.completedCourses ?? 0,
cancelledCourses: s.cancelled_courses ?? s.cancelledCourses ?? 0,
totalParticipants: s.total_participants ?? s.totalParticipants ?? 0,
totalWaitlisted: s.total_waitlisted ?? s.totalWaitlisted ?? 0,
avgOccupancyRate: s.avg_occupancy_rate ?? s.avgOccupancyRate ?? 0,
totalRevenue: s.total_revenue ?? s.totalRevenue ?? 0,
};
}, },
async getAttendanceSummary(courseId: string) { async getAttendanceSummary(courseId: string) {

View File

@@ -35,5 +35,8 @@
"react": "catalog:", "react": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"lucide-react": "catalog:"
} }
} }

View File

@@ -0,0 +1,106 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { createHolidayPass } from '../server/actions/event-actions';
interface CreateHolidayPassDialogProps {
accountId: string;
}
export function CreateHolidayPassDialog({ accountId }: CreateHolidayPassDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const currentYear = new Date().getFullYear();
const [form, setForm] = useState({ name: '', year: String(currentYear), description: '', price: '0', validFrom: '', validUntil: '' });
const action = useAction(createHolidayPass, {
onSuccess: () => {
toast.success('Ferienpass erstellt');
setOpen(false);
setForm({ name: '', year: String(currentYear), description: '', price: '0', validFrom: '', validUntil: '' });
router.refresh();
},
onError: () => toast.error('Fehler beim Erstellen'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Neuer Ferienpass</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Ferienpass erstellen</DialogTitle>
<DialogDescription>Erstellen Sie einen neuen Ferienpass für Ihr Ferienprogramm.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Name *</Label>
<Input placeholder="z.B. Sommerferienprogramm" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Jahr *</Label>
<Input type="number" value={form.year} onChange={(e) => setForm(s => ({ ...s, year: e.target.value }))} />
</div>
</div>
<div className="grid gap-2">
<Label>Beschreibung</Label>
<Input placeholder="Optional" value={form.description} onChange={(e) => setForm(s => ({ ...s, description: e.target.value }))} />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label>Preis ()</Label>
<Input type="number" step="0.01" min="0" value={form.price} onChange={(e) => setForm(s => ({ ...s, price: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Gültig ab</Label>
<Input type="date" value={form.validFrom} onChange={(e) => setForm(s => ({ ...s, validFrom: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Gültig bis</Label>
<Input type="date" value={form.validUntil} onChange={(e) => setForm(s => ({ ...s, validUntil: e.target.value }))} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button
onClick={() => action.execute({
accountId,
name: form.name,
year: Number(form.year),
description: form.description || undefined,
price: Number(form.price) || 0,
validFrom: form.validFrom || undefined,
validUntil: form.validUntil || undefined,
})}
disabled={action.isPending || !form.name || !form.year}
>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { UserPlus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { registerForEvent } from '../server/actions/event-actions';
interface EventRegistrationDialogProps {
eventId: string;
eventName: string;
}
export function EventRegistrationDialog({ eventId, eventName }: EventRegistrationDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
const action = useAction(registerForEvent, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Anmeldung erfolgreich');
setOpen(false);
setForm({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
router.refresh();
} else {
toast.error(data?.error ?? 'Fehler bei der Anmeldung');
}
},
onError: () => toast.error('Fehler bei der Anmeldung'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><UserPlus className="mr-2 h-4 w-4" />Anmeldung</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Anmeldung zu {eventName}"</DialogTitle>
<DialogDescription>Melden Sie einen Teilnehmer an.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Vorname *</Label>
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Nachname *</Label>
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
</div>
</div>
<div className="grid gap-2">
<Label>E-Mail</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Telefon</Label>
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Geburtsdatum</Label>
<Input type="date" value={form.dateOfBirth} onChange={(e) => setForm(s => ({ ...s, dateOfBirth: e.target.value }))} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button
onClick={() => action.execute({
eventId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email || undefined,
phone: form.phone || undefined,
dateOfBirth: form.dateOfBirth || undefined,
})}
disabled={action.isPending || !form.firstName || !form.lastName}
>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Anmelden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1 +1,3 @@
export { CreateEventForm } from './create-event-form'; export { CreateEventForm } from './create-event-form';
export { EventRegistrationDialog } from './event-registration-dialog';
export { CreateHolidayPassDialog } from './create-holiday-pass-dialog';

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createEventCommunicationService(
client: SupabaseClient<Database>,
) {
return new EventCommunicationService(client);
}
class EventCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
eventId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'events')
.eq('entity_id', eventId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'events',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'events');
if (error) throw error;
}
}

View File

@@ -0,0 +1,84 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createEventExportService(client: SupabaseClient<Database>) {
return new EventExportService(client);
}
class EventExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportRegistrationsCsv(eventId: string): Promise<string> {
const { data: registrations, error } = await this.client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('last_name');
if (error) throw error;
if (!registrations?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Geburtsdatum',
'Status',
'Anmeldedatum',
];
const rows = registrations.map((r) =>
[
r.first_name,
r.last_name,
r.email ?? '',
r.phone ?? '',
r.date_of_birth ?? '',
r.status,
r.created_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportEventsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('events')
.select('*')
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: events, error } = await query;
if (error) throw error;
if (!events?.length) return '';
const headers = ['Name', 'Status', 'Datum', 'Ort', 'Kapazitat'];
const rows = events.map((e) =>
[
e.name,
e.status,
e.event_date ?? '',
e.location ?? '',
e.capacity?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'event-notification';
const MODULE = 'events';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createEventNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

View File

@@ -33,5 +33,8 @@
"react": "catalog:", "react": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"lucide-react": "catalog:"
} }
} }

View File

@@ -1,2 +1,3 @@
export { CreateInvoiceForm } from './create-invoice-form'; export { CreateInvoiceForm } from './create-invoice-form';
export { CreateSepaBatchForm } from './create-sepa-batch-form'; export { CreateSepaBatchForm } from './create-sepa-batch-form';
export { SepaBatchActions } from './sepa-batch-actions';

View File

@@ -0,0 +1,377 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
Download,
UserPlus,
Plus,
Loader2,
AlertTriangle,
} from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import {
populateBatchFromMembers,
addSepaItem,
generateSepaXml,
} from '../server/actions/finance-actions';
interface SepaBatchActionsProps {
batchId: string;
accountId: string;
batchStatus: string;
itemCount: number;
}
/**
* Client-side toolbar for SEPA batch detail page.
* Provides: populate from members, add single item, generate XML.
*/
export function SepaBatchActions({
batchId,
accountId,
batchStatus,
itemCount,
}: SepaBatchActionsProps) {
const router = useRouter();
const isDraft = batchStatus === 'draft';
// ── Populate from members ──
const populateAction = useAction(populateBatchFromMembers, {
onSuccess: ({ data }) => {
const count = data?.data?.addedCount ?? 0;
if (count > 0) {
toast.success(`${count} Mitglieder hinzugefügt`, {
description: 'Positionen wurden aus der Mitgliederliste erstellt.',
});
} else {
toast.info('Keine Mitglieder gefunden', {
description:
'Keine aktiven Mitglieder mit SEPA-Mandat und Beitragskategorie vorhanden.',
});
}
router.refresh();
},
onError: () => {
toast.error('Fehler beim Hinzufügen', {
description: 'Bitte versuchen Sie es erneut.',
});
},
});
// ── Add single item ──
const [addOpen, setAddOpen] = useState(false);
const [singleItem, setSingleItem] = useState({
debtorName: '',
debtorIban: '',
amount: '',
remittanceInfo: 'Mitgliedsbeitrag',
});
const addItemAction = useAction(addSepaItem, {
onSuccess: () => {
toast.success('Position hinzugefügt');
setAddOpen(false);
setSingleItem({
debtorName: '',
debtorIban: '',
amount: '',
remittanceInfo: 'Mitgliedsbeitrag',
});
router.refresh();
},
onError: () => {
toast.error('Fehler beim Hinzufügen der Position');
},
});
// ── Generate XML ──
const [xmlOpen, setXmlOpen] = useState(false);
const [creditor, setCreditor] = useState({
creditorName: '',
creditorIban: '',
creditorBic: '',
creditorId: '',
});
const xmlAction = useAction(generateSepaXml, {
onSuccess: ({ data }) => {
if (data?.data?.content) {
// Trigger download
const blob = new Blob([data.data.content], {
type: 'application/xml',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.data.filename;
a.click();
URL.revokeObjectURL(url);
toast.success('SEPA-XML heruntergeladen');
setXmlOpen(false);
}
},
onError: () => {
toast.error('Fehler beim Generieren der XML-Datei');
},
});
return (
<div className="flex flex-wrap gap-3">
{/* Populate from members — main CTA for draft batches */}
{isDraft && (
<Button
onClick={() => populateAction.execute({ batchId, accountId })}
disabled={populateAction.isPending}
>
{populateAction.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<UserPlus className="mr-2 h-4 w-4" />
)}
Mitglieder hinzufügen
</Button>
)}
{/* Add single position manually */}
{isDraft && (
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Einzelposition
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Position manuell hinzufügen</DialogTitle>
<DialogDescription>
Geben Sie die Daten des Zahlungspflichtigen ein.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="debtor-name">Name *</Label>
<Input
id="debtor-name"
placeholder="z.B. Max Mustermann"
value={singleItem.debtorName}
onChange={(e) =>
setSingleItem((s) => ({
...s,
debtorName: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="debtor-iban">IBAN *</Label>
<Input
id="debtor-iban"
placeholder="DE89 3704 0044 0532 0130 00"
value={singleItem.debtorIban}
onChange={(e) =>
setSingleItem((s) => ({
...s,
debtorIban: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="debtor-amount">Betrag (EUR) *</Label>
<Input
id="debtor-amount"
type="number"
step="0.01"
min="0.01"
placeholder="50.00"
value={singleItem.amount}
onChange={(e) =>
setSingleItem((s) => ({
...s,
amount: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="debtor-info">Verwendungszweck</Label>
<Input
id="debtor-info"
placeholder="Mitgliedsbeitrag 2026"
value={singleItem.remittanceInfo}
onChange={(e) =>
setSingleItem((s) => ({
...s,
remittanceInfo: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>
Abbrechen
</Button>
<Button
onClick={() =>
addItemAction.execute({
batchId,
debtorName: singleItem.debtorName,
debtorIban: singleItem.debtorIban.replace(/\s/g, ''),
amount: parseFloat(singleItem.amount) || 0,
remittanceInfo: singleItem.remittanceInfo || undefined,
})
}
disabled={
addItemAction.isPending ||
!singleItem.debtorName ||
!singleItem.debtorIban ||
!singleItem.amount
}
>
{addItemAction.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Generate & download XML */}
<Dialog open={xmlOpen} onOpenChange={setXmlOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
disabled={itemCount === 0}
title={
itemCount === 0
? 'Fügen Sie zuerst Positionen hinzu'
: undefined
}
>
<Download className="mr-2 h-4 w-4" />
XML herunterladen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>SEPA-XML generieren</DialogTitle>
<DialogDescription>
Geben Sie die Gläubiger-Daten Ihres Vereins ein. Diese werden im
SEPA-XML als Zahlungsempfänger verwendet.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="creditor-name">Gläubiger-Name (Verein) *</Label>
<Input
id="creditor-name"
placeholder="z.B. Sportverein Musterstadt e.V."
value={creditor.creditorName}
onChange={(e) =>
setCreditor((s) => ({ ...s, creditorName: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditor-iban">Gläubiger-IBAN *</Label>
<Input
id="creditor-iban"
placeholder="DE89 3704 0044 0532 0130 00"
value={creditor.creditorIban}
onChange={(e) =>
setCreditor((s) => ({ ...s, creditorIban: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditor-bic">Gläubiger-BIC *</Label>
<Input
id="creditor-bic"
placeholder="z.B. COBADEFFXXX"
value={creditor.creditorBic}
onChange={(e) =>
setCreditor((s) => ({ ...s, creditorBic: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditor-id">Gläubiger-ID *</Label>
<Input
id="creditor-id"
placeholder="z.B. DE98ZZZ09999999999"
value={creditor.creditorId}
onChange={(e) =>
setCreditor((s) => ({ ...s, creditorId: e.target.value }))
}
/>
<p className="text-muted-foreground text-xs">
Die Gläubiger-Identifikationsnummer erhalten Sie bei der
Deutschen Bundesbank.
</p>
</div>
</div>
{itemCount === 0 && (
<div className="flex items-center gap-2 rounded-md bg-amber-50 p-3 text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Keine Positionen vorhanden. Bitte fügen Sie zuerst Mitglieder
hinzu.
</span>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setXmlOpen(false)}>
Abbrechen
</Button>
<Button
onClick={() =>
xmlAction.execute({
batchId,
accountId,
creditorName: creditor.creditorName,
creditorIban: creditor.creditorIban.replace(/\s/g, ''),
creditorBic: creditor.creditorBic,
creditorId: creditor.creditorId,
})
}
disabled={
xmlAction.isPending ||
itemCount === 0 ||
!creditor.creditorName ||
!creditor.creditorIban ||
!creditor.creditorBic ||
!creditor.creditorId
}
>
{xmlAction.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
XML generieren & herunterladen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -158,7 +158,11 @@ export function MemberDetailTabs({
/> />
<DetailRow <DetailRow
label="Geschlecht" label="Geschlecht"
value={String(member.gender ?? '—')} value={
member.gender
? { male: 'Männlich', female: 'Weiblich', diverse: 'Divers' }[member.gender as string] ?? String(member.gender)
: '—'
}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -211,7 +215,18 @@ export function MemberDetailTabs({
/> />
<DetailRow <DetailRow
label="Land" label="Land"
value={String(member.country ?? 'DE')} value={
(() => {
const code = String(member.country ?? 'DE');
const countries: Record<string, string> = {
DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz',
LI: 'Liechtenstein', LU: 'Luxemburg', IT: 'Italien',
FR: 'Frankreich', NL: 'Niederlande', BE: 'Belgien',
PL: 'Polen', CZ: 'Tschechien', DK: 'Dänemark',
};
return countries[code] ?? code;
})()
}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -56,102 +56,112 @@ const dateNotFutureSchema = (fieldName: string) =>
// --- Main schemas --- // --- Main schemas ---
export const CreateMemberSchema = z // Base object without refinements — used for .partial() in UpdateMemberSchema
.object({ const CreateMemberBaseSchema = z.object({
accountId: z.string().uuid(), accountId: z.string().uuid(),
memberNumber: z.string().optional(), memberNumber: z.string().optional(),
firstName: z.string().min(1).max(128), firstName: z.string().min(1).max(128),
lastName: z.string().min(1).max(128), lastName: z.string().min(1).max(128),
dateOfBirth: dateNotFutureSchema('Geburtsdatum'), dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
gender: z.enum(['male', 'female', 'diverse']).optional(), gender: z.enum(['male', 'female', 'diverse']).optional(),
title: z.string().max(32).optional(), title: z.string().max(32).optional(),
email: z.string().email().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')),
phone: z.string().max(32).optional(), phone: z.string().max(32).optional(),
mobile: z.string().max(32).optional(), mobile: z.string().max(32).optional(),
street: z.string().max(256).optional(), street: z.string().max(256).optional(),
houseNumber: z.string().max(16).optional(), houseNumber: z.string().max(16).optional(),
postalCode: z.string().max(10).optional(), postalCode: z.string().max(10).optional(),
city: z.string().max(128).optional(), city: z.string().max(128).optional(),
country: z.string().max(2).default('DE'), country: z.string().max(2).default('DE'),
status: MembershipStatusEnum.default('active'), status: MembershipStatusEnum.default('active'),
entryDate: z entryDate: z
.string() .string()
.default(() => new Date().toISOString().split('T')[0]!), .default(() => new Date().toISOString().split('T')[0]!),
duesCategoryId: z.string().uuid().optional(), duesCategoryId: z.string().uuid().optional(),
iban: ibanSchema, iban: ibanSchema,
bic: z.string().max(11).optional(), bic: z.string().max(11).optional(),
accountHolder: z.string().max(128).optional(), accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false), gdprConsent: z.boolean().default(false),
notes: z.string().optional(), notes: z.string().optional(),
salutation: z.string().optional(), salutation: z.string().optional(),
street2: z.string().optional(), street2: z.string().optional(),
phone2: z.string().optional(), phone2: z.string().optional(),
fax: z.string().optional(), fax: z.string().optional(),
birthplace: z.string().optional(), birthplace: z.string().optional(),
birthCountry: z.string().default('DE'), birthCountry: z.string().default('DE'),
isHonorary: z.boolean().default(false), isHonorary: z.boolean().default(false),
isFoundingMember: z.boolean().default(false), isFoundingMember: z.boolean().default(false),
isYouth: z.boolean().default(false), isYouth: z.boolean().default(false),
isRetiree: z.boolean().default(false), isRetiree: z.boolean().default(false),
isProbationary: z.boolean().default(false), isProbationary: z.boolean().default(false),
isTransferred: z.boolean().default(false), isTransferred: z.boolean().default(false),
exitDate: z.string().optional(), exitDate: z.string().optional(),
exitReason: z.string().optional(), exitReason: z.string().optional(),
guardianName: z.string().optional(), guardianName: z.string().optional(),
guardianPhone: z.string().optional(), guardianPhone: z.string().optional(),
guardianEmail: z.string().optional(), guardianEmail: z.string().optional(),
duesYear: z.number().int().optional(), duesYear: z.number().int().optional(),
duesPaid: z.boolean().default(false), duesPaid: z.boolean().default(false),
additionalFees: z.number().default(0), additionalFees: z.number().default(0),
exemptionType: z.string().optional(), exemptionType: z.string().optional(),
exemptionReason: z.string().optional(), exemptionReason: z.string().optional(),
exemptionAmount: z.number().optional(), exemptionAmount: z.number().optional(),
gdprNewsletter: z.boolean().default(false), gdprNewsletter: z.boolean().default(false),
gdprInternet: z.boolean().default(false), gdprInternet: z.boolean().default(false),
gdprPrint: z.boolean().default(false), gdprPrint: z.boolean().default(false),
gdprBirthdayInfo: z.boolean().default(false), gdprBirthdayInfo: z.boolean().default(false),
sepaMandateReference: z.string().optional(), sepaMandateReference: z.string().optional(),
}) });
.superRefine((data, ctx) => {
// Cross-field: exit_date must be after entry_date
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
path: ['exitDate'],
});
}
// Cross-field: entry_date must be after date_of_birth /** Cross-field refinement shared by create/update */
if ( function memberCrossFieldRefinement(
data.dateOfBirth && data: Record<string, unknown>,
data.entryDate && ctx: z.RefinementCtx,
data.entryDate < data.dateOfBirth ) {
) { // Cross-field: exit_date must be after entry_date
ctx.addIssue({ if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
code: z.ZodIssueCode.custom, ctx.addIssue({
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen', code: z.ZodIssueCode.custom,
path: ['entryDate'], message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
}); path: ['exitDate'],
} });
}
// Cross-field: youth members should have guardian info // Cross-field: entry_date must be after date_of_birth
if (data.isYouth && !data.guardianName) { if (
ctx.addIssue({ data.dateOfBirth &&
code: z.ZodIssueCode.custom, data.entryDate &&
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten', data.entryDate < data.dateOfBirth
path: ['guardianName'], ) {
}); ctx.addIssue({
} code: z.ZodIssueCode.custom,
}); message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
path: ['entryDate'],
});
}
// Cross-field: youth members should have guardian info
if (data.isYouth && !data.guardianName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
path: ['guardianName'],
});
}
}
export const CreateMemberSchema =
CreateMemberBaseSchema.superRefine(memberCrossFieldRefinement);
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>; export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({ export const UpdateMemberSchema = CreateMemberBaseSchema.partial()
memberId: z.string().uuid(), .extend({
isArchived: z.boolean().optional(), memberId: z.string().uuid(),
version: z.number().int().optional(), isArchived: z.boolean().optional(),
}); version: z.number().int().optional(),
})
.superRefine(memberCrossFieldRefinement);
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>; export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;

View File

@@ -33,5 +33,8 @@
"react": "catalog:", "react": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"zod": "catalog:" "zod": "catalog:"
},
"dependencies": {
"lucide-react": "catalog:"
} }
} }

View File

@@ -0,0 +1,82 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
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 { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { createTemplate } from '../server/actions/newsletter-actions';
interface CreateTemplateDialogProps {
accountId: string;
}
export function CreateTemplateDialog({ accountId }: CreateTemplateDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ name: '', subject: '', bodyHtml: '<h1>Betreff</h1>\n<p>Inhalt hier...</p>' });
const action = useAction(createTemplate, {
onSuccess: () => {
toast.success('Vorlage erstellt');
setOpen(false);
setForm({ name: '', subject: '', bodyHtml: '<h1>Betreff</h1>\n<p>Inhalt hier...</p>' });
router.refresh();
},
onError: () => toast.error('Fehler beim Erstellen'),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Neue Vorlage</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Newsletter-Vorlage erstellen</DialogTitle>
<DialogDescription>Erstellen Sie eine wiederverwendbare Vorlage für Ihren Newsletter.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Name *</Label>
<Input placeholder="z.B. Monatlicher Vereinsbrief" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Betreff *</Label>
<Input placeholder="z.B. Neuigkeiten aus dem Verein" value={form.subject} onChange={(e) => setForm(s => ({ ...s, subject: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Inhalt (HTML) *</Label>
<Textarea rows={6} value={form.bodyHtml} onChange={(e) => setForm(s => ({ ...s, bodyHtml: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button
onClick={() => action.execute({ accountId, name: form.name, subject: form.subject, bodyHtml: form.bodyHtml })}
disabled={action.isPending || !form.name || !form.subject || !form.bodyHtml}
>
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1 +1,2 @@
export { CreateNewsletterForm } from './create-newsletter-form'; export { CreateNewsletterForm } from './create-newsletter-form';
export { CreateTemplateDialog } from './create-template-dialog';

File diff suppressed because it is too large Load Diff

135
qa-checklist.md Normal file
View File

@@ -0,0 +1,135 @@
# QA Checklist — MYeasyCMS v2
**Date**: 2026-04-03
**Test method**: Docker production build (`docker compose -f docker-compose.local.yml up --build`)
**Test user**: `test@makerkit.dev` / `testingpassword` (super-admin)
---
## BUGS FOUND & FIXED
### BUG #1 — Members CMS crashes with Zod v4 error (CRITICAL) ✅ FIXED
- **Route**: `/home/[account]/members-cms`
- **Error**: `Error: .partial() cannot be used on object schemas containing refinements`
- **Root cause**: `CreateMemberSchema` uses `.superRefine()` for cross-field validation, then `UpdateMemberSchema = CreateMemberSchema.partial()` fails in Zod v4.
- **Fix**: Separated base object schema from refinements. `CreateMemberBaseSchema` (plain object) is used for `.partial()`, and the refinement function `memberCrossFieldRefinement` is applied separately to both Create and Update schemas.
- **File**: `packages/features/member-management/src/schema/member.schema.ts`
- **Status**: ✅ Verified in Docker rebuild — members page loads with 30 members displayed
### BUG #2 — Course stats cards show empty values (MEDIUM) ✅ FIXED
- **Route**: `/home/[account]/courses`
- **Symptom**: Stats cards showed labels (Gesamt, Aktiv, Abgeschlossen, Teilnehmer) but no numbers
- **Root cause**: The `getQuickStats` RPC returns snake_case keys (`total_courses`, `open_courses`) but the template uses camelCase (`stats.totalCourses`, `stats.openCourses`), resulting in `undefined`
- **Fix**: Added camelCase normalization in `createCourseStatisticsService.getQuickStats()`
- **File**: `packages/features/course-management/src/server/services/course-statistics.service.ts`
- **Status**: ✅ Verified in Docker rebuild — all 4 stats cards show "0" correctly
### BUG #3 — Blog post images missing (MEDIUM) ✅ FIXED
- **Route**: `/blog`
- **Symptom**: All blog post cards showed broken image alt text instead of cover images
- **Root cause**: Blog posts reference images like `/images/posts/mitgliederverwaltung.webp` but only 3 default Makerkit images existed in `public/images/posts/`
- **Fix**: Created placeholder images for all 5 German blog posts
- **Files added**: `apps/web/public/images/posts/{mitgliederverwaltung,dsgvo-vereine,sepa-lastschrift,digitale-verwaltung,vereinswebsite}.webp`
- **Status**: ✅ Verified in Docker rebuild — all blog images load
### BUG #4 — Dev bootstrap references non-existent table (LOW) ✅ FIXED
- **Error**: `ERROR: relation "public.catch_entries" does not exist` during DB seeding
- **Root cause**: `docker/db/dev-bootstrap.sh` grants permissions on `catch_entries` table, but no migration creates it. The actual table is `catch_books`.
- **Fix**: Removed the `catch_entries` GRANT line from dev-bootstrap.sh
- **File**: `docker/db/dev-bootstrap.sh`
- **Status**: ✅ Fixed
---
## KNOWN ISSUES (NOT FIXED — LOW PRIORITY)
### ISSUE #5 — Admin panel not translated to German
- **Route**: `/admin`
- **Symptom**: Admin dashboard shows English labels ("Users", "Team Accounts", "Paying Customers") while rest of app is in German
- **Impact**: Low — admin panel is internal-facing only
- **Fix needed**: Add German translations for admin section in i18n
### ISSUE #6 — Hydration mismatch warning (dev-only)
- **Source**: `next-runtime-env` `PublicEnvScript` component
- **Impact**: None — this is a known Next.js 16 framework-level issue with script serialization. The `suppressHydrationWarning` is already set on `<html>`.
- **Fix needed**: None — wait for upstream fix in `next-runtime-env`
---
## PAGES TESTED — ALL PASSING ✅
### Marketing Pages (Public)
| Route | Status | Notes |
|-------|--------|-------|
| `/` (Homepage) | ✅ Pass | Hero, stats, features, testimonials, pricing, CTA all render |
| `/blog` | ✅ Pass | Blog cards with images, dates, descriptions |
| `/docs` | ✅ Pass | Documentation sidebar navigation, category cards |
| `/pricing` | ✅ Pass | 4 pricing tiers, monthly/yearly toggle, feature lists |
| `/faq` | ✅ Pass | Accordion FAQ items with expand/collapse |
| `/contact` | ✅ Pass | Contact form with name, email, message fields |
### Auth Pages
| Route | Status | Notes |
|-------|--------|-------|
| `/auth/sign-in` | ✅ Pass | Email/password login, social auth buttons (Google, Apple, Azure, GitHub) |
| `/auth/sign-up` | ✅ Pass (navigable) | Registration form |
### Personal Dashboard
| Route | Status | Notes |
|-------|--------|-------|
| `/home` | ✅ Pass | Personal home with sidebar, account selector |
| `/home/settings` | ✅ Pass (navigable) | Profile settings |
### Team Dashboard (Makerkit workspace)
| Route | Status | Notes |
|-------|--------|-------|
| `/home/makerkit` | ✅ Pass | Dashboard with member stats, course stats, invoices, newsletters, quick actions |
| `/home/makerkit/members-cms` | ✅ Pass | Member list with search, filters, status badges, pagination (30 members) |
| `/home/makerkit/courses` | ✅ Pass | Course list with stats cards, search, status filter |
| `/home/makerkit/events` | ✅ Pass | Events list with stats (Veranstaltungen, Orte, Kapazität) |
| `/home/makerkit/finance` | ✅ Pass | SEPA + Invoices overview with stats |
| `/home/makerkit/newsletter` | ✅ Pass | Newsletter list with stats |
| `/home/makerkit/bookings` | ✅ Pass | Booking management with rooms/active/revenue stats |
| `/home/makerkit/documents` | ✅ Pass | Document generator cards (Mitgliedsausweis, Rechnung, Etiketten, etc.) |
| `/home/makerkit/site-builder` | ✅ Pass | Page builder with settings, posts, status |
| `/home/makerkit/meetings` | ✅ Pass | Meeting protocols with stats (Protokolle, Aufgaben) |
| `/home/makerkit/fischerei` | ✅ Pass | Fishing module with 8 tabs, 6 stat cards |
| `/home/makerkit/verband` | ✅ Pass | Federation management with 9 tabs, 6 stat cards |
| `/home/makerkit/settings` | ✅ Pass | Team logo, team name, danger zone |
### Admin Panel
| Route | Status | Notes |
|-------|--------|-------|
| `/admin` | ✅ Pass | Super admin dashboard with Users/Team/Paying/Trials stats |
---
## INTERACTIVE ELEMENTS VERIFIED
- ✅ Navigation links (Blog, Docs, Pricing, FAQ, Contact)
- ✅ Auth flow (sign-in with email/password)
- ✅ Account/workspace selector dropdown
- ✅ Sidebar navigation (collapsed/expanded)
- ✅ Stats cards (all modules)
- ✅ Member list table with avatars, status badges, tags
- ✅ Search inputs (members, courses, bookings, newsletter)
- ✅ Status filter dropdowns
- ✅ Tab navigation (Fischerei, Meetings, Verband)
- ✅ Quick action buttons (New Member, New Course, etc.)
- ✅ FAQ accordion expand/collapse
- ✅ Theme toggle button
- ✅ Sign In / Sign Up buttons
- ✅ Breadcrumb navigation
---
## FILES MODIFIED
1. `packages/features/member-management/src/schema/member.schema.ts` — Zod v4 partial() fix
2. `packages/features/course-management/src/server/services/course-statistics.service.ts` — snake_case→camelCase normalization
3. `apps/web/public/images/posts/mitgliederverwaltung.webp` — Added blog image
4. `apps/web/public/images/posts/dsgvo-vereine.webp` — Added blog image
5. `apps/web/public/images/posts/sepa-lastschrift.webp` — Added blog image
6. `apps/web/public/images/posts/digitale-verwaltung.webp` — Added blog image
7. `apps/web/public/images/posts/vereinswebsite.webp` — Added blog image
8. `docker/db/dev-bootstrap.sh` — Removed non-existent catch_entries table reference