chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:35:17 +02:00
commit 37eea09bf0
293 changed files with 28412 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { useState } from 'react'
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MantineProvider } from '@pikku/mantine/core'
import { Notifications } from '@mantine/notifications'
import { PikkuProvider, createPikku } from '@pikku/react'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { PikkuFetch } from '@perauset/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
import { useLocale } from '../i18n/config'
import { theme } from '@perauset/mantine-theme'
declare global {
interface Window {
__E2E_API_URL?: string
}
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'PerAuset' },
],
}),
component: RootDocument,
})
function RootDocument() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
}),
)
const runtimeApiUrl =
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
transformDate: true,
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
credentials: 'include',
}),
)
// Reactive locale from the Paraglide store (persists to localStorage +
// reflects <html lang> itself; initial detection runs on import).
const { locale, dir } = useLocale()
return (
<html lang={locale} dir={dir}>
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme}>
<Notifications />
<PikkuProvider pikku={pikku}>
<Outlet />
</PikkuProvider>
</MantineProvider>
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}

View File

@@ -0,0 +1,128 @@
import { createFileRoute } from '@tanstack/react-router'
import { Stack, Badge, Code, Loader, Center, Text } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/audit-log')({
component: AuditLogPage,
})
const actionColor: Record<string, string> = {
INSERT: 'green',
UPDATE: 'blue',
DELETE: 'red',
}
function formatTimestamp(ts: string): string {
return new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface AuditEntry {
auditId: string
occurredAt: string
action: string
tableName: string
recordId: string
userId: string | null
changedFields: Record<string, unknown> | null
}
function AuditLogPage() {
useLocale()
const { data, isLoading } = usePikkuQuery('listAuditLog', {
limit: 50,
offset: 0,
})
const entries = (data?.items ?? []) as AuditEntry[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<AuditEntry>[] = [
{
key: 'occurredAt',
label: m.audit_timestamp(),
render: (entry) => <Text size="sm">{asI18n(formatTimestamp(entry.occurredAt))}</Text>,
},
{
key: 'action',
label: m.audit_action(),
render: (entry) => (
<Badge color={actionColor[entry.action] ?? 'gray'} variant="light" size="sm">
{asI18n(entry.action)}
</Badge>
),
},
{
key: 'tableName',
label: m.audit_table(),
render: (entry) => (
<Text size="sm" ff="monospace">
{asI18n(entry.tableName)}
</Text>
),
},
{
key: 'recordId',
label: m.audit_record_id(),
render: (entry) => (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
{asI18n(entry.recordId)}
</Text>
),
},
{
key: 'userId',
label: m.audit_user(),
render: (entry) => (
<Text size="xs" c="dimmed" lineClamp={1}>
{asI18n(entry.userId ?? '--')}
</Text>
),
},
{
key: 'changedFields',
label: m.audit_changed_fields(),
render: (entry) =>
entry.changedFields ? (
<Code block style={{ fontSize: 11, maxWidth: 300 }}>
{JSON.stringify(entry.changedFields, null, 2)}
</Code>
) : (
<Text size="xs" c="dimmed">
{asI18n('--')}
</Text>
),
},
]
return (
<Stack gap="lg">
<PageHeader title={m.audit_title()} description={m.audit_description()} />
<DataTable
data={entries}
columns={columns}
rowKey={(entry) => entry.auditId}
emptyMessage="No audit log entries found."
/>
</Stack>
)
}

View File

@@ -0,0 +1,394 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Stack,
SimpleGrid,
Card,
Text,
Group,
Loader,
Center,
ActionIcon,
Tooltip,
Drawer,
Textarea,
Button,
Select,
TextInput,
} from '@pikku/mantine/core'
import { Sailboat, Play, Ban, Check, 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 { StatusBadge } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/')({
component: BoatsPage,
})
type BoatTrip = {
tripId: string
boatId: string | null
routeId: string
status: string
scheduledDepartureAt: string | Date
scheduledArrivalAt: string | Date | null
notes: string | null
}
function formatDateTime(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return String(dateStr)
}
}
function BoatActions({ onCreateTrip }: { onCreateTrip?: () => void }) {
const canManage = usePermission('boats.manage')
return (
<Group gap="sm">
<Button component={Link} to="/boats/request" variant="light" color="teal" size="sm">
{m.boats_request_seat()}
</Button>
{canManage && onCreateTrip && (
<Button onClick={onCreateTrip} leftSection={<Plus size={16} />} color="teal" size="sm">
{m.boats_create_trip()}
</Button>
)}
</Group>
)
}
function TripActions({ trip }: { trip: BoatTrip }) {
const queryClient = useQueryClient()
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
const openMutation = usePikkuMutation('openBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip opened', message: 'Boat trip is now open for seat requests.', color: 'green' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to open trip.', color: 'red' }),
})
const cancelMutation = usePikkuMutation('cancelBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip cancelled', message: 'Boat trip has been cancelled.', color: 'orange' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to cancel trip.', color: 'red' }),
})
const completeMutation = usePikkuMutation('completeBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip completed', message: 'Boat trip has been marked as completed.', color: 'green' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to complete trip.', color: 'red' }),
})
const canOpen = trip.status === 'planned'
const canComplete = trip.status === 'open' || trip.status === 'in_progress'
const canCancel = trip.status === 'planned' || trip.status === 'open'
if (!canOpen && !canComplete && !canCancel) return null
return (
<Group gap="xs" onClick={(e) => e.preventDefault()}>
{canOpen && (
<Tooltip label={m.boats_open_for_requests()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => openMutation.mutate({ tripId: trip.tripId })}
loading={openMutation.isPending}
>
<Play size={14} />
</ActionIcon>
</Tooltip>
)}
{canComplete && (
<Tooltip label={m.boats_complete_trip()}>
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => completeMutation.mutate({ tripId: trip.tripId })}
loading={completeMutation.isPending}
>
<Check size={14} />
</ActionIcon>
</Tooltip>
)}
{canCancel && (
<Tooltip label={m.boats_cancel_trip()}>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => cancelMutation.mutate({ tripId: trip.tripId })}
loading={cancelMutation.isPending}
>
<Ban size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
function CreateTripDrawer({
opened,
onClose,
routeOptions,
boatOptions,
}: {
opened: boolean
onClose: () => void
routeOptions: Array<{ value: string; label: string }>
boatOptions: Array<{ value: string; label: string }>
}) {
const queryClient = useQueryClient()
const [routeId, setRouteId] = useState<string | null>(null)
const [boatId, setBoatId] = useState<string | null>(null)
const [scheduledDepartureAt, setScheduledDepartureAt] = useState('')
const [scheduledArrivalAt, setScheduledArrivalAt] = useState('')
const [notes, setNotes] = useState('')
const resetForm = () => {
setRouteId(null)
setBoatId(null)
setScheduledDepartureAt('')
setScheduledArrivalAt('')
setNotes('')
}
const mutation = usePikkuMutation('createBoatTrip', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
notifications.show({ title: 'Trip created', message: 'Boat trip has been created successfully.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat trip.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!routeId || !scheduledDepartureAt) {
notifications.show({ title: 'Missing fields', message: 'Route and departure time are required.', color: 'red' })
return
}
mutation.mutate({
routeId,
boatId: boatId || undefined,
scheduledDepartureAt: new Date(scheduledDepartureAt).toISOString(),
scheduledArrivalAt: scheduledArrivalAt ? new Date(scheduledArrivalAt).toISOString() : undefined,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_create_trip()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.boats_route()}
placeholder={m.boats_select_route()}
data={routeOptions}
value={routeId}
onChange={setRouteId}
searchable
required
nothingFoundMessage={m.boats_no_routes_available()}
/>
<Select
label={m.boats_boat()}
placeholder={m.boats_select_boat_optional()}
data={boatOptions}
value={boatId}
onChange={setBoatId}
searchable
clearable
nothingFoundMessage={m.boats_no_boats_available()}
/>
<TextInput
label={m.boats_scheduled_departure()}
type="datetime-local"
value={scheduledDepartureAt}
onChange={(e) => setScheduledDepartureAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.boats_scheduled_arrival()}
type="datetime-local"
value={scheduledArrivalAt}
onChange={(e) => setScheduledArrivalAt(e.currentTarget.value)}
min={scheduledDepartureAt || undefined}
/>
<Textarea
label={m.boats_notes()}
placeholder={m.boats_additional_information()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<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.boats_create_trip()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function BoatsPage() {
useLocale()
const canManage = usePermission('boats.manage')
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const trips = data?.items ?? []
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.boats_trips_title()}
description={m.boats_trips_description()}
action={<BoatActions onCreateTrip={() => setCreateDrawerOpen(true)} />}
/>
{trips.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{ backgroundColor: '#ffffff', boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)' }}
>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_trips()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{trips.map((trip) => {
const route = routeMap.get(trip.routeId)
const boat = trip.boatId ? boatMap.get(trip.boatId) : null
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Route'
const boatLabel = boat ? boat.name : null
return (
<Card
key={trip.tripId}
component={Link}
to="/boats/trips/$tripId"
params={{ tripId: trip.tripId } as never}
padding="lg"
radius="md"
className="card-hover"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textDecoration: 'none',
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="sm">
<Sailboat size={20} color="#2A7B88" />
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(routeLabel)}
</Text>
</Group>
<Group gap="xs">
<StatusBadge status={trip.status} />
{canManage && <TripActions trip={trip} />}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.boats_departure()}
</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
</div>
{trip.scheduledArrivalAt && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.boats_arrival()}
</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
</div>
)}
</Group>
{boatLabel && (
<Text size="xs" c="dimmed" mt="sm">
{asI18n(`${m.boats_boat()}: ${boatLabel}`)}
</Text>
)}
{trip.boatId && !boatLabel && (
<Text size="xs" c="dimmed" mt="sm">
{asI18n(`${m.boats_boat()}: ${m.boats_assigned()}`)}
</Text>
)}
{trip.notes && (
<Text size="xs" c="dimmed" mt="xs" lineClamp={2}>
{asI18n(trip.notes)}
</Text>
)}
</Card>
)
})}
</SimpleGrid>
)}
<CreateTripDrawer
opened={createDrawerOpen}
onClose={() => setCreateDrawerOpen(false)}
routeOptions={(routesData?.items ?? []).map((r) => ({
value: r.routeId,
label: `${r.name} (${r.origin} - ${r.destination})`,
}))}
boatOptions={(boatsData?.items ?? []).map((b) => ({
value: b.boatId,
label: `${b.name} (${b.passengerCapacity} seats)`,
}))}
/>
</Stack>
)
}

View File

