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,98 @@
import { useEffect, useState } from 'react'
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MantineProvider, Box } from '@pikku/mantine/core'
import { PikkuProvider, createPikku } from '@pikku/react'
import { useLocale, getLocale } from '@/i18n/config'
import '@mantine/core/styles.css'
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
import { theme } from '../theme'
import '../theme.css'
import '../i18n/config'
import { readInitialDirection } from '../direction'
declare global {
interface Window {
__E2E_API_URL?: string
}
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Seminarhof Drawehn' },
],
}),
component: RootDocument,
})
function RootDocument() {
useLocale()
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: { retry: 0 },
},
}),
)
const runtimeApiUrl =
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
transformDate: true,
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
credentials: 'include',
}),
)
return (
<html
lang={(getLocale() ?? 'de').slice(0, 2)}
dir={readInitialDirection()}
data-app-hydrated="false"
>
<head>
<HeadContent />
</head>
<body>
<HydrationReady />
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme}>
<PikkuProvider pikku={pikku}>
<Box
mih="100vh"
bg="var(--mantine-color-body)"
c="var(--mantine-color-text)"
>
<Outlet />
</Box>
</PikkuProvider>
</MantineProvider>
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}
function HydrationReady() {
useEffect(() => {
window.document.documentElement.dataset.appHydrated = 'true'
}, [])
return null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
import { Link, 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 } 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: mKey(`common.pages.bookingStatus.label.${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 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,
})

View File

@@ -0,0 +1,10 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { RequireAuth } from '../auth'
export const Route = createFileRoute('/admin/bookings')({
component: () => (
<RequireAuth roles={['admin', 'owner']}>
<Outlet />
</RequireAuth>
),
})

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,
})

View File

@@ -0,0 +1,311 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { m, mKey } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Container,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@pikku/mantine/core'
import {
CalendarDays,
FileClock,
FileText,
Inbox,
Wallet,
type LucideIcon,
} from 'lucide-react'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { RequireAuth } from '../auth'
import { usePageTitle } from '../components/AppLayout'
import { fmtDateShort } from '../lib/dates'
// One row inside an action card: clickable, navigates to the relevant workspace.
function Row({
primary,
secondary,
date,
trailing,
onClick,
}: {
primary: string
secondary?: string | null
date: string
trailing?: React.ReactNode
onClick: () => void
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={6}
onClick={onClick}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${primary}`)}
</Text>
{secondary && (
<Text size="xs" c="dimmed" truncate>
{asI18n(`${secondary}`)}
</Text>
)}
</Box>
<Group gap={8} wrap="nowrap" style={{ flex: '0 0 auto' }}>
{trailing}
<Text size="xs" c="dimmed">
{asI18n(`${date}`)}
</Text>
</Group>
</Group>
)
}
function ActionCard({
icon: Icon,
label,
count,
children,
}: {
icon: LucideIcon
label: string
count: number
children: React.ReactNode
}) {
const empty = count === 0
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb={children ? 'xs' : 0}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon
variant="light"
color={empty ? 'gray' : 'plum'}
size="md"
radius="md"
>
<Icon size={16} />
</ThemeIcon>
<Text size="sm" fw={600}>
{asI18n(`${label}`)}
</Text>
</Group>
<Badge
color={empty ? 'gray' : 'plum'}
variant={empty ? 'light' : 'filled'}
radius="sm"
>
{count}
</Badge>
</Group>
{!empty && <Stack gap={2}>{children}</Stack>}
</Paper>
)
}
function DashboardInner() {
useLocale()
const navigate = useNavigate()
usePageTitle(m.common_pages_admin_dashboard_title())
const { data, isLoading, error } = usePikkuQuery('getAdminDashboard', {
venueSlug: 'drawehn',
})
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 goEnquiries = () => navigate({ to: '/admin/enquiries' })
const goBooking = (bookingId: string) =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId } })
const tk = (k: string, opts?: Record<string, unknown>) =>
mKey(`common.pages.admin.dashboard.${k}` as any, opts ?? {})
const more = (bucket: { count: number; items: unknown[] }) =>
bucket.count > bucket.items.length ? (
<Text size="xs" c="dimmed" px="xs" pt={4}>
{tk('more', { count: bucket.count - bucket.items.length })}
</Text>
) : null
const noAction =
data.pendingEnquiries.count === 0 &&
data.contractsToSend.count === 0 &&
data.contractsAwaitingResponse.count === 0 &&
data.depositsOutstanding.count === 0
// A "needs action" hub should only surface what needs action — zero-count
// cards are noise, so we drop them and let `noAction` cover the empty state.
const bookingCard = (
key: string,
icon: LucideIcon,
label: string,
bucket: typeof data.contractsToSend,
) =>
bucket.count === 0 ? null : (
<ActionCard key={key} icon={icon} label={label} count={bucket.count}>
{bucket.items.map((b) => (
<Row
key={b.bookingId}
primary={b.eventName}
secondary={b.clientName}
date={fmtDateShort(b.startDate, getLocale())}
onClick={() => goBooking(b.bookingId)}
/>
))}
{more(bucket)}
</ActionCard>
)
const actionCards = [
data.pendingEnquiries.count === 0 ? null : (
<ActionCard
key="enq"
icon={Inbox}
label={tk('cards.pendingEnquiries')}
count={data.pendingEnquiries.count}
>
{data.pendingEnquiries.items.map((e) => (
<Row
key={e.enquiryId}
primary={e.eventName}
secondary={e.contactName}
date={fmtDateShort(e.startDate, getLocale())}
trailing={
e.waitlisted ? (
<Badge size="xs" color="orange" variant="light">
{tk('waitlisted')}
</Badge>
) : null
}
onClick={goEnquiries}
/>
))}
{more(data.pendingEnquiries)}
</ActionCard>
),
bookingCard('send', FileText, tk('cards.contractsToSend'), data.contractsToSend),
bookingCard(
'await',
FileClock,
tk('cards.contractsAwaiting'),
data.contractsAwaitingResponse,
),
bookingCard('dep', Wallet, tk('cards.depositsOutstanding'), data.depositsOutstanding),
].filter(Boolean)
return (
<Container size="xl" py="md">
<Grid gap="lg">
{/* Needs action */}
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="sm">
<Title order={4}>{tk('needsAction')}</Title>
{noAction ? (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
{tk('allClear')}
</Text>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{actionCards}
</SimpleGrid>
)}
</Stack>
</Grid.Col>
{/* Upcoming events */}
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="sm">
<Group gap="xs" align="baseline">
<Title order={4}>{tk('upcoming')}</Title>
<Text size="xs" c="dimmed">
{tk('upcomingWindow')}
</Text>
</Group>
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
{data.upcomingEvents.count === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{tk('noUpcoming')}
</Text>
) : (
<Stack gap={2}>
{data.upcomingEvents.items.map((b) => (
<Group
key={b.bookingId}
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={8}
onClick={() => goBooking(b.bookingId)}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color="plum"
size="lg"
radius="md"
>
<CalendarDays size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${b.eventName}`)}
</Text>
<Text size="xs" c="dimmed" truncate>
{asI18n(
`${b.clientName}${b.expectedPersons != null ? ` · ${tk('persons', { count: b.expectedPersons })}` : ''}`,
)}
</Text>
</Box>
</Group>
<Text size="xs" c="dimmed" style={{ flex: '0 0 auto' }}>
{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}
</Text>
</Group>
))}
</Stack>
)}
</Paper>
</Stack>
</Grid.Col>
</Grid>
</Container>
)
}
export const Route = createFileRoute('/admin/')({
component: () => (
<RequireAuth roles={['admin', 'owner']}>
<DashboardInner />
</RequireAuth>
),
})

View File

@@ -0,0 +1,402 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
// Brand-aligned: free is the inviting state (cream + plum text, clickable),
// confirmed is fully booked (filled plum), enquiry/reserved sit between.
const STATUS_BG: Record<string, { bg: string; fg: string }> = {
free: { bg: '#FBF8FA', fg: '#4E3149' },
enquiry: { bg: '#FFF8E0', fg: '#806600' },
reserved: { bg: '#FFEDDC', fg: '#8A4A1A' },
confirmed: { bg: '#88557F', fg: '#FFFFFF' },
}
const STATUS_KEYS = ['free', 'enquiry', 'reserved', 'confirmed'] as const
type DayBooking = {
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
}
type DayCell = {
date: Date
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
bookings: DayBooking[]
}
// Berlin-local YYYY-MM-DD string from a Date
const berlinStr = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
function groupByMonth(days: DayCell[]) {
const months: Record<string, DayCell[]> = {}
for (const d of days) {
const key = berlinStr(d.date).slice(0, 7)
;(months[key] ??= []).push(d)
}
return Object.entries(months).map(([key, ds]) => ({ key, days: ds }))
}
function monthLabel(yyyymm: string, locale: string) {
const [y, m] = yyyymm.split('-')
const d = new Date(Number(y), Number(m) - 1, 1)
return d.toLocaleString(locale, { month: 'long', year: 'numeric' })
}
function dowMonStart(d: Date) {
return (d.getUTCDay() + 6) % 7
}
// Inclusive list of YYYY-MM-DD strings between two date strings (UTC day steps).
function rangeDates(start: string, end: string): string[] {
const out: string[] = []
const cur = new Date(`${start}T00:00:00Z`)
const last = new Date(`${end}T00:00:00Z`)
while (cur <= last) {
out.push(cur.toISOString().slice(0, 10))
cur.setUTCDate(cur.getUTCDate() + 1)
}
return out
}
const SELECT_BG = { bg: '#88557F', fg: '#FFFFFF' } // endpoint
const SELECT_MID = { bg: '#EFE3ED', fg: '#4E3149' } // in-between
function MonthGrid({
days,
dowLabels,
statusLabels,
halfHouseLabel,
selected,
endpoints,
onDayClick,
}: {
days: DayCell[]
dowLabels: string[]
statusLabels: Record<string, string>
halfHouseLabel: string
selected: Set<string>
endpoints: Set<string>
onDayClick: (dateStr: string) => void
}) {
const first = days[0]
if (!first) return null
const leadingBlanks = dowMonStart(first.date)
return (
<Box>
<SimpleGrid cols={7} spacing={4}>
{dowLabels.map((d, i) => (
<Text key={i} size="xs" c="dimmed" ta="center">{asI18n(`${d}`)}</Text>
))}
{Array.from({ length: leadingBlanks }).map((_, i) => (
<Box key={`blank-${i}`} />
))}
{days.map((d) => {
const dateStr = berlinStr(d.date)
const isEndpoint = endpoints.has(dateStr)
const isSelected = selected.has(dateStr)
const style = isEndpoint ? SELECT_BG : isSelected ? SELECT_MID : STATUS_BG[d.status]
const dayNum = Number(dateStr.slice(8, 10))
// Two half-house bookings sharing a day → split the cell with a
// diagonal line (top-left = first booking, bottom-right = second).
// 3+ half-house bookings on one day (data error) intentionally fall
// back to the single collapsed colour — don't "fix" that into an
// N-way split without a real requirement.
const isSplit =
!isEndpoint && !isSelected &&
d.bookings.length === 2 && d.bookings.every((b) => b.halfHouse)
const splitBg = isSplit
? `linear-gradient(135deg, ${STATUS_BG[d.bookings[0].status].bg} 0 49%, #FFFFFF 49% 51%, ${STATUS_BG[d.bookings[1].status].bg} 51% 100%)`
: undefined
const bookingLabel = (b: DayBooking) =>
`${b.eventName ?? statusLabels[b.status]}${b.halfHouse ? ` (${halfHouseLabel})` : ''}`
const tooltipLabel = isSplit ? (
<Stack gap={0}>
<Text size="xs" fw={600}>{asI18n(`${dateStr}`)}</Text>
{d.bookings.map((b, i) => (
<Text key={i} size="xs">{asI18n(`${bookingLabel(b)}`)}</Text>
))}
</Stack>
) : d.eventName
? asI18n(`${dateStr}${d.eventName}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
: asI18n(`${dateStr}${statusLabels[d.status]}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
return (
<Tooltip key={dateStr} label={tooltipLabel} withArrow>
<Box
style={{
background: splitBg ?? style.bg,
color: isSplit ? '#4E3149' : style.fg,
borderRadius: 4,
textAlign: 'center',
padding: '6px 0',
fontSize: 13,
fontWeight: isEndpoint ? 700 : undefined,
boxShadow: isSelected && !isEndpoint ? 'inset 0 0 0 1px #88557F' : undefined,
cursor: d.status === 'free' ? 'pointer' : 'default',
position: 'relative',
}}
onClick={() => {
if (d.status === 'free') onDayClick(dateStr)
}}
>
<span style={isSplit ? { textShadow: '0 0 3px rgba(255,255,255,0.95)' } : undefined}>
{dayNum}
</span>
{/* Single half-house booking keeps the corner dot; a split cell
already communicates the half-house state visually. */}
{d.halfHouse && !isSplit && (
<Box
style={{
position: 'absolute',
top: 2,
right: 4,
width: 6,
height: 6,
borderRadius: 3,
background: 'currentColor',
opacity: 0.5,
}}
/>
)}
</Box>
</Tooltip>
)
})}
</SimpleGrid>
</Box>
)
}
const THIS_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [THIS_YEAR, THIS_YEAR + 1, THIS_YEAR + 2].map((y) => ({
value: String(y),
label: String(y),
}))
function AvailabilityPage() {
useLocale()
const navigate = useNavigate()
const [year, setYear] = useState(THIS_YEAR)
// Two-click range selection: first free day sets `start`, second sets `end`.
const [sel, setSel] = useState<{ start: string; end: string | null } | null>(null)
const from = `${year}-01-01`
const to = `${year}-12-31`
const { data, isLoading, error } = usePikkuQuery('getAvailability', { venueSlug: 'drawehn', from, to })
// Lookup of day status by Berlin-local date string, for validating ranges.
const statusByDate = useMemo(() => {
const m = new Map<string, DayCell['status']>()
for (const d of (data?.days as DayCell[] | undefined) ?? []) {
m.set(berlinStr(d.date), d.status)
}
return m
}, [data])
const handleDayClick = (ds: string) => {
setSel((prev) => {
// No selection yet, or a completed range → start fresh.
if (!prev || prev.end) return { start: ds, end: null }
// Re-click the start → deselect.
if (ds === prev.start) return null
// Earlier than start → move the start.
if (ds < prev.start) return { start: ds, end: null }
// Later than start → close the range only if every day in it is free.
const allFree = rangeDates(prev.start, ds).every((x) => statusByDate.get(x) === 'free')
return allFree ? { start: prev.start, end: ds } : { start: ds, end: null }
})
}
const selectedDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set(rangeDates(sel.start, sel.end ?? sel.start))
}, [sel])
const endpointDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set([sel.start, sel.end ?? sel.start])
}, [sel])
const months = useMemo(() => (data ? groupByMonth(data.days as DayCell[]) : []), [data])
const stats = useMemo(() => {
if (!data) return { free: 0, total: 0 }
const total = data.days.length
const free = data.days.filter((d) => d.status === 'free').length
return { free, total }
}, [data])
// Localised mon-start day-of-week labels.
const dowLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' })
// 2024-01-01 was a Monday — convenient anchor.
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i))))
}, [getLocale()])
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common_states_loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common_states_error()}</Text></Container></MarketingShell>
if (!data) return null
const statusLabels: Record<string, string> = {
free: m.common_pages_availability_status_free(),
enquiry: m.common_pages_availability_status_enquiry(),
reserved: m.common_pages_availability_status_reserved(),
confirmed: m.common_pages_availability_status_confirmed(),
}
const halfHouseLabel = m.common_pages_availability_status_half_house()
// `enquiry` is only surfaced to staff (the backend hides it from anonymous
// viewers), so only show its legend swatch when such a day is actually
// present in the data.
const hasEnquiry = (data.days as DayCell[]).some((d) => d.status === 'enquiry')
const legendKeys = hasEnquiry ? STATUS_KEYS : STATUS_KEYS.filter((k) => k !== 'enquiry')
const fmtDate = (ds: string) =>
new Date(`${ds}T00:00:00Z`).toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
const selStart = sel?.start ?? null
const selEnd = sel?.end ?? null
const rangeLabel = selStart
? selEnd && selEnd !== selStart
? `${fmtDate(selStart)} ${fmtDate(selEnd)}`
: fmtDate(selStart)
: ''
const createEnquiry = () => {
if (!selStart) return
navigate({ to: '/enquiry', search: { from: selStart, to: selEnd ?? selStart } })
}
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common_pages_availability_title()} />
<Group align="flex-end" gap="md">
<Select
label={m.common_pages_availability_year_label()}
data={YEAR_OPTIONS}
value={String(year)}
onChange={(v) => {
if (v) {
setYear(Number(v))
setSel(null)
}
}}
allowDeselect={false}
w={120}
/>
<Text size="sm" pb={6}>
{m.common_pages_availability_free_stat({
free: stats.free,
total: stats.total,
percent: Math.round((stats.free / stats.total) * 100),
})}
</Text>
</Group>
<Group gap="md">
{legendKeys.map((key) => (
<Group key={key} gap={6} align="center">
<Box w={14} h={14} style={{ background: STATUS_BG[key].bg, borderRadius: 3 }} />
<Text size="xs">{asI18n(`${statusLabels[key]}`)}</Text>
</Group>
))}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{months.map((month) => (
<Paper key={month.key} withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4} tt="capitalize">{asI18n(`${monthLabel(month.key, getLocale())}`)}</Title>
<Badge variant="light">
{m.common_pages_availability_free_month_badge({
count: month.days.filter((d) => d.status === 'free').length,
})}
</Badge>
</Group>
<MonthGrid
days={month.days}
dowLabels={dowLabels}
statusLabels={statusLabels}
halfHouseLabel={halfHouseLabel}
selected={selectedDates}
endpoints={endpointDates}
onDayClick={handleDayClick}
/>
</Stack>
</Paper>
))}
</SimpleGrid>
{selStart && (
<Paper
withBorder
shadow="md"
radius="md"
p="md"
pos="sticky"
bottom={16}
style={{ zIndex: 2 }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Stack gap={2}>
<Text fw={600}>
{m.common_pages_availability_selected_range({ range: rangeLabel })}
</Text>
{!selEnd && (
<Text size="xs" c="dimmed">
{m.common_pages_availability_select_end_hint()}
</Text>
)}
</Stack>
<Group gap="xs">
<Button variant="subtle" color="gray" onClick={() => setSel(null)}>
{m.common_pages_availability_clear_selection()}
</Button>
<Button data-testid="create-enquiry" onClick={createEnquiry}>
{m.common_pages_availability_create_enquiry()}
</Button>
</Group>
</Group>
</Paper>
)}
<Text size="xs" c="dimmed" ta="center">
{m.common_pages_availability_footnote()}
</Text>
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/availability')({
component: AvailabilityPage,
})

