564 lines
21 KiB
TypeScript
564 lines
21 KiB
TypeScript
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
import { useMemo, useState } from 'react'
|
|
import { m, mKey } from '@/i18n/messages'
|
|
import { useLocale, getLocale } from '@/i18n/config'
|
|
import { asI18n } from '@pikku/react'
|
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
|
import { useDebouncedValue } from '@mantine/hooks'
|
|
import {
|
|
Alert,
|
|
Anchor,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
MultiSelect,
|
|
Paper,
|
|
SegmentedControl,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
} from '@pikku/mantine/core'
|
|
import { Check, Clock, X } from 'lucide-react'
|
|
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
|
import { usePageTitle } from '../components/AppLayout'
|
|
import { RequireAuth } from '../auth'
|
|
import { fmtDate, fmtDateShort } from '../lib/dates'
|
|
|
|
const STATUS_KEYS = ['pending', 'approved', 'declined'] as const
|
|
type EnquiryStatus = (typeof STATUS_KEYS)[number]
|
|
|
|
type EnquiryDateOption = {
|
|
optionId: string
|
|
startDate: string
|
|
endDate: string
|
|
priority: number
|
|
}
|
|
|
|
type Enquiry = {
|
|
enquiryId: string
|
|
contactName: string | null
|
|
contactEmail: string
|
|
contactPhone: string | null
|
|
website: string | null
|
|
eventName: string
|
|
eventOutline: string | null
|
|
description: string | null
|
|
dateOptions: EnquiryDateOption[]
|
|
expectedPersons: number | null
|
|
halfHouse: boolean
|
|
notes: string | null
|
|
status: string
|
|
waitlistedAt: Date | null
|
|
approvedAt: Date | null
|
|
declinedAt: Date | null
|
|
createdAt: Date
|
|
bookingId: string | null
|
|
}
|
|
|
|
// The SDK's date reviver (transform-date.js) turns any 'YYYY-MM-DD…' response
|
|
// string into a Date — including these option dates, despite their string type.
|
|
// Normalize back to a plain 'YYYY-MM-DD' string so the date inputs and equality
|
|
// checks below work, and parse at local midnight for display.
|
|
const toYmd = (v: string | Date): string =>
|
|
v instanceof Date ? v.toISOString().slice(0, 10) : v
|
|
const optDate = (s: string) => new Date(`${s}T00:00:00`)
|
|
|
|
function StatusBadge({ enquiry }: { enquiry: Enquiry }) {
|
|
useLocale()
|
|
const color =
|
|
enquiry.status === 'approved' ? 'green' : enquiry.status === 'declined' ? 'red' : 'blue'
|
|
return (
|
|
<Group gap={6} wrap="nowrap">
|
|
<Badge color={color} variant="light" size="sm">
|
|
{mKey(`common.pages.admin.enquiries.status.${enquiry.status}` as any)}
|
|
</Badge>
|
|
{enquiry.status === 'pending' && enquiry.waitlistedAt && (
|
|
<Badge color="orange" variant="light" size="sm">
|
|
{m.common_pages_admin_enquiries_status_waitlisted()}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
)
|
|
}
|
|
|
|
function AdminEnquiriesInner() {
|
|
useLocale()
|
|
const queryClient = useQueryClient()
|
|
const navigate = useNavigate()
|
|
|
|
usePageTitle(m.common_pages_admin_enquiries_title())
|
|
|
|
const [statusFilter, setStatusFilter] = useState<string[]>(['pending'])
|
|
|
|
const queryArgs = useMemo(
|
|
() => ({
|
|
venueSlug: 'drawehn',
|
|
status: statusFilter.length ? statusFilter : undefined,
|
|
}),
|
|
[statusFilter],
|
|
)
|
|
|
|
const { data, isLoading, isFetching, error } = usePikkuQuery('getEnquiries', queryArgs, {
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['getEnquiries'] })
|
|
|
|
// Which enquiry has an open transition modal, and which kind.
|
|
const [modal, setModal] = useState<{ kind: 'approve' | 'waitlist' | 'decline'; enquiry: Enquiry } | null>(null)
|
|
|
|
const statusOptions = useMemo(
|
|
() =>
|
|
STATUS_KEYS.map((s) => ({
|
|
value: s,
|
|
label: mKey(`common.pages.admin.enquiries.status.${s}` as any),
|
|
})),
|
|
[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 enquiries = (data.enquiries as Enquiry[]).map((e) => ({
|
|
...e,
|
|
dateOptions: e.dateOptions.map((o) => ({
|
|
...o,
|
|
startDate: toYmd(o.startDate),
|
|
endDate: toYmd(o.endDate),
|
|
})),
|
|
}))
|
|
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<Stack gap="sm">
|
|
<Group gap="sm" align="flex-end" wrap="wrap">
|
|
<MultiSelect
|
|
flex="1 1 240px"
|
|
clearable
|
|
placeholder={statusFilter.length ? undefined : m.common_pages_admin_enquiries_filters_status()}
|
|
data={statusOptions}
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
/>
|
|
</Group>
|
|
|
|
<Group gap="xs" align="center">
|
|
<Text size="sm" c="dimmed">
|
|
{m.common_pages_admin_enquiries_stats_showing({ count: enquiries.length })}
|
|
</Text>
|
|
{isFetching && <Loader size="xs" color="plum" />}
|
|
</Group>
|
|
|
|
<Paper withBorder radius="md" p={0}>
|
|
{enquiries.length === 0 ? (
|
|
<Box p="xl">
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{m.common_pages_admin_enquiries_empty()}
|
|
</Text>
|
|
</Box>
|
|
) : (
|
|
<Table highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_received()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_event()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_contact()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_dates()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_persons()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_status()}</Table.Th>
|
|
<Table.Th>{m.common_pages_admin_enquiries_cols_actions()}</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{enquiries.map((e) => (
|
|
<Table.Tr key={e.enquiryId}>
|
|
<Table.Td>
|
|
<Text size="sm">{asI18n(`${fmtDateShort(e.createdAt, getLocale())}`)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={500}>{asI18n(`${e.eventName}`)}</Text>
|
|
{e.eventOutline && (
|
|
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${e.eventOutline}`)}</Text>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{asI18n(`${e.contactName ?? '—'}`)}</Text>
|
|
<Text size="xs" c="dimmed">{asI18n(`${e.contactEmail}`)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{e.dateOptions.length === 0 ? (
|
|
<Text size="xs" c="dimmed">{m.common_pages_admin_enquiries_no_dates()}</Text>
|
|
) : (
|
|
<Stack gap={2}>
|
|
{e.dateOptions.map((o, i) => (
|
|
<Text key={o.optionId} size="sm" c={i === 0 ? undefined : 'dimmed'}>
|
|
{asI18n(`${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>{e.expectedPersons ?? '—'}</Table.Td>
|
|
<Table.Td><StatusBadge enquiry={e} /></Table.Td>
|
|
<Table.Td>
|
|
{e.status === 'pending' ? (
|
|
<Group gap={4} wrap="nowrap">
|
|
<Button
|
|
size="compact-xs"
|
|
color="green"
|
|
variant="light"
|
|
leftSection={<Check size={14} />}
|
|
onClick={() => setModal({ kind: 'approve', enquiry: e })}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_approve()}
|
|
</Button>
|
|
<Button
|
|
size="compact-xs"
|
|
color="orange"
|
|
variant="light"
|
|
leftSection={<Clock size={14} />}
|
|
onClick={() => setModal({ kind: 'waitlist', enquiry: e })}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_waitlist()}
|
|
</Button>
|
|
<Button
|
|
size="compact-xs"
|
|
color="red"
|
|
variant="subtle"
|
|
leftSection={<X size={14} />}
|
|
onClick={() => setModal({ kind: 'decline', enquiry: e })}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_decline()}
|
|
</Button>
|
|
</Group>
|
|
) : e.status === 'approved' && e.bookingId ? (
|
|
<Anchor
|
|
size="sm"
|
|
onClick={() =>
|
|
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: e.bookingId! } })
|
|
}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_view_booking()}
|
|
</Anchor>
|
|
) : (
|
|
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Paper>
|
|
</Stack>
|
|
|
|
{modal?.kind === 'approve' && (
|
|
<ApproveModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
|
)}
|
|
{modal?.kind === 'waitlist' && (
|
|
<WaitlistModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
|
)}
|
|
{modal?.kind === 'decline' && (
|
|
<DeclineModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
|
)}
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
// ─── Approve modal ──────────────────────────────────────────────────────────
|
|
|
|
function ApproveModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
|
useLocale()
|
|
|
|
const [mode, setMode] = useState<'existing' | 'new'>('existing')
|
|
// Prefill from the visitor's preferred (first) date option; the admin can
|
|
// pick a different option or override the inputs entirely.
|
|
const topOption = enquiry.dateOptions[0]
|
|
const [startDate, setStartDate] = useState(topOption?.startDate ?? '')
|
|
const [endDate, setEndDate] = useState(topOption?.endDate ?? '')
|
|
|
|
// Existing-client search (server-side via getClients).
|
|
const [clientId, setClientId] = useState<string | null>(null)
|
|
const [search, setSearch] = useState('')
|
|
const [debouncedSearch] = useDebouncedValue(search, 250)
|
|
const { data: clientData, isFetching: clientsFetching } = usePikkuQuery(
|
|
'getClients',
|
|
{ venueSlug: 'drawehn', q: debouncedSearch.trim() || undefined },
|
|
{ placeholderData: keepPreviousData, enabled: mode === 'existing' },
|
|
)
|
|
const clientOptions = useMemo(
|
|
() =>
|
|
(clientData?.clients ?? []).map((c) => ({
|
|
value: c.clientId,
|
|
label: c.name ? `${c.name}${c.contactEmail ? ` · ${c.contactEmail}` : ''}` : (c.contactEmail ?? c.clientId),
|
|
})),
|
|
[clientData],
|
|
)
|
|
|
|
// New-client fields, pre-filled from the enquiry contact.
|
|
const [name, setName] = useState(enquiry.contactName ?? '')
|
|
const [email, setEmail] = useState(enquiry.contactEmail)
|
|
const [phone, setPhone] = useState(enquiry.contactPhone ?? '')
|
|
const [website, setWebsite] = useState(enquiry.website ?? '')
|
|
const [billingAddress, setBillingAddress] = useState('')
|
|
|
|
const mutation = usePikkuMutation('approveEnquiry', { onSuccess: onDone })
|
|
|
|
const datesValid = !!startDate && !!endDate && startDate <= endDate
|
|
const clientValid = mode === 'existing' ? !!clientId : !!email.trim()
|
|
const canSubmit = datesValid && clientValid
|
|
|
|
const submit = () => {
|
|
mutation.mutate({
|
|
enquiryId: enquiry.enquiryId,
|
|
startDate,
|
|
endDate,
|
|
client:
|
|
mode === 'existing'
|
|
? { clientId: clientId! }
|
|
: {
|
|
name: name.trim() || undefined,
|
|
contactEmail: email.trim() || undefined,
|
|
contactPhone: phone.trim() || undefined,
|
|
website: website.trim() || undefined,
|
|
billingAddress: billingAddress.trim() || undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Modal opened onClose={onClose} title={m.common_pages_admin_enquiries_approve_modal_title()} size="lg">
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">{asI18n(`${enquiry.eventName}`)}</Text>
|
|
|
|
{enquiry.dateOptions.length > 0 && (
|
|
<Box>
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={6}>
|
|
{m.common_pages_admin_enquiries_approve_modal_date_options()}
|
|
</Text>
|
|
<Group gap="xs">
|
|
{enquiry.dateOptions.map((o, i) => {
|
|
const selected = o.startDate === startDate && o.endDate === endDate
|
|
return (
|
|
<Badge
|
|
key={o.optionId}
|
|
variant={selected ? 'filled' : 'light'}
|
|
color={selected ? 'plum' : 'gray'}
|
|
style={{ cursor: 'pointer', textTransform: 'none' }}
|
|
onClick={() => {
|
|
setStartDate(o.startDate)
|
|
setEndDate(o.endDate)
|
|
}}
|
|
>
|
|
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
|
</Badge>
|
|
)
|
|
})}
|
|
</Group>
|
|
</Box>
|
|
)}
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
data-testid="approve-start-date"
|
|
type="date"
|
|
label={m.common_pages_admin_enquiries_approve_modal_start_date()}
|
|
required
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
data-testid="approve-end-date"
|
|
type="date"
|
|
label={m.common_pages_admin_enquiries_approve_modal_end_date()}
|
|
required
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.currentTarget.value)}
|
|
error={startDate && endDate && startDate > endDate ? m.common_pages_admin_enquiries_approve_modal_date_error() : undefined}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<SegmentedControl
|
|
fullWidth
|
|
value={mode}
|
|
onChange={(v) => setMode(v as 'existing' | 'new')}
|
|
data={[
|
|
{ value: 'existing', label: m.common_pages_admin_enquiries_approve_modal_existing_client() },
|
|
{ value: 'new', label: m.common_pages_admin_enquiries_approve_modal_new_client() },
|
|
]}
|
|
/>
|
|
|
|
{mode === 'existing' ? (
|
|
<Select
|
|
data-testid="approve-client-select"
|
|
label={m.common_pages_admin_enquiries_approve_modal_select_client()}
|
|
placeholder={m.common_pages_admin_enquiries_approve_modal_search_client()}
|
|
searchable
|
|
data={clientOptions}
|
|
value={clientId}
|
|
onChange={setClientId}
|
|
searchValue={search}
|
|
onSearchChange={setSearch}
|
|
rightSection={clientsFetching ? <Loader size="xs" /> : undefined}
|
|
nothingFoundMessage={m.common_pages_admin_enquiries_approve_modal_no_clients()}
|
|
filter={({ options }) => options}
|
|
/>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<TextInput
|
|
data-testid="approve-new-name"
|
|
label={m.common_pages_admin_enquiries_approve_modal_client_name()}
|
|
value={name}
|
|
onChange={(e) => setName(e.currentTarget.value)}
|
|
/>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
data-testid="approve-new-email"
|
|
type="email"
|
|
label={m.common_pages_admin_enquiries_approve_modal_client_email()}
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label={m.common_pages_admin_enquiries_approve_modal_client_phone()}
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.currentTarget.value)}
|
|
/>
|
|
</SimpleGrid>
|
|
<TextInput
|
|
label={m.common_pages_admin_enquiries_approve_modal_client_website()}
|
|
value={website}
|
|
onChange={(e) => setWebsite(e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label={m.common_pages_admin_enquiries_approve_modal_client_billing_address()}
|
|
value={billingAddress}
|
|
onChange={(e) => setBillingAddress(e.currentTarget.value)}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
{mutation.error && (
|
|
<Alert color="red">{asI18n(`${mutation.error.message || m.common_pages_admin_enquiries_transition_error()}`)}</Alert>
|
|
)}
|
|
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>{m.common_actions_cancel()}</Button>
|
|
<Button
|
|
data-testid="approve-confirm"
|
|
color="green"
|
|
onClick={submit}
|
|
disabled={!canSubmit || mutation.isPending}
|
|
loading={mutation.isPending}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_approve()}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// ─── Waitlist modal ─────────────────────────────────────────────────────────
|
|
|
|
function WaitlistModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
|
useLocale()
|
|
const mutation = usePikkuMutation('waitlistEnquiry', { onSuccess: onDone })
|
|
|
|
const hasOptions = enquiry.dateOptions.length > 0
|
|
|
|
return (
|
|
<Modal opened onClose={onClose} title={m.common_pages_admin_enquiries_waitlist_modal_title()} size="md">
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">{m.common_pages_admin_enquiries_waitlist_modal_intro()}</Text>
|
|
{hasOptions ? (
|
|
<Stack gap={4}>
|
|
{enquiry.dateOptions.map((o, i) => (
|
|
<Text key={o.optionId} size="sm">
|
|
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<Alert color="orange">{m.common_pages_admin_enquiries_waitlist_modal_no_options()}</Alert>
|
|
)}
|
|
{mutation.error && (
|
|
<Alert color="red">{asI18n(`${mutation.error.message || m.common_pages_admin_enquiries_transition_error()}`)}</Alert>
|
|
)}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>{m.common_actions_cancel()}</Button>
|
|
<Button
|
|
data-testid="waitlist-confirm"
|
|
color="orange"
|
|
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
|
|
disabled={!hasOptions || mutation.isPending}
|
|
loading={mutation.isPending}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_waitlist()}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// ─── Decline modal ──────────────────────────────────────────────────────────
|
|
|
|
function DeclineModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
|
useLocale()
|
|
const mutation = usePikkuMutation('declineEnquiry', { onSuccess: onDone })
|
|
|
|
return (
|
|
<Modal opened onClose={onClose} title={m.common_pages_admin_enquiries_decline_modal_title()} size="md">
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
{m.common_pages_admin_enquiries_decline_modal_confirm({
|
|
event: enquiry.eventName,
|
|
date: enquiry.createdAt ? fmtDate(enquiry.createdAt, getLocale()) : '',
|
|
})}
|
|
</Text>
|
|
{mutation.error && (
|
|
<Alert color="red">{asI18n(`${mutation.error.message || m.common_pages_admin_enquiries_transition_error()}`)}</Alert>
|
|
)}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>{m.common_actions_cancel()}</Button>
|
|
<Button
|
|
data-testid="decline-confirm"
|
|
color="red"
|
|
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
|
|
disabled={mutation.isPending}
|
|
loading={mutation.isPending}
|
|
>
|
|
{m.common_pages_admin_enquiries_actions_decline()}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
function AdminEnquiriesPage() {
|
|
return (
|
|
<RequireAuth roles={['admin', 'owner']}>
|
|
<AdminEnquiriesInner />
|
|
</RequireAuth>
|
|
)
|
|
}
|
|
|
|
export const Route = createFileRoute('/admin/enquiries')({
|
|
component: AdminEnquiriesPage,
|
|
})
|