Files
seminarhof-e2e-mqo7np5j/apps/app/src/components/ParticipantsTab.tsx
2026-06-21 22:00:02 +02:00

602 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n, type I18nString } from '@pikku/react'
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from '@pikku/mantine/core'
type SeverityKey = 'mild' | 'moderate' | 'severe' | 'standard'
type Allergy = {
allergen: string
severity: string
note?: string | null
}
type Participant = {
participantId: string
name: string
kind: string
dietaryTag: string | null
roomId: string | null
roomNumber: number | null
allergies: Allergy[]
}
type DietaryTag =
| 'omnivore'
| 'vegetarian'
| 'vegan'
| 'gluten_free'
| 'lactose_free'
| 'pescatarian'
| 'unknown'
type Kind = 'overnight' | 'day_guest'
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('')
const TINTS = [
'#88557F',
'#A05C8E',
'#BC7AA6',
'#D3A7C4',
'#FFBC7D',
'#F37E15',
'#FFCE00',
'#806600',
'#4E3149',
'#583651',
]
const tintFor = (id: string) => {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
return TINTS[Math.abs(h) % TINTS.length]
}
const SEVERITY_STYLE: Record<
SeverityKey,
{ bg: string; border: string; fg: string }
> = {
mild: { bg: '#F1EEF0', border: '#D9D2D7', fg: '#5A4F56' },
moderate: { bg: '#FFE7CF', border: '#FFBC7D', fg: '#7A4A18' },
severe: { bg: '#4E3149', border: '#4E3149', fg: '#FFFFFF' },
standard: { bg: '#F5EAF1', border: '#E0CCD9', fg: '#4E3149' },
}
const severityKey = (s: string): SeverityKey =>
s === 'mild' || s === 'moderate' || s === 'severe' || s === 'standard'
? s
: 'standard'
const DIETARY_VALUES: DietaryTag[] = [
'omnivore',
'vegetarian',
'vegan',
'gluten_free',
'lactose_free',
'pescatarian',
'unknown',
]
export function ParticipantsTab({
participants,
bookingId,
addParticipant,
updateParticipant,
removeParticipant,
addAllergy,
removeAllergy,
isAddingParticipant,
}: {
participants: Participant[]
bookingId: string
addParticipant: (args: {
bookingId: string
name: string
kind: Kind
email: null
age: null
notes: null
}, opts?: { onSuccess?: () => void }) => void
updateParticipant: (args: {
participantId: string
dietaryTag: DietaryTag | null
}) => void
removeParticipant: (args: { participantId: string }) => void
addAllergy: (
args: {
participantId: string
allergen: string
severity: 'standard' | 'separate_prep' | 'severe'
},
opts?: { onSuccess?: () => void },
) => void
removeAllergy: (args: { participantId: string; allergen: string }) => void
isAddingParticipant: boolean
}) {
useLocale()
const [newName, setNewName] = useState('')
const [newKind, setNewKind] = useState<Kind>('overnight')
const [newAllergyByPid, setNewAllergyByPid] = useState<Record<string, string>>({})
const [allergyOpenByPid, setAllergyOpenByPid] = useState<Record<string, boolean>>({})
const groups = useMemo(() => {
const overnight = participants.filter((p) => p.kind === 'overnight')
const day = participants.filter((p) => p.kind === 'day_guest')
return [
{ kind: 'overnight' as const, items: overnight },
{ kind: 'day_guest' as const, items: day },
].filter((g) => g.items.length > 0)
}, [participants])
const dietaryOptions = DIETARY_VALUES.map((v) => ({
value: v,
label: mKey(`common.pages.booking.participants.dietary.${v}` as any),
}))
const submitAdd = () => {
const name = newName.trim()
if (!name) return
addParticipant(
{ bookingId, name, kind: newKind, email: null, age: null, notes: null },
{
onSuccess: () => {
setNewName('')
setNewKind('overnight')
},
},
)
}
return (
<Stack gap="md">
{/* Inline compact add-participant bar -------------------------------- */}
<Paper
withBorder
radius="md"
p="sm"
data-testid="add-participant-form"
style={{ background: '#FBF8FA', borderColor: '#E5DDE3' }}
>
<Group gap="sm" wrap="nowrap" align="center">
<Text
size="xs"
tt="uppercase"
fw={600}
c="dimmed"
style={{ letterSpacing: '0.08em', whiteSpace: 'nowrap' }}
>
{m.common_pages_booking_participants_add_modal_title()}
</Text>
<TextInput
size="sm"
data-testid="new-participant-name"
placeholder={m.common_pages_booking_participants_add_modal_name()}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitAdd()
}}
style={{ flex: 1, minWidth: 160 }}
aria-label={m.common_pages_booking_participants_add_modal_name()}
/>
<Select
size="sm"
data-testid="new-participant-kind"
value={newKind}
onChange={(v) => setNewKind(((v as Kind) ?? 'overnight'))}
data={[
{
value: 'overnight',
label: m.common_pages_booking_participants_kinds_overnight(),
},
{
value: 'day_guest',
label: m.common_pages_booking_participants_kinds_day_guest(),
},
]}
allowDeselect={false}
style={{ width: 160 }}
aria-label={m.common_pages_booking_participants_add_modal_kind()}
/>
<Button
size="sm"
data-testid="add-participant-submit"
disabled={!newName.trim() || isAddingParticipant}
loading={isAddingParticipant}
onClick={submitAdd}
color="plum"
>
{m.common_pages_booking_participants_add_modal_add()}
</Button>
</Group>
</Paper>
{participants.length === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{m.common_pages_booking_participants_empty()}
</Text>
) : (
<Stack gap="lg">
{groups.map((g) => (
<Stack key={g.kind} gap="xs">
<Group justify="space-between" align="baseline">
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={600}
style={{ letterSpacing: '0.08em' }}
>
{mKey(`common.pages.booking.participants.kinds.${g.kind}` as any)}
</Text>
<Text size="xs" c="dimmed">
{m.common_pages_booking_participants_total_participants({
count: g.items.length,
})}
</Text>
</Group>
<Stack gap="xs">
{g.items.map((p) => (
<ParticipantRow
key={p.participantId}
p={p}
dietaryOptions={dietaryOptions}
onDietChange={(v) =>
updateParticipant({
participantId: p.participantId,
dietaryTag: v,
})
}
onRemove={() => {
if (
window.confirm(
m.common_pages_booking_participants_remove_confirm({
name: p.name,
}),
)
) {
removeParticipant({ participantId: p.participantId })
}
}}
allergyDraft={newAllergyByPid[p.participantId] ?? ''}
onAllergyDraftChange={(v) =>
setNewAllergyByPid((m) => ({
...m,
[p.participantId]: v,
}))
}
allergyEditorOpen={allergyOpenByPid[p.participantId] ?? true}
onToggleAllergyEditor={() =>
setAllergyOpenByPid((m) => ({
...m,
[p.participantId]: !m[p.participantId],
}))
}
onAddAllergy={() => {
const val = (newAllergyByPid[p.participantId] ?? '').trim()
if (!val) return
addAllergy(
{
participantId: p.participantId,
allergen: val,
severity: 'standard',
},
{
onSuccess: () =>
setNewAllergyByPid((m) => ({
...m,
[p.participantId]: '',
})),
},
)
}}
onRemoveAllergy={(allergen) =>
removeAllergy({
participantId: p.participantId,
allergen,
})
}
/>
))}
</Stack>
</Stack>
))}
</Stack>
)}
</Stack>
)
}
function ParticipantRow({
p,
dietaryOptions,
onDietChange,
onRemove,
allergyDraft,
onAllergyDraftChange,
allergyEditorOpen,
onToggleAllergyEditor,
onAddAllergy,
onRemoveAllergy,
}: {
p: Participant
dietaryOptions: { value: string; label: string }[]
onDietChange: (v: DietaryTag | null) => void
onRemove: () => void
allergyDraft: string
onAllergyDraftChange: (v: string) => void
allergyEditorOpen: boolean
onToggleAllergyEditor: () => void
onAddAllergy: () => void
onRemoveAllergy: (allergen: string) => void
}) {
useLocale()
const tint = tintFor(p.participantId)
return (
<Paper
withBorder
radius="md"
p="md"
data-testid={`participant-row-${p.participantId}`}
style={{ borderColor: '#E5DDE3' }}
>
<Stack gap="sm">
<Box
style={{
display: 'grid',
gridTemplateColumns:
'auto minmax(160px, 1.4fr) minmax(140px, 1fr) auto auto',
gap: 'var(--mantine-spacing-md)',
alignItems: 'center',
}}
>
{/* Avatar */}
<Box
style={{
width: 36,
height: 36,
borderRadius: 18,
background: tint,
color: '#fff',
fontSize: 13,
fontWeight: 700,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{initials(p.name) || '·'}
</Box>
{/* Name + room */}
<Stack gap={2}>
<Text fw={600} size="sm" style={{ lineHeight: 1.2 }}>
{asI18n(`${p.name}`)}
</Text>
{p.roomNumber != null ? (
<Text size="xs" c="dimmed">
{asI18n(`${m.common_pages_booking_participants_cols_room()} #${p.roomNumber}`)}
</Text>
) : (
<Text size="xs" c="dimmed" fs="italic">
{asI18n('—')}
</Text>
)}
</Stack>
{/* Diet pill (select) */}
<Select
size="xs"
data-testid={`participant-diet-${p.participantId}`}
aria-label={m.common_pages_booking_participants_cols_diet()}
value={p.dietaryTag}
onChange={(v) => onDietChange((v as DietaryTag | null) ?? null)}
data={dietaryOptions}
clearable
placeholder={m.common_pages_booking_participants_dietary_unknown()}
/>
{/* Allergy summary chip */}
<AllergySummary count={p.allergies.length} />
{/* Delete */}
<Tooltip label={m.common_actions_delete()} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="md"
data-testid={`remove-participant-${p.participantId}`}
onClick={onRemove}
aria-label={m.common_actions_delete()}
>
<span aria-hidden style={{ fontSize: 16, lineHeight: 1 }}>×</span>
</ActionIcon>
</Tooltip>
</Box>
{/* Allergy chips + editor */}
<Box style={{ paddingLeft: 52 }}>
{p.allergies.length === 0 && !allergyEditorOpen ? (
<Button
size="compact-xs"
variant="subtle"
color="plum"
onClick={onToggleAllergyEditor}
styles={{ root: { paddingLeft: 0, paddingRight: 0 } }}
>
{m.common_pages_booking_participants_allergy_add()}
</Button>
) : (
<Group gap="xs" wrap="wrap" align="center">
{p.allergies.map((a) => (
<AllergyChip
key={a.allergen}
allergy={a}
onRemove={() => onRemoveAllergy(a.allergen)}
testId={`remove-allergy-${p.participantId}-${a.allergen}`}
removeLabel={m.common_pages_booking_participants_allergy_remove_aria({
allergen: a.allergen,
})}
/>
))}
{!allergyEditorOpen ? (
<Button
size="compact-xs"
variant="subtle"
color="plum"
onClick={onToggleAllergyEditor}
>
{m.common_pages_booking_participants_allergy_add()}
</Button>
) : null}
</Group>
)}
{allergyEditorOpen && (
<Group gap="xs" mt="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder={m.common_pages_admin_booking_allergies_add_placeholder()}
data-testid={`new-allergy-${p.participantId}`}
value={allergyDraft}
onChange={(e) => onAllergyDraftChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onAddAllergy()
}}
style={{ flex: 1, maxWidth: 280 }}
/>
<Button
size="compact-xs"
color="plum"
data-testid={`add-allergy-${p.participantId}`}
disabled={!allergyDraft.trim()}
onClick={onAddAllergy}
>
{m.common_pages_admin_booking_allergies_add()}
</Button>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={onToggleAllergyEditor}
>
{m.common_actions_cancel({ defaultValue: 'Cancel' })}
</Button>
</Group>
)}
</Box>
</Stack>
</Paper>
)
}
function AllergySummary({ count }: { count: number }) {
useLocale()
if (count === 0) {
return (
<Text size="xs" c="dimmed">
{m.common_pages_booking_participants_allergy_none()}
</Text>
)
}
return (
<Badge
variant="light"
color="peach"
size="sm"
styles={{
root: {
background: '#FFE7CF',
color: '#7A4A18',
textTransform: 'none',
fontWeight: 600,
},
}}
>
{m.common_pages_booking_participants_allergy_count({ count })}
</Badge>
)
}
function AllergyChip({
allergy,
onRemove,
testId,
removeLabel,
}: {
allergy: Allergy
onRemove: () => void
testId: string
removeLabel: I18nString
}) {
useLocale()
const sev = severityKey(allergy.severity)
const style = SEVERITY_STYLE[sev]
const sevLabel =
sev === 'standard'
? null
: mKey(`common.pages.booking.participants.severity.${sev}` as const)
return (
<Box
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: style.bg,
border: `1px solid ${style.border}`,
color: style.fg,
borderRadius: 999,
padding: '2px 4px 2px 10px',
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<span>{mKey(`common.pages.allergens.${allergy.allergen}` as any)}</span>
{sevLabel ? (
<span
style={{
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.8,
}}
>
· {sevLabel}
</span>
) : null}
<ActionIcon
size="xs"
variant="transparent"
data-testid={testId}
onClick={onRemove}
aria-label={removeLabel}
style={{ color: style.fg }}
>
<span aria-hidden style={{ fontSize: 12, lineHeight: 1 }}>×</span>
</ActionIcon>
</Box>
)
}