Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/bookings/page.tsx
T. Zehetbauer abac22feb1
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
feat: enhance accessibility and testing with data-test attributes and improve error handling
2026-04-01 10:46:44 +02:00

316 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 { createBookingManagementApi } from '@kit/booking-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
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((booking) => {
const room = booking.room as Record<string, string> | null;
const guest = booking.guest as Record<string, string> | null;
const roomName = (room?.name ?? '').toLowerCase();
const roomNumber = (room?.room_number ?? '').toLowerCase();
const guestFirst = (guest?.first_name ?? '').toLowerCase();
const guestLast = (guest?.last_name ?? '').toLowerCase();
return (
roomName.includes(q) ||
roomNumber.includes(q) ||
guestFirst.includes(q) ||
guestLast.includes(q)
);
});
}
const activeBookings = bookingsData.filter(
(booking) =>
booking.status === 'confirmed' || booking.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 data-test="bookings-new-btn">
<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="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<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="bg-muted/50 border-b">
<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="hover:bg-muted/30 border-b"
>
<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">
{formatDate(booking.check_in)}
</td>
<td className="p-3">
{formatDate(booking.check_out)}
</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-muted-foreground text-sm">
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>
);
}