chore: seminarhof customer project
This commit is contained in:
601
apps/app/src/components/ParticipantsTab.tsx
Normal file
601
apps/app/src/components/ParticipantsTab.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user