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

Major changes:
- Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI
- Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions
- Sitzungsprotokolle module: meeting protocols, agenda items, task tracking
- Verbandsverwaltung module: federation management, member clubs, contacts, fees
- Per-account module activation via Modules page toggle
- Site Builder: live CMS data in Puck blocks (courses, events, membership registration)
- Public registration APIs: course signup, event registration, membership application
- Document generation: PDF member cards, Excel reports, HTML labels
- Landing page: real Com.BISS content (no filler text)
- UX audit fixes: AccountNotFound component, shared status badges, confirm dialog,
  pagination, duplicate heading removal, emoji→badge replacement, a11y fixes
- QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
This commit is contained in:
Zaid Marzguioui
2026-03-31 16:35:46 +02:00
parent 16648c92eb
commit ebd0fd4638
176 changed files with 17133 additions and 981 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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