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