chore: perauset customer project
This commit is contained in:
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal file
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user