chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:54:43 +02:00
commit 49d05dfa0b
293 changed files with 28421 additions and 0 deletions

View 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>
)
}