chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-22 00:08:38 +02:00
commit 48b795b3be
161 changed files with 18517 additions and 0 deletions

View File

@@ -0,0 +1,492 @@
import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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 = mKey(`common.pages.adminBooking.rooms.buildings.${building}` as any)
const f = mKey(`common.pages.adminBooking.rooms.floors.${floor}` as any)
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>
)
}