395 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|