451 lines
18 KiB
TypeScript
451 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|