@@ -0,0 +1,450 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import {
Stack,
SimpleGrid,
Card,
Text,
Group,
Badge,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Textarea,
Button,
ActionIcon,
Tooltip,
Tabs,
Switch,
} from '@pikku/mantine/core'
import { Pencil, Plus, Sailboat, Route as RouteIcon } 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'
export const Route = createFileRoute('/_authenticated/boats/manage')({
component: BoatManagePage,
})
type Boat = {
boatId: string
name: string
passengerCapacity: number
cargoCapacityKg: number | null
isActive: boolean
notes: string | null
}
type BoatRoute = {
routeId: string
name: string
origin: string
destination: string
isActive: boolean
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
function CreateBoatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [passengerCapacity, setPassengerCapacity] = useState<number>(1)
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(undefined)
const [notes, setNotes] = useState('')
const resetForm = () => {
setName('')
setPassengerCapacity(1)
setCargoCapacityKg(undefined)
setNotes('')
}
const mutation = usePikkuMutation('createBoat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
notifications.show({ title: 'Boat created', message: 'New boat has been added to the fleet.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name) {
notifications.show({ title: 'Missing fields', message: 'Name is required.', color: 'red' })
return
}
mutation.mutate({
name,
passengerCapacity,
cargoCapacityKg: cargoCapacityKg || undefined,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_boat()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_boat_name()} placeholder={m.boats_boat_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
<NumberInput label={m.boats_cargo_capacity()} placeholder={m.boats_cargo_capacity_placeholder()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
<Textarea label={m.boats_notes()} placeholder={m.boats_boat_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.boats_save_boat()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditBoatDrawer({ boat, onClose }: { boat: Boat | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(boat?.name ?? '')
const [passengerCapacity, setPassengerCapacity] = useState<number>(boat?.passengerCapacity ?? 1)
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(boat?.cargoCapacityKg ?? undefined)
const [isActive, setIsActive] = useState(boat?.isActive ?? true)
const [notes, setNotes] = useState(boat?.notes ?? '')
useEffect(() => {
if (boat) {
setName(boat.name)
setPassengerCapacity(boat.passengerCapacity)
setCargoCapacityKg(boat.cargoCapacityKg ?? undefined)
setIsActive(boat.isActive)
setNotes(boat.notes ?? '')
}
}, [boat])
const mutation = usePikkuMutation('updateBoat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
notifications.show({ title: 'Boat updated', message: 'Boat details have been saved.', color: 'green' })
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update boat.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!boat) return
mutation.mutate({
boatId: boat.boatId,
name,
passengerCapacity,
cargoCapacityKg: cargoCapacityKg || undefined,
isActive,
notes: notes || undefined,
})
}
if (!boat) return null
return (
<Drawer position="right" size="md" opened={!!boat} onClose={onClose} title={m.boats_edit_boat()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_boat_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
<NumberInput label={m.boats_cargo_capacity()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
<Textarea label={m.boats_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 CreateRouteDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [origin, setOrigin] = useState('')
const [destination, setDestination] = useState('')
const resetForm = () => {
setName('')
setOrigin('')
setDestination('')
}
const mutation = usePikkuMutation('createBoatRoute', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
notifications.show({ title: 'Route created', message: 'New route has been added.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create route.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !origin || !destination) {
notifications.show({ title: 'Missing fields', message: 'Name, origin, and destination are required.', color: 'red' })
return
}
mutation.mutate({ name, origin, destination })
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_route()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_route_name()} placeholder={m.boats_route_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<TextInput label={m.boats_origin()} placeholder={m.boats_origin_placeholder()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
<TextInput label={m.boats_destination()} placeholder={m.boats_destination_placeholder()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
<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.boats_save_route()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRouteDrawer({ route, onClose }: { route: BoatRoute | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(route?.name ?? '')
const [origin, setOrigin] = useState(route?.origin ?? '')
const [destination, setDestination] = useState(route?.destination ?? '')
const [isActive, setIsActive] = useState(route?.isActive ?? true)
useEffect(() => {
if (route) {
setName(route.name)
setOrigin(route.origin)
setDestination(route.destination)
setIsActive(route.isActive)
}
}, [route])
const mutation = usePikkuMutation('updateBoatRoute', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
notifications.show({ title: 'Route updated', message: 'Route details have been saved.', color: 'green' })
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update route.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!route) return
mutation.mutate({ routeId: route.routeId, name, origin, destination, isActive })
}
if (!route) return null
return (
<Drawer position="right" size="md" opened={!!route} onClose={onClose} title={m.boats_edit_route()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_route_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<TextInput label={m.boats_origin()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
<TextInput label={m.boats_destination()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
<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 BoatManagePage() {
useLocale()
const canManage = usePermission('boats.manage')
const [activeTab, setActiveTab] = useState<string | null>('boats')
const [createBoatOpen, setCreateBoatOpen] = useState(false)
const [editingBoat, setEditingBoat] = useState<Boat | null>(null)
const [createRouteOpen, setCreateRouteOpen] = useState(false)
const [editingRoute, setEditingRoute] = useState<BoatRoute | null>(null)
useEffect(() => {
const hash = window.location.hash.replace('#', '')
if (hash === 'boats' || hash === 'routes') {
setActiveTab(hash)
}
}, [])
const handleTabChange = (value: string | null) => {
setActiveTab(value)
if (value) {
window.history.replaceState(null, '', `#${value}`)
}
}
const { data: boatsData, isLoading: boatsLoading } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const { data: routesData, isLoading: routesLoading } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const boats = boatsData?.items ?? []
const routes = routesData?.items ?? []
return (
<Stack gap="lg">
<PageHeader
title={m.boats_fleet_management()}
description={m.boats_fleet_description()}
action={
canManage ? (
<Group gap="sm">
{activeTab === 'boats' && (
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateBoatOpen(true)}>
{m.boats_add_boat()}
</Button>
)}
{activeTab === 'routes' && (
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateRouteOpen(true)}>
{m.boats_add_route()}
</Button>
)}
</Group>
) : undefined
}
/>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="boats" leftSection={<Sailboat size={16} />}>
{m.boats_boats()}
</Tabs.Tab>
<Tabs.Tab value="routes" leftSection={<RouteIcon size={16} />}>
{m.boats_routes()}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="boats" pt="md">
{boatsLoading ? (
<Center py="xl">
<Loader color="teal" />
</Center>
) : boats.length === 0 ? (
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_boats()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
{boats.map((boat) => (
<Card key={boat.boatId} 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">
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(boat.name)}
</Text>
<Group gap="xs">
<Badge color={boat.isActive ? 'green' : 'gray'} variant="light" size="sm">
{boat.isActive ? m.boats_active() : m.boats_inactive()}
</Badge>
{canManage && (
<Tooltip label={m.boats_edit_boat()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingBoat(boat)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_passengers()}</Text>
<Text size="sm">{boat.passengerCapacity}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_cargo_kg()}</Text>
<Text size="sm">{asI18n(boat.cargoCapacityKg != null ? String(boat.cargoCapacityKg) : '---')}</Text>
</div>
</Group>
{boat.notes && (
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
{asI18n(boat.notes)}
</Text>
)}
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
<Tabs.Panel value="routes" pt="md">
{routesLoading ? (
<Center py="xl">
<Loader color="teal" />
</Center>
) : routes.length === 0 ? (
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_routes()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
{routes.map((route) => (
<Card key={route.routeId} 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">
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(route.name)}
</Text>
<Group gap="xs">
<Badge color={route.isActive ? 'green' : 'gray'} variant="light" size="sm">
{route.isActive ? m.boats_active() : m.boats_inactive()}
</Badge>
{canManage && (
<Tooltip label={m.boats_edit_route()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoute(route)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_origin()}</Text>
<Text size="sm">{asI18n(route.origin)}</Text>
</div>
<div>
<Text size="sm" c="dimmed">{asI18n(' -> ')}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_destination()}</Text>
<Text size="sm">{asI18n(route.destination)}</Text>
</div>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
</Tabs>
<CreateBoatDrawer opened={createBoatOpen} onClose={() => setCreateBoatOpen(false)} />
<EditBoatDrawer boat={editingBoat} onClose={() => setEditingBoat(null)} />
<CreateRouteDrawer opened={createRouteOpen} onClose={() => setCreateRouteOpen(false)} />
<EditRouteDrawer route={editingRoute} onClose={() => setEditingRoute(null)} />
</Stack>
)
}

View File

@@ -0,0 +1,128 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Stack, Card, Select, Textarea, NumberInput, Button, Group } from '@pikku/mantine/core'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/request')({
validateSearch: (search: Record<string, unknown>): { tripId?: string } => ({
tripId: typeof search.tripId === 'string' ? search.tripId : undefined,
}),
component: RequestBoatSeatPage,
})
function RequestBoatSeatPage() {
useLocale()
const navigate = useNavigate()
const { tripId: preselectedTripId } = Route.useSearch()
const [tripId, setTripId] = useState<string | null>(preselectedTripId ?? null)
const [requestedSeats, setRequestedSeats] = useState<number>(1)
const [cargoNotes, setCargoNotes] = useState('')
const [specialRequirements, setSpecialRequirements] = useState('')
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: tripsData } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const tripOptions = (tripsData?.items ?? [])
.filter((t) => t.status === 'open')
.map((t) => {
const route = routeMap.get(t.routeId)
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Trip'
const dateLabel = new Date(t.scheduledDepartureAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return { value: t.tripId, label: `${routeLabel} - ${dateLabel}` }
})
const mutation = usePikkuMutation('requestBoatSeat', {
onSuccess: () => {
notifications.show({ title: 'Seat requested', message: 'Your seat request has been submitted.', color: 'green' })
navigate({ to: '/boats' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Something went wrong.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!tripId) {
notifications.show({ title: 'Missing fields', message: 'Please select a trip.', color: 'red' })
return
}
mutation.mutate({
tripId,
requestedSeats,
cargoNotes: cargoNotes || undefined,
specialRequirements: specialRequirements || undefined,
})
}
return (
<Stack gap="lg">
<PageHeader title={m.boats_request_seat_title()} description={m.boats_request_seat_description()} />
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 560,
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.boats_trip()}
placeholder={m.boats_select_trip()}
data={tripOptions}
value={tripId}
onChange={setTripId}
required
searchable
nothingFoundMessage={asI18n(tripOptions.length === 0 ? m.boats_no_open_trips() : m.boats_no_matching_trips())}
/>
<NumberInput
label={m.boats_number_of_seats()}
value={requestedSeats}
onChange={(val) => setRequestedSeats(Number(val) || 1)}
min={1}
max={10}
required
/>
<Textarea
label={m.boats_cargo_notes()}
placeholder={m.boats_describe_cargo()}
value={cargoNotes}
onChange={(e) => setCargoNotes(e.currentTarget.value)}
minRows={2}
/>
<Textarea
label={m.boats_special_requirements()}
placeholder={m.boats_accessibility_needs()}
value={specialRequirements}
onChange={(e) => setSpecialRequirements(e.currentTarget.value)}
minRows={2}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" component={Link} to="/boats">
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.boats_request_seat()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,306 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import {
Stack,
Card,
Text,
Group,
Button,
Loader,
Center,
Table,
Title,
Badge,
ActionIcon,
} from '@pikku/mantine/core'
import { Printer, Check, X } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { usePermission } from '@/lib/permissions'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/trips/$tripId')({
component: TripDetailPage,
})
type SeatRequest = {
requestId: string
tripId: string
userId: string
status: string
requestedSeats: number
cargoNotes: string | null
specialRequirements: string | null
priorityReason: string | null
createdAt: string | Date
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
function formatDateTime(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return String(dateStr)
}
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return String(dateStr)
}
}
function RequestActions({ requestId }: { requestId: string }) {
const queryClient = useQueryClient()
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatRequests'] })
const confirmMutation = usePikkuMutation('confirmBoatRequest', { onSuccess: invalidate })
const declineMutation = usePikkuMutation('declineBoatRequest', { onSuccess: invalidate })
return (
<Group gap="xs">
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => confirmMutation.mutate({ requestId })}
loading={confirmMutation.isPending}
aria-label={m.boats_confirm()}
>
<Check size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => declineMutation.mutate({ requestId })}
loading={declineMutation.isPending}
aria-label={m.boats_decline()}
>
<X size={14} />
</ActionIcon>
</Group>
)
}
function SeatRequestsSection({ seatRequests }: { seatRequests: SeatRequest[] }) {
const canManage = usePermission('boats.manage')
const columns: Column<SeatRequest>[] = [
{
key: 'userId',
label: m.boats_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userId)}
</Text>
),
},
{
key: 'seats',
label: m.boats_seats(),
render: (row) => <Text size="sm">{row.requestedSeats}</Text>,
},
{
key: 'status',
label: m.boats_status(),
render: (row) => <StatusBadge status={row.status} />,
},
{
key: 'createdAt',
label: m.boats_requested(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.createdAt))}</Text>,
},
{
key: 'cargo',
label: m.boats_cargo(),
render: (row) => (
<Text size="sm" c="dimmed" lineClamp={1}>
{asI18n(row.cargoNotes ?? '-')}
</Text>
),
},
...(canManage
? [
{
key: 'actions',
label: m.boats_actions(),
render: (row: SeatRequest) =>
row.status === 'submitted' || row.status === 'under_review' ? <RequestActions requestId={row.requestId} /> : null,
},
]
: []),
]
return (
<Stack gap="sm">
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.boats_seat_requests()}
</Title>
<DataTable
data={seatRequests}
columns={columns}
rowKey={(row) => row.requestId}
emptyMessage="No seat requests for this trip."
/>
</Stack>
)
}
function TripDetailPage() {
useLocale()
const { tripId } = Route.useParams()
const { data: tripsData, isLoading: tripsLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
const { data: manifestData } = usePikkuQuery('getBoatManifest', { tripId }, { enabled: !!tripId })
const { data: requestsData } = usePikkuQuery('listBoatRequests', { tripId, limit: 50, offset: 0 }, { enabled: !!tripId })
const trip = (tripsData?.items ?? []).find((t) => t.tripId === tripId) ?? null
const seatRequests = (requestsData?.items ?? []) as SeatRequest[]
const passengers = manifestData?.passengers ?? []
if (tripsLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!trip) {
return (
<Stack gap="lg">
<PageHeader title={m.boats_trip_not_found()} />
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_trip_not_found_message()}
</Text>
</Card>
</Stack>
)
}
const route = routeMap.get(trip.routeId)
return (
<Stack gap="lg">
<PageHeader
title={m.boats_trip_details()}
description={asI18n(route ? `${route.name} (${route.origin} - ${route.destination})` : m.boats_trip_details())}
action={
trip.status === 'open' ? (
<Button component={Link} to="/boats/request" search={{ tripId: trip.tripId } as never} color="teal" size="sm">
{m.boats_request_seat()}
</Button>
) : undefined
}
/>
<Card padding="lg" radius="md" style={cardStyle}>
<Group gap="xl" wrap="wrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_status()}</Text>
<StatusBadge status={trip.status} />
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_departure()}</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
</div>
{trip.scheduledArrivalAt && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_arrival()}</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
</div>
)}
{trip.boatId && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_boat()}</Text>
<Text size="sm">{asI18n(boatMap.get(trip.boatId)?.name ?? m.boats_unknown_boat())}</Text>
</div>
)}
</Group>
{trip.notes && (
<Text size="sm" c="dimmed" mt="md">
{asI18n(trip.notes)}
</Text>
)}
</Card>
<Card padding="lg" radius="md" style={cardStyle}>
<Group justify="space-between" mb="md">
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.boats_manifest()}
</Title>
<Button variant="light" color="teal" size="xs" leftSection={<Printer size={14} />} onClick={() => window.print()}>
{m.boats_print_manifest()}
</Button>
</Group>
{passengers.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{m.boats_no_confirmed_passengers()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.boats_name()}</Table.Th>
<Table.Th>{m.boats_seats()}</Table.Th>
<Table.Th>{m.boats_cargo_notes()}</Table.Th>
<Table.Th>{m.boats_special_requirements()}</Table.Th>
<Table.Th>{m.boats_status()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{passengers.map((p) => (
<Table.Tr key={p.requestId}>
<Table.Td>
<Text size="sm" fw={500}>{asI18n(p.displayName || p.email || 'Passenger')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{p.requestedSeats}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{asI18n(p.cargoNotes || '-')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{asI18n(p.specialRequirements || '-')}</Text>
</Table.Td>
<Table.Td>
<Badge color="green" variant="light" size="sm">{asI18n(p.status)}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<SeatRequestsSection seatRequests={seatRequests} />
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/boats')({
component: Outlet,
})

View File

@@ -0,0 +1,899 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Select,
ActionIcon,
Tooltip,
Textarea,
SimpleGrid,
Table,
} from '@pikku/mantine/core'
import { DollarSign, Plus, Pencil, Link2 } from 'lucide-react'
import { asI18n } from '@pikku/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 { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/finance')({
component: FinancePage,
})
interface FinanceRecord {
financeId: string
name: string
description: string | null
amount: number
amountEgp: number | null
currency: string
recordType: string
purchasedByUserId: string | null
purchasedAt: string
category: string
createdByUserId: string
}
interface FinanceLink {
linkId: string
financeId: string
entityType: string
entityId: string
notes: string | null
}
const RECORD_TYPES = [
{ value: 'expense', label: 'Expense' },
{ value: 'income', label: 'Income' },
{ value: 'reimbursement', label: 'Reimbursement' },
]
const CATEGORIES = [
{ value: 'operations', label: 'Operations' },
{ value: 'kitchen', label: 'Kitchen' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'transport', label: 'Transport' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'housekeeping', label: 'Housekeeping' },
{ value: 'other', label: 'Other' },
]
const ENTITY_TYPES = [
{ value: 'inventory_item', label: 'Inventory Item' },
{ value: 'stay', label: 'Stay' },
{ value: 'boat_trip', label: 'Boat Trip' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'task', label: 'Task' },
]
const recordTypeBadgeColor: Record<string, string> = {
expense: 'red',
income: 'green',
reimbursement: 'blue',
}
const entityTypeBadgeColor: Record<string, string> = {
inventory_item: 'orange',
stay: 'blue',
boat_trip: 'cyan',
retreat: 'grape',
task: 'teal',
}
type Currency = 'EUR' | 'USD' | 'GBP' | 'EGP'
const CURRENCY_OPTIONS = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' },
{ value: 'EGP', label: 'EGP (ج.م)' },
]
function AddRecordDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState<number>(0)
const [currency, setCurrency] = useState<Currency>('EUR')
const [recordType, setRecordType] = useState<string>('expense')
const [category, setCategory] = useState<string>('operations')
const resetForm = () => {
setName('')
setDescription('')
setAmount(0)
setCurrency('EUR')
setRecordType('expense')
setCategory('operations')
}
const mutation = usePikkuMutation('createFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record created',
message: 'Finance record has been added.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !amount) {
notifications.show({
title: 'Missing fields',
message: 'Name and amount are required.',
color: 'red',
})
return
}
mutation.mutate({
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.finance_add_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
placeholder={asI18n('e.g. Office Supplies, Boat Fuel')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
placeholder={asI18n('Optional details')}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
required
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<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.finance_create_record()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRecordDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [name, setName] = useState(record?.name ?? '')
const [description, setDescription] = useState(record?.description ?? '')
const [amount, setAmount] = useState<number>(record?.amount ?? 0)
const [currency, setCurrency] = useState<Currency>((record?.currency as Currency) ?? 'EUR')
const [recordType, setRecordType] = useState<string>(record?.recordType ?? 'expense')
const [category, setCategory] = useState<string>(record?.category ?? 'operations')
const mutation = usePikkuMutation('updateFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record updated',
message: 'Finance record has been updated.',
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to update record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record) return
mutation.mutate({
financeId: record.financeId,
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={m.finance_edit_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<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.finance_save_changes()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function LinkDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [entityType, setEntityType] = useState<string>('inventory_item')
const [entityId, setEntityId] = useState('')
const [notes, setNotes] = useState('')
const { data: linksData, isLoading: linksLoading } = usePikkuQuery(
'listFinanceLinks',
{ financeId: record?.financeId ?? '', limit: 50, offset: 0 },
{ enabled: !!record },
)
const existingLinks = (linksData?.items ?? []) as FinanceLink[]
const mutation = usePikkuMutation('linkFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceLinks'] })
notifications.show({
title: 'Link created',
message: 'Entity has been linked to this finance record.',
color: 'green',
})
setEntityId('')
setNotes('')
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create link.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record || !entityId) {
notifications.show({
title: 'Missing fields',
message: 'Entity ID is required.',
color: 'red',
})
return
}
mutation.mutate({
financeId: record.financeId,
entityType: entityType as
| 'inventory_item'
| 'inventory_request'
| 'stay'
| 'boat_trip'
| 'retreat'
| 'task'
| 'room',
entityId,
notes: notes || undefined,
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={asI18n(`Link: ${record.name}`)}>
<Stack gap="md">
{linksLoading ? (
<Center py="sm">
<Loader color="teal" size="sm" />
</Center>
) : existingLinks.length > 0 ? (
<div>
<Text size="sm" fw={600} mb="xs">
{m.finance_existing_links()}
</Text>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.finance_type()}</Table.Th>
<Table.Th>{m.finance_reference()}</Table.Th>
<Table.Th>{m.finance_notes()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{existingLinks.map((link) => (
<Table.Tr key={link.linkId}>
<Table.Td>
<Badge
size="xs"
variant="light"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || 'Linked')}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
) : (
<Text size="sm" c="dimmed">
{m.finance_no_links()}
</Text>
)}
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" fw={600}>
{m.finance_add_link()}
</Text>
<Select
label={m.finance_entity_type()}
data={ENTITY_TYPES}
value={entityType}
onChange={(val) => setEntityType(val ?? 'inventory_item')}
/>
<TextInput
label={m.finance_reference_id()}
description={asI18n('Enter the identifier of the entity to link (e.g. from the relevant list page)')}
placeholder={asI18n('Entity reference')}
value={entityId}
onChange={(e) => setEntityId(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_notes()}
placeholder={asI18n('Optional notes about this link')}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_close()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_link_entity()}
</Button>
</Group>
</Stack>
</form>
</Stack>
</Drawer>
)
}
function RecordLinkBadges({ financeId }: { financeId: string }) {
const { data } = usePikkuQuery('listFinanceLinks', {
financeId,
limit: 50,
offset: 0,
})
const links = (data?.items ?? []) as FinanceLink[]
if (links.length === 0) return null
return (
<Group gap={4} mt={4}>
{links.map((link) => (
<Badge
key={link.linkId}
size="xs"
variant="dot"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
))}
</Group>
)
}
function FinancePage() {
useLocale()
const canManage = usePermission('finance.manage')
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<FinanceRecord | null>(null)
const [linkingRecord, setLinkingRecord] = useState<FinanceRecord | null>(null)
const [filterCategory, setFilterCategory] = useState<string | null>(null)
const [filterType, setFilterType] = useState<string | null>(null)
const queryClient = useQueryClient()
const { data: summary } = usePikkuQuery('getFinanceSummary', {})
const { data: ratesData } = usePikkuQuery('getExchangeRates', {})
const fetchRatesMutation = usePikkuMutation('fetchExchangeRates', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getExchangeRates'] })
notifications.show({
title: 'Exchange rates updated',
message: 'Latest rates fetched successfully.',
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to fetch rates.',
color: 'red',
})
},
})
const { data, isLoading } = usePikkuQuery('listFinanceRecords', {
limit: 50,
offset: 0,
...(filterCategory
? {
category: filterCategory as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
}
: {}),
...(filterType
? { recordType: filterType as 'expense' | 'income' | 'reimbursement' }
: {}),
})
const items = (data?.items ?? []) as FinanceRecord[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<FinanceRecord>[] = [
{
key: 'name',
label: m.finance_name(),
render: (record) => (
<div>
<Text size="sm" fw={500}>
{asI18n(record.name)}
</Text>
<RecordLinkBadges financeId={record.financeId} />
</div>
),
},
{
key: 'amount',
label: m.finance_amount(),
render: (record) => (
<div>
<Text size="sm" fw={600}>
{asI18n(`${record.currency} ${record.amount.toFixed(2)}`)}
</Text>
{record.amountEgp != null && record.currency !== 'EGP' && (
<Text size="xs" c="dimmed">
{asI18n(`≈ EGP ${record.amountEgp.toFixed(2)}`)}
</Text>
)}
</div>
),
},
{
key: 'recordType',
label: m.finance_type(),
render: (record) => (
<Badge color={recordTypeBadgeColor[record.recordType] ?? 'gray'} variant="light" size="sm">
{asI18n(record.recordType)}
</Badge>
),
},
{
key: 'category',
label: m.finance_category(),
render: (record) => (
<Badge color="teal" variant="outline" size="sm">
{asI18n(record.category)}
</Badge>
),
},
{
key: 'purchasedAt',
label: m.finance_date(),
render: (record) => (
<Text size="sm" c="dimmed">
{asI18n(new Date(record.purchasedAt).toLocaleDateString())}
</Text>
),
},
...(canManage
? [
{
key: 'actions',
label: m.finance_actions(),
render: (record: FinanceRecord) => (
<Group gap={4}>
<Tooltip label={m.finance_link_entity()}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => setLinkingRecord(record)}
>
<Link2 size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={m.finance_edit_record()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => setEditingRecord(record)}
>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
},
]
: []),
]
return (
<Stack gap="lg">
<PageHeader
title={m.finance_title()}
description={m.finance_subtitle()}
action={
canManage ? (
<Button
leftSection={<Plus size={16} />}
color="teal"
radius="md"
onClick={() => setAddModalOpen(true)}
>
{m.finance_add_record()}
</Button>
) : undefined
}
/>
{summary && (
<>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_income()}
</Text>
<Text size="xl" fw={700} c="green">
{asI18n(`$${summary.totalIncome.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_expenses()}
</Text>
<Text size="xl" fw={700} c="red">
{asI18n(`$${summary.totalExpenses.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_net()}
</Text>
<Text
size="xl"
fw={700}
c={summary.totalIncome - summary.totalExpenses >= 0 ? 'teal' : 'red'}
>
{asI18n(`$${(summary.totalIncome - summary.totalExpenses).toFixed(2)}`)}
</Text>
</Card>
</SimpleGrid>
{summary.byCategory.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text
size="sm"
fw={600}
mb="sm"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.finance_by_category()}
</Text>
<Group gap="lg" wrap="wrap">
{summary.byCategory.map((cat) => (
<Group key={cat.category} gap="xs">
<Badge color="teal" variant="outline" size="sm">
{asI18n(cat.category)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(`$${cat.total.toFixed(2)}`)}
</Text>
</Group>
))}
</Group>
</Card>
)}
</>
)}
{ratesData && ratesData.rates.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Group justify="space-between" mb="sm">
<div>
<Text
size="sm"
fw={600}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{asI18n(`${m.finance_exchange_rates()} (${m.finance_base()}: ${ratesData.base})`)}
</Text>
<Text size="xs" c="dimmed">
{asI18n(`${m.finance_last_updated()}: ${ratesData.date}`)}
</Text>
</div>
<Button
size="xs"
variant="light"
color="teal"
onClick={() => fetchRatesMutation.mutate({})}
loading={fetchRatesMutation.isPending}
>
{m.finance_refresh_rates()}
</Button>
</Group>
<Group gap="lg" wrap="wrap">
{ratesData.rates
.filter((r) => r.targetCurrency !== ratesData.base)
.map((r) => (
<Group key={r.targetCurrency} gap="xs">
<Badge color="blue" variant="outline" size="sm">
{asI18n(r.targetCurrency)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(r.rate.toFixed(4))}
</Text>
</Group>
))}
</Group>
</Card>
)}
<Group gap="sm">
<Select
placeholder={asI18n('All categories')}
data={[{ value: '', label: 'All categories' }, ...CATEGORIES]}
value={filterCategory}
onChange={(val) => setFilterCategory(val || null)}
clearable
size="sm"
w={180}
/>
<Select
placeholder={asI18n('All types')}
data={[{ value: '', label: 'All types' }, ...RECORD_TYPES]}
value={filterType}
onChange={(val) => setFilterType(val || null)}
clearable
size="sm"
w={180}
/>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack align="center" py="xl">
<DollarSign size={48} strokeWidth={1} color="#aaa" />
<Text c="dimmed">{m.finance_empty()}</Text>
</Stack>
</Card>
) : (
<DataTable
data={items}
columns={columns}
rowKey={(record) => record.financeId}
emptyMessage="No finance records found."
/>
)}
<AddRecordDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
<EditRecordDrawer record={editingRecord} onClose={() => setEditingRecord(null)} />
<LinkDrawer record={linkingRecord} onClose={() => setLinkingRecord(null)} />
</Stack>
)
}

View File

@@ -0,0 +1,36 @@
import { createFileRoute } from '@tanstack/react-router'
import { Title, Text, SimpleGrid, Card, Group, Stack } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import type { I18nNode } from '@pikku/react'
import { BedDouble, CheckSquare, Sailboat, CalendarDays } from 'lucide-react'
export const Route = createFileRoute('/_authenticated/')({
component: DashboardPage,
})
function DashboardPage() {
useLocale()
return (
<Stack gap="lg">
<Title order={2}>{m.dashboard_title()}</Title>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<SummaryCard icon={BedDouble} label={m.dashboard_stays()} color="teal" />
<SummaryCard icon={CheckSquare} label={m.dashboard_tasks()} color="blue" />
<SummaryCard icon={Sailboat} label={m.dashboard_boats()} color="cyan" />
<SummaryCard icon={CalendarDays} label={m.dashboard_retreats()} color="grape" />
</SimpleGrid>
</Stack>
)
}
function SummaryCard({ icon: Icon, label, color }: { icon: React.ComponentType<any>; label: I18nNode; color: string }) {
return (
<Card withBorder radius="md" p="md">
<Group>
<Icon size={24} color={`var(--mantine-color-${color}-6)`} />
<Text fw={600}>{label}</Text>
</Group>
</Card>
)
}

View File

@@ -0,0 +1,703 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Tabs,
Center,
Drawer,
TextInput,
NumberInput,
Select,
ActionIcon,
Tooltip,
Table,
Textarea,
} from '@pikku/mantine/core'
import { Package, Plus, AlertTriangle, Pencil, History, ClipboardCheck } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { DataTable, type Column } from '@perauset/components'
export const Route = createFileRoute('/_authenticated/inventory')({
component: InventoryPage,
})
type InventoryCategory = 'kitchen' | 'rooms' | 'equipment' | 'garden' | 'other'
interface InventoryItem {
itemId: string
name: string
currentQuantity: number
minimumQuantity: number
unit: string
isActive: boolean
category: string
subcategory: string | null
}
interface InventoryTransaction {
transactionId: string
itemId: string
quantityChange: number
reason: string
notes: string | null
createdByUserId: string
createdAt: string | Date
}
const CATEGORY_OPTIONS = [
{ value: 'kitchen', label: m.inventory_cat_kitchen() },
{ value: 'rooms', label: m.inventory_cat_rooms() },
{ value: 'equipment', label: m.inventory_cat_equipment() },
{ value: 'garden', label: m.inventory_cat_garden() },
{ value: 'other', label: m.inventory_cat_other() },
]
function AddItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [currentQuantity, setCurrentQuantity] = useState<number>(0)
const [minimumQuantity, setMinimumQuantity] = useState<number>(0)
const [category, setCategory] = useState<InventoryCategory>('kitchen')
const mutation = usePikkuMutation('createInventoryItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_item_created_title(),
message: m.inventory_item_created_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_create(),
color: 'red',
})
},
})
const resetForm = () => {
setName('')
setUnit('')
setCurrentQuantity(0)
setMinimumQuantity(0)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !unit) {
notifications.show({
title: m.inventory_missing_fields_title(),
message: m.inventory_missing_fields_message(),
color: 'red',
})
return
}
mutation.mutate({ name, unit, currentQuantity, minimumQuantity, category })
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_add_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
placeholder={m.inventory_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.inventory_field_unit()}
placeholder={m.inventory_unit_placeholder()}
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
required
/>
<NumberInput
label={m.inventory_current_quantity()}
value={currentQuantity}
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
min={0}
/>
<NumberInput
label={m.inventory_minimum_quantity()}
description={m.inventory_alert_threshold()}
value={minimumQuantity}
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
min={0}
/>
<Select
label={m.inventory_category()}
data={CATEGORY_OPTIONS}
value={category}
onChange={(val) => setCategory((val as InventoryCategory) || 'kitchen')}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_create_item()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditItemDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(item?.name ?? '')
const [unit, setUnit] = useState(item?.unit ?? '')
const [currentQuantity, setCurrentQuantity] = useState<number>(item?.currentQuantity ?? 0)
const [minimumQuantity, setMinimumQuantity] = useState<number>(item?.minimumQuantity ?? 0)
const mutation = usePikkuMutation('updateInventoryItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_item_updated_title(),
message: m.inventory_item_updated_message(),
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_update(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!item) return
mutation.mutate({
itemId: item.itemId,
name,
unit,
currentQuantity,
minimumQuantity,
})
}
if (!item) return null
return (
<Drawer position="right" size="md" opened={!!item} onClose={onClose} title={m.inventory_edit_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.inventory_field_unit()}
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
required
/>
<NumberInput
label={m.inventory_current_quantity()}
value={currentQuantity}
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
min={0}
/>
<NumberInput
label={m.inventory_minimum_quantity()}
description={m.inventory_alert_threshold()}
value={minimumQuantity}
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
min={0}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_save_changes()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function HistoryDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
const { data, isLoading } = usePikkuQuery(
'listInventoryTransactions',
{ itemId: item?.itemId ?? '', limit: 50, offset: 0 },
{ enabled: !!item }
)
const transactions = (data?.items ?? []) as InventoryTransaction[]
if (!item) return null
return (
<Drawer
position="right"
size="lg"
opened={!!item}
onClose={onClose}
title={m.inventory_history_title({ name: item.name })}
>
{isLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : transactions.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
{m.inventory_no_history()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.inventory_col_date()}</Table.Th>
<Table.Th>{m.inventory_col_change()}</Table.Th>
<Table.Th>{m.inventory_col_reason()}</Table.Th>
<Table.Th>{m.inventory_col_notes()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{transactions.map((tx) => (
<Table.Tr key={tx.transactionId}>
<Table.Td>
<Text size="xs">{asI18n(new Date(tx.createdAt).toLocaleString())}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600} c={tx.quantityChange >= 0 ? 'green' : 'red'}>
{asI18n(`${tx.quantityChange >= 0 ? '+' : ''}${tx.quantityChange}`)}
</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color="gray">
{asI18n(tx.reason)}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" lineClamp={1}>
{asI18n(tx.notes || '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Drawer>
)
}
function StocktakeDrawer({
opened,
onClose,
items,
}: {
opened: boolean
onClose: () => void
items: InventoryItem[]
}) {
const queryClient = useQueryClient()
const [quantities, setQuantities] = useState<Record<string, number>>(() => {
const q: Record<string, number> = {}
for (const item of items) {
q[item.itemId] = item.currentQuantity
}
return q
})
const mutation = usePikkuMutation('submitStocktake', {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_stocktake_submitted_title(),
message: m.inventory_stocktake_submitted_message({ count: result.adjustedCount }),
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_stocktake(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const stocktakeItems = items.map((item) => ({
itemId: item.itemId,
actualQuantity: quantities[item.itemId] ?? item.currentQuantity,
}))
mutation.mutate({ items: stocktakeItems })
}
return (
<Drawer position="right" size="lg" opened={opened} onClose={onClose} title={m.inventory_stocktake_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Text size="sm" c="dimmed" mb="sm">
{m.inventory_stocktake_intro()}
</Text>
{items.map((item) => (
<Group key={item.itemId} justify="space-between" align="center">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{asI18n(item.name)}
</Text>
<Text size="xs" c="dimmed">
{m.inventory_stocktake_current({ quantity: item.currentQuantity, unit: item.unit })}
</Text>
</div>
<NumberInput
w={120}
size="sm"
value={quantities[item.itemId] ?? item.currentQuantity}
onChange={(val) =>
setQuantities((prev) => ({ ...prev, [item.itemId]: Number(val) || 0 }))
}
min={0}
/>
</Group>
))}
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_submit_stocktake()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function RequestItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [itemName, setItemName] = useState('')
const [quantity, setQuantity] = useState<number | string>('')
const [unit, setUnit] = useState('')
const [purpose, setPurpose] = useState('')
const mutation = usePikkuMutation('requestInventory', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_request_submitted_title(),
message: m.inventory_request_submitted_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_request(),
color: 'red',
})
},
})
const resetForm = () => {
setItemName('')
setQuantity('')
setUnit('')
setPurpose('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
...(itemName ? { itemName } : {}),
quantity: Number(quantity),
unit,
...(purpose ? { purpose } : {}),
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_request_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
placeholder={m.inventory_request_name_placeholder()}
required
value={itemName}
onChange={(e) => setItemName(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.inventory_request_quantity()}
placeholder={m.inventory_request_quantity_placeholder()}
required
min={1}
value={quantity}
onChange={setQuantity}
/>
<TextInput
label={m.inventory_field_unit()}
placeholder={m.inventory_request_unit_placeholder()}
required
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
/>
</Group>
<Textarea
label={m.inventory_request_purpose()}
placeholder={m.inventory_request_purpose_placeholder()}
minRows={2}
value={purpose}
onChange={(e) => setPurpose(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_submit_request()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function InventoryPage() {
useLocale()
const canManage = usePermission('inventory.manage')
const [addModalOpen, setAddModalOpen] = useState(false)
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null)
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null)
const [stocktakeOpen, setStocktakeOpen] = useState(false)
const [activeCategory, setActiveCategory] = useState<string>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash.slice(1)
if (['all', 'kitchen', 'rooms', 'equipment', 'garden', 'other'].includes(hash)) return hash
}
return 'all'
})
const { data, isLoading } = usePikkuQuery('listInventory', {
activeOnly: true,
limit: 200,
offset: 0,
})
const allItems = (data?.items ?? []) as InventoryItem[]
const items =
activeCategory === 'all' ? allItems : allItems.filter((i) => i.category === activeCategory)
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<InventoryItem>[] = [
{
key: 'name',
label: m.inventory_col_item(),
render: (item) => (
<Text size="sm" fw={500}>
{asI18n(item.name)}
</Text>
),
},
{
key: 'quantity',
label: m.inventory_col_quantity(),
render: (item) => {
const isLow = item.currentQuantity <= item.minimumQuantity
return (
<Group gap={6}>
<Text size="sm">{item.currentQuantity}</Text>
{isLow && <AlertTriangle size={14} style={{ color: '#e67700' }} />}
</Group>
)
},
},
{
key: 'unit',
label: m.inventory_col_unit(),
render: (item) => (
<Text size="sm" c="dimmed">
{asI18n(item.unit)}
</Text>
),
},
{
key: 'status',
label: m.inventory_col_status(),
render: (item) => {
const isLow = item.currentQuantity <= item.minimumQuantity
return isLow ? (
<Badge color="orange" variant="light" size="sm">
{m.inventory_low_stock()}
</Badge>
) : (
<Badge color="green" variant="light" size="sm">
{m.inventory_in_stock()}
</Badge>
)
},
},
...(canManage
? [
{
key: 'actions',
label: m.inventory_col_actions(),
render: (item: InventoryItem) => (
<Group gap={4}>
<Tooltip label={m.inventory_transaction_history()}>
<ActionIcon variant="light" color="gray" size="sm" onClick={() => setHistoryItem(item)}>
<History size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={m.inventory_edit_item()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingItem(item)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
},
]
: []),
]
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.inventory_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.inventory_description()}
</Text>
</div>
<Group gap="sm">
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" radius="md">
{m.inventory_request_item()}
</Button>
{canManage && (
<>
<Button
leftSection={<ClipboardCheck size={16} />}
variant="light"
color="orange"
radius="md"
onClick={() => setStocktakeOpen(true)}
>
{m.inventory_stocktake()}
</Button>
<Button
leftSection={<Plus size={16} />}
color="teal"
radius="md"
onClick={() => setAddModalOpen(true)}
>
{m.inventory_add_item()}
</Button>
</>
)}
</Group>
</Group>
<Tabs
value={activeCategory}
onChange={(val) => {
setActiveCategory(val || 'all')
window.history.replaceState(null, '', `#${val || 'all'}`)
}}
>
<Tabs.List>
<Tabs.Tab value="all">{m.inventory_tab_all({ count: allItems.length })}</Tabs.Tab>
<Tabs.Tab value="kitchen">
{m.inventory_tab_kitchen({ count: allItems.filter((i) => i.category === 'kitchen').length })}
</Tabs.Tab>
<Tabs.Tab value="rooms">
{m.inventory_tab_rooms({ count: allItems.filter((i) => i.category === 'rooms').length })}
</Tabs.Tab>
<Tabs.Tab value="equipment">
{m.inventory_tab_equipment({ count: allItems.filter((i) => i.category === 'equipment').length })}
</Tabs.Tab>
<Tabs.Tab value="garden">
{m.inventory_tab_garden({ count: allItems.filter((i) => i.category === 'garden').length })}
</Tabs.Tab>
<Tabs.Tab value="other">
{m.inventory_tab_other({ count: allItems.filter((i) => i.category === 'other').length })}
</Tabs.Tab>
</Tabs.List>
</Tabs>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack align="center" py="xl">
<Package size={48} style={{ color: '#aaa' }} />
<Text c="dimmed">{m.inventory_no_items()}</Text>
</Stack>
</Card>
) : (
<DataTable
data={items}
columns={columns}
rowKey={(item) => item.itemId}
emptyMessage={m.inventory_no_items()}
/>
)}
<AddItemDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
<EditItemDrawer item={editingItem} onClose={() => setEditingItem(null)} />
<HistoryDrawer item={historyItem} onClose={() => setHistoryItem(null)} />
<StocktakeDrawer opened={stocktakeOpen} onClose={() => setStocktakeOpen(false)} items={items} />
<RequestItemDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,163 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Checkbox,
Textarea,
Button,
Stack,
Group,
Alert,
SimpleGrid,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
export const Route = createFileRoute('/_authenticated/kitchen/dietary')({
component: DietaryProfilePage,
})
function DietaryProfilePage() {
useLocale()
const [isVegan, setIsVegan] = useState(false)
const [isVegetarian, setIsVegetarian] = useState(false)
const [isGlutenFree, setIsGlutenFree] = useState(false)
const [isDairyFree, setIsDairyFree] = useState(false)
const [hasNutAllergy, setHasNutAllergy] = useState(false)
const [allergyNotes, setAllergyNotes] = useState('')
const [otherRequirements, setOtherRequirements] = useState('')
const [dislikes, setDislikes] = useState('')
const mutation = usePikkuMutation('upsertDietaryProfile')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
isVegan,
isVegetarian,
isGlutenFree,
isDairyFree,
hasNutAllergy,
...(allergyNotes ? { allergyNotes } : {}),
...(otherRequirements ? { otherRequirements } : {}),
...(dislikes ? { dislikes } : {}),
})
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.kitchen_dietary_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.kitchen_dietary_description()}
</Text>
</div>
<Card
padding="xl"
radius="md"
maw={600}
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? m.kitchen_dietary_error())}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.kitchen_dietary_success()}
</Alert>
)}
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
{m.kitchen_dietary_preferences()}
</Text>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
<Checkbox
label={m.kitchen_dietary_vegan()}
checked={isVegan}
onChange={(e) => setIsVegan(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_vegetarian()}
checked={isVegetarian}
onChange={(e) => setIsVegetarian(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_gluten_free()}
checked={isGlutenFree}
onChange={(e) => setIsGlutenFree(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_dairy_free()}
checked={isDairyFree}
onChange={(e) => setIsDairyFree(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_nut_allergy()}
checked={hasNutAllergy}
onChange={(e) => setHasNutAllergy(e.currentTarget.checked)}
color="red"
/>
</SimpleGrid>
<Textarea
label={m.kitchen_dietary_allergy_notes()}
placeholder={m.kitchen_dietary_allergy_notes_placeholder()}
minRows={2}
value={allergyNotes}
onChange={(e) => setAllergyNotes(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Textarea
label={m.kitchen_dietary_other_requirements()}
placeholder={m.kitchen_dietary_other_requirements_placeholder()}
minRows={2}
value={otherRequirements}
onChange={(e) => setOtherRequirements(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Textarea
label={m.kitchen_dietary_dislikes()}
placeholder={m.kitchen_dietary_dislikes_placeholder()}
minRows={2}
value={dislikes}
onChange={(e) => setDislikes(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Group justify="flex-end" mt="sm">
<Button component={Link} to="/kitchen" variant="subtle" color="gray">
{m.kitchen_dietary_cancel()}
</Button>
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.kitchen_dietary_save()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,265 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
SimpleGrid,
Loader,
Center,
Drawer,
Select,
NumberInput,
TextInput,
Textarea,
} from '@pikku/mantine/core'
import { UtensilsCrossed, Plus } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useState } from 'react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
export const Route = createFileRoute('/_authenticated/kitchen/')({
component: KitchenPage,
})
const mealTypeIcon: Record<string, string> = {
breakfast: '\u{1F305}',
lunch: '\u{2600}',
dinner: '\u{1F319}',
snack: '\u{1F34E}',
}
const statusColor: Record<string, string> = {
planned: 'blue',
preparing: 'orange',
served: 'green',
cancelled: 'red',
}
const STATUS_LABEL: Record<string, () => string> = {
planned: () => m.kitchen_status_planned(),
preparing: () => m.kitchen_status_preparing(),
served: () => m.kitchen_status_served(),
cancelled: () => m.kitchen_status_cancelled(),
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})
}
function KitchenPage() {
useLocale()
const queryClient = useQueryClient()
const [opened, { open, close }] = useDisclosure(false)
const [serviceType, setServiceType] = useState<string | null>(null)
const [serviceDate, setServiceDate] = useState('')
const [plannedHeadcount, setPlannedHeadcount] = useState<number | string>('')
const [menuNotes, setMenuNotes] = useState('')
const { data, isLoading } = usePikkuQuery('listMealServices', { limit: 50, offset: 0 })
const createMutation = usePikkuMutation('createMealService', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listMealServices'] })
notifications.show({
title: m.kitchen_meal_created_title(),
message: m.kitchen_meal_created_message(),
color: 'green',
})
resetForm()
close()
},
onError: (err) => {
notifications.show({
title: m.kitchen_error_title(),
message: err.message || m.kitchen_error_create_meal(),
color: 'red',
})
},
})
function resetForm() {
setServiceType(null)
setServiceDate('')
setPlannedHeadcount('')
setMenuNotes('')
}
function handleSubmit() {
if (!serviceType || !serviceDate) return
createMutation.mutate({
serviceType: serviceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
serviceDate: new Date(serviceDate).toISOString(),
...(plannedHeadcount !== '' ? { plannedHeadcount: Number(plannedHeadcount) } : {}),
...(menuNotes.trim() ? { menuNotes: menuNotes.trim() } : {}),
})
}
const meals = data?.items ?? []
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.kitchen_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.kitchen_description()}
</Text>
</div>
<Group gap="sm">
<Button leftSection={<Plus size={16} />} color="teal" radius="md" onClick={open}>
{m.kitchen_create_meal()}
</Button>
</Group>
</Group>
{meals.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<UtensilsCrossed size={48} style={{ color: '#aaa', marginBottom: 8 }} />
<Text c="dimmed">{m.kitchen_no_meals()}</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{meals.map((meal) => (
<Card
key={meal.serviceId}
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease',
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Group gap="xs">
<Text size="lg">{asI18n(mealTypeIcon[meal.serviceType] ?? '\u{1F37D}')}</Text>
<Title
order={4}
style={{
fontFamily: '"Cormorant", serif',
color: '#3D2B1F',
textTransform: 'capitalize',
}}
>
{asI18n(meal.serviceType)}
</Title>
</Group>
<Badge color={statusColor[meal.status] ?? 'gray'} variant="light" size="sm">
{asI18n(STATUS_LABEL[meal.status]?.() ?? meal.status)}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{asI18n(formatDate(meal.serviceDate))}
</Text>
{meal.plannedHeadcount != null && (
<Text size="sm" c="dimmed">
{m.kitchen_expected_headcount({ count: meal.plannedHeadcount })}
</Text>
)}
{meal.menuNotes && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(meal.menuNotes)}
</Text>
)}
</Stack>
</Card>
))}
</SimpleGrid>
)}
</Stack>
<Drawer opened={opened} onClose={close} title={m.kitchen_create_meal_service()} position="right" size="md">
<Stack gap="md">
<Select
label={m.kitchen_service_type()}
placeholder={m.kitchen_select_meal_type()}
data={[
{ value: 'breakfast', label: m.kitchen_meal_type_breakfast() },
{ value: 'lunch', label: m.kitchen_meal_type_lunch() },
{ value: 'dinner', label: m.kitchen_meal_type_dinner() },
{ value: 'snack', label: m.kitchen_meal_type_snack() },
]}
value={serviceType}
onChange={setServiceType}
required
/>
<TextInput
label={m.kitchen_service_date()}
type="date"
value={serviceDate}
onChange={(e) => setServiceDate(e.currentTarget.value)}
required
/>
<NumberInput
label={m.kitchen_planned_headcount()}
placeholder={m.kitchen_planned_headcount_placeholder()}
min={1}
value={plannedHeadcount}
onChange={setPlannedHeadcount}
/>
<Textarea
label={m.kitchen_menu_notes()}
placeholder={m.kitchen_menu_notes_placeholder()}
rows={4}
value={menuNotes}
onChange={(e) => setMenuNotes(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={close}>
{m.kitchen_cancel()}
</Button>
<Button
color="teal"
onClick={handleSubmit}
loading={createMutation.isPending}
disabled={!serviceType || !serviceDate}
>
{m.kitchen_create_meal()}
</Button>
</Group>
</Stack>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/kitchen')({
component: Outlet,
})

View File

@@ -0,0 +1,274 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
TextInput,
Button,
Alert,
Loader,
Center,
ThemeIcon,
SimpleGrid,
} from '@pikku/mantine/core'
import { Link } from '@tanstack/react-router'
import {
AlertCircle,
Check,
User,
Mail,
Phone,
MessageCircle,
Salad,
ChevronRight,
} from 'lucide-react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useSessionUser } from '@/lib/auth-context'
export const Route = createFileRoute('/_authenticated/my/profile')({
component: MyProfilePage,
})
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
function MyProfilePage() {
useLocale()
const sessionUser = useSessionUser()
const queryClient = useQueryClient()
const userId = sessionUser?.id
const { data: user, isLoading } = usePikkuQuery(
'getUser',
{ userId: userId! },
{ enabled: !!userId },
)
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [mobileNumber, setMobileNumber] = useState('')
const [whatsappNumber, setWhatsappNumber] = useState('')
const [formInitialized, setFormInitialized] = useState(false)
if (user && !formInitialized) {
setName(user.name ?? '')
setDisplayName(user.displayName ?? '')
setMobileNumber(user.mobileNumber ?? '')
setWhatsappNumber(user.whatsappNumber ?? '')
setFormInitialized(true)
}
const mutation = usePikkuMutation('updateUserProfile', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getUser'] })
},
})
if (!sessionUser) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_profile_title()}
</Title>
<Text c="dimmed">{m.my_sign_in_to_view()}</Text>
</Stack>
)
}
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
userId: userId!,
name: name || undefined,
displayName: displayName || undefined,
mobileNumber: mobileNumber || undefined,
whatsappNumber: whatsappNumber || undefined,
})
}
const fullName =
user?.displayName ??
user?.name ??
sessionUser?.name ??
'User'
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_profile_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_profile_description()}
</Text>
</div>
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<div>
<Group gap={6} mb={4}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<User size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.my_field_name()}
</Text>
</Group>
<Text size="sm" pl={26}>
{asI18n(fullName)}
</Text>
</div>
<div>
<Group gap={6} mb={4}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<Mail size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.my_field_email()}
</Text>
</Group>
<Text size="sm" pl={26}>
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
</Text>
</div>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>
{m.my_field_roles()}
</Text>
<Group gap={4}>
{(user?.memberRoles ?? []).map((role: string) => (
<Badge key={role} color="teal" variant="light" size="sm">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
</Group>
</div>
</Stack>
</Card>
<Card padding="lg" radius="md" maw={640} style={cardStyle}>
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_edit_profile()}
</Title>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{mutation.error?.message
? asI18n(mutation.error.message)
: m.my_update_failed()}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.my_update_success()}
</Alert>
)}
<TextInput
label={m.profile_name()}
placeholder={m.profile_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.my_display_name()}
placeholder={m.my_display_name_placeholder()}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
styles={labelStyle}
/>
<Group grow>
<TextInput
label={m.my_mobile_number()}
placeholder={m.my_phone_placeholder()}
value={mobileNumber}
onChange={(e) => setMobileNumber(e.currentTarget.value)}
leftSection={<Phone size={16} />}
styles={labelStyle}
/>
<TextInput
label={m.my_whatsapp_number()}
placeholder={m.my_phone_placeholder()}
value={whatsappNumber}
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
leftSection={<MessageCircle size={16} />}
styles={labelStyle}
/>
</Group>
<Group justify="flex-end">
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.my_save_profile()}
</Button>
</Group>
</Stack>
</form>
</Card>
<Card
padding="lg"
radius="md"
component={Link}
to={'/kitchen/dietary' as any}
style={{
...cardStyle,
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
textDecoration: 'none',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} variant="light" color="teal" radius="xl">
<Salad size={24} />
</ThemeIcon>
<div>
<Text
fw={600}
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
>
{m.my_dietary_profile()}
</Text>
<Text size="sm" c="dimmed">
{m.my_dietary_profile_description()}
</Text>
</div>
</Group>
<ThemeIcon size="md" variant="transparent" color="gray">
<ChevronRight size={20} />
</ThemeIcon>
</Group>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,237 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Stack,
Badge,
Loader,
Center,
Group,
SimpleGrid,
ThemeIcon,
} from '@pikku/mantine/core'
import { Home, FileText, CalendarDays, NotebookPen } from 'lucide-react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { StatusBadge } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n, type I18nNode } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/my/stays')({
component: MyStaysPage,
})
interface StayRequest {
requestId: string
requestType: string
requestedStartAt: string | Date
requestedEndAt: string | Date
status: string
notes: string | null
}
interface Stay {
stayId: string
stayType: string
startAt: string | Date
endAt: string | Date
status: string
}
function formatDateRange(startStr: string | Date, endStr: string | Date): string {
try {
const start = new Date(startStr)
const end = new Date(endStr)
const startFormatted = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
const sameYear = start.getFullYear() === end.getFullYear()
const endFormatted = end.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
if (sameYear) {
return `${startFormatted} ${endFormatted}`
}
const startWithYear = start.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${startWithYear} ${endFormatted}`
} catch {
return `${String(startStr)} - ${String(endStr)}`
}
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
function StayRequestCard({ request }: { request: StayRequest }) {
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
{asI18n(formatType(request.requestType))}
</Text>
<StatusBadge status={request.status} />
</Group>
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<CalendarDays size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(formatDateRange(request.requestedStartAt, request.requestedEndAt))}
</Text>
</Group>
{request.notes && (
<Group gap={6} align="flex-start">
<ThemeIcon size="sm" variant="transparent" color="gray">
<NotebookPen size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(request.notes)}
</Text>
</Group>
)}
</Stack>
</Card>
)
}
function StayCard({ stay }: { stay: Stay }) {
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
{asI18n(formatType(stay.stayType))}
</Text>
<StatusBadge status={stay.status} />
</Group>
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<CalendarDays size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(formatDateRange(stay.startAt, stay.endAt))}
</Text>
</Group>
</Stack>
</Card>
)
}
function EmptyState({ message, icon }: { message: I18nNode; icon: React.ReactNode }) {
return (
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
<Stack align="center" gap="sm">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
{icon}
</ThemeIcon>
<Text c="dimmed" size="sm">
{message}
</Text>
</Stack>
</Card>
)
}
function MyStaysPage() {
useLocale()
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
limit: 50,
offset: 0,
})
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
limit: 50,
offset: 0,
})
const stayRequests = requestsData?.items ?? []
const stays = staysData?.items ?? []
if (requestsLoading || staysLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_stays_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_stays_description()}
</Text>
</div>
<div>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
<FileText size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_requests()}
</Title>
{stayRequests.length > 0 && (
<Badge size="sm" color="teal" variant="light">
{stayRequests.length}
</Badge>
)}
</Group>
{stayRequests.length === 0 ? (
<EmptyState message={m.my_no_requests()} icon={<FileText size={24} />} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{stayRequests.map((request) => (
<StayRequestCard key={request.requestId} request={request} />
))}
</SimpleGrid>
)}
</div>
<div>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
<Home size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_active_stays()}
</Title>
{stays.length > 0 && (
<Badge size="sm" color="teal" variant="light">
{stays.length}
</Badge>
)}
</Group>
{stays.length === 0 ? (
<EmptyState message={m.my_no_active_stays()} icon={<Home size={24} />} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{stays.map((stay) => (
<StayCard key={stay.stayId} stay={stay} />
))}
</SimpleGrid>
)}
</div>
</Stack>
)
}

View File

@@ -0,0 +1,220 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Stack,
Badge,
Group,
Loader,
Center,
SimpleGrid,
ThemeIcon,
} from '@pikku/mantine/core'
import { MapPin, Clock, CheckSquare, Smile } from 'lucide-react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { TaskActions } from '@/components/task-actions'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n, type I18nNode } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/my/tasks')({
component: MyTasksPage,
})
const STATUS_CONFIG: Record<string, { labelKey: string; color: string }> = {
in_progress: { labelKey: 'my.tasks_in_progress', color: 'orange' },
assigned: { labelKey: 'my.tasks_assigned', color: 'blue' },
open: { labelKey: 'my.tasks_open', color: 'teal' },
blocked: { labelKey: 'my.tasks_blocked', color: 'red' },
}
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
const DISPLAY_STATUSES = ['in_progress', 'assigned', 'open', 'blocked']
interface TaskItem {
taskId: string
templateId: string | null
title: string
description: string | null
category: string
status: string
location: string | null
scheduledStartAt: string | Date | null
scheduledEndAt: string | Date | null
createdByUserId: string
}
function formatTime(val: string | Date | null): string | null {
if (!val) return null
const d = typeof val === 'string' ? new Date(val) : val
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
function TaskCard({ task }: { task: TaskItem }) {
const cfg = STATUS_CONFIG[task.status]
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text
fw={600}
lineClamp={2}
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
>
{asI18n(task.title)}
</Text>
<Badge size="sm" color={cfg?.color ?? 'gray'} variant="light" style={{ flexShrink: 0 }}>
{cfg ? mKey(cfg.labelKey) : asI18n(task.status)}
</Badge>
</Group>
<Group gap="xs">
<Badge size="sm" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
{asI18n(task.category)}
</Badge>
</Group>
{task.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(task.description)}
</Text>
)}
{task.location && (
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="gray">
<MapPin size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(task.location)}
</Text>
</Group>
)}
{task.scheduledStartAt && (
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="gray">
<Clock size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(
`${formatTime(task.scheduledStartAt)}${
task.scheduledEndAt ? ` ${formatTime(task.scheduledEndAt)}` : ''
}`,
)}
</Text>
</Group>
)}
<TaskActions taskId={task.taskId} status={task.status} />
</Stack>
</Card>
)
}
function EmptyState({ message }: { message: I18nNode }) {
return (
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
<Stack align="center" gap="sm">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
<Smile size={24} />
</ThemeIcon>
<Text c="dimmed" size="sm">
{message}
</Text>
</Stack>
</Card>
)
}
function MyTasksPage() {
useLocale()
const { data, isLoading } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
const tasks = (data?.items ?? []) as TaskItem[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const grouped: Record<string, TaskItem[]> = {}
for (const status of DISPLAY_STATUSES) {
grouped[status] = []
}
for (const task of tasks) {
if (DISPLAY_STATUSES.includes(task.status)) {
grouped[task.status]!.push(task)
}
}
const hasActiveTasks = DISPLAY_STATUSES.some((s) => (grouped[s]?.length ?? 0) > 0)
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_tasks_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_tasks_description()}
</Text>
</div>
{!hasActiveTasks ? (
<EmptyState message={m.my_no_active_tasks()} />
) : (
DISPLAY_STATUSES.map((status) => {
const items = grouped[status]!
if (items.length === 0) return null
const cfg = STATUS_CONFIG[status]!
return (
<div key={status}>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color={cfg.color} radius="xl">
<CheckSquare size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{mKey(cfg.labelKey)}
</Title>
<Badge size="sm" color={cfg.color} variant="light">
{items.length}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{items.map((task) => (
<TaskCard key={task.taskId} task={task} />
))}
</SimpleGrid>
</div>
)
})
)}
</Stack>
)
}

View File

@@ -0,0 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { Card, Stack, Loader, Center, Text } from '@pikku/mantine/core'
import { CheckCheck } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { NotificationRow } from '@/components/notification-row'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/notifications')({
component: NotificationsPage,
})
function NotificationsPage() {
useLocale()
const { data, isLoading, error } = usePikkuQuery('listNotifications', {
limit: 50,
offset: 0,
})
const items = data?.items ?? []
const unreadCount = items.filter((n) => !n.readAt).length
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.notifications_title()}
description={
unreadCount > 0
? m.notifications_unread_count({ count: unreadCount })
: m.notifications_all_caught_up()
}
/>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{asI18n(error.message ?? 'Failed to load notifications')}
</Text>
</Card>
)}
{items.length === 0 && !error ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<CheckCheck size={40} strokeWidth={1} color="#aaa" />
<Text size="sm" c="dimmed">
{m.notifications_empty()}
</Text>
</Stack>
</Card>
) : (
<Card
padding={0}
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
overflow: 'hidden',
}}
>
<Stack gap={0}>
{items.map((n) => (
<NotificationRow key={n.notificationId} notification={n} />
))}
</Stack>
</Card>
)}
</Stack>
)
}

View File

@@ -0,0 +1,224 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Anchor,
TextInput,
Button,
Alert,
Loader,
Center,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useSessionUser } from '@/lib/auth-context'
export const Route = createFileRoute('/_authenticated/profile')({
component: ProfilePage,
})
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
function ProfileEditor({
userId,
name: initialName,
displayName: initialDisplay,
mobileNumber: initialMobile,
whatsappNumber: initialWhatsapp,
}: {
userId: string
name: string
displayName: string
mobileNumber: string
whatsappNumber: string
}) {
const [name, setName] = useState(initialName)
const [displayName, setDisplayName] = useState(initialDisplay)
const [mobileNumber, setMobileNumber] = useState(initialMobile)
const [whatsappNumber, setWhatsappNumber] = useState(initialWhatsapp)
const mutation = usePikkuMutation('updateUserProfile')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
userId,
name: name || undefined,
displayName: displayName || undefined,
mobileNumber: mobileNumber || undefined,
whatsappNumber: whatsappNumber || undefined,
})
}
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{mutation.error?.message ? asI18n(mutation.error.message) : m.profile_update_failed()}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.profile_update_success()}
</Alert>
)}
<TextInput
label={m.profile_name()}
placeholder={m.profile_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.profile_display_name()}
placeholder={m.profile_display_name_placeholder()}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
styles={labelStyle}
/>
<Group grow>
<TextInput
label={m.profile_mobile_number()}
placeholder={m.profile_mobile_number_placeholder()}
value={mobileNumber}
onChange={(e) => setMobileNumber(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.profile_whatsapp_number()}
placeholder={m.profile_whatsapp_number_placeholder()}
value={whatsappNumber}
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
styles={labelStyle}
/>
</Group>
<Group justify="flex-end">
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.profile_save_profile()}
</Button>
</Group>
</Stack>
</form>
)
}
function ProfilePage() {
useLocale()
const sessionUser = useSessionUser()
const userId = sessionUser?.id
const { data: user, isLoading } = usePikkuQuery(
'getUser',
{ userId: userId! },
{ enabled: !!userId },
)
if (!sessionUser) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_title()}
</Title>
<Text c="dimmed">{m.profile_sign_in_to_view()}</Text>
</Stack>
)
}
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.profile_description()}
</Text>
</div>
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="md">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.profile_field_email()}
</Text>
<Text size="sm" mt={4}>
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
</Text>
</div>
</Group>
<Group gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.profile_field_roles()}
</Text>
</Group>
<Group gap={4}>
{(user?.memberRoles ?? []).map((role: string) => (
<Badge key={role} color="teal" variant="light" size="sm">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
</Group>
</Stack>
</Card>
<Card padding="lg" radius="md" maw={600} style={cardStyle}>
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_edit_profile()}
</Title>
<ProfileEditor
userId={userId!}
name={user?.name ?? ''}
displayName={user?.displayName ?? ''}
mobileNumber={user?.mobileNumber ?? ''}
whatsappNumber={user?.whatsappNumber ?? ''}
/>
</Card>
<Card padding="lg" radius="md" style={cardStyle}>
<Group justify="space-between" align="center">
<div>
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
{m.profile_dietary_profile()}
</Text>
<Text size="xs" c="dimmed">
{m.profile_dietary_profile_description()}
</Text>
</div>
<Anchor component={Link} to={'/kitchen/dietary' as any} size="sm">
{m.profile_edit_dietary_profile()}
</Anchor>
</Group>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,896 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Tabs,
Table,
Button,
Anchor,
Loader,
Center,
Drawer,
TextInput,
Select,
Textarea,
NumberInput,
SimpleGrid,
} from '@pikku/mantine/core'
import { CalendarDays, Users, ClipboardList, Plus, UtensilsCrossed, Bell } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { UserSelect } from '@/components/user-select'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/retreats/$retreatId')({
component: RetreatDetailPage,
})
const statusColor: Record<string, string> = {
draft: 'gray',
published: 'blue',
active: 'teal',
completed: 'green',
cancelled: 'red',
}
const roleColor: Record<string, string> = {
participant: 'teal',
facilitator: 'violet',
assistant_facilitator: 'grape',
organizer: 'orange',
support_staff: 'blue',
}
const mealTypeColor: Record<string, string> = {
breakfast: 'yellow',
lunch: 'orange',
dinner: 'violet',
snack: 'pink',
}
const mealStatusColor: Record<string, string> = {
planned: 'gray',
ready: 'blue',
served: 'teal',
completed: 'green',
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatTime(d: string | Date): string {
return new Date(d).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function RetreatDetailPage() {
useLocale()
const { retreatId } = Route.useParams()
const queryClient = useQueryClient()
const [personDrawerOpened, { open: openPersonDrawer, close: closePersonDrawer }] =
useDisclosure(false)
const [scheduleDrawerOpened, { open: openScheduleDrawer, close: closeScheduleDrawer }] =
useDisclosure(false)
const [mealDrawerOpened, { open: openMealDrawer, close: closeMealDrawer }] =
useDisclosure(false)
const [notifyDrawerOpened, { open: openNotifyDrawer, close: closeNotifyDrawer }] =
useDisclosure(false)
// Person form state
const [personUserId, setPersonUserId] = useState('')
const [personRole, setPersonRole] = useState<string | null>(null)
// Schedule form state
const [scheduleTitle, setScheduleTitle] = useState('')
const [scheduleDescription, setScheduleDescription] = useState('')
const [scheduleStartsAt, setScheduleStartsAt] = useState('')
const [scheduleEndsAt, setScheduleEndsAt] = useState('')
const [scheduleLocation, setScheduleLocation] = useState('')
const [scheduleFacilitatorId, setScheduleFacilitatorId] = useState('')
// Meal form state
const [mealServiceType, setMealServiceType] = useState<string | null>(null)
const [mealServiceDate, setMealServiceDate] = useState('')
const [mealHeadcount, setMealHeadcount] = useState<number | string>('')
const [mealMenuNotes, setMealMenuNotes] = useState('')
// Notify form state
const [notifyTitle, setNotifyTitle] = useState('')
const [notifyBody, setNotifyBody] = useState('')
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
const { data: personsData, isLoading: personsLoading } = usePikkuQuery('listRetreatPersons', {
retreatId,
})
const { data: scheduleData, isLoading: scheduleLoading } = usePikkuQuery(
'listRetreatSchedule',
{ retreatId },
)
const { data: mealsData, isLoading: mealsLoading } = usePikkuQuery('listRetreatMeals', {
retreatId,
limit: 100,
offset: 0,
})
const addPersonMutation = usePikkuMutation('addRetreatPerson', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatPersons'] })
notifications.show({
title: 'Person added',
message: 'Person has been added to the retreat.',
color: 'green',
})
resetPersonForm()
closePersonDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add person.',
color: 'red',
})
},
})
const addScheduleMutation = usePikkuMutation('addRetreatScheduleItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatSchedule'] })
notifications.show({
title: 'Schedule item added',
message: 'Schedule item has been added to the retreat.',
color: 'green',
})
resetScheduleForm()
closeScheduleDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add schedule item.',
color: 'red',
})
},
})
const addMealMutation = usePikkuMutation('createRetreatMeal', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatMeals'] })
notifications.show({
title: 'Meal added',
message: 'Meal service has been added to the retreat.',
color: 'green',
})
resetMealForm()
closeMealDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add meal service.',
color: 'red',
})
},
})
const notifyMutation = usePikkuMutation('notifyRetreatParticipants', {
onSuccess: (result) => {
notifications.show({
title: 'Notifications sent',
message: `Notified ${result.notified} participant(s).`,
color: 'green',
})
resetNotifyForm()
closeNotifyDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to send notifications.',
color: 'red',
})
},
})
function resetPersonForm() {
setPersonUserId('')
setPersonRole(null)
}
function resetScheduleForm() {
setScheduleTitle('')
setScheduleDescription('')
setScheduleStartsAt('')
setScheduleEndsAt('')
setScheduleLocation('')
setScheduleFacilitatorId('')
}
function resetMealForm() {
setMealServiceType(null)
setMealServiceDate('')
setMealHeadcount('')
setMealMenuNotes('')
}
function resetNotifyForm() {
setNotifyTitle('')
setNotifyBody('')
}
function handleAddPerson() {
if (!personRole) return
addPersonMutation.mutate({
retreatId,
...(personUserId.trim() ? { userId: personUserId.trim() } : {}),
roleType: personRole as
| 'participant'
| 'facilitator'
| 'assistant_facilitator'
| 'organizer'
| 'support_staff',
})
}
function handleAddScheduleItem() {
if (!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt) return
addScheduleMutation.mutate({
retreatId,
title: scheduleTitle.trim(),
...(scheduleDescription.trim() ? { description: scheduleDescription.trim() } : {}),
startsAt: new Date(scheduleStartsAt).toISOString(),
endsAt: new Date(scheduleEndsAt).toISOString(),
...(scheduleLocation.trim() ? { location: scheduleLocation.trim() } : {}),
...(scheduleFacilitatorId.trim()
? { leadRetreatPersonId: scheduleFacilitatorId.trim() }
: {}),
})
}
function handleAddMeal() {
if (!mealServiceType || !mealServiceDate) return
addMealMutation.mutate({
retreatId,
serviceType: mealServiceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
serviceDate: mealServiceDate,
...(typeof mealHeadcount === 'number' && mealHeadcount > 0
? { plannedHeadcount: mealHeadcount }
: {}),
...(mealMenuNotes.trim() ? { menuNotes: mealMenuNotes.trim() } : {}),
})
}
function handleNotify() {
if (!notifyTitle.trim() || !notifyBody.trim()) return
notifyMutation.mutate({
retreatId,
title: notifyTitle.trim(),
body: notifyBody.trim(),
})
}
const retreat = (data?.items ?? []).find((r) => r.retreatId === retreatId) ?? null
const people = personsData?.items ?? []
const schedule = scheduleData?.items ?? []
const meals = mealsData?.items ?? []
// Group meals by date
const mealsByDate: Record<string, typeof meals> = {}
for (const meal of meals) {
const dateKey = new Date(meal.serviceDate).toISOString().split('T')[0]
if (!mealsByDate[dateKey]) mealsByDate[dateKey] = []
mealsByDate[dateKey].push(meal)
}
const sortedMealDates = Object.keys(mealsByDate).sort()
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!retreat) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.retreats_not_found_title()}
</Title>
<Text c="dimmed">{m.retreats_not_found_body()}</Text>
<Anchor component={Link} to="/retreats" size="sm">
{m.retreats_back()}
</Anchor>
</Stack>
)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Group gap="sm" align="center">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(retreat.name)}
</Title>
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" size="lg">
{asI18n(retreat.status)}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={4}>
{asI18n(
`${formatDate(retreat.startAt)} ${formatDate(retreat.endAt)}${retreat.capacity != null ? ` | Capacity: ${retreat.capacity}` : ''}`,
)}
</Text>
</div>
<Group gap="xs">
<Button
size="sm"
variant="light"
color="violet"
leftSection={<Bell size={14} />}
onClick={openNotifyDrawer}
>
{m.retreats_notify_participants()}
</Button>
<Anchor component={Link} to="/retreats" size="sm">
{m.retreats_back()}
</Anchor>
</Group>
</Group>
<Tabs defaultValue="overview" color="teal">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<CalendarDays size={16} />}>
{m.retreats_tab_overview()}
</Tabs.Tab>
<Tabs.Tab value="people" leftSection={<Users size={16} />}>
{asI18n(`${m.retreats_tab_people()} (${people.length})`)}
</Tabs.Tab>
<Tabs.Tab value="schedule" leftSection={<ClipboardList size={16} />}>
{asI18n(`${m.retreats_tab_schedule()} (${schedule.length})`)}
</Tabs.Tab>
<Tabs.Tab value="meals" leftSection={<UtensilsCrossed size={16} />}>
{asI18n(`${m.retreats_tab_meals()} (${meals.length})`)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="sm">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_status()}
</Text>
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" mt={4}>
{asI18n(retreat.status)}
</Badge>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_slug()}
</Text>
<Text size="sm" mt={4}>
{asI18n(retreat.slug)}
</Text>
</div>
{retreat.capacity != null && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_capacity()}
</Text>
<Text size="sm" mt={4}>
{asI18n(String(retreat.capacity))}
</Text>
</div>
)}
</Group>
{retreat.description && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_description()}
</Text>
<Text size="sm" mt={4}>
{asI18n(retreat.description)}
</Text>
</div>
)}
</Stack>
</Card>
</Tabs.Panel>
<Tabs.Panel value="people" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openPersonDrawer}
>
{m.retreats_add_person()}
</Button>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
{personsLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : people.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_people()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.retreats_person_name()}</Table.Th>
<Table.Th>{m.retreats_role()}</Table.Th>
<Table.Th>{m.retreats_status()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{people.map((person) => (
<Table.Tr key={person.retreatPersonId}>
<Table.Td>{person.fullName ?? person.email ?? 'Unknown'}</Table.Td>
<Table.Td>
<Badge
color={roleColor[person.roleType] ?? 'gray'}
variant="light"
size="sm"
>
{asI18n(person.roleType?.replace(/_/g, ' ') ?? '')}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="dot" color="teal" size="sm">
{asI18n(person.attendanceStatus)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="schedule" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openScheduleDrawer}
>
{m.retreats_add_schedule_item()}
</Button>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
{scheduleLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : schedule.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_schedule()}
</Text>
) : (
<Stack gap="sm">
{schedule.map((item) => (
<Group key={item.itemId} gap="md" align="flex-start">
<Text size="sm" fw={600} style={{ minWidth: 140 }}>
{asI18n(formatTime(item.startsAt))}
</Text>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{asI18n(item.title)}
</Text>
{item.description && (
<Text size="xs" c="dimmed">
{asI18n(item.description)}
</Text>
)}
{item.location && (
<Text size="xs" c="dimmed">
{asI18n(`${m.retreats_location()}: ${item.location}`)}
</Text>
)}
</div>
<Text size="xs" c="dimmed">
{asI18n(formatTime(item.endsAt))}
</Text>
</Group>
))}
</Stack>
)}
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="meals" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openMealDrawer}
>
{m.retreats_add_meal()}
</Button>
</Group>
{mealsLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : meals.length === 0 ? (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_meals()}
</Text>
</Card>
) : (
sortedMealDates.map((dateKey) => (
<div key={dateKey}>
<Text
size="sm"
fw={700}
mb="xs"
style={{
fontFamily: '"Cormorant", serif',
fontSize: 18,
color: '#3D2B1F',
}}
>
{asI18n(formatDate(dateKey))}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="md">
{mealsByDate[dateKey].map((meal) => (
<Card
key={meal.serviceId}
padding="md"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="xs">
<Group justify="space-between">
<Badge
color={mealTypeColor[meal.serviceType] ?? 'gray'}
variant="light"
size="sm"
tt="capitalize"
>
{asI18n(meal.serviceType)}
</Badge>
<Badge
color={mealStatusColor[meal.status] ?? 'gray'}
variant="dot"
size="sm"
>
{asI18n(meal.status)}
</Badge>
</Group>
{meal.plannedHeadcount != null && (
<Text size="xs" c="dimmed">
{asI18n(`${m.retreats_headcount()}: ${meal.plannedHeadcount}`)}
</Text>
)}
{meal.menuNotes && (
<Text size="xs" c="dimmed" lineClamp={3}>
{asI18n(meal.menuNotes)}
</Text>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</div>
))
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Stack>
<Drawer
opened={personDrawerOpened}
onClose={closePersonDrawer}
title={m.retreats_add_person_title()}
position="right"
size="md"
>
<Stack gap="md">
<UserSelect
label="User"
placeholder="Search for a user..."
value={personUserId || null}
onChange={(val) => setPersonUserId(val ?? '')}
clearable
/>
<Select
label={m.retreats_role()}
placeholder={asI18n('Select role')}
data={[
{ value: 'participant', label: 'Participant' },
{ value: 'facilitator', label: 'Facilitator' },
{ value: 'assistant_facilitator', label: 'Assistant Facilitator' },
{ value: 'organizer', label: 'Organizer' },
{ value: 'support_staff', label: 'Support Staff' },
]}
value={personRole}
onChange={setPersonRole}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closePersonDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddPerson}
loading={addPersonMutation.isPending}
disabled={!personRole}
>
{m.retreats_add_person()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={scheduleDrawerOpened}
onClose={closeScheduleDrawer}
title={m.retreats_add_schedule_item()}
position="right"
size="md"
>
<Stack gap="md">
<TextInput
label={m.retreats_schedule_title()}
placeholder={asI18n('Schedule item title')}
value={scheduleTitle}
onChange={(e) => setScheduleTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.retreats_description()}
placeholder={asI18n('Optional description')}
rows={3}
value={scheduleDescription}
onChange={(e) => setScheduleDescription(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_starts_at()}
type="datetime-local"
value={scheduleStartsAt}
onChange={(e) => setScheduleStartsAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.retreats_ends_at()}
type="datetime-local"
value={scheduleEndsAt}
onChange={(e) => setScheduleEndsAt(e.currentTarget.value)}
min={scheduleStartsAt || undefined}
required
/>
<TextInput
label={m.retreats_location()}
placeholder={asI18n('Optional location')}
value={scheduleLocation}
onChange={(e) => setScheduleLocation(e.currentTarget.value)}
/>
<Select
label={m.retreats_lead_facilitator()}
placeholder={asI18n('Select a retreat participant...')}
data={people.map((p) => ({
value: p.retreatPersonId,
label: p.fullName ?? p.email ?? p.roleType?.replace(/_/g, ' ') ?? 'Unknown',
}))}
value={scheduleFacilitatorId || null}
onChange={(val) => setScheduleFacilitatorId(val ?? '')}
searchable
clearable
nothingFoundMessage={asI18n('No people added to retreat')}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeScheduleDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddScheduleItem}
loading={addScheduleMutation.isPending}
disabled={!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt}
>
{m.retreats_add_schedule_item()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={mealDrawerOpened}
onClose={closeMealDrawer}
title={m.retreats_add_meal_service()}
position="right"
size="md"
>
<Stack gap="md">
<Select
label={m.retreats_service_type()}
placeholder={asI18n('Select meal type')}
data={[
{ value: 'breakfast', label: 'Breakfast' },
{ value: 'lunch', label: 'Lunch' },
{ value: 'dinner', label: 'Dinner' },
{ value: 'snack', label: 'Snack' },
]}
value={mealServiceType}
onChange={setMealServiceType}
required
/>
<TextInput
label={m.retreats_service_date()}
type="date"
value={mealServiceDate}
onChange={(e) => setMealServiceDate(e.currentTarget.value)}
required
/>
<NumberInput
label={m.retreats_planned_headcount()}
placeholder={asI18n('Expected number of diners')}
min={1}
value={mealHeadcount}
onChange={setMealHeadcount}
/>
<Textarea
label={m.retreats_menu_notes()}
placeholder={asI18n('Optional menu description or notes')}
rows={3}
value={mealMenuNotes}
onChange={(e) => setMealMenuNotes(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeMealDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddMeal}
loading={addMealMutation.isPending}
disabled={!mealServiceType || !mealServiceDate}
>
{m.retreats_add_meal()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={notifyDrawerOpened}
onClose={closeNotifyDrawer}
title={m.retreats_notify_title()}
position="right"
size="md"
>
<Stack gap="md">
<TextInput
label={m.retreats_notify_subject()}
placeholder={asI18n('Notification title')}
value={notifyTitle}
onChange={(e) => setNotifyTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.retreats_notify_body()}
placeholder={asI18n('Notification message')}
rows={5}
value={notifyBody}
onChange={(e) => setNotifyBody(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeNotifyDrawer}>
{m.common_cancel()}
</Button>
<Button
color="violet"
onClick={handleNotify}
loading={notifyMutation.isPending}
disabled={!notifyTitle.trim() || !notifyBody.trim()}
leftSection={<Bell size={14} />}
>
{m.retreats_send_notification()}
</Button>
</Group>
</Stack>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,314 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
SimpleGrid,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Center,
Drawer,
TextInput,
Textarea,
NumberInput,
Alert,
Title,
} from '@pikku/mantine/core'
import { CalendarDays, Plus, User, Users, AlertCircle } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/retreats/')({
component: RetreatsPage,
})
const statusColor: Record<string, string> = {
draft: 'gray',
published: 'blue',
active: 'teal',
completed: 'green',
cancelled: 'red',
}
const statusLabel: Record<string, string> = {
draft: 'Draft',
published: 'Open',
active: 'Active',
completed: 'Completed',
cancelled: 'Cancelled',
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function CreateRetreatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [description, setDescription] = useState('')
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [capacity, setCapacity] = useState<number | string>('')
const handleNameChange = (val: string) => {
setName(val)
setSlug(
val
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
)
}
const resetForm = () => {
setName('')
setSlug('')
setDescription('')
setStartAt('')
setEndAt('')
setCapacity('')
}
const mutation = usePikkuMutation('createRetreat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreats'] })
notifications.show({
title: 'Retreat created',
message: 'Retreat has been created successfully.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create retreat.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
name,
slug,
description: description || undefined,
startAt: startAt ? new Date(startAt).toISOString() : '',
endAt: endAt ? new Date(endAt).toISOString() : '',
capacity: capacity ? Number(capacity) : undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.retreats_create()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? 'Failed to create retreat')}
</Alert>
)}
<TextInput
label={m.retreats_name()}
placeholder={asI18n('Spring Equinox Retreat')}
required
value={name}
onChange={(e) => handleNameChange(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_slug()}
placeholder={asI18n('spring-equinox-retreat')}
required
value={slug}
onChange={(e) => setSlug(e.currentTarget.value)}
/>
<Textarea
label={m.retreats_description()}
placeholder={asI18n('Describe the retreat...')}
minRows={3}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_start_date()}
type="date"
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.retreats_end_date()}
type="date"
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
min={startAt || undefined}
required
/>
<NumberInput
label={m.retreats_capacity()}
placeholder={asI18n('Maximum participants')}
min={1}
value={capacity}
onChange={setCapacity}
/>
<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.retreats_create()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function RetreatsPage() {
useLocale()
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
const retreats = data?.items ?? []
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.retreats_title()}
description={m.retreats_subtitle()}
action={
<Button
onClick={() => setCreateDrawerOpen(true)}
leftSection={<Plus size={16} />}
color="teal"
radius="md"
>
{m.retreats_create()}
</Button>
}
/>
{retreats.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<CalendarDays size={48} strokeWidth={1} color="#aaa" />
<Text c="dimmed">{m.retreats_empty()}</Text>
</Stack>
</Card>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{retreats.map((retreat) => (
<Card
key={retreat.retreatId}
padding="lg"
radius="md"
component={Link}
to="/retreats/$retreatId"
params={{ retreatId: retreat.retreatId } as never}
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
textDecoration: 'none',
cursor: 'pointer',
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Title
order={4}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{asI18n(retreat.name)}
</Title>
<Badge
color={statusColor[retreat.status] ?? 'gray'}
variant="light"
size="sm"
>
{asI18n(statusLabel[retreat.status] ?? retreat.status)}
</Badge>
</Group>
{retreat.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(retreat.description)}
</Text>
)}
<Text size="sm" c="dimmed">
{asI18n(`${formatDate(retreat.startAt)} ${formatDate(retreat.endAt)}`)}
</Text>
{retreat.facilitatorName && (
<Group gap="xs">
<User size={14} strokeWidth={1.5} color="#2A7B88" />
<Text size="sm" fw={500} c="teal">
{asI18n(retreat.facilitatorName)}
</Text>
</Group>
)}
{retreat.capacity != null && (
<Group gap="xs">
<Users size={14} strokeWidth={1.5} color="#888" />
<Text size="sm" c="dimmed">
{asI18n(`${m.retreats_capacity()}: ${retreat.capacity}`)}
</Text>
</Group>
)}
</Stack>
</Card>
))}
</SimpleGrid>
)}
<CreateRetreatDrawer
opened={createDrawerOpen}
onClose={() => setCreateDrawerOpen(false)}
/>
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/retreats')({
component: Outlet,
})

View File

@@ -0,0 +1,115 @@
import { createFileRoute } from '@tanstack/react-router'
import { Stack, Loader, Center, Text } from '@pikku/mantine/core'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/rooms/allocations')({
validateSearch: (search: Record<string, unknown>): { roomId?: string } => ({
roomId: typeof search.roomId === 'string' ? search.roomId : undefined,
}),
component: RoomAllocationsPage,
})
type RoomAllocation = {
allocationId: string
roomId: string
stayId: string
startsAt: string | Date
endsAt: string | Date
status: string
allocationReason: string | null
createdByUserId: string
createdAt: string | Date
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return String(dateStr)
}
}
function AllocationsTable({ allocations }: { allocations: RoomAllocation[] }) {
const { data: roomsData } = usePikkuQuery('listRooms', { limit: 100, offset: 0 })
const roomMap = new Map((roomsData?.items ?? []).map((r) => [r.roomId, r]))
const columns: Column<RoomAllocation>[] = [
{
key: 'roomId',
label: m.rooms_room(),
render: (row) => {
const room = roomMap.get(row.roomId)
return (
<Text size="sm" truncate>
{asI18n(room ? `${room.name} (${room.code})` : 'Room')}
</Text>
)
},
},
{
key: 'dates',
label: m.rooms_dates(),
render: (row) => (
<Text size="sm">
{asI18n(`${formatDate(row.startsAt)} - ${formatDate(row.endsAt)}`)}
</Text>
),
},
{
key: 'status',
label: m.rooms_status(),
render: (row) => <StatusBadge status={row.status} />,
},
{
key: 'reason',
label: m.rooms_reason(),
render: (row) => (
<Text size="sm" c="dimmed" lineClamp={1}>
{asI18n(row.allocationReason ?? '-')}
</Text>
),
},
]
return (
<DataTable
data={allocations}
columns={columns}
rowKey={(row) => row.allocationId}
emptyMessage="No room allocations found."
/>
)
}
function RoomAllocationsPage() {
useLocale()
const { roomId } = Route.useSearch()
const { data, isLoading } = usePikkuQuery('listRoomAllocations', { roomId, limit: 50, offset: 0 })
const allocations = (data?.items ?? []) as RoomAllocation[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.rooms_allocations_title()}
description={roomId ? m.rooms_allocations_room_description() : m.rooms_allocations_all_description()}
/>
<AllocationsTable allocations={allocations} />
</Stack>
)
}

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

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/rooms')({
component: Outlet,
})

View File

@@ -0,0 +1,320 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Stack,
Button,
Loader,
Center,
Group,
Select,
Textarea,
TextInput,
Drawer,
Text,
Alert,
} from '@pikku/mantine/core'
import { Plus, Info } from 'lucide-react'
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 { StaysTabs } from '@/components/stays-tabs'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/stays')({
component: StaysPage,
})
type RequestType = 'day_visit' | 'facilitator' | 'guest' | 'staff' | 'volunteer'
const REQUEST_TYPES: { value: RequestType; labelKey: string }[] = [
{ value: 'day_visit', labelKey: 'stays.type_day_visit' },
{ value: 'facilitator', labelKey: 'stays.type_facilitator' },
{ value: 'guest', labelKey: 'stays.type_guest' },
{ value: 'staff', labelKey: 'stays.type_staff' },
{ value: 'volunteer', labelKey: 'stays.type_volunteer' },
]
function RequestStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [requestType, setRequestType] = useState<string | null>(null)
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [notes, setNotes] = useState('')
const resetForm = () => {
setRequestType(null)
setStartAt('')
setEndAt('')
setNotes('')
}
const mutation = usePikkuMutation('requestStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
notifications.show({
title: m.stays_request_submitted(),
message: m.stays_request_submitted_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_something_went_wrong(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!requestType || !startAt || !endAt) {
notifications.show({
title: m.stays_missing_fields(),
message: m.stays_missing_fields_message(),
color: 'red',
})
return
}
mutation.mutate({
requestType: requestType as RequestType,
requestedStartAt: startAt,
requestedEndAt: endAt,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_request_stay()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.stays_request_type()}
placeholder={m.stays_select_type()}
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
value={requestType}
onChange={setRequestType}
required
/>
<TextInput
type="date"
label={m.stays_start_date()}
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
type="date"
label={m.stays_end_date()}
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
required
/>
<Textarea
label={m.stays_notes()}
placeholder={m.stays_notes_placeholder()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<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.stays_submit_request()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function CreateStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [requestType, setRequestType] = useState<string | null>(null)
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [notes, setNotes] = useState('')
const [isPending, setIsPending] = useState(false)
const resetForm = () => {
setRequestType(null)
setStartAt('')
setEndAt('')
setNotes('')
}
const requestStay = usePikkuMutation('requestStay')
const approveStayRequest = usePikkuMutation('approveStayRequest')
const createStayFromRequest = usePikkuMutation('createStayFromRequest')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!requestType || !startAt || !endAt) {
notifications.show({
title: m.stays_missing_fields(),
message: m.stays_missing_fields_message(),
color: 'red',
})
return
}
setIsPending(true)
try {
const requestResult = await requestStay.mutateAsync({
requestType: requestType as RequestType,
requestedStartAt: startAt,
requestedEndAt: endAt,
notes: notes || undefined,
})
await approveStayRequest.mutateAsync({ requestId: requestResult.requestId })
await createStayFromRequest.mutateAsync({ requestId: requestResult.requestId })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
notifications.show({
title: m.stays_stay_created(),
message: m.stays_stay_created_message(),
color: 'green',
})
resetForm()
onClose()
} catch (err) {
notifications.show({
title: m.common_error(),
message: err instanceof Error && err.message ? asI18n(err.message) : m.stays_create_failed(),
color: 'red',
})
} finally {
setIsPending(false)
}
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_create_stay()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Alert icon={<Info size={16} />} color="teal" variant="light">
<Text size="sm">{m.stays_create_stay_hint()}</Text>
</Alert>
<Select
label={m.stays_stay_type()}
placeholder={m.stays_select_type()}
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
value={requestType}
onChange={setRequestType}
required
/>
<TextInput
type="date"
label={m.stays_start_date()}
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
type="date"
label={m.stays_end_date()}
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
required
/>
<Textarea
label={m.stays_notes()}
placeholder={m.stays_notes_placeholder()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={isPending}>
{m.stays_create_stay()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function StaysPage() {
useLocale()
const canManage = usePermission('stays.manage')
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
limit: 50,
offset: 0,
})
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
limit: 50,
offset: 0,
})
const stays = staysData?.items ?? []
const stayRequests = requestsData?.items ?? []
if (staysLoading || requestsLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.stays_title()}
description={m.stays_description()}
action={
<Group gap="sm">
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" size="sm">
{m.stays_request_stay()}
</Button>
{canManage && (
<Button
onClick={() => setCreateDrawerOpen(true)}
leftSection={<Plus size={16} />}
color="teal"
size="sm"
>
{m.stays_create_stay()}
</Button>
)}
</Group>
}
/>
<StaysTabs stays={stays} stayRequests={stayRequests} />
<RequestStayDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
<CreateStayDrawer opened={createDrawerOpen} onClose={() => setCreateDrawerOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,577 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
SimpleGrid,
Accordion,
Anchor,
Box,
Drawer,
Select,
TextInput,
Textarea,
} from '@pikku/mantine/core'
import { MapPin, Clock, Plus, Repeat, ShieldCheck } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useState } from 'react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { TaskActions } from '@/components/task-actions'
export const Route = createFileRoute('/_authenticated/tasks/')({
component: TasksPage,
})
const STATUS_CONFIG: Record<string, { color: string; order: number }> = {
open: { color: 'teal', order: 0 },
assigned: { color: 'blue', order: 1 },
in_progress: { color: 'orange', order: 2 },
blocked: { color: 'red', order: 3 },
planned: { color: 'gray', order: 4 },
completed: { color: 'green', order: 5 },
cancelled: { color: 'gray', order: 6 },
}
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
const KANBAN_STATUSES = ['open', 'assigned', 'in_progress', 'blocked']
const CATEGORIES = [
{ value: 'housekeeping', label: m.tasks_category_housekeeping() },
{ value: 'kitchen', label: m.tasks_category_kitchen() },
{ value: 'maintenance', label: m.tasks_category_maintenance() },
{ value: 'operations', label: m.tasks_category_operations() },
{ value: 'retreat', label: m.tasks_category_retreat() },
{ value: 'transport', label: m.tasks_category_transport() },
]
const ROLE_OPTIONS = [
{ value: 'admin', label: m.tasks_role_admin() },
{ value: 'coordinator', label: m.tasks_role_coordinator() },
{ value: 'facilitator', label: m.tasks_role_facilitator() },
{ value: 'boat_coordinator', label: m.tasks_role_boat_coordinator() },
{ value: 'kitchen_lead', label: m.tasks_role_kitchen_lead() },
{ value: 'staff', label: m.tasks_role_staff() },
{ value: 'volunteer', label: m.tasks_role_volunteer() },
{ value: 'community_member', label: m.tasks_role_community_member() },
]
const STATUS_LABEL: Record<string, () => string> = {
open: () => m.tasks_status_open(),
assigned: () => m.tasks_status_assigned(),
in_progress: () => m.tasks_status_in_progress(),
blocked: () => m.tasks_status_blocked(),
planned: () => m.tasks_status_planned(),
completed: () => m.tasks_status_completed(),
cancelled: () => m.tasks_status_cancelled(),
}
interface TaskItem {
taskId: string
templateId: string | null
title: string
description: string | null
category: string
status: string
location: string | null
scheduledStartAt: string | Date | null
scheduledEndAt: string | Date | null
assignedRole: string | null
createdByUserId: string
}
function formatTime(val: string | Date | null): string | null {
if (!val) return null
const d = typeof val === 'string' ? new Date(val) : val
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function statusLabel(status: string): string {
return STATUS_LABEL[status]?.() ?? status
}
function TaskCard({ task, onAssignRole }: { task: TaskItem; onAssignRole: (taskId: string) => void }) {
const cfg = STATUS_CONFIG[task.status] ?? { color: 'gray', order: 99 }
return (
<Card
padding="md"
radius="md"
className="card-hover"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Stack gap="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text
fw={600}
size="sm"
lineClamp={2}
style={{ fontFamily: '"Cormorant", serif', fontSize: 16 }}
>
{asI18n(task.title)}
</Text>
<Badge size="xs" color={cfg.color} variant="light" style={{ flexShrink: 0 }}>
{asI18n(statusLabel(task.status))}
</Badge>
</Group>
<Group gap="xs">
<Badge size="xs" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
{asI18n(task.category)}
</Badge>
{task.assignedRole && (
<Badge
size="xs"
variant="light"
color="grape"
leftSection={<ShieldCheck size={10} />}
>
{asI18n(task.assignedRole.replace(/_/g, ' '))}
</Badge>
)}
</Group>
{task.location && (
<Group gap={4}>
<MapPin size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(task.location)}
</Text>
</Group>
)}
{task.scheduledStartAt && (
<Group gap={4}>
<Clock size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(
`${formatTime(task.scheduledStartAt)}${task.scheduledEndAt ? ` - ${formatTime(task.scheduledEndAt)}` : ''}`
)}
</Text>
</Group>
)}
<Group gap="xs">
<TaskActions taskId={task.taskId} status={task.status} />
{(task.status === 'open' || task.status === 'planned') && (
<Button size="xs" variant="light" color="grape" onClick={() => onAssignRole(task.taskId)}>
{m.tasks_assign_role()}
</Button>
)}
</Group>
</Stack>
</Card>
)
}
function CreateTaskDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<string | null>(null)
const [location, setLocation] = useState('')
const [scheduledStartAt, setScheduledStartAt] = useState('')
const [scheduledEndAt, setScheduledEndAt] = useState('')
const [assignedRole, setAssignedRole] = useState<string | null>(null)
const assignToRole = usePikkuMutation('assignTaskToRole')
const mutation = usePikkuMutation('createTaskInstance', {
onSuccess: async (result) => {
if (assignedRole && result.taskId) {
await assignToRole.mutateAsync({ taskId: result.taskId, role: assignedRole })
}
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_created_title(),
message: m.tasks_created_message({ title }),
color: 'teal',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message ?? m.tasks_error_generic(),
color: 'red',
})
},
})
const resetForm = () => {
setTitle('')
setDescription('')
setCategory(null)
setLocation('')
setScheduledStartAt('')
setScheduledEndAt('')
setAssignedRole(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !category) {
notifications.show({
title: m.tasks_validation_title(),
message: m.tasks_validation_message(),
color: 'orange',
})
return
}
mutation.mutate({
title: title.trim(),
category: category as 'housekeeping' | 'kitchen' | 'maintenance' | 'operations' | 'retreat' | 'transport',
...(description.trim() ? { description: description.trim() } : {}),
...(location.trim() ? { location: location.trim() } : {}),
...(scheduledStartAt
? { scheduledStartAt: new Date(scheduledStartAt).toISOString() }
: {}),
...(scheduledEndAt
? { scheduledEndAt: new Date(scheduledEndAt).toISOString() }
: {}),
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.tasks_create_new_task()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.tasks_field_title()}
placeholder={m.tasks_title_placeholder()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.tasks_field_description()}
placeholder={m.tasks_description_placeholder()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={3}
/>
<Select
label={m.tasks_category()}
placeholder={m.tasks_select_category()}
data={CATEGORIES}
value={category}
onChange={setCategory}
required
/>
<TextInput
label={m.tasks_location()}
placeholder={m.tasks_location_placeholder()}
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
/>
<Group grow>
<TextInput
label={m.tasks_scheduled_start()}
type="datetime-local"
value={scheduledStartAt}
onChange={(e) => setScheduledStartAt(e.currentTarget.value)}
/>
<TextInput
label={m.tasks_scheduled_end()}
type="datetime-local"
value={scheduledEndAt}
onChange={(e) => setScheduledEndAt(e.currentTarget.value)}
min={scheduledStartAt || undefined}
/>
</Group>
<Select
label={m.tasks_assign_to_role_optional()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={assignedRole}
onChange={setAssignedRole}
clearable
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.tasks_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending || assignToRole.isPending}>
{m.tasks_create_task()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function TasksPage() {
useLocale()
const queryClient = useQueryClient()
const [roleDrawerOpened, { open: openRoleDrawer, close: closeRoleDrawer }] = useDisclosure(false)
const [createDrawerOpened, { open: openCreateDrawer, close: closeCreateDrawer }] = useDisclosure(false)
const [roleTaskId, setRoleTaskId] = useState<string | null>(null)
const [selectedRole, setSelectedRole] = useState<string | null>(null)
function handleOpenRoleDrawer(taskId: string) {
setRoleTaskId(taskId)
setSelectedRole(null)
openRoleDrawer()
}
const assignRoleMutation = usePikkuMutation('assignTaskToRole', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_role_assigned_title(),
message: m.tasks_role_assigned_message(),
color: 'green',
})
closeRoleDrawer()
setRoleTaskId(null)
setSelectedRole(null)
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_assign_role(),
color: 'red',
})
},
})
const generateMutation = usePikkuMutation('generateRecurringTasks', {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_generated_title(),
message: m.tasks_generated_message({ count: result.generated }),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_generate(),
color: 'red',
})
},
})
const { data, error: queryError } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
const tasks = (data?.items ?? []) as TaskItem[]
const error = queryError?.message ?? null
const grouped: Record<string, TaskItem[]> = {}
for (const status of KANBAN_STATUSES) {
grouped[status] = []
}
for (const task of tasks) {
const key = KANBAN_STATUSES.includes(task.status) ? task.status : 'open'
grouped[key]!.push(task)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.tasks_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.tasks_description()}
</Text>
</div>
<Group gap="xs">
<Button
variant="light"
color="teal"
size="xs"
leftSection={<Repeat size={14} />}
onClick={() => generateMutation.mutate({ date: new Date().toISOString().split('T')[0] })}
loading={generateMutation.isPending}
>
{m.tasks_generate_recurring()}
</Button>
<Anchor component={Link} to="/tasks/templates" size="sm" c="dimmed" style={{ textDecoration: 'none' }}>
{m.tasks_templates()}
</Anchor>
<Button variant="subtle" size="xs" onClick={openCreateDrawer} leftSection={<Plus size={14} />}>
{m.tasks_new_task()}
</Button>
</Group>
</Group>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{asI18n(error)}
</Text>
</Card>
)}
{/* Mobile: Accordion */}
<Box hiddenFrom="sm">
<Accordion
variant="separated"
radius="md"
multiple
defaultValue={KANBAN_STATUSES}
styles={{
item: {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
},
}}
>
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Accordion.Item key={status} value={status}>
<Accordion.Control>
<Group gap="xs">
<Badge size="sm" color={cfg.color} variant="light">
{items.length}
</Badge>
<Text fw={600} size="sm">
{asI18n(statusLabel(status))}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
{items.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{m.tasks_no_status_tasks({ status: statusLabel(status).toLowerCase() })}
</Text>
) : (
<Stack gap="sm">
{items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))}
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
)
})}
</Accordion>
</Box>
{/* Desktop: Kanban columns */}
<Box visibleFrom="sm">
<SimpleGrid cols={4} spacing="md">
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Stack key={status} gap="sm">
<Group gap="xs" mb={4}>
<Badge size="sm" color={cfg.color} variant="filled">
{items.length}
</Badge>
<Text
fw={600}
size="sm"
style={{ fontFamily: '"Cormorant", serif', fontSize: 16, color: '#3D2B1F' }}
>
{asI18n(statusLabel(status))}
</Text>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: 'rgba(245, 240, 232, 0.5)',
border: '2px dashed rgba(61, 43, 31, 0.1)',
}}
>
<Text size="sm" c="dimmed" ta="center">
{m.tasks_no_tasks()}
</Text>
</Card>
) : (
items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))
)}
</Stack>
)
})}
</SimpleGrid>
</Box>
</Stack>
<Drawer
opened={roleDrawerOpened}
onClose={closeRoleDrawer}
title={m.tasks_assign_to_role()}
position="right"
size="md"
>
<Stack gap="md">
<Select
label={m.tasks_role()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={selectedRole}
onChange={setSelectedRole}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeRoleDrawer}>
{m.tasks_cancel()}
</Button>
<Button
color="grape"
onClick={() => {
if (roleTaskId && selectedRole) {
assignRoleMutation.mutate({ taskId: roleTaskId, role: selectedRole })
}
}}
loading={assignRoleMutation.isPending}
disabled={!selectedRole}
>
{m.tasks_assign_role()}
</Button>
</Group>
</Stack>
</Drawer>
<CreateTaskDrawer opened={createDrawerOpened} onClose={closeCreateDrawer} />
</>
)
}

View File

@@ -0,0 +1,180 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Title, Text, Card, Group, Stack, Badge, Table, Anchor, Box } from '@pikku/mantine/core'
import { LayoutTemplate } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { CreateTemplateForm } from '@/components/create-template-form'
export const Route = createFileRoute('/_authenticated/tasks/templates')({
component: TaskTemplatesPage,
})
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
interface TaskTemplate {
templateId: string
title: string
description: string | null
category: string
isClaimable: boolean
defaultLocation: string | null
estimatedMinutes: number | null
recurrenceCron: string | null
}
function TaskTemplatesPage() {
useLocale()
// There is no list-templates RPC; new templates are created via the form below.
const templates: TaskTemplate[] = []
const error: string | null = null
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Anchor
component={Link}
to="/tasks"
size="sm"
c="dimmed"
style={{ textDecoration: 'none', marginBottom: 8, display: 'block' }}
>
{m.tasks_templates_back()}
</Anchor>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.tasks_templates_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.tasks_templates_description()}
</Text>
</div>
</Group>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{error}
</Text>
</Card>
)}
<CreateTemplateForm />
{templates.length === 0 && !error ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<LayoutTemplate size={40} color="#aaa" />
<Text size="sm" c="dimmed">
{m.tasks_templates_empty()}
</Text>
</Stack>
</Card>
) : (
<Card
padding={0}
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
overflow: 'hidden',
}}
>
{/* Desktop table */}
<Box visibleFrom="sm">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.tasks_templates_col_title()}</Table.Th>
<Table.Th>{m.tasks_templates_col_category()}</Table.Th>
<Table.Th>{m.tasks_templates_col_claimable()}</Table.Th>
<Table.Th>{m.tasks_templates_col_recurrence()}</Table.Th>
<Table.Th>{m.tasks_templates_col_duration()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{templates.map((t) => (
<Table.Tr key={t.templateId}>
<Table.Td>
<Text fw={500} size="sm">
{asI18n(t.title)}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
{asI18n(t.category)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
{t.isClaimable ? m.tasks_templates_yes() : m.tasks_templates_no()}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{asI18n(t.recurrenceCron ?? m.tasks_templates_one_time())}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{asI18n(t.estimatedMinutes ? m.tasks_templates_minutes({ minutes: t.estimatedMinutes }) : '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
{/* Mobile cards */}
<Box hiddenFrom="sm" p="md">
<Stack gap="sm">
{templates.map((t) => (
<Card key={t.templateId} padding="sm" radius="sm" withBorder>
<Group justify="space-between" mb={4}>
<Text fw={600} size="sm">
{asI18n(t.title)}
</Text>
<Badge size="xs" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
{asI18n(t.category)}
</Badge>
</Group>
<Group gap="xs">
<Badge size="xs" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
{t.isClaimable ? m.tasks_templates_claimable() : m.tasks_templates_not_claimable()}
</Badge>
{t.recurrenceCron && (
<Badge size="xs" variant="light" color="grape">
{m.tasks_templates_recurring()}
</Badge>
)}
{t.estimatedMinutes && (
<Text size="xs" c="dimmed">
{m.tasks_templates_minutes({ minutes: t.estimatedMinutes })}
</Text>
)}
</Group>
</Card>
))}
</Stack>
</Box>
</Card>
)}
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/tasks')({
component: Outlet,
})

View File

@@ -0,0 +1,331 @@
import { createFileRoute, Outlet, Navigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
AppShell,
Burger,
Group,
NavLink,
Text,
ActionIcon,
Menu,
Avatar,
Divider,
Box,
Stack,
Center,
Loader,
} from '@pikku/mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Home,
BedDouble,
CheckSquare,
User,
Sailboat,
DoorOpen,
CalendarDays,
UtensilsCrossed,
Package,
Users,
FileText,
Bell,
LogOut,
Menu as MenuIcon,
LayoutDashboard,
FileText as FileDescription,
DollarSign,
Bot,
} from 'lucide-react'
import { authClient, signOut } from '../lib/auth'
import { useNavigate } from '@tanstack/react-router'
import { useRoles, hasPermission } from '@/lib/permissions'
import { AuthContext, useSessionUser, type SessionUser } from '@/lib/auth-context'
import { NotificationBell } from '@/components/notification-bell'
import { LocaleSwitcher } from '@/components/locale-switcher'
import { AIChatDrawer } from '@/components/ai-chat'
export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const { data: user, isPending } = useQuery({
queryKey: ['session'],
queryFn: async () => {
const { data } = await authClient.getSession()
return (data?.user as SessionUser | undefined) ?? null
},
retry: false,
})
if (isPending) {
return (
<Center h="100vh">
<Loader color="teal" />
</Center>
)
}
if (!user) {
return <Navigate to="/login" />
}
return (
<AuthContext.Provider value={user}>
<AuthenticatedShell />
</AuthContext.Provider>
)
}
interface NavItem {
labelKey: string
href: string
icon: React.ElementType
permission?: string
}
const personalNav: NavItem[] = [
{ labelKey: 'nav.dashboard', href: '/', icon: Home },
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: BedDouble },
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
{ labelKey: 'nav.my_profile', href: '/my/profile', icon: User },
]
const operationsNav: NavItem[] = [
{ labelKey: 'nav.stays', href: '/stays', icon: BedDouble, permission: 'stays.view_all' },
{ labelKey: 'nav.boats', href: '/boats', icon: Sailboat, permission: 'boats.view_all' },
{ labelKey: 'nav.tasks', href: '/tasks', icon: CheckSquare, permission: 'tasks.view_all' },
{ labelKey: 'nav.kitchen', href: '/kitchen', icon: UtensilsCrossed, permission: 'kitchen.view' },
{ labelKey: 'nav.inventory', href: '/inventory', icon: Package, permission: 'inventory.request' },
{ labelKey: 'nav.retreats', href: '/retreats', icon: CalendarDays, permission: 'retreats.view' },
{ labelKey: 'nav.finance', href: '/finance', icon: DollarSign, permission: 'finance.view' },
]
const managementNav: NavItem[] = [
{ labelKey: 'nav.rooms', href: '/rooms', icon: DoorOpen, permission: 'rooms.view_all' },
{ labelKey: 'nav.boat_management', href: '/boats/manage', icon: Sailboat, permission: 'boats.manage' },
{ labelKey: 'nav.users', href: '/users', icon: Users, permission: 'users.manage' },
{ labelKey: 'nav.audit_log', href: '/audit-log', icon: FileText, permission: 'audit_log.view' },
]
const mobileTabItems: NavItem[] = [
{ labelKey: 'nav.dashboard', href: '/', icon: LayoutDashboard },
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: FileDescription },
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
{ labelKey: 'nav.more', href: '#more', icon: MenuIcon },
]
function AuthenticatedShell() {
useLocale()
const user = useSessionUser()
const navigate = useNavigate()
const [opened, { toggle, close }] = useDisclosure()
const isMobile = useMediaQuery('(max-width: 768px)')
const [chatOpen, setChatOpen] = useState(false)
const roles = useRoles()
const can = (permission?: string) =>
!permission || hasPermission(roles, permission)
const canUseAI = can('ai_agent.use')
const visibleOps = operationsNav.filter((item) => can(item.permission))
const visibleMgmt = managementNav.filter((item) => can(item.permission))
const handleSignOut = async () => {
await signOut()
navigate({ to: '/login' })
}
const go = (href: string) => {
navigate({ to: href as any })
close()
}
const renderNavLink = (item: NavItem) => (
<NavLink
key={item.href}
label={mKey(item.labelKey)}
leftSection={<item.icon size={18} />}
onClick={() => go(item.href)}
styles={{ root: { borderRadius: 8 } }}
/>
)
const sidebar = (
<Stack gap={0} p="sm">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_personal()}
</Text>
{personalNav.map(renderNavLink)}
{visibleOps.length > 0 && (
<>
<Divider my="sm" />
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_operations()}
</Text>
{visibleOps.map(renderNavLink)}
</>
)}
{visibleMgmt.length > 0 && (
<>
<Divider my="sm" />
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_management()}
</Text>
{visibleMgmt.map(renderNavLink)}
</>
)}
</Stack>
)
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
styles={{
main: {
backgroundColor: '#F5F0E8',
minHeight: '100vh',
paddingBottom: isMobile ? 72 : undefined,
},
header: {
backgroundColor: '#ffffff',
borderBottom: '1px solid rgba(61, 43, 31, 0.08)',
},
navbar: {
backgroundColor: '#ffffff',
borderRight: '1px solid rgba(61, 43, 31, 0.08)',
overflowY: 'auto',
},
}}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Group gap={8} align="center">
<img src="/favicon-32x32.png" alt="PerAuset" width={28} height={28} />
<Text
size="xl"
fw={700}
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
>
{m.brand()}
</Text>
</Group>
</Group>
<Group gap="sm">
<LocaleSwitcher />
{canUseAI && (
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={() => setChatOpen(true)}
aria-label={asI18n('AI Assistant')}
>
<Bot size={20} />
</ActionIcon>
)}
<NotificationBell />
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
<Avatar size="sm" color="teal" radius="xl">
{user?.name?.[0]?.toUpperCase() ?? 'U'}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{asI18n(user?.name ?? 'User')}
<Text size="xs" c="dimmed">
{asI18n(user?.email ?? '')}
</Text>
</Menu.Label>
<Menu.Divider />
<Menu.Item
leftSection={<User size={14} />}
onClick={() => navigate({ to: '/my/profile' as any })}
>
{m.nav_profile()}
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={14} />}
color="red"
onClick={handleSignOut}
>
{m.nav_sign_out()}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>{sidebar}</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
{/* Mobile bottom tab bar */}
{isMobile && (
<Box
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 64,
backgroundColor: '#ffffff',
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
zIndex: 200,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{mobileTabItems.map((item) => (
<Box
key={item.labelKey}
component="button"
onClick={() => (item.href === '#more' ? toggle() : go(item.href))}
style={mobileTabStyle}
>
<item.icon size={22} />
<span>{mKey(item.labelKey)}</span>
</Box>
))}
</Box>
)}
{canUseAI && (
<AIChatDrawer opened={chatOpen} onClose={() => setChatOpen(false)} />
)}
</AppShell>
)
}
const mobileTabStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
padding: '8px 12px',
textDecoration: 'none',
color: '#3D2B1F',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 11,
fontFamily: '"Raleway", sans-serif',
}

View File

@@ -0,0 +1,184 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Card,
Group,
Stack,
Anchor,
Loader,
Center,
Title,
Checkbox,
Button,
Alert,
SimpleGrid,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/users/$userId')({
component: UserDetailPage,
})
function RoleEditor({
userId,
currentRoles,
availableRoles,
}: {
userId: string
currentRoles: string[]
availableRoles: string[]
}) {
const [roles, setRoles] = useState<string[]>(currentRoles)
const mutation = usePikkuMutation('setUserRoles')
const toggleRole = (role: string) => {
setRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
)
mutation.reset()
}
const handleSave = () => {
mutation.mutate({ userId, memberRoles: roles })
}
return (
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? 'Failed to update roles')}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.users_roles_updated()}
</Alert>
)}
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }} spacing="sm">
{availableRoles.map((role) => (
<Checkbox
key={role}
label={asI18n(role.replace(/_/g, ' '))}
checked={roles.includes(role)}
onChange={() => toggleRole(role)}
color="teal"
styles={{ label: { textTransform: 'capitalize' } }}
/>
))}
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={handleSave} loading={mutation.isPending} color="teal" radius="md">
{m.users_save_roles()}
</Button>
</Group>
</Stack>
)
}
function UserDetailPage() {
useLocale()
const { userId } = Route.useParams()
const { data: user, isLoading: userLoading } = usePikkuQuery('getUser', { userId })
const { data: rolesData } = usePikkuQuery('listRoles', {})
const availableRoles: string[] = (rolesData?.roles ?? []).map((r) => r.key)
if (userLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!user) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.users_not_found_title()}
</Title>
<Text c="dimmed">{m.users_not_found_body()}</Text>
<Anchor component={Link} to="/users" size="sm">
{m.users_back()}
</Anchor>
</Stack>
)
}
const name = user.displayName ?? user.name ?? user.email
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(name)}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{asI18n(`${user.email}${user.mobileNumber ? ` | ${user.mobileNumber}` : ''}`)}
</Text>
</div>
<Anchor component={Link} to="/users" size="sm">
{m.users_back()}
</Anchor>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="md">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.users_display_name()}
</Text>
<Text size="sm" mt={4}>
{asI18n(user.displayName ?? '--')}
</Text>
</div>
</Group>
</Stack>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Title
order={4}
mb="md"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.users_role_management()}
</Title>
<RoleEditor
userId={user.userId}
currentRoles={user.memberRoles ?? []}
availableRoles={availableRoles}
/>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,263 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Group,
Stack,
Badge,
Anchor,
Loader,
Center,
Button,
Drawer,
TextInput,
MultiSelect,
} from '@pikku/mantine/core'
import { UserPlus } from 'lucide-react'
import { asI18n } from '@pikku/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 { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/users/')({
component: UsersPage,
})
const roleColor: Record<string, string> = {
admin: 'red',
coordinator: 'violet',
facilitator: 'grape',
boat_coordinator: 'blue',
kitchen_lead: 'orange',
staff: 'teal',
volunteer: 'green',
community_member: 'cyan',
guest: 'gray',
}
const AVAILABLE_ROLES = [
{ value: 'coordinator', label: 'Coordinator' },
{ value: 'facilitator', label: 'Facilitator' },
{ value: 'boat_coordinator', label: 'Boat Coordinator' },
{ value: 'kitchen_lead', label: 'Kitchen Lead' },
{ value: 'staff', label: 'Staff' },
{ value: 'volunteer', label: 'Volunteer' },
{ value: 'community_member', label: 'Community Member' },
{ value: 'guest', label: 'Guest' },
]
interface UserRow {
userId: string
name: string | null
displayName: string | null
email: string
memberRoles: string[]
avatarUrl: string | null
}
function InviteUserDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [roles, setRoles] = useState<string[]>([])
const resetForm = () => {
setEmail('')
setName('')
setDisplayName('')
setRoles([])
}
const mutation = usePikkuMutation('inviteUser', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listUsers'] })
notifications.show({
title: 'User invited',
message: 'Invitation has been sent.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to invite user.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !name || roles.length === 0) {
notifications.show({
title: 'Missing fields',
message: 'Email, name, and at least one role are required.',
color: 'red',
})
return
}
mutation.mutate({
email,
name,
displayName: displayName || undefined,
memberRoles: roles,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.users_invite()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.common_email()}
placeholder={asI18n('user@example.com')}
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<TextInput
label={m.profile_name()}
placeholder={asI18n('Name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.users_display_name()}
placeholder={asI18n('Display name (optional)')}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
/>
<MultiSelect
label={m.users_roles()}
placeholder={asI18n('Select roles')}
data={AVAILABLE_ROLES}
value={roles}
onChange={setRoles}
required
/>
<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.users_send_invitation()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function UsersPage() {
useLocale()
const canManage = usePermission('users.manage')
const [inviteOpen, setInviteOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listUsers', { limit: 50, offset: 0 })
const users = (data?.items ?? []) as UserRow[]
const total = data?.total ?? users.length
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<UserRow>[] = [
{
key: 'name',
label: m.users_name(),
render: (user) => {
const name = user.displayName ?? user.name ?? '--'
return (
<Text size="sm" fw={500}>
{asI18n(name)}
</Text>
)
},
},
{
key: 'email',
label: m.common_email(),
render: (user) => (
<Text size="sm" c="dimmed">
{asI18n(user.email)}
</Text>
),
},
{
key: 'roles',
label: m.users_roles(),
render: (user) => {
const roles = user.memberRoles ?? []
return (
<Group gap={4}>
{roles.map((role) => (
<Badge key={role} color={roleColor[role] ?? 'gray'} variant="light" size="xs">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
{roles.length === 0 && (
<Text size="xs" c="dimmed">
{m.users_no_roles()}
</Text>
)}
</Group>
)
},
},
{
key: 'actions',
label: asI18n(''),
render: (user) => (
<Anchor component={Link} to="/users/$userId" params={{ userId: user.userId } as never} size="sm">
{m.users_edit_roles()}
</Anchor>
),
},
]
return (
<Stack gap="lg">
<PageHeader
title={m.users_title()}
description={m.users_registered_count({ count: total })}
action={
canManage ? (
<Button
leftSection={<UserPlus size={16} />}
color="teal"
radius="md"
onClick={() => setInviteOpen(true)}
>
{m.users_invite()}
</Button>
) : undefined
}
/>
<DataTable
data={users}
columns={columns}
rowKey={(user) => user.userId}
emptyMessage="No users found."
/>
<InviteUserDrawer opened={inviteOpen} onClose={() => setInviteOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/users')({
component: Outlet,
})

View File

@@ -0,0 +1,96 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Box,
Card,
TextInput,
PasswordInput,
Button,
Text,
Stack,
Alert,
Group,
} from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import type { I18nString } from '@pikku/react'
import { signIn, INVALID_CREDENTIALS } from '../lib/auth'
function LoginPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
const [email, setEmail] = useState(import.meta.env.DEV ? 'admin@perauset.org' : '')
const [password, setPassword] = useState(import.meta.env.DEV ? 'test' : '')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<I18nString | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPending(true)
setError(null)
try {
await signIn(email, password)
await qc.invalidateQueries({ queryKey: ['me'] })
navigate({ to: '/' })
} catch (err: any) {
setError(err.message === INVALID_CREDENTIALS ? m.login_invalid_credentials() : m.login_error())
} finally {
setIsPending(false)
}
}
return (
<Box
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F5F0E8',
padding: 16,
}}
>
<Card shadow="lg" padding="xl" radius="lg" w={400} maw="100%" style={{ backgroundColor: '#ffffff' }}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Group justify="center">
<Text size="xl" fw={700} style={{ color: '#2A7B88', fontSize: 28 }}>
{m.brand()}
</Text>
</Group>
<Text ta="center" size="sm" c="dimmed">
{m.login_subtitle()}
</Text>
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label={m.common_email()}
type="email"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PasswordInput
label={m.common_password()}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Button type="submit" fullWidth loading={isPending} color="teal" size="md" radius="md" mt="sm">
{m.login_cta()}
</Button>
</Stack>
</form>
</Card>
</Box>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})