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