Files
seminarhof-e2e-mqoc92hi/apps/app/src/pages/admin.bookings.$bookingId.tsx
2026-06-22 00:08:38 +02:00

1030 lines
45 KiB
TypeScript

import { createFileRoute, Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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 '../components/FileUploader'
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>
{mKey(`common.pages.adminBooking.invoices.kind.${inv.kind}` as any)}
</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"
>
{mKey(`common.pages.adminBooking.invoices.status.${inv.status}` as any)}
</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: m.common_pages_admin_booking_invoices_kind_deposit() },
{ value: 'final', label: m.common_pages_admin_booking_invoices_kind_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}
/>
)
}
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(`${mKey(`common.pages.adminBooking.audit.emails.${e.afterValue}` as any, { defaultValue: 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,
})