View File

@@ -0,0 +1,337 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import {
Alert,
Box,
Button,
Checkbox,
Container,
Divider,
Group,
Loader,
NumberInput,
Paper,
Radio,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@pikku/mantine/core'
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { PageTitle } from '../components/AppLayout'
import { fmtDate } from '../lib/dates'
type FormState = {
name: string
billingAddress: string
contactPhone: string
website: string
paymentMethod: 'cash' | 'transfer'
expectedPersons: string
arrivalTime: string
departureTime: string
dailyPlan: string
onlineAd: boolean
notes: string
acceptTerms: boolean
acceptPrivacy: boolean
}
/**
* Public landing for the passwordless booking-form magic link. Consumes the
* token (which issues a client session cookie), then renders the binding form.
* The token IS the auth — this route is intentionally not behind RequireAuth.
*/
function BookingFormLanding() {
const { token } = Route.useParams()
useLocale()
const qc = useQueryClient()
const [bookingId, setBookingId] = useState<string | null>(null)
// Outcome is tracked in component state (not the mutation observer) so it
// survives StrictMode's mount→unmount→mount and the consumedRef guard below.
const [phase, setPhase] = useState<'pending' | 'ready' | 'error'>('pending')
const consumedRef = useRef(false)
const consume = usePikkuMutation('consumeBookingFormLink', {
onSuccess: async (data) => {
await qc.invalidateQueries({ queryKey: ['me'] })
setBookingId(data.bookingId)
setPhase('ready')
},
onError: () => setPhase('error'),
})
useEffect(() => {
if (consumedRef.current) return
consumedRef.current = true
consume.mutate({ token })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
if (phase === 'error') {
return (
<Container size="sm" py="xl">
<PageTitle title={m.common_pages_booking_form_title()} />
<Alert color="red" title={m.common_pages_booking_form_link_invalid_title()}>
{m.common_pages_booking_form_link_invalid_body()}
</Alert>
</Container>
)
}
if (!bookingId) {
return (
<Container size="sm" py="xl">
<Group justify="center" py="xl">
<Loader color="plum" />
<Text c="dimmed">{m.common_states_loading()}</Text>
</Group>
</Container>
)
}
return <BookingFormInner bookingId={bookingId} />
}
function BookingFormInner({ bookingId }: { bookingId: string }) {
useLocale()
const [form, setForm] = useState<FormState | null>(null)
const [success, setSuccess] = useState(false)
const { data, isLoading, error } = usePikkuQuery('getBookingFormData', { bookingId })
const submit = usePikkuMutation('submitBookingForm', {
onSuccess: () => setSuccess(true),
})
// Seed editable state once the prefill data arrives.
useEffect(() => {
if (data && !form) {
setForm({
name: data.client.name ?? '',
billingAddress: data.client.billingAddress ?? '',
contactPhone: data.client.contactPhone ?? '',
website: data.client.website ?? '',
paymentMethod: (data.paymentMethod as 'cash' | 'transfer') ?? 'cash',
expectedPersons: data.expectedPersons != null ? String(data.expectedPersons) : '',
arrivalTime: data.arrivalTime ?? '',
departureTime: data.departureTime ?? '',
dailyPlan: data.dailyPlan ?? '',
onlineAd: data.onlineAd,
notes: data.notes ?? '',
acceptTerms: false,
acceptPrivacy: false,
})
}
}, [data, form])
const tk = (k: string, opts?: Record<string, unknown>) =>
mKey(`common.pages.bookingForm.${k}` as any, opts ?? {})
if (isLoading || !form) {
if (error) {
return (
<Container size="sm" py="xl">
<Alert color="red">{m.common_states_error()}</Alert>
</Container>
)
}
return (
<Container size="sm" py="xl">
<Group justify="center" py="xl">
<Loader color="plum" />
</Group>
</Container>
)
}
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => (f ? { ...f, [key]: value } : f))
if (success) {
return (
<Container size="sm" py="xl">
<PageTitle title={tk('title')} />
<Alert color="green" title={tk('success.title')}>
{tk('success.body')}
</Alert>
</Container>
)
}
const canSubmit =
form.name.trim() &&
form.billingAddress.trim() &&
form.acceptTerms &&
form.acceptPrivacy
const submitForm = () => {
submit.mutate({
bookingId,
name: form.name.trim(),
billingAddress: form.billingAddress.trim(),
contactPhone: form.contactPhone || undefined,
website: form.website || undefined,
paymentMethod: form.paymentMethod,
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
arrivalTime: form.arrivalTime || undefined,
departureTime: form.departureTime || undefined,
dailyPlan: form.dailyPlan || undefined,
onlineAd: form.onlineAd,
notes: form.notes || undefined,
acceptTerms: form.acceptTerms,
acceptPrivacy: form.acceptPrivacy,
})
}
return (
<Container size="md" py="xl">
<PageTitle title={tk('title')} />
<Stack gap="lg">
<Box>
<Title order={2}>{tk('heading')}</Title>
<Text c="dimmed" mt={4}>
{asI18n(
`${data!.eventName}${data!.startDate ? ` · ${fmtDate(new Date(data!.startDate), getLocale())}` : ''}${data!.endDate ? `${fmtDate(new Date(data!.endDate), getLocale())}` : ''}`,
)}
</Text>
</Box>
<Paper withBorder radius="md" p="lg">
<Stack gap="lg">
<Section title={tk('sections.billing')}>
<TextInput
label={tk('fields.name')}
required
value={form.name}
onChange={(e) => set('name', e.currentTarget.value)}
/>
<Textarea
label={tk('fields.billingAddress')}
required
autosize
minRows={3}
value={form.billingAddress}
onChange={(e) => set('billingAddress', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={tk('fields.contactPhone')}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={tk('fields.website')}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
<Radio.Group
label={tk('fields.paymentMethod')}
value={form.paymentMethod}
onChange={(v) => set('paymentMethod', v as 'cash' | 'transfer')}
required
>
<Group mt="xs" gap="lg">
<Radio value="cash" label={tk('payment.cash')} />
<Radio value="transfer" label={tk('payment.transfer')} />
</Group>
</Radio.Group>
</Section>
<Divider />
<Section title={tk('sections.event')}>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={tk('fields.expectedPersons')}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<TextInput
label={tk('fields.arrivalTime')}
value={form.arrivalTime}
onChange={(e) => set('arrivalTime', e.currentTarget.value)}
/>
<TextInput
label={tk('fields.departureTime')}
value={form.departureTime}
onChange={(e) => set('departureTime', e.currentTarget.value)}
/>
</SimpleGrid>
<Textarea
label={tk('fields.dailyPlan')}
autosize
minRows={2}
value={form.dailyPlan}
onChange={(e) => set('dailyPlan', e.currentTarget.value)}
/>
<Checkbox
label={tk('fields.onlineAd')}
checked={form.onlineAd}
onChange={(e) => set('onlineAd', e.currentTarget.checked)}
/>
<Textarea
label={tk('fields.notes')}
autosize
minRows={2}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
/>
</Section>
<Divider />
<Section title={tk('sections.terms')}>
<Checkbox
label={tk('fields.acceptTerms')}
checked={form.acceptTerms}
onChange={(e) => set('acceptTerms', e.currentTarget.checked)}
/>
<Checkbox
label={tk('fields.acceptPrivacy')}
checked={form.acceptPrivacy}
onChange={(e) => set('acceptPrivacy', e.currentTarget.checked)}
/>
</Section>
{submit.error && <Alert color="red">{tk('error')}</Alert>}
<Group justify="flex-end">
<Button
color="plum"
onClick={submitForm}
disabled={!canSubmit || submit.isPending}
loading={submit.isPending}
>
{tk('submit')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{asI18n(`${title}`)}
</Text>
<Box>
<Stack gap="md">{children}</Stack>
</Box>
</Stack>
)
}
export const Route = createFileRoute('/booking-form/$token')({
component: BookingFormLanding,
})

View File

@@ -0,0 +1,477 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { m, mKey } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Group,
Modal,
Progress,
RingProgress,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
import { RequireAuth } from '../auth'
import { BOOKING_TRANSITIONS, type BookingStatus } from '../lib/status'
import { fmtDate, toDateInputStr } from '../lib/dates'
const STATUS_COLORS: Record<string, string> = {
enquiry: 'pink',
reserved: 'orange',
confirmed: 'green',
ended: 'gray',
cancelled: 'red',
}
const DIETARY_KEYS = ['omnivore', 'vegetarian', 'vegan', 'gluten_free', 'lactose_free', 'pescatarian'] as const
const DIETARY_COLORS: Record<string, string> = {
omnivore: '#94a3b8',
vegetarian: '#84cc16',
vegan: '#22c55e',
gluten_free: '#eab308',
lactose_free: '#06b6d4',
pescatarian: '#0ea5e9',
unknown: '#64748b',
}
function BookingDetailInner() {
useLocale()
const { bookingId } = Route.useParams()
const qc = useQueryClient()
const queryArgs = { bookingId }
const { data, isLoading, error } = usePikkuQuery('getBookingDetail', queryArgs)
const invalidate = () =>
qc.invalidateQueries({ queryKey: ['getBookingDetail', queryArgs] })
const addMut = usePikkuMutation('addParticipant', { onSuccess: invalidate })
const updateMut = usePikkuMutation('updateParticipant', { onSuccess: invalidate })
const removeMut = usePikkuMutation('removeParticipant', { onSuccess: invalidate })
const [adding, setAdding] = useState(false)
const [newName, setNewName] = useState('')
const [newKind, setNewKind] = useState<'overnight' | 'day_guest'>('overnight')
if (isLoading) return <Container py="xl"><Text>{m.common_states_loading()}</Text></Container>
if (error) return <Container py="xl"><Text c="red">{m.common_states_error()}</Text></Container>
if (!data) return null
const { booking, client, participants, invoices, dietaryBreakdown } = data
const totalParticipants = participants.length
const dietaryTotal = dietaryBreakdown.reduce((s, d) => s + d.count, 0) || 1
const dietaryOptions = DIETARY_KEYS.map((k) => ({
value: k,
label: mKey(`common.pages.booking.participants.dietary.${k}` as any),
}))
const submitNew = () => {
if (!newName.trim()) return
addMut.mutate(
{ bookingId, name: newName.trim(), kind: newKind, email: null, age: null, dietaryTag: null, notes: null },
{
onSuccess: () => {
setNewName('')
setNewKind('overnight')
setAdding(false)
},
},
)
}
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<Stack gap={4}>
<Group gap="xs">
<Button component={Link} to="/admin/bookings" size="xs" variant="subtle">
{asI18n(`${m.common_pages_admin_bookings_title()}`)}
</Button>
</Group>
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Title order={1}>{asI18n(`${booking.eventName}`)}</Title>
<Text size="sm" c="dimmed">{asI18n(`${client.name}`)}</Text>
<Text size="sm">{asI18n(`${fmtDate(booking.startDate, getLocale())}${fmtDate(booking.endDate, getLocale())}`)}</Text>
</Stack>
<Group gap="xs" align="center">
<Badge color={STATUS_COLORS[booking.status] ?? 'gray'} size="lg">
{mKey(`common.pages.bookingStatus.label.${booking.status}` as any)}
</Badge>
<StatusChanger
bookingId={booking.bookingId}
status={booking.status}
onChanged={invalidate}
/>
</Group>
</Group>
</Stack>
<Tabs defaultValue="participants">
<Tabs.List>
<Tabs.Tab value="overview">{m.common_pages_booking_tabs_overview()}</Tabs.Tab>
<Tabs.Tab value="participants">
{asI18n(`${m.common_pages_booking_tabs_participants()} (${totalParticipants})`)}
</Tabs.Tab>
<Tabs.Tab value="invoices">
{asI18n(`${m.common_pages_booking_tabs_invoices()} (${invoices.length})`)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Stack gap="md">
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Title order={4}>{m.common_pages_booking_overview_schedule()}</Title>
<Group gap="lg">
<Text size="sm">
<b>{asI18n(`${m.common_pages_booking_overview_breakfast()}:`)}</b>{asI18n(` ${booking.mealTimeBreakfast ?? '—'}`)}
</Text>
<Text size="sm">
<b>{asI18n(`${m.common_pages_booking_overview_lunch()}:`)}</b>{asI18n(` ${booking.mealTimeLunch ?? '—'}`)}
</Text>
<Text size="sm">
<b>{asI18n(`${m.common_pages_booking_overview_dinner()}:`)}</b>{asI18n(` ${booking.mealTimeDinner ?? '—'}`)}
</Text>
</Group>
{booking.eventOutline && (
<Text size="sm" fw={500}>{asI18n(`${booking.eventOutline}`)}</Text>
)}
{booking.description && (
<Text size="sm" c="dimmed">{asI18n(`${booking.description}`)}</Text>
)}
</Stack>
</Card>
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Title order={4}>{m.common_pages_booking_overview_headcount()}</Title>
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm">
{m.common_pages_booking_overview_added({
count: totalParticipants,
target: booking.expectedPersons ?? '?',
})}
</Text>
</Group>
<Progress value={(totalParticipants / Math.max(booking.expectedPersons ?? 1, 1)) * 100} />
</Box>
</Stack>
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="participants" pt="md">
<Stack gap="md">
<Card withBorder radius="md" p="lg">
<Group align="flex-start" justify="space-between">
<Stack gap="xs">
<Title order={4}>{m.common_pages_booking_participants_dietary_breakdown()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_booking_participants_total_participants({ count: totalParticipants })}
</Text>
</Stack>
<RingProgress
size={120}
thickness={14}
sections={dietaryBreakdown.map((d) => ({
value: (d.count / dietaryTotal) * 100,
color: DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown,
tooltip: `${mKey(`common.pages.booking.participants.dietary.${d.tag}` as any, { defaultValue: d.tag })}: ${d.count}`,
}))}
/>
</Group>
<Group gap="xs" mt="md">
{dietaryBreakdown.map((d) => (
<Badge
key={d.tag}
color="gray"
variant="light"
style={{ borderLeft: `3px solid ${DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown}` }}
>
{asI18n(`${mKey(`common.pages.booking.participants.dietary.${d.tag}` as any, { defaultValue: d.tag })}: ${d.count}`)}
</Badge>
))}
</Group>
</Card>
<Card withBorder radius="md" p="lg">
<Stack gap="md">
<Group justify="space-between">
<Title order={4}>{m.common_pages_booking_participants_title()}</Title>
<Button size="xs" onClick={() => setAdding(true)}>
{m.common_pages_booking_participants_add_cta()}
</Button>
</Group>
{participants.length === 0 ? (
<Text size="sm" c="dimmed">{m.common_pages_booking_participants_empty()}</Text>
) : (
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common_pages_booking_participants_cols_name()}</Table.Th>
<Table.Th>{m.common_pages_booking_participants_cols_kind()}</Table.Th>
<Table.Th>{m.common_pages_booking_participants_cols_diet()}</Table.Th>
<Table.Th>{m.common_pages_booking_participants_cols_allergies()}</Table.Th>
<Table.Th>{m.common_pages_booking_participants_cols_room()}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{participants.map((p) => (
<Table.Tr key={p.participantId}>
<Table.Td>{p.name}</Table.Td>
<Table.Td>
<Badge variant="light" color={p.kind === 'day_guest' ? 'cyan' : 'blue'}>
{mKey(`common.pages.booking.participants.kinds.${p.kind}` as any)}
</Badge>
</Table.Td>
<Table.Td>
<Select
size="xs"
placeholder={asI18n('—')}
data={dietaryOptions}
value={p.dietaryTag ?? null}
onChange={(value) =>
updateMut.mutate({
participantId: p.participantId,
dietaryTag: value as any,
})
}
clearable
style={{ minWidth: 140 }}
/>
</Table.Td>
<Table.Td>
{p.allergies.length === 0 ? (
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
) : (
<Group gap={4}>
{p.allergies.map((a) => (
<Badge
key={a.allergen}
size="xs"
color={a.severity === 'separate_prep' ? 'red' : 'orange'}
variant="light"
>
{mKey(`common.pages.allergens.${a.allergen}` as any)}
</Badge>
))}
</Group>
)}
</Table.Td>
<Table.Td>{p.roomNumber ?? '—'}</Table.Td>
<Table.Td>
<ActionIcon
size="sm"
variant="subtle"
color="red"
onClick={() => {
if (
confirm(
m.common_pages_booking_participants_remove_confirm({ name: p.name }),
)
) {
removeMut.mutate({
participantId: p.participantId,
})
}
}}
>
×
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="invoices" pt="md">
<Card withBorder radius="md" p="lg">
{invoices.length === 0 ? (
<Text size="sm" c="dimmed">{m.common_pages_booking_invoices_empty()}</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common_pages_booking_invoices_cols_number()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_kind()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_amount()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_status()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_issued()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_due()}</Table.Th>
<Table.Th>{m.common_pages_booking_invoices_cols_paid()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{invoices.map((inv) => (
<Table.Tr key={inv.invoiceId}>
<Table.Td>
{inv.pdfUrl ? (
<Button
component="a"
href={inv.pdfUrl}
target="_blank"
rel="noreferrer"
download={`${inv.invoiceNumber}.pdf`}
variant="subtle"
px={0}
>
{asI18n(`${inv.invoiceNumber}`)}
</Button>
) : (
inv.invoiceNumber
)}
</Table.Td>
<Table.Td>{inv.kind}</Table.Td>
<Table.Td>{(inv.amountCents / 100).toFixed(2)}</Table.Td>
<Table.Td>
<Badge color={inv.status === 'paid' ? 'green' : 'orange'}>{asI18n(`${inv.status}`)}</Badge>
</Table.Td>
<Table.Td>{fmtDate(inv.issuedOn, getLocale())}</Table.Td>
<Table.Td>{inv.dueOn ? fmtDate(inv.dueOn, getLocale()) : '—'}</Table.Td>
<Table.Td>{inv.paidOn ? fmtDate(inv.paidOn, getLocale()) : '—'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
<Modal
opened={adding}
onClose={() => setAdding(false)}
title={m.common_pages_booking_participants_add_modal_title()}
size="md"
>
<Stack gap="md">
<TextInput
label={m.common_pages_booking_participants_add_modal_name()}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
required
/>
<Select
label={m.common_pages_booking_participants_add_modal_kind()}
data={[
{ value: 'overnight', label: m.common_pages_booking_participants_kinds_overnight() },
{ value: 'day_guest', label: m.common_pages_booking_participants_kinds_day_guest() },
]}
value={newKind}
onChange={(v) => setNewKind((v as any) ?? 'overnight')}
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setAdding(false)}>
{m.common_actions_cancel()}
</Button>
<Button onClick={submitNew} loading={addMut.isPending}>
{m.common_pages_booking_participants_add_modal_add()}
</Button>
</Group>
</Stack>
</Modal>
</Container>
)
}
function StatusChanger({
bookingId,
status,
onChanged,
}: {
bookingId: string
status: string
onChanged: () => void
}) {
useLocale()
const [pending, setPending] = useState<string | null>(null)
const mutation = usePikkuMutation('setBookingStatus', {
onSuccess: () => {
setPending(null)
onChanged()
},
})
const next = BOOKING_TRANSITIONS[status as BookingStatus] ?? []
if (next.length === 0) return null
return (
<>
<Select
size="xs"
placeholder={m.common_pages_booking_status_advance()}
data={next.map((s) => ({
value: s,
label: mKey(`common.pages.bookingStatus.label.${s}` as any),
}))}
value={null}
onChange={(v) => v && setPending(v)}
clearable={false}
/>
<Modal
opened={pending !== null}
onClose={() => setPending(null)}
title={m.common_pages_booking_status_confirm_title()}
>
<Stack gap="md">
{pending && (
<Text size="sm">
{m.common_pages_booking_status_confirm_body({
status: mKey(`common.pages.bookingStatus.label.${pending}` as any),
})}
</Text>
)}
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setPending(null)}>
{m.common_actions_cancel()}
</Button>
<Button
loading={mutation.isPending}
onClick={() =>
pending &&
mutation.mutate({
bookingId,
status: pending as BookingStatus,
})
}
>
{m.common_actions_confirm()}
</Button>
</Group>
</Stack>
</Modal>
</>
)
}
function BookingDetailPage() {
return (
<RequireAuth>
<BookingDetailInner />
</RequireAuth>
)
}
export const Route = createFileRoute('/booking/$bookingId')({
component: BookingDetailPage,
})

View File

@@ -0,0 +1,382 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { m, mKey, mExists } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
ActionIcon,
Alert,
Box,
Button,
Checkbox,
Container,
Divider,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Tooltip,
} from '@pikku/mantine/core'
import { Info, Plus, Trash2 } from 'lucide-react'
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
type DateOption = { startDate: string; endDate: string }
const MAX_DATE_OPTIONS = 3
type State = {
eventName: string
eventOutline: string
description: string
// 03 prioritized date options, in priority order (first = preferred).
dateOptions: DateOption[]
expectedPersons: string
halfHouse: boolean
clientName: string
contactEmail: string
contactPhone: string
website: string
notes: string
agbAccepted: boolean
privacyAccepted: boolean
}
const empty = (search?: { date?: string; from?: string; to?: string }): State => {
const seedStart = search?.from ?? search?.date ?? ''
const seedEnd = search?.to ?? search?.date ?? ''
return {
eventName: '',
eventOutline: '',
description: '',
dateOptions: [{ startDate: seedStart, endDate: seedEnd }],
expectedPersons: '',
halfHouse: false,
clientName: '',
contactEmail: '',
contactPhone: '',
website: '',
notes: '',
agbAccepted: false,
privacyAccepted: false,
}
}
function EnquiryPage() {
useLocale()
const search = Route.useSearch()
const [form, setForm] = useState<State>(() => empty(search))
const [success, setSuccess] = useState<string | null>(null)
const mutation = usePikkuMutation('submitEnquiry', {
onSuccess: (data) => setSuccess(data.enquiryId),
})
const set = <K extends keyof State>(key: K, value: State[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const setOption = (i: number, patch: Partial<DateOption>) =>
setForm((f) => ({
...f,
dateOptions: f.dateOptions.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
}))
const addOption = () =>
setForm((f) =>
f.dateOptions.length >= MAX_DATE_OPTIONS
? f
: { ...f, dateOptions: [...f.dateOptions, { startDate: '', endDate: '' }] },
)
const removeOption = (i: number) =>
setForm((f) => ({ ...f, dateOptions: f.dateOptions.filter((_, idx) => idx !== i) }))
// A row is "complete" only when both dates are set and ordered. Partially
// filled or reversed rows block submission; fully empty form = no dates (ok).
const rowState = (o: DateOption): 'empty' | 'complete' | 'invalid' => {
if (!o.startDate && !o.endDate) return 'empty'
if (o.startDate && o.endDate && o.startDate <= o.endDate) return 'complete'
return 'invalid'
}
const optionsValid = form.dateOptions.every((o) => rowState(o) !== 'invalid')
const submit = () => {
const dateOptions = form.dateOptions
.filter((o) => rowState(o) === 'complete')
.map((o) => ({ startDate: o.startDate, endDate: o.endDate }))
mutation.mutate({
venueSlug: 'drawehn',
contact: {
name: form.clientName.trim() || undefined,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone || undefined,
website: form.website || undefined,
},
event: {
eventName: form.eventName,
eventOutline: form.eventOutline || undefined,
description: form.description || undefined,
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
halfHouse: form.halfHouse,
},
dateOptions: dateOptions.length ? dateOptions : undefined,
notes: form.notes || undefined,
privacyAccepted: form.privacyAccepted as true,
})
}
if (success) {
return (
<MarketingShell>
<Container size="sm" py="xl">
<Alert color="green" title={m.common_pages_enquiry_success_title()}>
{m.common_pages_enquiry_success_body({ enquiryId: success })}
</Alert>
</Container>
</MarketingShell>
)
}
const canSubmit =
form.eventName.trim() &&
form.contactEmail.trim() &&
form.agbAccepted &&
form.privacyAccepted &&
optionsValid
return (
<MarketingShell>
<Container size="md" py="xl">
<Stack gap="lg">
<PageTitle title={m.common_pages_enquiry_title()} />
<Paper withBorder radius="md" p="lg">
<Stack gap="lg">
<Section title={m.common_pages_enquiry_sections_event()}>
<TextInput
data-testid="courseName"
label={<FieldLabel label={m.common_pages_enquiry_fields_event_name()} hint="eventName" />}
required
value={form.eventName}
onChange={(e) => set('eventName', e.currentTarget.value)}
/>
<TextInput
data-testid="eventOutline"
label={<FieldLabel label={m.common_pages_enquiry_fields_event_outline()} hint="eventOutline" />}
value={form.eventOutline}
onChange={(e) => set('eventOutline', e.currentTarget.value)}
/>
<Textarea
data-testid="description"
label={<FieldLabel label={m.common_pages_enquiry_fields_description()} hint="description" />}
value={form.description}
onChange={(e) => set('description', e.currentTarget.value)}
autosize
minRows={4}
/>
<Stack gap="xs">
<FieldLabel label={m.common_pages_enquiry_fields_date_options()} hint="dateOptions" />
{form.dateOptions.length === 0 && (
<Text size="sm" c="dimmed">{m.common_pages_enquiry_date_options_none()}</Text>
)}
{form.dateOptions.map((opt, i) => {
const invalid = rowState(opt) === 'invalid'
return (
<Group key={i} gap="xs" align="flex-end" wrap="nowrap" data-testid={`date-option-${i}`}>
<TextInput
data-testid={i === 0 ? 'startDate' : `date-option-${i}-start`}
type="date"
flex={1}
label={i === 0 ? m.common_pages_enquiry_fields_start_date() : undefined}
value={opt.startDate}
onChange={(e) => setOption(i, { startDate: e.currentTarget.value })}
error={invalid ? m.common_pages_enquiry_date_options_invalid() : undefined}
/>
<TextInput
data-testid={i === 0 ? 'endDate' : `date-option-${i}-end`}
type="date"
flex={1}
label={i === 0 ? m.common_pages_enquiry_fields_end_date() : undefined}
value={opt.endDate}
onChange={(e) => setOption(i, { endDate: e.currentTarget.value })}
/>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={m.common_pages_enquiry_date_options_remove()}
data-testid={`date-option-${i}-remove`}
onClick={() => removeOption(i)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
)
})}
{form.dateOptions.length < MAX_DATE_OPTIONS && (
<Group>
<Button
variant="light"
color="plum"
size="xs"
leftSection={<Plus size={14} />}
onClick={addOption}
data-testid="add-date-option"
>
{m.common_pages_enquiry_date_options_add()}
</Button>
</Group>
)}
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={<FieldLabel label={m.common_pages_enquiry_fields_expected_persons()} hint="expectedPersons" />}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<Select
label={<FieldLabel label={m.common_pages_enquiry_fields_half_house()} hint="halfHouse" />}
value={form.halfHouse ? 'true' : 'false'}
onChange={(v) => set('halfHouse', v === 'true')}
allowDeselect={false}
data={[
{ value: 'false', label: m.common_pages_enquiry_fields_half_house_no() },
{ value: 'true', label: m.common_pages_enquiry_fields_half_house_yes() },
]}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_client()}>
<TextInput
data-testid="organisationName"
label={<FieldLabel label={m.common_pages_enquiry_fields_client_name()} hint="clientName" />}
value={form.clientName}
onChange={(e) => set('clientName', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
data-testid="contactEmail"
type="email"
label={<FieldLabel label={m.common_pages_enquiry_fields_contact_email()} hint="contactEmail" />}
required
value={form.contactEmail}
onChange={(e) => set('contactEmail', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_contact_phone()} hint="contactPhone" />}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_website()} hint="website" />}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_notes()}>
<Textarea
label={<FieldLabel label={m.common_pages_enquiry_fields_notes()} hint="notes" />}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
autosize
minRows={3}
/>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_terms()}>
<Checkbox
data-testid="agbAccepted"
label={m.common_pages_enquiry_agb_accept()}
checked={form.agbAccepted}
onChange={(e) => set('agbAccepted', e.currentTarget.checked)}
/>
<Checkbox
data-testid="privacyAccepted"
label={<FieldLabel label={m.common_pages_enquiry_fields_privacy_accepted()} hint="privacyAccepted" />}
checked={form.privacyAccepted}
onChange={(e) => set('privacyAccepted', e.currentTarget.checked)}
/>
</Section>
{mutation.error && (
<Alert color="red">{m.common_pages_enquiry_error()}</Alert>
)}
<Group justify="flex-end">
<Button
onClick={submit}
disabled={!canSubmit || mutation.isPending}
loading={mutation.isPending}
>
{m.common_pages_enquiry_submit()}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
</MarketingShell>
)
}
/**
* Field label with an optional tooltip. The icon only renders when a hint
* string exists at `pages.enquiry.hints.<hint>` — so adding/removing a hint key
* in the locale files is all it takes to show or hide the tooltip per field.
*/
function FieldLabel({ label, hint }: { label: string; hint: string }) {
useLocale()
const key = `common.pages.enquiry.hints.${hint}`
if (!mExists(key)) return <>{label}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{label}
<Tooltip
label={mKey(key as any)}
multiline
w={240}
withArrow
events={{ hover: true, focus: true, touch: true }}
>
<Info
size={14}
aria-label={mKey(key as any)}
tabIndex={0}
style={{ cursor: 'help', opacity: 0.55, flexShrink: 0 }}
/>
</Tooltip>
</span>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">{asI18n(`${title}`)}</Text>
<Box>
<Stack gap="md">{children}</Stack>
</Box>
</Stack>
)
}
export const Route = createFileRoute('/enquiry')({
component: EnquiryPage,
validateSearch: (s: Record<string, unknown>): { date?: string; from?: string; to?: string } => ({
date: typeof s.date === 'string' ? s.date : undefined,
from: typeof s.from === 'string' ? s.from : undefined,
to: typeof s.to === 'string' ? s.to : undefined,
}),
})

View File

@@ -0,0 +1,113 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Container,
Group,
Paper,
Stack,
Text,
TextInput,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { EventCard } from '../components/EventCard'
import { PageTitle } from '../components/AppLayout'
type Filter = 'this_year' | 'next_year' | 'all'
function FilterChip({
active,
children,
onClick,
}: {
active: boolean
children: React.ReactNode
onClick: () => void
}) {
return (
<Badge
variant={active ? 'filled' : 'light'}
color={active ? 'plum' : 'gray'}
style={{ cursor: 'pointer', textTransform: 'none' }}
onClick={onClick}
>
{asI18n(`${children}`)}
</Badge>
)
}
function EventsPage() {
useLocale()
const [filter, setFilter] = useState<Filter>('all')
const [search, setSearch] = useState('')
const { data, isLoading, error } = usePikkuQuery('getEventsListing', {
venueSlug: 'drawehn',
filter,
search: search.trim() || undefined,
})
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common_states_loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common_states_error()}</Text></Container></MarketingShell>
if (!data) return null
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common_pages_events_title()} />
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<TextInput
placeholder={m.common_pages_events_search_placeholder()}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Group gap="xs">
<FilterChip active={filter === 'this_year'} onClick={() => setFilter('this_year')}>
{m.common_pages_events_filters_this_year()}
</FilterChip>
<FilterChip active={filter === 'next_year'} onClick={() => setFilter('next_year')}>
{m.common_pages_events_filters_next_year()}
</FilterChip>
<FilterChip active={filter === 'all'} onClick={() => setFilter('all')}>
{m.common_pages_events_filters_all()}
</FilterChip>
</Group>
</Stack>
</Paper>
{data.events.length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">{m.common_pages_events_empty()}</Text>
</Paper>
) : (
<Stack gap="md">
{data.events.map((e) => (
<EventCard
key={e.bookingId}
eventName={e.eventName}
startDate={e.startDate}
endDate={e.endDate}
clientName={e.clientName}
coverImageUrl={e.coverImageUrl}
eventOutline={e.eventOutline}
description={e.description}
organiserWebsite={e.organiserWebsite}
/>
))}
</Stack>
)}
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/events')({
component: EventsPage,
})

View File

@@ -0,0 +1,276 @@
import { Navigate, createFileRoute } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Paper,
Progress,
Stack,
Table,
Text,
Title,
} from '@pikku/mantine/core'
import { BrandHeader } from '../components/Brand'
import { RequireAuth, useAuth } from '../auth'
import { usePageTitle } from '../components/AppLayout'
import { fmtDate } from '../lib/dates'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
function MarketingPage({ hero, sections }: { hero: React.ReactNode; sections: React.ReactNode[] }) {
return (
<Box>
{hero}
<Container size="md" py="xl">
<Stack gap="lg">{sections}</Stack>
</Container>
</Box>
)
}
function IndexPage() {
useLocale()
return (
<MarketingPage
hero={(
<Box bg="var(--brand-plum)" py="xl" c="white">
<Container size="md">
<BrandHeader variant="white" />
</Container>
</Box>
)}
sections={[
<Paper key="launchpad" withBorder radius="md" p="lg">
<Stack gap="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{m.common_pages_launchpad_title()}
</Text>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common_pages_launchpad_client_title()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_launchpad_client_body()}
</Text>
<Box>
<Button component="a" href="/admin/bookings" size="sm" variant="light">
{m.common_pages_launchpad_client_cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common_pages_launchpad_availability_title()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_launchpad_availability_body()}
</Text>
<Box>
<Button component="a" href="/availability" size="sm" variant="light">
{m.common_pages_launchpad_availability_cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common_pages_launchpad_admin_title()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_launchpad_admin_body()}
</Text>
<Box>
<Button component="a" href="/admin/bookings" size="sm" variant="light">
{m.common_pages_launchpad_admin_cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common_pages_launchpad_events_title()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_launchpad_events_body()}
</Text>
<Box>
<Button component="a" href="/events" size="sm" variant="light">
{m.common_pages_launchpad_events_cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common_pages_launchpad_enquiry_title()}</Title>
<Text size="sm" c="dimmed">
{m.common_pages_launchpad_enquiry_body()}
</Text>
<Box>
<Button component="a" href="/enquiry" size="sm" variant="light">
{m.common_pages_launchpad_enquiry_cta()}
</Button>
</Box>
</Stack>
</Paper>
</Stack>
</Paper>,
]}
/>
)
}
function ClientOverview() {
useLocale()
usePageTitle(m.common_pages_client_title())
const { data, isLoading, error } = usePikkuQuery('getClientOverview', null)
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
return (
<Container size="lg" py="md">
<Stack gap="lg">
<Paper withBorder radius="md" p="lg">
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{m.common_pages_client_title()}
</Text>
<Title order={2}>{m.common_brand_name()}</Title>
{data.clients.length > 0 && (
<Text c="dimmed">
{asI18n(data.clients.map((client) => client.name).join(', '))}
</Text>
)}
</Stack>
</Paper>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={3}>{m.common_pages_client_upcoming()}</Title>
{data.upcomingBookings.length === 0 ? (
<Text c="dimmed">{m.common_pages_client_no_upcoming()}</Text>
) : (
data.upcomingBookings.map((booking) => (
<Paper key={booking.bookingId} withBorder radius="md" p="md">
<Stack gap="xs">
<Stack gap={4}>
<Text fw={600}>{asI18n(`${booking.eventName}`)}</Text>
<Text size="sm" c="dimmed">
{asI18n(`${fmtDate(booking.startDate, getLocale())} - ${fmtDate(booking.endDate, getLocale())}`)}
</Text>
</Stack>
{booking.isCurrentEvent && (
<Box>
<Badge color="plum" variant="light">
{m.common_pages_client_current_badge()}
</Badge>
</Box>
)}
<Text size="sm">
{booking.expectedPersons
? m.common_pages_client_participants_count({
count: booking.participantCount,
target: booking.expectedPersons,
})
: m.common_pages_client_participants_count_unknown({
count: booking.participantCount,
})}
</Text>
<Stack gap={4}>
<Text size="sm" c="dimmed">
{m.common_pages_client_completeness()}
</Text>
<Progress
color="plum"
radius="xl"
size="lg"
value={booking.completenessPercent}
/>
</Stack>
</Stack>
</Paper>
))
)}
</Stack>
</Paper>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={3}>{m.common_pages_client_recent_invoices()}</Title>
{data.recentInvoices.length === 0 ? (
<Text c="dimmed">{m.common_pages_client_no_invoices()}</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common_pages_client_invoice_cols_booking()}</Table.Th>
<Table.Th>{m.common_pages_client_invoice_cols_number()}</Table.Th>
<Table.Th>{m.common_pages_client_invoice_cols_kind()}</Table.Th>
<Table.Th>{m.common_pages_client_invoice_cols_amount()}</Table.Th>
<Table.Th>{m.common_pages_client_invoice_cols_status()}</Table.Th>
<Table.Th>{m.common_pages_client_invoice_cols_issued()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.recentInvoices.map((invoice) => (
<Table.Tr key={invoice.invoiceId}>
<Table.Td>{invoice.bookingName}</Table.Td>
<Table.Td>
{invoice.pdfUrl ? (
<Button
component="a"
href={invoice.pdfUrl}
target="_blank"
rel="noreferrer"
download={`${invoice.invoiceNumber}.pdf`}
variant="subtle"
px={0}
>
{asI18n(`${invoice.invoiceNumber}`)}
</Button>
) : (
invoice.invoiceNumber
)}
</Table.Td>
<Table.Td>{invoice.kind}</Table.Td>
<Table.Td>{(invoice.amountCents / 100).toFixed(2)} </Table.Td>
<Table.Td>{invoice.status}</Table.Td>
<Table.Td>{fmtDate(invoice.issuedOn, getLocale())}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
</Stack>
</Container>
)
}
function Dashboard() {
const { role } = useAuth()
if (role === 'admin' || role === 'owner') return <Navigate to="/admin/bookings" />
if (role === 'client') return <ClientOverview />
return <IndexPage />
}
function IndexRoute() {
return (
<RequireAuth>
<Dashboard />
</RequireAuth>
)
}
export const Route = createFileRoute('/')({
component: IndexRoute,
})

View File

@@ -0,0 +1,164 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { m, mKey } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { Box, Container, Group, Paper, Stack, Text } from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { usePageTitle } from '../components/AppLayout'
import { PublicShell } from '../components/PublicShell'
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
import { fmtDateShort } from '../lib/dates'
const COLUMNS: BookingStatus[] = ['enquiry', 'reserved', 'confirmed', 'ended', 'cancelled']
const COLUMN_LABELS: Record<BookingStatus, string> = {
enquiry: 'pages.bookingStatus.label.enquiry',
reserved: 'pages.bookingStatus.label.reserved',
confirmed: 'pages.bookingStatus.label.confirmed',
ended: 'pages.bookingStatus.label.ended',
cancelled: 'pages.bookingStatus.label.cancelled',
}
type Booking = {
bookingId: string
eventName: string
startDate: Date
endDate: Date
status: string
clientName: string
isStammgruppe: boolean
depositPaid: boolean
hasDeposit: boolean
halfHouse: boolean
}
function BookingCard({ booking, locale }: { booking: Booking; locale: string }) {
useLocale()
const palette = BOOKING_STATUS[booking.status as BookingStatus]
return (
<Link
to="/admin/bookings/$bookingId"
params={{ bookingId: booking.bookingId }}
style={{ textDecoration: 'none', display: 'block' }}
>
<Paper
withBorder
radius="md"
p="sm"
style={{
borderLeft: `3px solid ${palette.dot}`,
cursor: 'pointer',
}}
>
<Stack gap={4}>
<Text size="sm" fw={600} lineClamp={2} c="var(--mantine-color-text)">
{asI18n(`${booking.eventName}`)}
</Text>
<Group gap={4}>
{booking.isStammgruppe && (
<Text size="xs" c="yellow.6" title={m.common_pages_admin_booking_client_regular_group()} style={{ cursor: 'default' }}>{asI18n('★')}</Text>
)}
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${booking.clientName}`)}</Text>
</Group>
<Text size="xs" c="dimmed">
{asI18n(`${fmtDateShort(booking.startDate, locale)} ${fmtDateShort(booking.endDate, locale)}`)}
</Text>
{booking.halfHouse && (
<Text size="xs" c="dimmed">{asI18n('½')}</Text>
)}
{booking.hasDeposit && (
<Text size="xs" c={booking.depositPaid ? 'green' : 'orange'} fw={500}>
{(booking.depositPaid ? m.common_pages_admin_bookings_deposit_paid : m.common_pages_admin_bookings_deposit_outstanding)()}
</Text>
)}
</Stack>
</Paper>
</Link>
)
}
function KanbanInner() {
useLocale()
usePageTitle(m.common_pages_kanban_title())
const { data, isLoading, error } = usePikkuQuery('getAdminBookings', { venueSlug: 'drawehn' })
if (isLoading) return <Container py="xl"><Text>{m.common_states_loading()}</Text></Container>
if (error) return <Container py="xl"><Text c="red">{m.common_states_error()}</Text></Container>
if (!data) return null
const byStatus = new Map<BookingStatus, Booking[]>()
for (const col of COLUMNS) byStatus.set(col, [])
for (const b of data.bookings) {
const col = b.status as BookingStatus
if (byStatus.has(col)) byStatus.get(col)!.push({ ...b, halfHouse: !!b.halfHouse } as unknown as Booking)
}
return (
<Box
style={{
overflowX: 'auto',
minHeight: 0,
paddingBottom: 24,
}}
px="xl"
pt="md"
>
<Group align="flex-start" wrap="nowrap" gap="md" style={{ minWidth: 'max-content' }}>
{COLUMNS.map((col) => {
const palette = BOOKING_STATUS[col]
const cards = byStatus.get(col) ?? []
return (
<Stack
key={col}
gap="sm"
style={{ width: 260, flexShrink: 0 }}
>
<Group gap="xs" align="center">
<Box
style={{
width: 8,
height: 8,
borderRadius: 4,
background: palette.dot,
flexShrink: 0,
}}
/>
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: '0.06em', color: palette.fg }}>
{mKey(`common.${COLUMN_LABELS[col]}` as any)}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{cards.length}
</Text>
</Group>
<Stack gap="xs">
{cards.length === 0 ? (
<Paper withBorder radius="md" p="sm" style={{ borderStyle: 'dashed' }}>
<Text size="xs" c="dimmed" ta="center">{asI18n('—')}</Text>
</Paper>
) : (
cards.map((b) => (
<BookingCard key={b.bookingId} booking={b} locale={getLocale()} />
))
)}
</Stack>
</Stack>
)
})}
</Group>
</Box>
)
}
function KanbanPage() {
return (
<PublicShell>
<KanbanInner />
</PublicShell>
)
}
export const Route = createFileRoute('/kanban')({
component: KanbanPage,
})

View File

@@ -0,0 +1,92 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { startTransition, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
Container,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { BrandMark } from '../components/Brand'
import { signIn } from '../lib/auth'
function LoginPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
const [email, setEmail] = useState(import.meta.env.DEV ? 'sarah@seminarhof.example' : '')
const [password, setPassword] = useState(import.meta.env.DEV ? 'owner1234' : '')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState(false)
const handleSubmit = async () => {
setIsPending(true)
setError(false)
try {
await signIn(email, password)
await qc.invalidateQueries({ queryKey: ['me'] })
startTransition(() => {
navigate({ to: '/' })
})
} catch {
setError(true)
} finally {
setIsPending(false)
}
}
return (
<Container
size="xs"
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Paper withBorder radius="md" p="lg" w="100%">
<Stack gap="md" align="stretch">
<Stack gap={4} align="center">
<BrandMark size={64} />
<Title order={2} ta="center" c="var(--brand-plum)">{m.auth_login_title()}</Title>
<Text size="sm" c="dimmed" ta="center">{m.auth_login_subtitle()}</Text>
</Stack>
<TextInput
type="email"
label={m.auth_login_email()}
placeholder={m.auth_login_email_placeholder()}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PasswordInput
label={m.auth_login_password()}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
{error && (
<Alert color="red">{m.auth_login_errors_invalid_credentials()}</Alert>
)}
<Button
onClick={handleSubmit}
disabled={!email || !password || isPending}
loading={isPending}
>
{m.auth_login_submit()}
</Button>
</Stack>
</Paper>
</Container>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})

View File

@@ -0,0 +1,36 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { useQueryClient } from '@tanstack/react-query'
import { Container, Paper, Stack, Text, Title, Button } from '@pikku/mantine/core'
import { signOut } from '../lib/auth'
function LogoutPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
useEffect(() => {
signOut().then(() => qc.clear()).catch(() => qc.clear())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Container size="xs" py="xl">
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={2}>{m.auth_logout_title()}</Title>
<Text size="sm" c="dimmed">{m.auth_logout_subtitle()}</Text>
<Button onClick={() => navigate({ to: '/login' })}>
{m.auth_logout_back_to_login()}
</Button>
</Stack>
</Paper>
</Container>
)
}
export const Route = createFileRoute('/logout')({
component: LogoutPage,
})