Compare commits

6 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
53 changed files with 1894 additions and 223 deletions

View File

@@ -1 +1 @@
[]
[]

View File

@@ -4,13 +4,11 @@ import {
ArrowLeft,
BedDouble,
CalendarDays,
LogIn,
LogOut,
XCircle,
User,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { BookingStatusActions } from '@kit/booking-management/components';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
@@ -288,41 +286,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardDescription>{t('detail.changeStatus')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{(status === 'pending' || status === 'confirmed') && (
<Button variant="default">
<LogIn className="mr-2 h-4 w-4" />
{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>
<BookingStatusActions
bookingId={bookingId}
status={status}
/>
</CardContent>
</Card>
</div>

View File

@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -51,8 +52,9 @@ function isDateInRange(
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 search = await searchParams;
const t = await getTranslations('bookings');
const client = getSupabaseServerClient();
@@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) {
const api = createBookingManagementApi(client);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const year = Number(search.year) || now.getFullYear();
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 firstWeekday = getFirstWeekday(year, month);
@@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button
variant="ghost"
size="icon"
disabled
asChild
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>
<CardTitle>
{MONTH_NAMES[month]} {year}
@@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button
variant="ghost"
size="icon"
disabled
asChild
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>
</div>
</CardHeader>

View File

@@ -2,6 +2,7 @@ import { UserCircle, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateGuestDialog } from '@kit/booking-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
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 items-center justify-between">
<p className="text-muted-foreground">{t('guests.manage')}</p>
<Button data-test="guests-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('guests.newGuest')}
</Button>
<CreateGuestDialog accountId={acct.id} />
</div>
{guests.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { BedDouble, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateRoomDialog } from '@kit/booking-management/components';
import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
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 items-center justify-between">
<p className="text-muted-foreground">{t('rooms.manage')}</p>
<Button data-test="rooms-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('rooms.newRoom')}
</Button>
<CreateRoomDialog accountId={acct.id} />
</div>
{rooms.length === 0 ? (

View File

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

View File

@@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
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">
{t('common.parent')}
</th>
<th scope="col" className="w-16 p-3" />
</tr>
</thead>
<tbody>
@@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) {
{String(cat.description ?? '—')}
</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>
))}
</tbody>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,29 @@ export default async function StatisticsPage({ params }: Props) {
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 (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<FischereiTabNavigation account={account} activeTab="statistics" />
@@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) {
Fangstatistiken und Auswertungen
</p>
</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>
<CardHeader>
<CardTitle>Fangstatistiken</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">
Noch keine Daten vorhanden
</h3>
<CardContent className="p-6">
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 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">
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
hier Statistiken und Auswertungen.
Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.
</p>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);

View File

@@ -35,7 +35,11 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
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 = [
{ 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 { createNewsletterApi } from '@kit/newsletter/api';
import { CreateTemplateDialog } from '@kit/newsletter/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
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>
</div>
<Button data-test="newsletter-templates-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('templates.newTemplate')}
</Button>
<CreateTemplateDialog accountId={acct.id} />
</div>
{/* Table or Empty State */}

View File

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

View File

@@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [
{ 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 (
<div className="space-y-6">
<div>
<p className="text-muted-foreground">
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
Aktuelle Kennzahlen des Verbands
</p>
</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">
<Card>
<CardHeader>

View File

@@ -1,7 +1,9 @@
import { getTranslations } from 'next-intl/server';
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 StatisticsContent from './_components/statistics-content';
@@ -13,11 +15,50 @@ interface Props {
export default async function StatisticsPage({ params }: Props) {
const { account } = await params;
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 (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<VerbandTabNavigation account={account} activeTab="statistics" />
<StatisticsContent />
<StatisticsContent
activeClubs={activeClubs}
totalClubs={clubs.length}
totalMembers={totalMembers || directMembers}
openFees={openFees}
/>
</CmsPageShell>
);
}

View File

@@ -139,7 +139,58 @@
"administration": "Administration",
"accountSettings": "Kontoeinstellungen",
"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": {
"owner": {
@@ -223,4 +274,4 @@
"action": "Zum Dashboard"
},
"confirm": "Bestätigen"
}
}

View File

@@ -139,7 +139,58 @@
"associationTemplates": "Shared Templates",
"administration": "Administration",
"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": {
"owner": {
@@ -223,4 +274,4 @@
"action": "Go to Dashboard"
},
"confirm": "Confirm"
}
}

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

@@ -59,7 +59,6 @@ $PSQL -c "
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.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_competitions TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;

View File

@@ -35,5 +35,8 @@
"react": "catalog:",
"react-hook-form": "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 { BookingStatusActions } from './booking-status-actions';
export { CreateRoomDialog } from './create-room-dialog';
export { CreateGuestDialog } from './create-guest-dialog';

View File

@@ -35,5 +35,8 @@
"react": "catalog:",
"react-hook-form": "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 { 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');
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

@@ -104,5 +104,80 @@ export function createCourseReferenceDataService(
if (error) throw error;
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 },
);
if (error) throw error;
// RPC returns a single row as an array
const stats = Array.isArray(data) ? data[0] : data;
return (
stats ?? {
total_courses: 0,
open_courses: 0,
running_courses: 0,
completed_courses: 0,
cancelled_courses: 0,
total_participants: 0,
total_waitlisted: 0,
avg_occupancy_rate: 0,
total_revenue: 0,
}
);
// RPC returns a single row as an array with snake_case keys
const raw = Array.isArray(data) ? data[0] : data;
const s = raw ?? {
total_courses: 0,
open_courses: 0,
running_courses: 0,
completed_courses: 0,
cancelled_courses: 0,
total_participants: 0,
total_waitlisted: 0,
avg_occupancy_rate: 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) {

View File

@@ -35,5 +35,8 @@
"react": "catalog:",
"react-hook-form": "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 { EventRegistrationDialog } from './event-registration-dialog';
export { CreateHolidayPassDialog } from './create-holiday-pass-dialog';

View File

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

View File

@@ -1,2 +1,3 @@
export { CreateInvoiceForm } from './create-invoice-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
label="Geschlecht"
value={String(member.gender ?? '—')}
value={
member.gender
? { male: 'Männlich', female: 'Weiblich', diverse: 'Divers' }[member.gender as string] ?? String(member.gender)
: '—'
}
/>
</CardContent>
</Card>
@@ -211,7 +215,18 @@ export function MemberDetailTabs({
/>
<DetailRow
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>
</Card>

View File

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

View File

@@ -33,5 +33,8 @@
"react": "catalog:",
"react-hook-form": "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 { CreateTemplateDialog } from './create-template-dialog';

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