315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
import { useState } from 'react'
|
|
import {
|
|
Stack,
|
|
SimpleGrid,
|
|
Card,
|
|
Text,
|
|
Group,
|
|
Badge,
|
|
Loader,
|
|
Center,
|
|
Drawer,
|
|
TextInput,
|
|
NumberInput,
|
|
Select,
|
|
Textarea,
|
|
Button,
|
|
ActionIcon,
|
|
Tooltip,
|
|
} from '@pikku/mantine/core'
|
|
import { Pencil, Search, Plus } from 'lucide-react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { notifications } from '@mantine/notifications'
|
|
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
|
import { usePermission } from '@/lib/permissions'
|
|
import { PageHeader } from '@perauset/components'
|
|
import { m } from '@/i18n/messages'
|
|
import { useLocale } from '@/i18n/config'
|
|
import { asI18n } from '@pikku/react'
|
|
|
|
type RoomType = 'dorm' | 'facilitator' | 'private' | 'shared' | 'staff'
|
|
|
|
export const Route = createFileRoute('/_authenticated/rooms/')({
|
|
component: RoomsPage,
|
|
})
|
|
|
|
type Room = {
|
|
roomId: string
|
|
code: string
|
|
name: string
|
|
roomType: string
|
|
capacity: number
|
|
pricePerNight: number | null
|
|
isActive: boolean
|
|
notes: string | null
|
|
}
|
|
|
|
const ROOM_TYPES = [
|
|
{ value: 'dorm', label: 'Dorm' },
|
|
{ value: 'facilitator', label: 'Facilitator' },
|
|
{ value: 'private', label: 'Private' },
|
|
{ value: 'shared', label: 'Shared' },
|
|
{ value: 'staff', label: 'Staff' },
|
|
]
|
|
|
|
const cardStyle = {
|
|
backgroundColor: '#ffffff',
|
|
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
|
}
|
|
|
|
function formatType(type: string): string {
|
|
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
function RoomActions({ onCreateRoom }: { onCreateRoom?: () => void }) {
|
|
const canManage = usePermission('rooms.manage')
|
|
if (!canManage) return null
|
|
return (
|
|
<Group gap="sm">
|
|
<Button component={Link} to="/rooms/allocations" variant="light" color="teal" size="sm">
|
|
{m.rooms_view_allocations()}
|
|
</Button>
|
|
<Button leftSection={<Plus size={16} />} color="teal" size="sm" onClick={onCreateRoom}>
|
|
{m.rooms_create_room()}
|
|
</Button>
|
|
</Group>
|
|
)
|
|
}
|
|
|
|
function CreateRoomDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
|
const queryClient = useQueryClient()
|
|
const [code, setCode] = useState('')
|
|
const [name, setName] = useState('')
|
|
const [roomType, setRoomType] = useState<RoomType | null>(null)
|
|
const [capacity, setCapacity] = useState<number>(1)
|
|
const [pricePerNight, setPricePerNight] = useState<number | undefined>(undefined)
|
|
const [notes, setNotes] = useState('')
|
|
|
|
const resetForm = () => {
|
|
setCode('')
|
|
setName('')
|
|
setRoomType(null)
|
|
setCapacity(1)
|
|
setPricePerNight(undefined)
|
|
setNotes('')
|
|
}
|
|
|
|
const mutation = usePikkuMutation('createRoom', {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
|
|
notifications.show({ title: 'Room created', message: 'New room has been added.', color: 'green' })
|
|
resetForm()
|
|
onClose()
|
|
},
|
|
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create room.', color: 'red' }),
|
|
})
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!code || !name || !roomType) {
|
|
notifications.show({ title: 'Missing fields', message: 'Code, name, and room type are required.', color: 'red' })
|
|
return
|
|
}
|
|
mutation.mutate({
|
|
code,
|
|
name,
|
|
roomType,
|
|
capacity,
|
|
pricePerNight: pricePerNight || undefined,
|
|
notes: notes || undefined,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.rooms_create_room()}>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="md">
|
|
<TextInput label={m.rooms_room_code()} placeholder={m.rooms_room_code_placeholder()} value={code} onChange={(e) => setCode(e.currentTarget.value)} required />
|
|
<TextInput label={m.rooms_room_name()} placeholder={m.rooms_room_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
|
<Select label={m.rooms_room_type()} placeholder={m.rooms_select_type()} data={ROOM_TYPES} value={roomType} onChange={(v) => setRoomType(v as RoomType | null)} required />
|
|
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
|
|
<NumberInput label={m.rooms_price_per_night()} placeholder={m.rooms_price_placeholder()} value={pricePerNight} onChange={(val) => setPricePerNight(val ? Number(val) : undefined)} min={0} decimalScale={2} prefix={asI18n('€')} />
|
|
<Textarea label={m.rooms_notes()} placeholder={m.rooms_notes_placeholder()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
|
<Button type="submit" color="teal" loading={mutation.isPending}>{m.rooms_save_room()}</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
function EditRoomDrawer({ room, onClose }: { room: Room | null; onClose: () => void }) {
|
|
const queryClient = useQueryClient()
|
|
const [name, setName] = useState(room?.name ?? '')
|
|
const [roomType, setRoomType] = useState<string | null>(room?.roomType ?? null)
|
|
const [capacity, setCapacity] = useState<number>(room?.capacity ?? 1)
|
|
const [notes, setNotes] = useState(room?.notes ?? '')
|
|
|
|
const mutation = usePikkuMutation('updateRoom', {
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
|
|
notifications.show({ title: 'Room updated', message: 'Room details have been saved.', color: 'green' })
|
|
onClose()
|
|
},
|
|
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update room.', color: 'red' }),
|
|
})
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!room) return
|
|
mutation.mutate({
|
|
roomId: room.roomId,
|
|
name,
|
|
roomType: roomType ?? undefined,
|
|
capacity,
|
|
notes: notes || undefined,
|
|
})
|
|
}
|
|
|
|
if (!room) return null
|
|
|
|
return (
|
|
<Drawer position="right" size="md" opened={!!room} onClose={onClose} title={m.rooms_edit_room()}>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="md">
|
|
<TextInput label={m.rooms_room_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
|
<Select label={m.rooms_room_type()} data={ROOM_TYPES} value={roomType} onChange={setRoomType} required />
|
|
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
|
|
<Textarea label={m.rooms_notes()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
|
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
function RoomsPage() {
|
|
useLocale()
|
|
const canManage = usePermission('rooms.manage')
|
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
|
const [editingRoom, setEditingRoom] = useState<Room | null>(null)
|
|
const [startDate, setStartDate] = useState('')
|
|
const [endDate, setEndDate] = useState('')
|
|
|
|
const { data, isLoading } = usePikkuQuery('listRooms', { limit: 50, offset: 0 })
|
|
|
|
const { data: availabilityData } = usePikkuQuery(
|
|
'checkRoomAvailability',
|
|
{ startDate, endDate },
|
|
{ enabled: !!startDate && !!endDate },
|
|
)
|
|
|
|
const availabilityMap = new Map((availabilityData?.rooms ?? []).map((r) => [r.roomId, r]))
|
|
|
|
const rooms = data?.items ?? []
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center py="xl">
|
|
<Loader color="teal" />
|
|
</Center>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<PageHeader
|
|
title={m.rooms_title()}
|
|
description={m.rooms_description()}
|
|
action={<RoomActions onCreateRoom={() => setCreateModalOpen(true)} />}
|
|
/>
|
|
|
|
<Card padding="md" radius="md" style={cardStyle}>
|
|
<Group gap="md" align="flex-end">
|
|
<TextInput type="date" label={m.rooms_availability_start()} value={startDate} onChange={(e) => setStartDate(e.currentTarget.value)} size="sm" />
|
|
<TextInput type="date" label={m.rooms_availability_end()} value={endDate} onChange={(e) => setEndDate(e.currentTarget.value)} size="sm" min={startDate || undefined} />
|
|
{startDate && endDate && (
|
|
<Badge color="teal" variant="light" size="lg">
|
|
<Search size={12} style={{ marginRight: 4 }} />
|
|
{m.rooms_showing_availability()}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
</Card>
|
|
|
|
{rooms.length === 0 ? (
|
|
<Card padding="xl" radius="md" style={cardStyle}>
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{m.rooms_no_rooms()}
|
|
</Text>
|
|
</Card>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
|
|
{rooms.map((room) => {
|
|
const availability = availabilityMap.get(room.roomId)
|
|
return (
|
|
<Card key={room.roomId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
|
|
<Group justify="space-between" align="flex-start" mb="sm">
|
|
<Link to="/rooms/allocations" search={{ roomId: room.roomId }} style={{ textDecoration: 'none', flex: 1 }}>
|
|
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
|
{asI18n(room.name)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mt={2}>
|
|
{asI18n(room.code)}
|
|
</Text>
|
|
</Link>
|
|
<Group gap="xs">
|
|
<Badge color={room.isActive ? 'green' : 'gray'} variant="light" size="sm">
|
|
{room.isActive ? m.rooms_active() : m.rooms_inactive()}
|
|
</Badge>
|
|
{canManage && (
|
|
<Tooltip label={m.rooms_edit_room()}>
|
|
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoom(room)}>
|
|
<Pencil size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Group gap="lg" mt="md">
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_type()}</Text>
|
|
<Text size="sm">{asI18n(formatType(room.roomType))}</Text>
|
|
</div>
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_capacity()}</Text>
|
|
<Text size="sm">{room.capacity}</Text>
|
|
</div>
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_price_night()}</Text>
|
|
<Text size="sm" fw={500}>{asI18n(room.pricePerNight != null ? `€${Number(room.pricePerNight).toFixed(2)}` : '—')}</Text>
|
|
</div>
|
|
{availability && (
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_availability()}</Text>
|
|
<Badge color={availability.available ? 'green' : 'red'} variant="filled" size="sm">
|
|
{availability.available ? m.rooms_available() : m.rooms_full()}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</Group>
|
|
|
|
{room.notes && (
|
|
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
|
|
{asI18n(room.notes)}
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
)
|
|
})}
|
|
</SimpleGrid>
|
|
)}
|
|
|
|
<CreateRoomDrawer opened={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
|
<EditRoomDrawer room={editingRoom} onClose={() => setEditingRoom(null)} />
|
|
</Stack>
|
|
)
|
|
}
|