Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/bookings/page.tsx
T. Zehetbauer c6d564836f
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 17m4s
Workflow / ⚫️ Test (push) Has been skipped
fix: add missing newlines at the end of JSON files; clean up formatting in page components
2026-04-02 11:02:58 +02:00

318 lines
12 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 { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { formatDate, formatCurrencyAmount } 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';
import {
BOOKING_STATUS_VARIANT as STATUS_BADGE_VARIANT,
BOOKING_STATUS_LABEL_KEYS as STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
const PAGE_SIZE = 25;
export default async function BookingsPage({
params,
searchParams,
}: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('bookings');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) {
return (
<CmsPageShell account={account} title={t('list.title')}>
<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;
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, unknown>>;
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={t('list.title')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">{t('list.manage')}</p>
<Button data-test="bookings-new-btn" asChild>
<Link href={`/home/${account}/bookings/new`}>
<Plus className="mr-2 h-4 w-4" />
{t('list.newBooking')}
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatsCard
title={t('rooms.title')}
value={rooms.length}
icon={<BedDouble className="h-5 w-5" />}
/>
<StatsCard
title={t('list.activeBookings')}
value={activeBookings.length}
icon={<CalendarCheck className="h-5 w-5" />}
/>
<StatsCard
title={t('common.of')}
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"
aria-hidden="true"
/>
<Input
name="q"
defaultValue={searchQuery}
placeholder={t('list.searchPlaceholder')}
aria-label={t('list.searchPlaceholder')}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary" size="sm">
{t('list.search')}
</Button>
{searchQuery && (
<Button type="button" variant="ghost" size="sm" asChild>
<Link href={`/home/${account}/bookings`}>{t('list.reset')}</Link>
</Button>
)}
</form>
{/* Table or Empty State */}
{bookingsData.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
title={searchQuery ? t('list.noResults') : t('list.noBookings')}
description={
searchQuery
? t('list.noResultsFor', { query: searchQuery })
: t('list.createFirst')
}
actionLabel={searchQuery ? undefined : t('list.newBooking')}
actionHref={
searchQuery ? undefined : `/home/${account}/bookings/new`
}
/>
) : (
<Card>
<CardHeader>
<CardTitle>
{searchQuery
? t('list.searchResults', { count: bookingsData.length })
: t('list.allBookings', { count: total })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('list.room')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.guest')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.checkIn')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.checkOut')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.status')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('list.amount')}
</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 as string)}
</td>
<td className="p-3">
{formatDate(booking.check_out as string)}
</td>
<td className="p-3">
<Badge
variant={
STATUS_BADGE_VARIANT[String(booking.status)] ??
'secondary'
}
>
{t(
STATUS_LABEL_KEYS[String(booking.status)] ??
String(booking.status),
)}
</Badge>
</td>
<td className="p-3 text-right">
{booking.total_price != null
? formatCurrencyAmount(
booking.total_price as number,
)
: '—'}
</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">
{t('common.page')} {page} {t('common.of')} {totalPages} (
{total} {t('common.entries')})
</p>
<div className="flex items-center gap-2">
{page > 1 ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/bookings?page=${page - 1}`}
>
{t('common.previous')}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
{t('common.previous')}
</Button>
)}
{page < totalPages ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/home/${account}/bookings?page=${page + 1}`}
>
{t('common.next')}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
{t('common.next')}
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}