Files
seminarhof-e2e-mrffdsce/apps/app/src/components/RoomsTab.tsx
2026-07-10 23:06:04 +02:00

494 lines
14 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 } from '@/i18n/messages'
import { building as buildingLabels, floor as floorLabels } from '@/i18n/i18n-enum.gen'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
} from '@pikku/mantine/core'
type Room = {
roomId: string
number: number
beds: number
roomType: string
building: string
floor: string
wheelchair: number
groundFloor: number
quiet: number
sharedBath: number
dogsAllowed: number
doubleBed_140: number
allergyFriendly: number
occupiedByOtherBooking: boolean
assignedParticipants: { participantId: string; name: string }[]
}
type Participant = { participantId: string; name: string; roomId: string | null }
const PALETTE = {
empty: '#FBF8FA',
emptyBorder: '#E5DDE3',
filled: '#E9D4E2',
filledBorder: '#88557F',
partial: '#F0E4EC',
full: '#88557F',
fullText: '#FFFFFF',
occupiedOther: '#EDEDED',
occupiedOtherText: '#9B9499',
hover: '#FFCE00',
}
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('')
// Stable, soft per-participant tint so each guest reads as a distinct chip
// in the floor plan without leaving the brand palette.
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]
}
type Group = { building: string; floor: string; rooms: Room[] }
function groupRooms(rooms: Room[]): Group[] {
const map = new Map<string, Room[]>()
for (const r of rooms) {
const key = `${r.building}::${r.floor}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(r)
}
const order = ['gaestehaus::eg', 'gaestehaus::og', 'haupthaus::eg', 'haupthaus::og']
return Array.from(map.entries())
.map(([k, rs]) => {
const [building, floor] = k.split('::')
return { building, floor, rooms: rs.sort((a, b) => a.number - b.number) }
})
.sort(
(a, b) =>
order.indexOf(`${a.building}::${a.floor}`) - order.indexOf(`${b.building}::${b.floor}`),
)
}
function buildingLabel(building: string, floor: string) {
const b = building in buildingLabels ? buildingLabels[building as keyof typeof buildingLabels]() : asI18n(building)
const f = floor in floorLabels ? floorLabels[floor as keyof typeof floorLabels]() : asI18n(floor)
return `${b} · ${f}`
}
export function RoomsTab({
rooms,
participants,
bookingId,
assign,
unassign,
}: {
rooms: Room[]
participants: Participant[]
bookingId: string
assign: (args: { bookingId: string; participantId: string; roomId: string }) => void
unassign: (args: { participantId: string }) => void
}) {
useLocale()
const [hovered, setHovered] = useState<string | null>(null)
const groups = useMemo(() => groupRooms(rooms), [rooms])
const unassigned = participants.filter((p) => !p.roomId)
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" data-testid="rooms-tab">
{/* Left pane: room list ----------------------------------------- */}
<Paper withBorder radius="md" p={0}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 64 }}>{m.common__pages__admin_booking__rooms__cols__room()}</Table.Th>
<Table.Th>{m.common__pages__admin_booking__rooms__cols__guests()}</Table.Th>
<Table.Th style={{ width: 200 }}>{m.common__pages__admin_booking__rooms__cols__assign()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((g) => (
<RoomGroupRows
key={`${g.building}-${g.floor}`}
group={g}
hovered={hovered}
onHover={setHovered}
unassigned={unassigned}
assign={assign}
unassign={unassign}
bookingId={bookingId}
/>
))}
</Table.Tbody>
</Table>
</Paper>
{/* Right pane: SVG floorplan -------------------------------------- */}
<Stack gap="md">
{groups.map((g) => (
<Paper key={`${g.building}-${g.floor}`} withBorder radius="md" p="md">
<Stack gap="sm">
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
{asI18n(`${buildingLabel(g.building, g.floor)}`)}
</Text>
<FloorPlan
rooms={g.rooms}
hovered={hovered}
onHover={setHovered}
/>
</Stack>
</Paper>
))}
</Stack>
</SimpleGrid>
)
}
function RoomGroupRows({
group,
hovered,
onHover,
unassigned,
assign,
unassign,
bookingId,
}: {
group: Group
hovered: string | null
onHover: (id: string | null) => void
unassigned: Participant[]
assign: (args: { bookingId: string; participantId: string; roomId: string }) => void
unassign: (args: { participantId: string }) => void
bookingId: string
}) {
useLocale()
return (
<>
<Table.Tr>
<Table.Td colSpan={3} style={{ background: '#FBF8FA' }}>
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
{asI18n(`${buildingLabel(group.building, group.floor)}`)}
</Text>
</Table.Td>
</Table.Tr>
{group.rooms.map((r) => (
<Table.Tr
key={r.roomId}
onMouseEnter={() => onHover(r.roomId)}
onMouseLeave={() => onHover(null)}
style={{
background: hovered === r.roomId ? 'rgba(255, 206, 0, 0.12)' : undefined,
cursor: 'default',
}}
>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text fw={700}>{r.number}</Text>
<Badge size="xs" variant="light" color="gray">
{asI18n(`${r.roomType}`)}
</Badge>
</Group>
<Text size="xs" c="dimmed">{asI18n(`${r.beds} ${m.common__pages__admin_booking__rooms__beds()}`)}</Text>
</Table.Td>
<Table.Td>
{r.occupiedByOtherBooking ? (
<Text size="xs" c="dimmed" fs="italic">
{m.common__pages__admin_booking__rooms__occupied_other()}
</Text>
) : r.assignedParticipants.length === 0 ? (
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
) : (
<Stack gap={4}>
{r.assignedParticipants.map((p) => (
<Group key={p.participantId} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 18,
height: 18,
borderRadius: 9,
background: tintFor(p.participantId),
color: '#fff',
fontSize: 9,
fontWeight: 700,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{initials(p.name)}
</Box>
<Text size="sm">{asI18n(`${p.name}`)}</Text>
</Group>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={() => unassign({ participantId: p.participantId })}
>
{m.common__pages__admin_booking__rooms__unassign()}
</Button>
</Group>
))}
</Stack>
)}
</Table.Td>
<Table.Td>
{!r.occupiedByOtherBooking && unassigned.length > 0 && (
<Select
size="xs"
data-testid={`assign-room-${r.roomId}`}
placeholder={m.common__pages__admin_booking__rooms__assign_to_participant()}
data={unassigned.map((p) => ({ value: p.participantId, label: p.name }))}
value={null}
onChange={(v) =>
v && assign({ bookingId, participantId: v, roomId: r.roomId })
}
clearable={false}
/>
)}
</Table.Td>
</Table.Tr>
))}
</>
)
}
// ─── SVG floor plan ─────────────────────────────────────────────────────
// Schematic only — not architecturally accurate. Rooms are laid out as a
// grid with their real number; layout is responsive to the room count per
// floor section.
function FloorPlan({
rooms,
hovered,
onHover,
}: {
rooms: Room[]
hovered: string | null
onHover: (id: string | null) => void
}) {
const cols = Math.min(6, Math.max(2, Math.ceil(Math.sqrt(rooms.length * 1.6))))
const rows = Math.ceil(rooms.length / cols)
const W = 480
const cellGap = 6
const padX = 12
const padY = 12
const cellW = (W - padX * 2 - cellGap * (cols - 1)) / cols
const cellH = 84
const H = padY * 2 + rows * cellH + (rows - 1) * cellGap
return (
<Box style={{ width: '100%' }}>
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
style={{ display: 'block', borderRadius: 8 }}
>
<rect x="0" y="0" width={W} height={H} fill="#ffffff" stroke="#E5DDE3" rx="8" />
{rooms.map((r, i) => {
const c = i % cols
const row = Math.floor(i / cols)
const x = padX + c * (cellW + cellGap)
const y = padY + row * (cellH + cellGap)
return (
<RoomCell
key={r.roomId}
room={r}
x={x}
y={y}
w={cellW}
h={cellH}
hovered={hovered === r.roomId}
onHover={onHover}
/>
)
})}
</svg>
</Box>
)
}
function RoomCell({
room: r,
x,
y,
w,
h,
hovered,
onHover,
}: {
room: Room
x: number
y: number
w: number
h: number
hovered: boolean
onHover: (id: string | null) => void
}) {
const isOther = r.occupiedByOtherBooking
const count = r.assignedParticipants.length
const isFull = count >= r.beds && r.beds > 0
const isPartial = count > 0 && !isFull
const fill = isOther
? PALETTE.occupiedOther
: isFull
? PALETTE.full
: isPartial
? PALETTE.filled
: PALETTE.empty
const stroke = hovered
? PALETTE.hover
: isOther
? PALETTE.occupiedOtherText
: count > 0
? PALETTE.filledBorder
: PALETTE.emptyBorder
const numberColor = isFull ? PALETTE.fullText : '#4E3149'
// Avatar dots up to 4 per room; rest collapses to "+N".
const visible = r.assignedParticipants.slice(0, 4)
const overflow = count - visible.length
const avatarR = 11
const avatarGap = 2
const totalW = visible.length * (avatarR * 2) + Math.max(0, visible.length - 1) * avatarGap
const startX = x + w / 2 - totalW / 2 + avatarR
const avatarY = y + h - avatarR - 8
return (
<g
onMouseEnter={() => onHover(r.roomId)}
onMouseLeave={() => onHover(null)}
style={{ cursor: 'default' }}
>
<rect
x={x}
y={y}
width={w}
height={h}
fill={fill}
stroke={stroke}
strokeWidth={hovered ? 2 : 1}
rx={6}
/>
<text
x={x + 8}
y={y + 18}
fontSize="12"
fontWeight="700"
fill={numberColor}
fontFamily="Open Sans, system-ui, sans-serif"
>
{r.number}
</text>
<text
x={x + w - 8}
y={y + 18}
fontSize="9"
textAnchor="end"
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
fontFamily="Open Sans, system-ui, sans-serif"
>
{r.roomType}
</text>
{isOther ? (
<text
x={x + w / 2}
y={y + h / 2 + 4}
fontSize="10"
textAnchor="middle"
fill={PALETTE.occupiedOtherText}
fontStyle="italic"
fontFamily="Open Sans, system-ui, sans-serif"
>
</text>
) : (
<>
{visible.map((p, idx) => {
const cx = startX + idx * (avatarR * 2 + avatarGap)
return (
<g key={p.participantId}>
<title>{p.name}</title>
<circle cx={cx} cy={avatarY} r={avatarR} fill={tintFor(p.participantId)} stroke="#fff" strokeWidth={1.5} />
<text
x={cx}
y={avatarY + 3}
fontSize="9"
textAnchor="middle"
fill="#fff"
fontWeight="700"
fontFamily="Open Sans, system-ui, sans-serif"
>
{initials(p.name)}
</text>
</g>
)
})}
{overflow > 0 && (
<g>
<circle
cx={startX + visible.length * (avatarR * 2 + avatarGap)}
cy={avatarY}
r={avatarR}
fill="#fff"
stroke={PALETTE.filledBorder}
strokeWidth={1}
/>
<text
x={startX + visible.length * (avatarR * 2 + avatarGap)}
y={avatarY + 3}
fontSize="9"
textAnchor="middle"
fill={PALETTE.filledBorder}
fontWeight="700"
fontFamily="Open Sans, system-ui, sans-serif"
>
+{overflow}
</text>
</g>
)}
</>
)}
<text
x={x + 8}
y={y + 34}
fontSize="9"
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
fontFamily="Open Sans, system-ui, sans-serif"
>
{count}/{r.beds}
</text>
</g>
)
}