Files
perauset-e2e-mrfzot2p/apps/app/src/pages/_authenticated.boats.index.tsx
2026-07-11 08:34:30 +02:00

395 lines
13 KiB
TypeScript

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