Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/bookings/page.tsx
Zaid Marzguioui f10a34c505
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
fix: comprehensive CMS QA fixes — i18n, UI labels, breadcrumbs
- Fix i18n dotted permission keys causing INVALID_KEY console spam (en/de cms.json)
- Fix member detail breadcrumb showing UUID instead of member name
- Fix bookings stats card showing 'of' instead of 'Total'
- Fix events/registrations table 'status' column header resolving to object
- Fix finance search placeholder showing 'Show All' instead of search text
- Fix finance status filter default showing 'No data' instead of 'All'
- Fix applications page German pluralization 'Antrage' → 'Anträge'
- Add breadcrumbValues prop to CmsPageShell for UUID→name overrides
2026-04-02 22:59:42 +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('list.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="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>
);
}