493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|