Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/bookings/page.tsx
Zaid Marzguioui ebd0fd4638
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m26s
Workflow / ⚫️ Test (push) Has been skipped
feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
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
2026-03-31 16:35:46 +02:00

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from 'next/link';
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'
> = {
pending: 'secondary',
confirmed: 'default',
checked_in: 'info',
checked_out: 'outline',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
checked_in: 'Eingecheckt',
checked_out: 'Ausgecheckt',
cancelled: 'Storniert',
no_show: 'Nicht erschienen',
};
export default async function BookingsPage({ params, searchParams }: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) {
return (
<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);
// 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 { 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">
<p className="text-muted-foreground">
Zimmer und Buchungen verwalten
</p>
<Link href={`/home/${account}/bookings/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Buchung
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatsCard
title="Zimmer"
value={rooms.length}
icon={<BedDouble className="h-5 w-5" />}
/>
<StatsCard
title="Aktive Buchungen"
value={activeBookings.length}
icon={<CalendarCheck className="h-5 w-5" />}
/>
<StatsCard
title="Gesamt"
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 */}
{bookingsData.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
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>
{searchQuery
? `Ergebnisse (${bookingsData.length})`
: `Alle Buchungen (${total})`}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmer</th>
<th className="p-3 text-left font-medium">Gast</th>
<th className="p-3 text-left font-medium">Anreise</th>
<th className="p-3 text-left font-medium">Abreise</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Betrag</th>
</tr>
</thead>
<tbody>
{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>
)}
</div>
</CmsPageShell>
);
}