261 lines
9.2 KiB
TypeScript
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,
|
|
})
|