chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-22 09:44:35 +02:00
commit c3035e16ec
161 changed files with 18517 additions and 0 deletions

View File

@@ -0,0 +1,563 @@
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,
})