602 lines
17 KiB
TypeScript
602 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|