frontend fully integreated with the backend

This commit is contained in:
Kalana B. Thilakarathna
2026-08-25 23:47:33 +05:30
parent 1bee5f91c6
commit 83d3272784
151 changed files with 10724 additions and 573 deletions
@@ -0,0 +1,127 @@
'use client'
import { useEffect } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, getApiError } from '@/lib/api/index'
import type { RegionResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface RegionSheetProps {
open: boolean
onClose: () => void
region: RegionResponse | null
onSuccess: () => void
}
export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetProps) {
const isEdit = !!region
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(region ? { name: region.name, isActive: region.isActive } : { name: '', isActive: true })
}
}, [open, region])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await regionsApi.update(region!.id, values)
toast.add({ title: 'Region updated', type: 'success' })
} else {
await regionsApi.create(values)
toast.add({ title: 'Region created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Region Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Western Province"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,161 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RegionResponse } from '@/types/api'
import { RegionSheet } from './region-sheet'
export function RegionsClient() {
const [data, setData] = useState<RegionResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RegionResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await regionsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(Array.isArray(res) ? res.length : res.total)
} catch (err) {
toast.add({ title: 'Failed to load regions', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(region: RegionResponse) {
setEditing(region)
setSheetOpen(true)
}
async function toggleActive(region: RegionResponse) {
try {
await regionsApi.update(region.id, { isActive: !region.isActive })
toast.add({
title: region.isActive ? 'Region deactivated' : 'Region activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update region', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<RegionResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No regions yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Region',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{
key: 'createdAt',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit region',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate region',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate region',
onClick: toggleActive,
visible: (target) => !target.isActive,
},
],
},
}), [pageSize, openEdit, toggleActive])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Regions</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Geographic territories the distribution network operates in
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Region
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<RegionSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
region={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RegionsClient } from './_components/regions-client'
export default function RegionsPage() {
return <RegionsClient />
}
@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { routes as routesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse, RegionResponse } from '@/types/api'
const schema = z.object({
regionId: z.string().min(1, 'Region is required'),
routeName: z.string().min(1, 'Route name is required').max(100, 'Route name must be under 100 characters'),
startPoint: z.string().min(1, 'Start point is required').max(200),
endPoint: z.string().min(1, 'End point is required').max(200),
length: z.number().positive('Length must be greater than 0'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface RouteSheetProps {
open: boolean
onClose: () => void
route: RouteResponse | null
onSuccess: () => void
}
export function RouteSheet({ open, onClose, route, onSuccess }: RouteSheetProps) {
const isEdit = !!route
const [regions, setRegions] = useState<RegionResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { regionId: '', routeName: '', startPoint: '', endPoint: '', length: 0, isActive: true },
})
useEffect(() => {
if (open) {
regionsApi.list({ page: 1, pageSize: 200 })
.then((res) => setRegions(getPaginatedItems(res)))
.catch(() => toast.add({ title: 'Failed to load regions', type: 'error' }))
form.reset(route
? {
regionId: route.regionId,
routeName: route.routeName,
startPoint: route.startPoint,
endPoint: route.endPoint,
length: route.length,
isActive: route.isActive,
}
: { regionId: '', routeName: '', startPoint: '', endPoint: '', length: 0, isActive: true }
)
}
}, [open, route])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await routesApi.update(route!.id, values)
toast.add({ title: 'Route updated', type: 'success' })
} else {
await routesApi.create(values)
toast.add({ title: 'Route created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Route' : 'Add Route'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the route details below.' : 'Add a new delivery route to a region.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="regionId"
label="Region"
required
render={({ field, invalid }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder="Select a region">
{regions.find((region) => region.id === field.value)?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="routeName"
label="Route Name"
required
render={({ field, invalid }) => (
<Input
id="routeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Route A1"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="startPoint"
label="Start Point"
required
render={({ field, invalid }) => (
<Input
id="startPoint"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Colombo Fort"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="endPoint"
label="End Point"
required
render={({ field, invalid }) => (
<Input
id="endPoint"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kaduwela"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="length"
label="Length (km)"
required
render={({ field, invalid }) => (
<Input
id="length"
type="number"
name={field.name}
ref={field.ref}
value={field.value as number}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 25"
min={0}
step="0.1"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Route'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RouteResponse } from '@/types/api'
import { RouteSheet } from './route-sheet'
export function RoutesClient() {
const [data, setData] = useState<RouteResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RouteResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await routesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(route: RouteResponse) {
setEditing(route)
setSheetOpen(true)
}
async function toggleActive(route: RouteResponse) {
try {
await routesApi.update(route.id, { isActive: !route.isActive })
toast.add({
title: route.isActive ? 'Route deactivated' : 'Route activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update route', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<RouteResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No routes yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'routeName',
title: 'Route',
fields: [{ key: 'routeName', primary: true }],
sortable: true,
minWidth: 180,
},
{
key: 'path',
title: 'Path',
fields: ['startPoint', 'endPoint'],
minWidth: 200,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.startPoint} <span className="mx-1 text-foreground">→</span> {row.endPoint}
</span>
),
},
{
key: 'length',
title: 'Length',
fields: [{ key: 'length', transform: (v) => `${v} km` }],
width: 100,
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{
key: 'createdAt',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit route',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate route',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate route',
onClick: toggleActive,
visible: (target) => !target.isActive,
},
],
},
}), [pageSize, openEdit, toggleActive])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Routes</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Delivery paths within a region assigned to rides
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Route
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<RouteSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
route={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RoutesClient } from './_components/routes-client'
export default function RoutesPage() {
return <RoutesClient />
}
@@ -0,0 +1,164 @@
'use client'
import { useEffect } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { vehicles as vehiclesApi, getApiError } from '@/lib/api/index'
import type { VehicleResponse, VehicleType } from '@/types/api'
const VEHICLE_TYPES: VehicleType[] = ['Car', 'Truck', 'Van', 'Motorcycle', 'Bicycle']
const schema = z.object({
regNumber: z.string().min(1, 'Registration number is required').max(50),
numberPlate: z.string().min(1, 'Number plate is required').max(20),
type: z.enum(['Car', 'Truck', 'Van', 'Motorcycle', 'Bicycle']),
})
type FormValues = z.infer<typeof schema>
interface VehicleSheetProps {
open: boolean
onClose: () => void
vehicle: VehicleResponse | null
onSuccess: () => void
}
export function VehicleSheet({ open, onClose, vehicle, onSuccess }: VehicleSheetProps) {
const isEdit = !!vehicle
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { regNumber: '', numberPlate: '', type: 'Car' },
})
useEffect(() => {
if (open) {
form.reset(vehicle
? {
regNumber: vehicle.regNumber,
numberPlate: vehicle.numberPlate,
type: vehicle.type,
}
: { regNumber: '', numberPlate: '', type: 'Car' }
)
}
}, [open, vehicle])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await vehiclesApi.update(vehicle!.id, values)
toast.add({ title: 'Vehicle updated', type: 'success' })
} else {
await vehiclesApi.create(values)
toast.add({ title: 'Vehicle created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Vehicle' : 'Add Vehicle'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the vehicle details below.' : 'Add a new vehicle to the fleet.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="regNumber"
label="Registration Number"
required
render={({ field, invalid }) => (
<Input
id="regNumber"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. WP-CAB-1234"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="numberPlate"
label="Number Plate"
required
render={({ field, invalid }) => (
<Input
id="numberPlate"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. CAB-1234"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="type"
label="Vehicle Type"
required
render={({ field, invalid }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder="Select vehicle type" />
</SelectTrigger>
<SelectContent>
{VEHICLE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Vehicle'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { vehicles as vehiclesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { VehicleResponse } from '@/types/api'
import { VehicleSheet } from './vehicle-sheet'
export function VehiclesClient() {
const [data, setData] = useState<VehicleResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<VehicleResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await vehiclesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load vehicles', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(vehicle: VehicleResponse) {
setEditing(vehicle)
setSheetOpen(true)
}
async function deactivate(vehicle: VehicleResponse) {
try {
await vehiclesApi.remove(vehicle.id)
toast.add({
title: 'Vehicle deactivated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to deactivate vehicle', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<VehicleResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No vehicles yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'regNumber',
title: 'Reg. Number',
fields: [{ key: 'regNumber', primary: true }],
sortable: true,
minWidth: 160,
},
{
key: 'numberPlate',
title: 'Number Plate',
fields: [{ key: 'numberPlate' }],
minWidth: 140,
},
{
key: 'type',
title: 'Type',
fields: ['type'],
width: 130,
cellRender: (row) => (
<Badge variant="outline">{row.type}</Badge>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{
key: 'createdAt',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 140,
},
],
rowActions: {
actions: (row) => [
{
action: 'edit',
label: 'Edit vehicle',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate vehicle',
onClick: deactivate,
visible: (target) => target.isActive,
},
],
},
}), [pageSize, openEdit, deactivate])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Vehicles</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Fleet vehicles available for delivery rides
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Vehicle
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<VehicleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
vehicle={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { VehiclesClient } from './_components/vehicles-client'
export default function VehiclesPage() {
return <VehiclesClient />
}
@@ -0,0 +1,238 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { warehouses as warehousesApi, vehicles as vehiclesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { WarehouseResponse, VehicleResponse, WarehouseType } from '@/types/api'
const WAREHOUSE_TYPES: WarehouseType[] = [
'DistributionCenter',
'FulfillmentCenter',
'CrossDocking',
'ColdStorage',
'BondedWarehouse',
'AutomatedWarehouse',
]
const WAREHOUSE_TYPE_LABELS: Record<WarehouseType, string> = {
DistributionCenter: 'Distribution Center',
FulfillmentCenter: 'Fulfillment Center',
CrossDocking: 'Cross Docking',
ColdStorage: 'Cold Storage',
BondedWarehouse: 'Bonded Warehouse',
AutomatedWarehouse: 'Automated Warehouse',
}
const schema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
location: z.string().min(1, 'Location is required').max(200),
type: z.enum([
'DistributionCenter',
'FulfillmentCenter',
'CrossDocking',
'ColdStorage',
'BondedWarehouse',
'AutomatedWarehouse',
]),
vehicleId: z.string().nullable(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface WarehouseSheetProps {
open: boolean
onClose: () => void
warehouse: WarehouseResponse | null
onSuccess: () => void
}
export function WarehouseSheet({ open, onClose, warehouse, onSuccess }: WarehouseSheetProps) {
const isEdit = !!warehouse
const [vehicles, setVehicles] = useState<VehicleResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', location: '', type: 'DistributionCenter', vehicleId: null, isActive: true },
})
useEffect(() => {
if (open) {
vehiclesApi.list({ page: 1, pageSize: 200 })
.then((res) => setVehicles(getPaginatedItems(res)))
.catch(() => toast.add({ title: 'Failed to load vehicles', type: 'error' }))
form.reset(warehouse
? {
name: warehouse.name,
location: warehouse.location,
type: warehouse.type,
vehicleId: warehouse.vehicleId,
isActive: warehouse.isActive,
}
: { name: '', location: '', type: 'DistributionCenter', vehicleId: null, isActive: true }
)
}
}, [open, warehouse])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await warehousesApi.update(warehouse!.id, values)
toast.add({ title: 'Warehouse updated', type: 'success' })
} else {
await warehousesApi.create(values)
toast.add({ title: 'Warehouse created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Warehouse' : 'Add Warehouse'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the warehouse details below.' : 'Add a new storage location to the network.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Warehouse Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Colombo Central Warehouse"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="location"
label="Location"
required
render={({ field, invalid }) => (
<Input
id="location"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. No. 10, Main Street, Colombo 01"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="type"
label="Warehouse Type"
required
render={({ field, invalid }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder="Select warehouse type" />
</SelectTrigger>
<SelectContent>
{WAREHOUSE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{WAREHOUSE_TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="vehicleId"
label="Assigned Vehicle (optional)"
render={({ field }) => (
<Select
value={(field.value as string | null) ?? ''}
onValueChange={(v) => field.onChange(v === '' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{vehicles.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.regNumber} — {v.numberPlate}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Warehouse'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,176 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { warehouses as warehousesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { WarehouseResponse } from '@/types/api'
import { WarehouseSheet } from './warehouse-sheet'
export function WarehousesClient() {
const [data, setData] = useState<WarehouseResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<WarehouseResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await warehousesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load warehouses', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(warehouse: WarehouseResponse) {
setEditing(warehouse)
setSheetOpen(true)
}
async function toggleActive(warehouse: WarehouseResponse) {
try {
await warehousesApi.update(warehouse.id, { isActive: !warehouse.isActive })
toast.add({
title: warehouse.isActive ? 'Warehouse deactivated' : 'Warehouse activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update warehouse', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<WarehouseResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No warehouses yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Warehouse',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'location',
title: 'Location',
fields: [{ key: 'location' }],
minWidth: 180,
},
{
key: 'type',
title: 'Type',
fields: ['type'],
minWidth: 180,
cellRender: (row) => (
<Badge variant="outline">{row.type}</Badge>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{
key: 'createdAt',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit warehouse',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate warehouse',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate warehouse',
onClick: toggleActive,
visible: (target) => !target.isActive,
},
],
},
}), [pageSize, openEdit, toggleActive])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Warehouses</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Storage locations where stock is held and loaded from
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Warehouse
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<WarehouseSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
warehouse={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { WarehousesClient } from './_components/warehouses-client'
export default function WarehousesPage() {
return <WarehousesClient />
}