Files
seminarhof-e2e-mrfyfnqt/apps/app/src/pages/admin.bookings.index.tsx
2026-07-11 07:59:25 +02:00

261 lines
9.2 KiB
TypeScript

import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { bookingStatus } from '@/i18n/i18n-enum.gen'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { keepPreviousData } from '@tanstack/react-query'
import {
Badge,
Box,
Button,
Checkbox,
Container,
Group,
Loader,
MultiSelect,
Paper,
Stack,
Table,
Text,
TextInput,
} from '@pikku/mantine/core'
import { Search } from 'lucide-react'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { StatusPill } from '../components/StatusPill'
import { usePageTitle } from '../components/AppLayout'
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
import { fmtDateShort } from '../lib/dates'
const STATUS_KEYS: BookingStatus[] = [
'enquiry',
'reserved',
'confirmed',
'ended',
'cancelled',
]
function StatusDot({ color }: { color: string }) {
return (
<Box
component="span"
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 4,
background: color,
flex: '0 0 auto',
}}
/>
)
}
function AdminBookingsInner() {
useLocale()
const navigate = useNavigate()
usePageTitle(m.common__pages__admin__bookings__title())
const [years, setYears] = useState<string[]>([])
const [statusFilter, setStatusFilter] = useState<string[]>([])
const [search, setSearch] = useState('')
const [halfHouseOnly, setHalfHouseOnly] = useState(false)
const queryArgs = useMemo(
() => ({
venueSlug: 'drawehn',
years: years.length ? years.map(Number) : undefined,
status: statusFilter.length ? statusFilter : undefined,
search: search.trim() || undefined,
halfHouseOnly: halfHouseOnly || undefined,
}),
[years, statusFilter, search, halfHouseOnly],
)
const normalizedSearch = search.trim().toLocaleLowerCase(getLocale())
const { data, isLoading, isFetching, error } = usePikkuQuery(
'getAdminBookings',
queryArgs,
{ placeholderData: keepPreviousData },
)
const statusOptions = useMemo(
() =>
STATUS_KEYS.map((s) => ({
value: s,
label: bookingStatus[s](),
})),
[getLocale()],
)
if (isLoading && !data) {
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
}
if (error && !data) {
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
}
if (!data) return null
const bookings = [...data.bookings].sort((a, b) => {
if (!normalizedSearch) return 0
const rank = (value: string | null | undefined) => {
const text = value?.toLocaleLowerCase(getLocale()) ?? ''
if (text === normalizedSearch) return 0
if (text.startsWith(normalizedSearch)) return 1
if (text.includes(normalizedSearch)) return 2
return 3
}
const aRank = Math.min(rank(a.eventName), rank(a.clientName))
const bRank = Math.min(rank(b.eventName), rank(b.clientName))
if (aRank !== bRank) return aRank - bRank
return a.eventName.localeCompare(b.eventName, getLocale())
})
return (
<Container size="xl" py="md">
<Stack gap="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
flex="1 1 220px"
leftSection={<Search size={16} />}
placeholder={m.common__pages__admin__bookings__search_placeholder()}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<MultiSelect
flex="0 1 200px"
clearable
placeholder={
years.length ? undefined : m.common__pages__admin__bookings__filters__year()
}
data={data.yearOptions.map((y) => String(y))}
value={years}
onChange={setYears}
/>
<MultiSelect
flex="1 1 240px"
clearable
placeholder={
statusFilter.length
? undefined
: m.common__pages__admin__bookings__filters__status()
}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
renderOption={({ option }) => (
<Group gap="xs" wrap="nowrap">
<StatusDot color={BOOKING_STATUS[option.value as BookingStatus].dot} />
<span>{option.label}</span>
</Group>
)}
/>
<Checkbox
color="plum"
label={m.common__pages__admin__bookings__filters__half_house_only()}
checked={halfHouseOnly}
onChange={(e) => setHalfHouseOnly(e.currentTarget.checked)}
styles={{ root: { alignSelf: 'center' } }}
/>
<Button
component={Link}
to="/enquiry"
color="plum"
variant="filled"
ml="auto"
>
{m.common__pages__admin__bookings__new_booking()}
</Button>
</Group>
<Group gap="xs" align="center">
<Text size="sm" c="dimmed">
{m.common__pages__admin__bookings__stats__showing({ count: data.totalCount })}
</Text>
{isFetching && <Loader size="xs" color="plum" />}
</Group>
<Paper withBorder radius="md" p={0}>
{bookings.length === 0 ? (
<Box p="xl">
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__bookings__empty()}
</Text>
</Box>
) : (
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__admin__bookings__cols__dates()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__course()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__client()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__status()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__persons()}</Table.Th>
<Table.Th ta="center">{m.common__pages__admin__bookings__cols__half_house()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__deposit()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__final()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((b) => (
<Table.Tr
key={b.bookingId}
style={{ cursor: 'pointer' }}
onClick={() =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: b.bookingId } })
}
>
<Table.Td>
<Text size="sm">{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}</Text>
<Text size="xs" c="dimmed">{asI18n(`${fmtDateShort(b.endDate, getLocale())}`)}</Text>
</Table.Td>
<Table.Td>{asI18n(`${b.eventName}`)}</Table.Td>
<Table.Td>
<Group gap={4}>
{b.isStammgruppe && (
<Text size="xs" c="yellow" title={m.common__pages__admin__bookings__cols__regular_group()}>{asI18n('★')}</Text>
)}
{asI18n(`${b.clientName}`)}
</Group>
</Table.Td>
<Table.Td>
<StatusPill kind="booking" status={b.status as BookingStatus} />
</Table.Td>
<Table.Td>
{b.participantCount} / {b.expectedPersons ?? '?'}
</Table.Td>
<Table.Td ta="center">{b.halfHouse ? '½' : ''}</Table.Td>
<Table.Td>
{!b.hasDeposit
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
: <Badge size="xs" color={b.depositPaid ? 'green' : 'orange'} variant="light">
{(b.depositPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
</Badge>}
</Table.Td>
<Table.Td>
{!b.hasFinal
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
: <Badge size="xs" color={b.finalPaid ? 'green' : 'orange'} variant="light">
{(b.finalPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
</Badge>}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
</Container>
)
}
export const Route = createFileRoute('/admin/bookings/')({
component: AdminBookingsInner,
})