chore: perauset customer project
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user