1037 lines
46 KiB
TypeScript
1037 lines
46 KiB
TypeScript
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
import { useEffect, useState } from 'react'
|
|
import { m } from '@/i18n/messages'
|
|
import { invoiceKind, invoiceStatus, emailKind } from '@/i18n/i18n-enum.gen'
|
|
import { useLocale, getLocale } from '@/i18n/config'
|
|
import { asI18n, type I18nNode } from '@pikku/react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Container,
|
|
Divider,
|
|
Group,
|
|
NumberInput,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
Title,
|
|
} from '@pikku/mantine/core'
|
|
import { usePikkuQuery, usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
|
|
import { FileUploader, type UploadedAsset } from '@project/components'
|
|
import { RoomsTab } from '../components/RoomsTab'
|
|
import { ParticipantsTab } from '../components/ParticipantsTab'
|
|
import { BookingStatusPanel } from '../components/BookingStatusPanel'
|
|
import { BookingActions } from '../components/BookingActions'
|
|
import { EventCard } from '../components/EventCard'
|
|
import type { BookingStatus } from '../lib/status'
|
|
import { fmtDate, toDateInputStr } from '../lib/dates'
|
|
|
|
type Patch = {
|
|
eventName?: string
|
|
startDate?: string
|
|
endDate?: string
|
|
halfHouse?: boolean
|
|
onlineAd?: boolean
|
|
expectedPersons?: number | null
|
|
eventOutline?: string | null
|
|
description?: string | null
|
|
coverImageUrl?: string | null
|
|
organiserWebsite?: string | null
|
|
mealTimeBreakfast?: string | null
|
|
mealTimeLunch?: string | null
|
|
mealTimeDinner?: string | null
|
|
arrivalTime?: string | null
|
|
departureTime?: string | null
|
|
dailyPlan?: string | null
|
|
notes?: string | null
|
|
}
|
|
|
|
function AdminBookingDetailInner() {
|
|
useLocale()
|
|
const { bookingId } = Route.useParams()
|
|
const qc = useQueryClient()
|
|
const queryArgs = { bookingId }
|
|
const { data, isLoading, error, isFetching } = usePikkuQuery('getAdminBookingDetail', queryArgs, {
|
|
refetchOnMount: 'always',
|
|
})
|
|
|
|
const invalidate = async () => {
|
|
await qc.invalidateQueries({ queryKey: ['getAdminBookingDetail', queryArgs] })
|
|
await qc.invalidateQueries({ queryKey: ['getBookingAuditLog', queryArgs] })
|
|
await qc.refetchQueries({ queryKey: ['getAdminBookingDetail', queryArgs], type: 'active' })
|
|
return qc.refetchQueries({ queryKey: ['getBookingAuditLog', queryArgs], type: 'active' })
|
|
}
|
|
|
|
const updateMut = usePikkuMutation('updateAdminBooking', { onSuccess: invalidate })
|
|
const assignMut = usePikkuMutation('assignRoom', { onSuccess: invalidate })
|
|
const unassignMut = usePikkuMutation('unassignRoom', { onSuccess: invalidate })
|
|
const createInvoiceMut = usePikkuMutation('createInvoice', { onSuccess: invalidate })
|
|
const getUploadUrlMut = usePikkuMutation('getUploadUrl')
|
|
const updateInvoiceMut = usePikkuMutation('updateInvoice', { onSuccess: invalidate })
|
|
const recordPaymentMut = usePikkuMutation('recordInvoicePayment', { onSuccess: invalidate })
|
|
const cancelInvoiceMut = usePikkuMutation('cancelInvoice', { onSuccess: invalidate })
|
|
const updateOrgMut = usePikkuMutation('updateClient', { onSuccess: invalidate })
|
|
const addParticipantMut = usePikkuMutation('addParticipant', { onSuccess: invalidate })
|
|
const updateParticipantMut = usePikkuMutation('updateParticipant', { onSuccess: invalidate })
|
|
const removeParticipantMut = usePikkuMutation('removeParticipant', { onSuccess: invalidate })
|
|
const addAllergyMut = usePikkuMutation('addParticipantAllergy', { onSuccess: invalidate })
|
|
const removeAllergyMut = usePikkuMutation('removeParticipantAllergy', { onSuccess: invalidate })
|
|
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const [invDraft, setInvDraft] = useState({
|
|
kind: 'deposit' as 'deposit' | 'final',
|
|
invoiceNumber: '',
|
|
issuedOn: today,
|
|
dueOn: '',
|
|
amountCents: 0,
|
|
pdfAssetKey: undefined as string | undefined,
|
|
pdfName: '',
|
|
})
|
|
|
|
const [patch, setPatch] = useState<Patch>({})
|
|
const [saved, setSaved] = useState(false)
|
|
const [isInvoicePdfUploading, setIsInvoicePdfUploading] = useState(false)
|
|
const [invoiceUploadError, setInvoiceUploadError] = useState(false)
|
|
const [orgPatch, setOrgPatch] = useState<{
|
|
name?: string
|
|
contactEmail?: string | null
|
|
contactPhone?: string | null
|
|
billingAddress?: string | null
|
|
website?: string | null
|
|
}>({})
|
|
const [orgSaved, setOrgSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (data) setPatch({})
|
|
}, [data?.booking.bookingId])
|
|
|
|
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, rooms, invoices } = data
|
|
|
|
const cur = <K extends keyof Patch>(key: K, fallback: Patch[K]): Patch[K] =>
|
|
(key in patch ? patch[key] : fallback)
|
|
|
|
const set = <K extends keyof Patch>(key: K, value: Patch[K]) => {
|
|
setPatch((p) => ({ ...p, [key]: value }))
|
|
setSaved(false)
|
|
}
|
|
|
|
const requestInvoicePdfUpload = async (file: File) =>
|
|
getUploadUrlMut.mutateAsync({
|
|
purpose: 'invoicePdf',
|
|
bookingId,
|
|
fileName: file.name,
|
|
contentType: file.type || 'application/pdf',
|
|
size: file.size,
|
|
})
|
|
|
|
const save = () => {
|
|
if (Object.keys(patch).length === 0) return
|
|
updateMut.mutate(
|
|
{ bookingId, patch },
|
|
{
|
|
onSuccess: () => {
|
|
setPatch({})
|
|
setSaved(true)
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
const unassignedParticipants = participants.filter((p) => !p.roomId)
|
|
const organizationForm = (
|
|
<Paper withBorder radius="md" p="lg" data-testid="client-form">
|
|
<Stack gap="md">
|
|
<Title order={5}>{m.common__pages__admin_booking__client__title()}</Title>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
data-testid="client-name"
|
|
label={m.common__pages__admin_booking__client__name()}
|
|
value={orgPatch.name ?? client.name ?? ''}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setOrgPatch((p) => ({ ...p, name: v }))
|
|
setOrgSaved(false)
|
|
}}
|
|
/>
|
|
<TextInput
|
|
data-testid="organisation-email"
|
|
type="email"
|
|
label={m.common__pages__admin_booking__client__contact_email()}
|
|
value={
|
|
orgPatch.contactEmail !== undefined
|
|
? orgPatch.contactEmail ?? ''
|
|
: client.contactEmail ?? ''
|
|
}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setOrgPatch((p) => ({ ...p, contactEmail: v || null }))
|
|
setOrgSaved(false)
|
|
}}
|
|
/>
|
|
<TextInput
|
|
data-testid="organisation-phone"
|
|
label={m.common__pages__admin_booking__client__contact_phone()}
|
|
value={
|
|
orgPatch.contactPhone !== undefined
|
|
? orgPatch.contactPhone ?? ''
|
|
: client.contactPhone ?? ''
|
|
}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setOrgPatch((p) => ({ ...p, contactPhone: v || null }))
|
|
setOrgSaved(false)
|
|
}}
|
|
/>
|
|
<TextInput
|
|
data-testid="client-website"
|
|
label={m.common__pages__admin_booking__client__website()}
|
|
value={
|
|
orgPatch.website !== undefined
|
|
? orgPatch.website ?? ''
|
|
: client.website ?? ''
|
|
}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setOrgPatch((p) => ({ ...p, website: v || null }))
|
|
setOrgSaved(false)
|
|
}}
|
|
/>
|
|
</SimpleGrid>
|
|
<Textarea
|
|
data-testid="client-billing-address"
|
|
label={m.common__pages__admin_booking__client__billing_address()}
|
|
value={
|
|
orgPatch.billingAddress !== undefined
|
|
? orgPatch.billingAddress ?? ''
|
|
: client.billingAddress ?? ''
|
|
}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setOrgPatch((p) => ({ ...p, billingAddress: v || null }))
|
|
setOrgSaved(false)
|
|
}}
|
|
autosize
|
|
minRows={2}
|
|
/>
|
|
{updateOrgMut.error && (
|
|
<Alert color="red">{m.common__pages__admin_booking__client__save_error()}</Alert>
|
|
)}
|
|
{orgSaved && (
|
|
<Alert color="green">{m.common__pages__admin_booking__client__saved()}</Alert>
|
|
)}
|
|
<Group justify="flex-end">
|
|
<Button
|
|
data-testid="client-save"
|
|
disabled={Object.keys(orgPatch).length === 0 || updateOrgMut.isPending}
|
|
loading={updateOrgMut.isPending}
|
|
onClick={() =>
|
|
updateOrgMut.mutate(
|
|
{
|
|
clientId: client.clientId,
|
|
patch: orgPatch,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setOrgPatch({})
|
|
setOrgSaved(true)
|
|
},
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{m.common__pages__admin_booking__client__save()}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
)
|
|
|
|
return (
|
|
<Container
|
|
size="lg"
|
|
py="xl"
|
|
data-testid={!isFetching ? 'booking-detail-ready' : 'booking-detail-pending'}
|
|
aria-busy={isFetching}
|
|
>
|
|
<Stack gap="lg">
|
|
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
|
<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>
|
|
<Title order={1}>
|
|
{m.common__pages__admin_booking__title({ course: booking.eventName })}
|
|
</Title>
|
|
<Group gap={4}>
|
|
<Text size="sm" c="dimmed">{asI18n(`${client.name}`)}</Text>
|
|
{client.isStammgruppe ? (
|
|
<Text size="sm" c="yellow.6" title={m.common__pages__admin_booking__client__regular_group()} style={{ cursor: 'default' }}>{asI18n('★')}</Text>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
|
|
<BookingActions
|
|
booking={{
|
|
bookingId: booking.bookingId,
|
|
status: booking.status as BookingStatus,
|
|
contractSentAt: booking.contractSentAt ?? null,
|
|
contractResponse: booking.contractResponse ?? null,
|
|
}}
|
|
depositPaid={!!invoices.find((inv) => inv.kind === 'deposit')?.paidOn}
|
|
onMutated={invalidate}
|
|
/>
|
|
</Group>
|
|
|
|
<BookingStatusPanel
|
|
booking={{
|
|
bookingId: booking.bookingId,
|
|
status: booking.status as BookingStatus,
|
|
startDate: booking.startDate,
|
|
enquiryAt: booking.enquiryAt,
|
|
reservedAt: booking.reservedAt ?? null,
|
|
confirmedAt: booking.confirmedAt ?? null,
|
|
endedAt: booking.endedAt ?? null,
|
|
cancelledAt: booking.cancelledAt ?? null,
|
|
contractSentAt: booking.contractSentAt ?? null,
|
|
contractResponse: booking.contractResponse ?? null,
|
|
contractResponseAt: booking.contractResponseAt ?? null,
|
|
depositReminderSentAt: booking.depositReminderSentAt ?? null,
|
|
flaggedAt: booking.flaggedAt ?? null,
|
|
flagReason: booking.flagReason ?? null,
|
|
}}
|
|
invoices={invoices}
|
|
/>
|
|
|
|
<Tabs defaultValue="details">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="overview">{m.common__pages__admin_booking__tabs__overview()}</Tabs.Tab>
|
|
<Tabs.Tab value="details">{m.common__pages__admin_booking__tabs__details()}</Tabs.Tab>
|
|
<Tabs.Tab value="client">{m.common__pages__admin_booking__tabs__client()}</Tabs.Tab>
|
|
<Tabs.Tab value="rooms">
|
|
{asI18n(`${m.common__pages__admin_booking__tabs__rooms()} (${rooms.length})`)}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="participants">
|
|
{asI18n(`${m.common__pages__admin_booking__tabs__participants()} (${participants.length})`)}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="invoices">
|
|
{asI18n(`${m.common__pages__admin_booking__tabs__invoices()} (${invoices.length})`)}
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="audit">{asI18n(`${m.common__pages__admin_booking__tabs__audit()}`)}</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="overview" pt="md">
|
|
<Stack gap="md">
|
|
<EventCard
|
|
eventName={booking.eventName}
|
|
startDate={booking.startDate}
|
|
endDate={booking.endDate}
|
|
coverImageUrl={booking.coverImageUrl}
|
|
eventOutline={booking.eventOutline}
|
|
description={booking.description}
|
|
organiserWebsite={booking.organiserWebsite}
|
|
/>
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__event_name()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.eventName}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__expected_persons()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.expectedPersons ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__start_date()}</Text>
|
|
<Text size="sm">{asI18n(`${fmtDate(booking.startDate, getLocale())}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__end_date()}</Text>
|
|
<Text size="sm">{asI18n(`${fmtDate(booking.endDate, getLocale())}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__half_house()}</Text>
|
|
<Text size="sm">{booking.halfHouse ? m.common__yes() : m.common__no()}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__online_ad()}</Text>
|
|
<Text size="sm">{booking.onlineAd ? m.common__yes() : m.common__no()}</Text>
|
|
</Stack>
|
|
</SimpleGrid>
|
|
{booking.eventOutline && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__event_outline()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.eventOutline}`)}</Text>
|
|
</Stack>
|
|
)}
|
|
{booking.description && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__description()}</Text>
|
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{asI18n(`${booking.description}`)}</Text>
|
|
</Stack>
|
|
)}
|
|
{booking.organiserWebsite && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__organiser_website()}</Text>
|
|
<Text size="sm" component="a" href={booking.organiserWebsite} target="_blank" rel="noopener noreferrer">
|
|
{asI18n(`${booking.organiserWebsite}`)}
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md">
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__arrival_time()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.arrivalTime ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__departure_time()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.departureTime ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<div />
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__meal_time_breakfast()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.mealTimeBreakfast ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__meal_time_lunch()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.mealTimeLunch ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__meal_time_dinner()}</Text>
|
|
<Text size="sm">{asI18n(`${booking.mealTimeDinner ?? '—'}`)}</Text>
|
|
</Stack>
|
|
</SimpleGrid>
|
|
{booking.dailyPlan && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__daily_plan()}</Text>
|
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{asI18n(`${booking.dailyPlan}`)}</Text>
|
|
</Stack>
|
|
)}
|
|
{booking.notes && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__fields__notes()}</Text>
|
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{asI18n(`${booking.notes}`)}</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: '0.08em' }}>
|
|
{m.common__pages__admin_booking__client__title()}
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__client__name()}</Text>
|
|
<Text size="sm">{asI18n(`${client.name}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__client__contact_email()}</Text>
|
|
<Text size="sm">{asI18n(`${client.contactEmail ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__client__contact_phone()}</Text>
|
|
<Text size="sm">{asI18n(`${client.contactPhone ?? '—'}`)}</Text>
|
|
</Stack>
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__client__website()}</Text>
|
|
{client.website
|
|
? <Text size="sm" component="a" href={client.website} target="_blank" rel="noopener noreferrer">{asI18n(`${client.website}`)}</Text>
|
|
: <Text size="sm">{asI18n('—')}</Text>}
|
|
</Stack>
|
|
</SimpleGrid>
|
|
{client.billingAddress && (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" fw={500}>{m.common__pages__admin_booking__client__billing_address()}</Text>
|
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{asI18n(`${client.billingAddress}`)}</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="details" pt="md">
|
|
<Stack gap="md">
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<TextInput
|
|
data-testid="courseName"
|
|
label={m.common__pages__admin_booking__fields__event_name()}
|
|
value={cur('eventName', booking.eventName) ?? ''}
|
|
onChange={(e) => set('eventName', e.currentTarget.value)}
|
|
/>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
data-testid="startDate"
|
|
type="date"
|
|
label={m.common__pages__admin_booking__fields__start_date()}
|
|
value={cur('startDate', toDateInputStr(booking.startDate)) ?? ''}
|
|
onChange={(e) => set('startDate', e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
data-testid="endDate"
|
|
type="date"
|
|
label={m.common__pages__admin_booking__fields__end_date()}
|
|
value={cur('endDate', toDateInputStr(booking.endDate)) ?? ''}
|
|
onChange={(e) => set('endDate', e.currentTarget.value)}
|
|
/>
|
|
<NumberInput
|
|
data-testid="expectedPersons"
|
|
label={m.common__pages__admin_booking__fields__expected_persons()}
|
|
value={cur('expectedPersons', booking.expectedPersons) ?? ''}
|
|
onChange={(v) => set('expectedPersons', v === '' ? null : Number(v))}
|
|
min={1}
|
|
/>
|
|
<BooleanSelect
|
|
data-testid="halfHouse"
|
|
label={m.common__pages__admin_booking__fields__half_house()}
|
|
value={!!cur('halfHouse', booking.halfHouse === 1)}
|
|
onChange={(v) => set('halfHouse', v)}
|
|
/>
|
|
<BooleanSelect
|
|
data-testid="onlineAd"
|
|
label={m.common__pages__admin_booking__fields__online_ad()}
|
|
value={!!cur('onlineAd', booking.onlineAd === 1)}
|
|
onChange={(v) => set('onlineAd', v)}
|
|
/>
|
|
</SimpleGrid>
|
|
<TextInput
|
|
label={m.common__pages__admin_booking__fields__event_outline()}
|
|
value={cur('eventOutline', booking.eventOutline) ?? ''}
|
|
onChange={(e) => set('eventOutline', e.currentTarget.value || null)}
|
|
/>
|
|
<Textarea
|
|
label={m.common__pages__admin_booking__fields__description()}
|
|
value={cur('description', booking.description) ?? ''}
|
|
onChange={(e) => set('description', e.currentTarget.value || null)}
|
|
autosize
|
|
minRows={4}
|
|
/>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput
|
|
label={m.common__pages__admin_booking__fields__cover_image_url()}
|
|
value={cur('coverImageUrl', booking.coverImageUrl) ?? ''}
|
|
onChange={(e) => set('coverImageUrl', e.currentTarget.value || null)}
|
|
/>
|
|
<TextInput
|
|
label={m.common__pages__admin_booking__fields__organiser_website()}
|
|
value={cur('organiserWebsite', booking.organiserWebsite) ?? ''}
|
|
onChange={(e) => set('organiserWebsite', e.currentTarget.value || null)}
|
|
/>
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
|
<TextInput
|
|
type="time"
|
|
label={m.common__pages__admin_booking__fields__arrival_time()}
|
|
value={cur('arrivalTime', booking.arrivalTime) ?? ''}
|
|
onChange={(e) => set('arrivalTime', e.currentTarget.value || null)}
|
|
/>
|
|
<TextInput
|
|
type="time"
|
|
label={m.common__pages__admin_booking__fields__departure_time()}
|
|
value={cur('departureTime', booking.departureTime) ?? ''}
|
|
onChange={(e) => set('departureTime', e.currentTarget.value || null)}
|
|
/>
|
|
<div />
|
|
<TextInput
|
|
type="time"
|
|
label={m.common__pages__admin_booking__fields__meal_time_breakfast()}
|
|
value={cur('mealTimeBreakfast', booking.mealTimeBreakfast) ?? ''}
|
|
onChange={(e) => set('mealTimeBreakfast', e.currentTarget.value || null)}
|
|
/>
|
|
<TextInput
|
|
type="time"
|
|
label={m.common__pages__admin_booking__fields__meal_time_lunch()}
|
|
value={cur('mealTimeLunch', booking.mealTimeLunch) ?? ''}
|
|
onChange={(e) => set('mealTimeLunch', e.currentTarget.value || null)}
|
|
/>
|
|
<TextInput
|
|
type="time"
|
|
label={m.common__pages__admin_booking__fields__meal_time_dinner()}
|
|
value={cur('mealTimeDinner', booking.mealTimeDinner) ?? ''}
|
|
onChange={(e) => set('mealTimeDinner', e.currentTarget.value || null)}
|
|
/>
|
|
</SimpleGrid>
|
|
<Textarea
|
|
label={m.common__pages__admin_booking__fields__daily_plan()}
|
|
value={cur('dailyPlan', booking.dailyPlan) ?? ''}
|
|
onChange={(e) => set('dailyPlan', e.currentTarget.value || null)}
|
|
autosize
|
|
minRows={2}
|
|
/>
|
|
<Textarea
|
|
label={m.common__pages__admin_booking__fields__notes()}
|
|
value={cur('notes', booking.notes) ?? ''}
|
|
onChange={(e) => set('notes', e.currentTarget.value || null)}
|
|
autosize
|
|
minRows={3}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{updateMut.error && (
|
|
<Alert color="red">{m.common__pages__admin_booking__save_error()}</Alert>
|
|
)}
|
|
{saved && <Alert color="green">{m.common__pages__admin_booking__saved()}</Alert>}
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
data-testid="save-booking"
|
|
onClick={save}
|
|
disabled={Object.keys(patch).length === 0 || updateMut.isPending}
|
|
loading={updateMut.isPending}
|
|
>
|
|
{m.common__pages__admin_booking__save()}
|
|
</Button>
|
|
</Group>
|
|
|
|
{organizationForm}
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="client" pt="md">
|
|
{organizationForm}
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="rooms" pt="md">
|
|
<Stack gap="md">
|
|
<Title order={5}>{m.common__pages__admin_booking__rooms__title()}</Title>
|
|
<RoomsTab
|
|
rooms={rooms}
|
|
participants={participants}
|
|
bookingId={bookingId}
|
|
assign={(args) => assignMut.mutate(args)}
|
|
unassign={(args) => unassignMut.mutate(args)}
|
|
/>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="participants" pt="md">
|
|
<ParticipantsTab
|
|
participants={participants}
|
|
bookingId={bookingId}
|
|
addParticipant={(args, opts) => addParticipantMut.mutate(args, opts)}
|
|
updateParticipant={(args) => updateParticipantMut.mutate(args)}
|
|
removeParticipant={(args) => removeParticipantMut.mutate(args)}
|
|
addAllergy={(args, opts) => addAllergyMut.mutate(args, opts)}
|
|
removeAllergy={(args) => removeAllergyMut.mutate(args)}
|
|
isAddingParticipant={addParticipantMut.isPending}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="invoices" pt="md">
|
|
<Stack gap="md">
|
|
<Paper withBorder radius="md" p={0}>
|
|
{invoices.length === 0 ? (
|
|
<Text size="sm" c="dimmed" ta="center" p="xl">
|
|
{m.common__pages__admin_booking__invoices__empty()}
|
|
</Text>
|
|
) : (
|
|
<Table>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__number()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__kind()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__amount()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__issued()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__due()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__paid()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__status()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__invoices__cols__action()}</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}
|
|
data-testid={`invoice-pdf-link-${inv.invoiceNumber}`}
|
|
>
|
|
{asI18n(`${inv.invoiceNumber}`)}
|
|
</Button>
|
|
) : (
|
|
inv.invoiceNumber
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{invoiceKind[inv.kind as keyof typeof invoiceKind]()}
|
|
</Table.Td>
|
|
<Table.Td>{(inv.amountCents / 100).toFixed(2)} €</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.Td>
|
|
<Badge
|
|
color={inv.status === 'paid' ? 'green' : inv.status === 'cancelled' ? 'gray' : 'orange'}
|
|
variant="light"
|
|
>
|
|
{invoiceStatus[inv.status as keyof typeof invoiceStatus]()}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group gap="xs">
|
|
<FileUploader
|
|
compact
|
|
inputTestId={`invoice-pdf-upload-${inv.invoiceNumber}`}
|
|
accept={['application/pdf']}
|
|
idleLabel={(inv.pdfUrl
|
|
? m.common__pages__admin_booking__invoices__replace_pdf
|
|
: m.common__pages__admin_booking__invoices__upload_pdf)()}
|
|
activeLabel={m.common__components__file_uploader__drop_now()}
|
|
uploadingLabel={m.common__components__file_uploader__uploading()}
|
|
uploadedLabel={m.common__components__file_uploader__uploaded()}
|
|
browseLabel={m.common__components__file_uploader__browse()}
|
|
requestUpload={requestInvoicePdfUpload}
|
|
onUploadStateChange={setIsInvoicePdfUploading}
|
|
onUploaded={(asset) => {
|
|
setInvoiceUploadError(false)
|
|
updateInvoiceMut.mutate({
|
|
invoiceId: inv.invoiceId,
|
|
patch: { pdfAssetKey: asset.contentKey },
|
|
})
|
|
}}
|
|
onError={() => setInvoiceUploadError(true)}
|
|
/>
|
|
{inv.pdfUrl && (
|
|
<Button
|
|
component="a"
|
|
href={inv.pdfUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
download={`${inv.invoiceNumber}.pdf`}
|
|
size="xs"
|
|
variant="light"
|
|
data-testid={`invoice-pdf-open-${inv.invoiceNumber}`}
|
|
>
|
|
{m.common__pages__admin_booking__invoices__open_pdf()}
|
|
</Button>
|
|
)}
|
|
{inv.status === 'outstanding' && (
|
|
<>
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
data-testid={`invoice-mark-paid-${inv.invoiceNumber}`}
|
|
onClick={() => {
|
|
const paidOn = window.prompt(
|
|
m.common__pages__admin_booking__invoices__paid_on_prompt(),
|
|
today,
|
|
)
|
|
if (paidOn) {
|
|
recordPaymentMut.mutate({ invoiceId: inv.invoiceId, paidOn })
|
|
}
|
|
}}
|
|
>
|
|
{m.common__pages__admin_booking__invoices__mark_paid()}
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="red"
|
|
data-testid={`invoice-cancel-${inv.invoiceNumber}`}
|
|
onClick={() => {
|
|
if (window.confirm(m.common__pages__admin_booking__invoices__cancel_confirm())) {
|
|
cancelInvoiceMut.mutate({ invoiceId: inv.invoiceId })
|
|
}
|
|
}}
|
|
>
|
|
{m.common__pages__admin_booking__invoices__cancel()}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="md" data-testid="invoice-create-form">
|
|
<Stack gap="sm">
|
|
<Title order={4}>{m.common__pages__admin_booking__invoices__create_title()}</Title>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<Select
|
|
data-testid="invoice-kind"
|
|
label={m.common__pages__admin_booking__invoices__cols__kind()}
|
|
value={invDraft.kind}
|
|
onChange={(v) =>
|
|
setInvDraft((d) => ({ ...d, kind: (v as 'deposit' | 'final') ?? 'deposit' }))
|
|
}
|
|
data={[
|
|
{ value: 'deposit', label: invoiceKind.deposit() },
|
|
{ value: 'final', label: invoiceKind.final() },
|
|
]}
|
|
/>
|
|
<TextInput
|
|
data-testid="invoice-number"
|
|
label={m.common__pages__admin_booking__invoices__cols__number()}
|
|
value={invDraft.invoiceNumber}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setInvDraft((d) => ({ ...d, invoiceNumber: v }))
|
|
}}
|
|
/>
|
|
<TextInput
|
|
data-testid="invoice-issued-on"
|
|
type="date"
|
|
label={m.common__pages__admin_booking__invoices__cols__issued()}
|
|
value={invDraft.issuedOn}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setInvDraft((d) => ({ ...d, issuedOn: v }))
|
|
}}
|
|
/>
|
|
<TextInput
|
|
data-testid="invoice-due-on"
|
|
type="date"
|
|
label={m.common__pages__admin_booking__invoices__cols__due()}
|
|
value={invDraft.dueOn}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value
|
|
setInvDraft((d) => ({ ...d, dueOn: v }))
|
|
}}
|
|
/>
|
|
<NumberInput
|
|
data-testid="invoice-amount"
|
|
label={m.common__pages__admin_booking__invoices__cols__amount()}
|
|
value={invDraft.amountCents / 100}
|
|
onChange={(v) =>
|
|
setInvDraft((d) => ({
|
|
...d,
|
|
amountCents: Math.round((typeof v === 'number' ? v : Number(v) || 0) * 100),
|
|
}))
|
|
}
|
|
min={0}
|
|
decimalScale={2}
|
|
fixedDecimalScale
|
|
step={10}
|
|
suffix={asI18n(' €')}
|
|
/>
|
|
<FileUploader
|
|
inputTestId="invoice-pdf"
|
|
accept={['application/pdf']}
|
|
idleLabel={m.common__pages__admin_booking__invoices__attach_pdf()}
|
|
activeLabel={m.common__components__file_uploader__drop_now()}
|
|
uploadingLabel={m.common__components__file_uploader__uploading()}
|
|
uploadedLabel={m.common__pages__admin_booking__invoices__pdf_selected()}
|
|
browseLabel={m.common__components__file_uploader__browse()}
|
|
hint={m.common__components__file_uploader__pdf_only()}
|
|
requestUpload={requestInvoicePdfUpload}
|
|
onUploadStateChange={setIsInvoicePdfUploading}
|
|
onUploaded={(asset: UploadedAsset) => {
|
|
setInvoiceUploadError(false)
|
|
setInvDraft((d) => ({
|
|
...d,
|
|
pdfAssetKey: asset.contentKey,
|
|
pdfName: asset.fileName,
|
|
}))
|
|
}}
|
|
onError={() => setInvoiceUploadError(true)}
|
|
/>
|
|
</SimpleGrid>
|
|
{createInvoiceMut.error && (
|
|
<Alert color="red" variant="light">
|
|
{m.common__pages__admin_booking__invoices__errors__create()}
|
|
</Alert>
|
|
)}
|
|
{(updateInvoiceMut.error || invoiceUploadError) && (
|
|
<Alert color="red" variant="light">
|
|
{m.common__pages__admin_booking__invoices__errors__upload_pdf()}
|
|
</Alert>
|
|
)}
|
|
<Group>
|
|
<Button
|
|
onClick={() => {
|
|
if (!invDraft.invoiceNumber || invDraft.amountCents <= 0) return
|
|
createInvoiceMut.mutate(
|
|
{
|
|
bookingId,
|
|
kind: invDraft.kind,
|
|
invoiceNumber: invDraft.invoiceNumber,
|
|
issuedOn: invDraft.issuedOn,
|
|
dueOn: invDraft.dueOn || undefined,
|
|
amountCents: invDraft.amountCents,
|
|
pdfAssetKey: invDraft.pdfAssetKey,
|
|
},
|
|
{
|
|
onSuccess: () =>
|
|
setInvDraft({
|
|
kind: 'deposit',
|
|
invoiceNumber: '',
|
|
issuedOn: today,
|
|
dueOn: '',
|
|
amountCents: 0,
|
|
pdfAssetKey: undefined,
|
|
pdfName: '',
|
|
}),
|
|
},
|
|
)
|
|
}}
|
|
disabled={
|
|
!invDraft.invoiceNumber ||
|
|
invDraft.amountCents <= 0 ||
|
|
createInvoiceMut.isPending ||
|
|
isInvoicePdfUploading
|
|
}
|
|
>
|
|
{m.common__pages__admin_booking__invoices__create()}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="audit" pt="md">
|
|
<AuditTab bookingId={bookingId} />
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
function BooleanSelect({
|
|
label,
|
|
value,
|
|
onChange,
|
|
'data-testid': testId,
|
|
}: {
|
|
label: I18nNode
|
|
value: boolean
|
|
onChange: (v: boolean) => void
|
|
'data-testid'?: string
|
|
}) {
|
|
useLocale()
|
|
return (
|
|
<Select
|
|
label={label}
|
|
data-testid={testId}
|
|
value={value ? 'true' : 'false'}
|
|
onChange={(v) => onChange(v === 'true')}
|
|
data={[
|
|
{ value: 'true', label: m.common__yes() },
|
|
{ value: 'false', label: m.common__no() },
|
|
]}
|
|
allowDeselect={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/** Audit rows store the email method name (camelCase); the enum labels are keyed by the snake_case enum value. */
|
|
function emailKindLabel(value: string): string {
|
|
const key = value.replace(/([A-Z])/g, '_$1').toLowerCase()
|
|
return key in emailKind ? emailKind[key as keyof typeof emailKind]() : value
|
|
}
|
|
|
|
function AuditTab({ bookingId }: { bookingId: string }) {
|
|
useLocale()
|
|
const { data, isLoading } = usePikkuQuery('getBookingAuditLog', { bookingId })
|
|
|
|
if (isLoading) return <Text size="sm" c="dimmed">{m.common__states__loading()}</Text>
|
|
|
|
const entries = data?.entries ?? []
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<Paper withBorder radius="md" p="xl">
|
|
<Text size="sm" c="dimmed" ta="center">{m.common__pages__admin_booking__audit__empty()}</Text>
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
const fmt = new Intl.DateTimeFormat(getLocale().startsWith('de') ? 'de-DE' : 'en-GB', {
|
|
day: 'numeric', month: 'short', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
|
|
return (
|
|
<Paper withBorder radius="md" p={0}>
|
|
<Table>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>{m.common__pages__admin_booking__audit__cols__when()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__audit__cols__who()}</Table.Th>
|
|
<Table.Th>{m.common__pages__admin_booking__audit__cols__change()}</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{entries.map((e) => (
|
|
<Table.Tr key={e.auditId}>
|
|
<Table.Td>
|
|
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{asI18n(`${fmt.format(e.at)}`)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{asI18n(`${e.userName ?? m.common__pages__admin_booking__audit__system()}`)}</Text>
|
|
{e.userEmail && <Text size="xs" c="dimmed">{asI18n(`${e.userEmail}`)}</Text>}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{e.field ? (
|
|
<Text size="sm">
|
|
<Text span fw={500}>{asI18n(`${e.field}`)}</Text>
|
|
{e.beforeValue != null && (
|
|
<> {e.beforeValue} → </>
|
|
)}
|
|
{e.afterValue != null && (
|
|
<Text span fw={500}>{asI18n(`${e.afterValue}`)}</Text>
|
|
)}
|
|
</Text>
|
|
) : e.action === 'email_sent' ? (
|
|
<Text size="sm">
|
|
<Text span fw={500}>{asI18n(`${m.common__pages__admin_booking__audit__email_sent()}`)}</Text>
|
|
{e.afterValue ? asI18n(` — ${emailKindLabel(e.afterValue)}`) : null}
|
|
</Text>
|
|
) : (
|
|
<Text size="sm" c="dimmed">{asI18n(`${e.action}`)}</Text>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
export const Route = createFileRoute('/admin/bookings/$bookingId')({
|
|
component: AdminBookingDetailInner,
|
|
})
|