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