chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:35:17 +02:00
commit 37eea09bf0
293 changed files with 28412 additions and 0 deletions

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