chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-21 22:00:02 +02:00
commit c11335df7a
161 changed files with 18517 additions and 0 deletions

View 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>
)
}