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,131 @@
'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 { brands as brandsApi, getApiError } from '@/lib/api/index'
import type { BrandResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface BrandSheetProps {
open: boolean
onClose: () => void
brand: BrandResponse | null
onSuccess: () => void
}
export function BrandSheet({ open, onClose, brand, onSuccess }: BrandSheetProps) {
const isEdit = !!brand
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
brand
? { name: brand.name, isActive: brand.isActive }
: { name: '', isActive: true }
)
}
}, [open, brand])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await brandsApi.update(brand!.id, values)
toast.add({ title: 'Brand updated', type: 'success' })
} else {
await brandsApi.create(values)
toast.add({ title: 'Brand 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 Brand' : 'Add Brand'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the brand details below.' : 'Add a new brand to the catalogue.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Brand 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. Coca-Cola"
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 Brand'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,136 @@
'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 { brands as brandsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { BrandResponse } from '@/types/api'
import { BrandSheet } from './brand-sheet'
export function BrandsClient() {
const [data, setData] = useState<BrandResponse[]>([])
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<BrandResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await brandsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load brands', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(brand: BrandResponse) {
setEditing(brand)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<BrandResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No brands yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Brand',
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 brand',
onClick: openEdit,
},
],
},
}), [pageSize, openEdit])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Brands</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Brands associated with catalogue items
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Brand
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<BrandSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
brand={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { BrandsClient } from './_components/brands-client'
export default function BrandsPage() {
return <BrandsClient />
}
@@ -0,0 +1,140 @@
'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 { categories as categoriesApi, getApiError } from '@/lib/api/index'
import type { CategoryResponse } from '@/types/api'
import { CategorySheet } from './category-sheet'
export function CategoriesClient() {
const [data, setData] = useState<CategoryResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<CategoryResponse | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await categoriesApi.list()
setData(res)
setTotal(res.length)
} catch (err) {
toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(category: CategoryResponse) {
setEditing(category)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<CategoryResponse>>(() => ({
rowKey: 'id',
serverSide: false,
stickyHeader: true,
emptyMessage: 'No categories yet. Add one to get started.',
pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Category',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'subCategories',
title: 'Sub-categories',
fields: ['subCategories'],
width: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.subCategories.length} sub-categories
</span>
),
},
{
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 category',
onClick: openEdit,
},
],
},
}), [openEdit])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Product groupings and sub-categories
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Category
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
/>
<CategorySheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
category={editing}
onSuccess={() => fetchData()}
/>
</>
)
}
@@ -0,0 +1,131 @@
'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 { categories as categoriesApi, getApiError } from '@/lib/api/index'
import type { CategoryResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface CategorySheetProps {
open: boolean
onClose: () => void
category: CategoryResponse | null
onSuccess: () => void
}
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
const isEdit = !!category
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
category
? { name: category.name, isActive: category.isActive }
: { name: '', isActive: true }
)
}
}, [open, category])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await categoriesApi.update(category!.id, values)
toast.add({ title: 'Category updated', type: 'success' })
} else {
await categoriesApi.create(values)
toast.add({ title: 'Category 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 Category' : 'Add Category'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the category details below.' : 'Add a new product category.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Category 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. Beverages"
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 Category'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,5 @@
import { CategoriesClient } from './_components/categories-client'
export default function CategoriesPage() {
return <CategoriesClient />
}
@@ -0,0 +1,291 @@
'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 { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
const schema = z.object({
sku: z.string().min(1, 'SKU is required'),
name: z.string().min(1, 'Name is required'),
categoryId: z.string().min(1, 'Category is required'),
brandId: z.string().optional(),
baseUomId: z.string().min(1, 'Base UOM is required'),
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface ItemSheetProps {
open: boolean
onClose: () => void
item: ItemSummaryResponse | null
onSuccess: () => void
}
export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
const isEdit = !!item
const [categories, setCategories] = useState<CategoryResponse[]>([])
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
const [uomsList, setUomsList] = useState<UomResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
sku: '',
name: '',
categoryId: '',
brandId: undefined,
baseUomId: '',
salePrice: 0,
isActive: true,
},
})
useEffect(() => {
if (open) {
Promise.all([
categoriesApi.list(),
brandsApi.list({ page: 1, pageSize: 200 }),
uomsApi.list(),
]).then(([cats, brandsRes, uomsRes]) => {
setCategories(cats)
setBrandsList(getPaginatedItems(brandsRes))
setUomsList(uomsRes)
}).catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
form.reset(
item
? {
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? undefined,
baseUomId: item.baseUomId,
salePrice: item.salePrice,
isActive: item.isActive,
}
: {
sku: '',
name: '',
categoryId: '',
brandId: undefined,
baseUomId: '',
salePrice: 0,
isActive: true,
}
)
}
}, [open, item])
async function onSubmit(values: FormValues) {
try {
const body = {
...values,
brandId: values.brandId || null,
}
if (isEdit) {
await itemsApi.update(item!.id, body)
toast.add({ title: 'Item updated', type: 'success' })
} else {
await itemsApi.create(body)
toast.add({ title: 'Item 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 Item' : 'Add Item'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="sku"
label="SKU"
required
render={({ field, invalid }) => (
<Input
id="sku"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. ITEM-001"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="name"
label="Item 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. Coca-Cola 330ml"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="categoryId"
label="Category"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="brandId"
label="Brand"
render={({ field }) => (
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder="Select brand..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{brandsList.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="baseUomId"
label="Base UOM"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select unit of measure..." />
</SelectTrigger>
<SelectContent>
{uomsList.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="salePrice"
label="Sale Price"
required
render={({ field, invalid }) => (
<Input
id="salePrice"
name={field.name}
ref={field.ref}
type="number"
min={0}
step="0.01"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0.00"
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 Item'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,163 @@
'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 { items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { ItemSummaryResponse } from '@/types/api'
import { ItemSheet } from './item-sheet'
export function ItemsClient() {
const [data, setData] = useState<ItemSummaryResponse[]>([])
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<ItemSummaryResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await itemsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(item: ItemSummaryResponse) {
setEditing(item)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<ItemSummaryResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No items yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'sku',
title: 'SKU',
fields: [{ key: 'sku' }],
width: 140,
},
{
key: 'name',
title: 'Name',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'categoryName',
title: 'Category',
fields: [{ key: 'categoryName' }],
minWidth: 140,
},
{
key: 'brandName',
title: 'Brand',
fields: ['brandName'],
width: 140,
cellRender: (row) => (
<span className="text-sm">
{row.brandName ?? '—'}
</span>
),
},
{
key: 'baseUomName',
title: 'Base UOM',
fields: [{ key: 'baseUomName' }],
width: 120,
},
{
key: 'salePrice',
title: 'Sale Price',
fields: ['salePrice'],
width: 120,
cellRender: (row) => (
<span className="text-sm">Rs. {row.salePrice.toFixed(2)}</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit item',
onClick: openEdit,
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Items</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Products available for sale and distribution
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<ItemSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
item={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { ItemsClient } from './_components/items-client'
export default function ItemsPage() {
return <ItemsClient />
}
@@ -0,0 +1,131 @@
'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 { uoms as uomsApi, getApiError } from '@/lib/api/index'
import type { UomResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface UomSheetProps {
open: boolean
onClose: () => void
uom: UomResponse | null
onSuccess: () => void
}
export function UomSheet({ open, onClose, uom, onSuccess }: UomSheetProps) {
const isEdit = !!uom
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
uom
? { name: uom.name, isActive: uom.isActive }
: { name: '', isActive: true }
)
}
}, [open, uom])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await uomsApi.update(uom!.id, values)
toast.add({ title: 'UOM updated', type: 'success' })
} else {
await uomsApi.create(values)
toast.add({ title: 'UOM 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 Unit of Measure' : 'Add Unit of Measure'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the unit of measure details below.' : 'Add a new unit of measure.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Unit 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. Kilogram"
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 UOM'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,129 @@
'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 { uoms as uomsApi, getApiError } from '@/lib/api/index'
import type { UomResponse } from '@/types/api'
import { UomSheet } from './uom-sheet'
export function UomsClient() {
const [data, setData] = useState<UomResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<UomResponse | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await uomsApi.list()
setData(res)
setTotal(res.length)
} catch (err) {
toast.add({ title: 'Failed to load units of measure', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(uom: UomResponse) {
setEditing(uom)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<UomResponse>>(() => ({
rowKey: 'id',
serverSide: false,
stickyHeader: true,
emptyMessage: 'No units of measure yet. Add one to get started.',
pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Unit',
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 unit of measure',
onClick: openEdit,
},
],
},
}), [openEdit])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Units of Measure</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Units of measure and conversion factors
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add UOM
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
/>
<UomSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
uom={editing}
onSuccess={() => fetchData()}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { UomsClient } from './_components/uoms-client'
export default function UomsPage() {
return <UomsClient />
}
@@ -0,0 +1,221 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { drivers as driversApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DriverProfileResponse, UserResponse } from '@/types/api'
const schema = z.object({
userId: z.string().min(1, 'User is required'),
licenseNumber: z.string().min(1, 'License number is required'),
licenseExpiry: z.string().min(1, 'License expiry is required'),
medicalClearanceExpiry: z.string().min(1, 'Medical clearance expiry is required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface DriverSheetProps {
open: boolean
onClose: () => void
driver: DriverProfileResponse | null
onSuccess: () => void
}
export function DriverSheet({ open, onClose, driver, onSuccess }: DriverSheetProps) {
const isEdit = !!driver
const [usersList, setUsersList] = useState<UserResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
userId: '',
licenseNumber: '',
licenseExpiry: '',
medicalClearanceExpiry: '',
isActive: true,
},
})
useEffect(() => {
if (open) {
usersApi.list({ pageSize: 100 }).then((res) => setUsersList(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })
)
form.reset(
driver
? {
userId: driver.userId,
licenseNumber: driver.licenseNumber,
licenseExpiry: driver.licenseExpiry.split('T')[0],
medicalClearanceExpiry: driver.medicalClearanceExpiry.split('T')[0],
isActive: driver.isActive,
}
: {
userId: '',
licenseNumber: '',
licenseExpiry: '',
medicalClearanceExpiry: '',
isActive: true,
}
)
}
}, [open, driver])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await driversApi.update(driver!.id, values)
toast.add({ title: 'Driver updated', type: 'success' })
} else {
await driversApi.create(values)
toast.add({ title: 'Driver 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 Driver' : 'Add Driver'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the driver profile below.' : 'Add a new driver profile to the system.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="userId"
label="User"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{usersList.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="licenseNumber"
label="License Number"
required
render={({ field, invalid }) => (
<Input
id="licenseNumber"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. B1234567"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="licenseExpiry"
label="License Expiry"
required
render={({ field }) => (
<Input
type="date"
id="licenseExpiry"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<FormField
control={form.control}
name="medicalClearanceExpiry"
label="Medical Clearance Expiry"
required
render={({ field }) => (
<Input
type="date"
id="medicalClearanceExpiry"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<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 Driver'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,177 @@
'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 { drivers as driversApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DriverProfileResponse } from '@/types/api'
import { DriverSheet } from './driver-sheet'
export function DriversClient() {
const [data, setData] = useState<DriverProfileResponse[]>([])
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<DriverProfileResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await driversApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load drivers', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(driver: DriverProfileResponse) {
setEditing(driver)
setSheetOpen(true)
}
async function toggleActive(driver: DriverProfileResponse) {
try {
await driversApi.update(driver.id, { isActive: !driver.isActive })
toast.add({
title: driver.isActive ? 'Driver deactivated' : 'Driver activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update driver', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<DriverProfileResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No driver profiles yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'userFullName',
title: 'Driver',
fields: [{ key: 'userFullName', primary: true }],
minWidth: 180,
},
{
key: 'licenseNumber',
title: 'License No.',
fields: [{ key: 'licenseNumber' }],
minWidth: 140,
},
{
key: 'licenseExpiry',
title: 'License Expiry',
fields: [{
key: 'licenseExpiry',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 150,
},
{
key: 'medicalClearanceExpiry',
title: 'Medical Expiry',
fields: [{
key: 'medicalClearanceExpiry',
transform: (v) => new Date(v as string).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
}),
}],
width: 150,
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit driver',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate driver',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate driver',
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">Driver Profiles</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Driver records including license and medical clearance dates
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Driver
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<DriverSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
driver={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,73 @@
'use client'
import { useState, useEffect } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { compliance as complianceApi, getApiError } from '@/lib/api/index'
const schema = z.object({
enforceLicenseExpiry: z.boolean(),
enforceMedicalClearance: z.boolean(),
})
type FormValues = z.infer<typeof schema>
export default function CompliancePage() {
const [loading, setLoading] = useState(true)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { enforceLicenseExpiry: false, enforceMedicalClearance: false },
})
useEffect(() => {
complianceApi.get().then(cfg => {
form.reset({ enforceLicenseExpiry: cfg.enforceLicenseExpiry, enforceMedicalClearance: cfg.enforceMedicalClearance })
}).catch(err => toast.add({ title: 'Failed to load config', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function onSubmit(values: FormValues) {
try {
await complianceApi.update(values)
toast.add({ title: 'Compliance config saved', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex flex-col gap-6 max-w-lg">
<div>
<h1 className="text-2xl font-semibold text-foreground">Compliance</h1>
<p className="text-sm text-muted-foreground mt-0.5">Rules that enforce license and medical clearance before a ride starts</p>
</div>
{loading ? <p className="text-muted-foreground text-sm">Loading...</p> : (
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField control={form.control} name="enforceLicenseExpiry"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="enforceLicenseExpiry" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="enforceLicenseExpiry" className="text-sm font-medium cursor-pointer">Enforce license expiry check</label>
</div>
)} />
<FormField control={form.control} name="enforceMedicalClearance"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="enforceMedicalClearance" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="enforceMedicalClearance" className="text-sm font-medium cursor-pointer">Enforce medical clearance check</label>
</div>
)} />
</FieldGroup>
<div>
<Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}</Button>
</div>
</form>
)}
</div>
)
}
@@ -0,0 +1,5 @@
import { DriversClient } from './_components/drivers-client'
export default function DriversPage() {
return <DriversClient />
}
@@ -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 />
}
@@ -0,0 +1,172 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { deliveryTasks as deliveryTasksApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DeliveryTaskResponse, DeliveryTaskStatus, RideResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const TASK_STATUS_VARIANT: Record<DeliveryTaskStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
Delivered: 'default',
PartiallyDelivered: 'secondary',
Failed: 'destructive',
}
export function DeliveryTasksClient() {
const [data, setData] = useState<DeliveryTaskResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [rides, setRides] = useState<RideResponse[]>([])
const [selectedRideId, setSelectedRideId] = useState('')
const selectedRide = useMemo(
() => rides.find((ride) => ride.id === selectedRideId) ?? null,
[rides, selectedRideId]
)
const fetchRides = useCallback(async () => {
try {
const res = await ridesApi.list({ page: 1, pageSize: 200, rideType: 'Delivery' })
const items = getPaginatedItems(res)
setRides(items)
setSelectedRideId((current) => current || items[0]?.id || '')
} catch (err) {
toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' })
}
}, [])
const fetchData = useCallback(async (rideId: string, p: number, ps: number) => {
if (!rideId) {
setData([])
setTotal(0)
setLoading(false)
return
}
setLoading(true)
try {
const res = await deliveryTasksApi.listByRide(rideId, { page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load delivery tasks', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchRides()
}, [fetchRides])
useEffect(() => {
fetchData(selectedRideId, page, pageSize)
}, [fetchData, selectedRideId, page, pageSize])
const tableConfig = useMemo<TableConfig<DeliveryTaskResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: selectedRideId ? 'No delivery tasks found for this ride.' : 'Select a ride to view its delivery tasks.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'storeName',
title: 'Store',
fields: [{ key: 'storeName', primary: true }],
minWidth: 200,
},
{
key: 'sequence',
title: 'Sequence',
fields: ['sequence'],
width: 120,
cellRender: (row) => (
<span className="text-sm">{row.sequence}</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 180,
cellRender: (row) => (
<Badge variant={TASK_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Delivery Tasks</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Individual store drop-offs within the selected ride
</p>
</div>
<div className="w-full max-w-sm">
<Select
value={selectedRideId || '__none__'}
onValueChange={(value) => {
const nextRideId = !value || value === '__none__' ? '' : value
setSelectedRideId(nextRideId)
setPage(1)
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a ride..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" disabled={rides.length > 0}>Select a ride...</SelectItem>
{rides.map((ride) => (
<SelectItem key={ride.id} value={ride.id}>
{ride.routeName} - {ride.vehicleNumberPlate}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRide && (
<p className="mt-2 text-xs text-muted-foreground">
Status: {selectedRide.status} | Driver: {selectedRide.driverFullName}
</p>
)}
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { DeliveryTasksClient } from './_components/delivery-tasks-client'
export default function DeliveryTasksPage() {
return <DeliveryTasksClient />
}
@@ -0,0 +1,156 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const truncId = (id: string) => id.substring(0, 8)
const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
DispatchConfirmed: 'default',
ReceiptConfirmed: 'default',
DiscrepancyFlagged: 'destructive',
Resolved: 'secondary',
}
export function LoadingSheetsClient() {
const [data, setData] = useState<LoadingSheetResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await loadingSheetsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load loading sheets', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
async function handleConfirmDispatch(row: LoadingSheetResponse) {
try {
await loadingSheetsApi.confirmDispatch(row.id)
toast.add({ title: 'Dispatch confirmed', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to confirm dispatch', description: getApiError(err), type: 'error' })
}
}
async function handleConfirmReceipt(row: LoadingSheetResponse) {
try {
await loadingSheetsApi.confirmReceipt(row.id, [])
toast.add({ title: 'Receipt confirmed', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to confirm receipt', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<LoadingSheetResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No loading sheets found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'Sheet ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'warehouseName',
title: 'Warehouse',
fields: [{ key: 'warehouseName', primary: true }],
minWidth: 180,
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 180,
cellRender: (row) => (
<Badge variant={LS_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'itemCount',
title: 'Items',
fields: ['items'],
width: 120,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Confirm dispatch',
onClick: handleConfirmDispatch,
visible: (target) => target.status === 'Pending',
},
{
action: 'confirm',
label: 'Confirm receipt',
onClick: handleConfirmReceipt,
visible: (target) => target.status === 'DispatchConfirmed',
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Loading Sheets</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Track goods loaded onto a vehicle before dispatch
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { LoadingSheetsClient } from './_components/loading-sheets-client'
export default function LoadingSheetsPage() {
return <LoadingSheetsClient />
}
@@ -0,0 +1,266 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 {
rides as ridesApi,
vehicles as vehiclesApi,
drivers as driversApi,
routes as routesApi,
warehouses as warehousesApi,
getApiError,
} from '@/lib/api/index'
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api'
const schema = z.object({
rideType: z.enum(['Delivery', 'Sale']),
vehicleId: z.string().min(1, 'Vehicle is required'),
driverProfileId: z.string().min(1, 'Driver is required'),
routeId: z.string().min(1, 'Route is required'),
sourceWarehouseId: z.string().min(1, 'Warehouse is required'),
meterstart: z.number().min(0, 'Meter start must be 0 or more'),
})
type FormValues = z.infer<typeof schema>
interface RideSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
const [driversList, setDriversList] = useState<DriverProfileResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
rideType: 'Delivery',
vehicleId: '',
driverProfileId: '',
routeId: '',
sourceWarehouseId: '',
meterstart: 0,
},
})
useEffect(() => {
if (!open) return
form.reset({
rideType: 'Delivery',
vehicleId: '',
driverProfileId: '',
routeId: '',
sourceWarehouseId: '',
meterstart: 0,
})
// available=true tells backend to exclude vehicles already on an open ride
Promise.all([
vehiclesApi.list({ available: true, isActive: true, pageSize: 200 }),
driversApi.list({ pageSize: 200 }),
routesApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
])
.then(([v, d, r, w]) => {
setVehiclesList(ensureItems(v))
setDriversList(ensureItems(d))
setRoutesList(ensureItems(r))
setWarehousesList(ensureItems(w))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
}, [open])
async function onSubmit(values: FormValues) {
try {
await ridesApi.create(values)
toast.add({ title: 'Ride created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create ride', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Ride</DialogTitle>
<DialogDescription>Start a new delivery or sales run.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="rideType"
label="Ride Type"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select ride type..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select ride type...</SelectItem>
<SelectItem value="Delivery">Delivery</SelectItem>
<SelectItem value="Sale">Sale</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="vehicleId"
label="Vehicle"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select available vehicle..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select available vehicle...</SelectItem>
{vehiclesList.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.regNumber} — {v.numberPlate}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="driverProfileId"
label="Driver"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select driver..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select driver...</SelectItem>
{driversList.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.userFullName} — {d.licenseNumber}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select route..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select route...</SelectItem>
{routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName} ({r.startPoint} → {r.endPoint})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="sourceWarehouseId"
label="Source Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select warehouse...</SelectItem>
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name} — {w.location}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="meterstart"
label="Meter Start (km)"
required
render={({ field, invalid }) => (
<Input
id="meterstart"
name={field.name}
ref={field.ref}
type="number"
min={0}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create Ride'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,188 @@
'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 { rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RideResponse, RideStatus, RideType } from '@/types/api'
import { RideSheet } from './ride-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const RIDE_STATUS_VARIANT: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary',
Open: 'secondary',
Active: 'default',
CancelRequested: 'destructive',
Cancelled: 'destructive',
Closed: 'secondary',
}
const RIDE_TYPE_VARIANT: Record<RideType, 'default' | 'secondary'> = {
Delivery: 'default',
Sale: 'secondary',
}
export function RidesClient() {
const [data, setData] = useState<RideResponse[]>([])
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 fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await ridesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
async function handleActivate(row: RideResponse) {
try {
await ridesApi.activate(row.id)
toast.add({ title: 'Ride activated', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to activate ride', description: getApiError(err), type: 'error' })
}
}
async function handleClose(row: RideResponse) {
const input = window.prompt('Enter meter end reading:')
if (input === null) return
const meterend = Number(input)
if (isNaN(meterend) || meterend < 0) {
toast.add({ title: 'Invalid meter reading', type: 'error' })
return
}
try {
await ridesApi.close(row.id, meterend)
toast.add({ title: 'Ride closed', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to close ride', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<RideResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No rides found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'vehicle',
title: 'Vehicle / Driver',
fields: ['vehicleNumberPlate', 'driverFullName'],
minWidth: 200,
cellRender: (row) => (
<div>
<p className="text-sm font-medium">{row.vehicleNumberPlate}</p>
<p className="text-xs text-muted-foreground">{row.driverFullName}</p>
</div>
),
},
{
key: 'routeName',
title: 'Route',
fields: [{ key: 'routeName', primary: true }],
minWidth: 160,
},
{
key: 'rideType',
title: 'Type',
fields: ['rideType'],
width: 120,
cellRender: (row) => (
<Badge variant={RIDE_TYPE_VARIANT[row.rideType]}>{row.rideType}</Badge>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 160,
cellRender: (row) => (
<Badge variant={RIDE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'activate',
label: 'Activate ride',
onClick: handleActivate,
visible: (target) => target.status === 'Open',
},
{
action: 'close',
label: 'Close ride',
onClick: handleClose,
visible: (target) => target.status === 'Active',
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Rides</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Delivery runs assigned to a driver and vehicle
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Ride
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<RideSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RidesClient } from './_components/rides-client'
export default function RidesPage() {
return <RidesClient />
}
@@ -0,0 +1,222 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { priceLists as priceListsApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PriceListResponse, RegionResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
regionId: z.string().optional(),
effectiveFrom: z.string().min(1, 'Required'),
effectiveTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface PriceListSheetProps {
open: boolean
onClose: () => void
priceList: PriceListResponse | null
onSuccess: () => void
}
export function PriceListSheet({ open, onClose, priceList, onSuccess }: PriceListSheetProps) {
const isEdit = !!priceList
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
priceList
? {
name: priceList.name,
regionId: priceList.regionId ?? '',
effectiveFrom: priceList.effectiveFrom.split('T')[0],
effectiveTo: priceList.effectiveTo.split('T')[0],
isActive: priceList.isActive,
}
: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true }
)
}
}, [open, priceList])
useEffect(() => {
if (open) {
regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {})
}
}, [open])
async function onSubmit(values: FormValues) {
try {
const body = {
name: values.name,
regionId: values.regionId || null,
effectiveFrom: values.effectiveFrom,
effectiveTo: values.effectiveTo,
isActive: values.isActive,
items: [],
}
if (isEdit) {
await priceListsApi.update(priceList!.id, body)
toast.add({ title: 'Price list updated', type: 'success' })
} else {
await priceListsApi.create(body)
toast.add({ title: 'Price list 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 Price List' : 'Add Price List'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the price list details below.' : 'Create a new price list.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="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. Standard Q1 2025"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="regionId"
label="Region"
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="effectiveFrom"
label="Effective From"
required
render={({ field, invalid }) => (
<Input
id="effectiveFrom"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="effectiveTo"
label="Effective To"
required
render={({ field, invalid }) => (
<Input
id="effectiveTo"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
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 Price List'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,150 @@
'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 { priceLists as priceListsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PriceListResponse } from '@/types/api'
import { PriceListSheet } from './price-list-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
export function PriceListsClient() {
const [data, setData] = useState<PriceListResponse[]>([])
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<PriceListResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await priceListsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load price lists', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(priceList: PriceListResponse) {
setEditing(priceList)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<PriceListResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No price lists yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Name',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'regionName',
title: 'Region',
fields: ['regionName'],
width: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.regionName ?? 'All regions'}
</span>
),
},
{
key: 'dateRange',
title: 'Effective Period',
fields: ['effectiveFrom', 'effectiveTo'],
width: 220,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{fmtDate(row.effectiveFrom)} &rarr; {fmtDate(row.effectiveTo)}
</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit price list',
onClick: openEdit,
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Price Lists</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Item prices scoped by region and active date range
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Price List
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<PriceListSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
priceList={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { PriceListsClient } from './_components/price-lists-client'
export default function PriceListsPage() {
return <PriceListsClient />
}
@@ -0,0 +1,261 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SchemeResponse, RegionResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
regionId: z.string().optional(),
dateRangeFrom: z.string().min(1, 'Required'),
dateRangeTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface SchemeSheetProps {
open: boolean
onClose: () => void
scheme: SchemeResponse | null
onSuccess: () => void
}
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
const isEdit = !!scheme
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
},
})
useEffect(() => {
if (open) {
form.reset(
scheme
? {
name: scheme.name,
ruleType: scheme.ruleType,
regionId: scheme.regionId ?? '',
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
dateRangeTo: scheme.dateRangeTo.split('T')[0],
isActive: scheme.isActive,
}
: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
}
)
}
}, [open, scheme])
useEffect(() => {
if (open) {
regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {})
}
}, [open])
async function onSubmit(values: FormValues) {
try {
const body = {
name: values.name,
ruleType: values.ruleType,
regionId: values.regionId || null,
dateRangeFrom: values.dateRangeFrom,
dateRangeTo: values.dateRangeTo,
isActive: values.isActive,
}
if (isEdit) {
await schemesApi.update(scheme!.id, body)
toast.add({ title: 'Scheme updated', type: 'success' })
} else {
await schemesApi.create(body)
toast.add({ title: 'Scheme 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 Scheme' : 'Add Scheme'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="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. Buy 2 Get 1 Free"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ruleType"
label="Rule Type"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select rule type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="regionId"
label="Region"
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="dateRangeFrom"
label="Date Range From"
required
render={({ field, invalid }) => (
<Input
id="dateRangeFrom"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="dateRangeTo"
label="Date Range To"
required
render={({ field, invalid }) => (
<Input
id="dateRangeTo"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
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 Scheme'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,164 @@
'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 { schemes as schemesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
import { SchemeSheet } from './scheme-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
BuyXGetY: 'default',
SlabDiscount: 'secondary',
FlatPercent: 'secondary',
FreeBundle: 'default',
}
export function SchemesClient() {
const [data, setData] = useState<SchemeResponse[]>([])
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<SchemeResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await schemesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load schemes', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(scheme: SchemeResponse) {
setEditing(scheme)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<SchemeResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No schemes yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Name',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'ruleType',
title: 'Rule Type',
fields: ['ruleType'],
width: 160,
cellRender: (row) => (
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
),
},
{
key: 'regionName',
title: 'Region',
fields: ['regionName'],
width: 140,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.regionName ?? 'All'}</span>
),
},
{
key: 'dateRange',
title: 'Date Range',
fields: ['dateRangeFrom', 'dateRangeTo'],
width: 220,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{fmtDate(row.dateRangeFrom)} &rarr; {fmtDate(row.dateRangeTo)}
</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit scheme',
onClick: openEdit,
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Schemes</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Promotions, bundle deals, and slab discounts applied at sale
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Scheme
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<SchemeSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
scheme={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { SchemesClient } from './_components/schemes-client'
export default function SchemesPage() {
return <SchemesClient />
}
@@ -0,0 +1,118 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const truncId = (id: string) => id.substring(0, 8)
export function GrnClient() {
const [data, setData] = useState<GrnResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await grnApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load goods receipts', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<GrnResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No goods receipts found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'GRN ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'purchaseOrderId',
title: 'PO ID',
fields: ['purchaseOrderId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
),
},
{
key: 'warehouseName',
title: 'Warehouse',
fields: [{ key: 'warehouseName', primary: true }],
minWidth: 180,
},
{
key: 'itemCount',
title: 'Items',
fields: ['items'],
width: 120,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
),
},
{
key: 'receivedAt',
title: 'Received At',
fields: [{ key: 'receivedAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stock received into a warehouse against a purchase order
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { GrnClient } from './_components/grn-client'
export default function GrnPage() {
return <GrnClient />
}
@@ -0,0 +1,147 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const truncId = (id: string) => id.substring(0, 8)
const PO_STATUS_VARIANT: Record<PurchaseOrderStatus, 'default' | 'secondary' | 'destructive'> = {
Issued: 'default',
PartiallyReceived: 'secondary',
Received: 'secondary',
Closed: 'secondary',
Cancelled: 'destructive',
}
export function PurchaseOrdersClient() {
const [data, setData] = useState<PurchaseOrderResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await purchaseOrdersApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load purchase orders', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
async function handleCancel(row: PurchaseOrderResponse) {
const reason = window.prompt('Enter cancellation reason:')
if (reason === null) return
if (!reason.trim()) {
toast.add({ title: 'Cancellation reason is required', type: 'error' })
return
}
try {
await purchaseOrdersApi.cancel(row.id, reason.trim())
toast.add({ title: 'Purchase order cancelled', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to cancel order', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<PurchaseOrderResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No purchase orders found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'Order ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'supplierName',
title: 'Supplier',
fields: [{ key: 'supplierName', primary: true }],
minWidth: 180,
},
{
key: 'itemCount',
title: 'Items',
fields: ['items'],
width: 120,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 160,
cellRender: (row) => (
<Badge variant={PO_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
rowActions: {
actions: () => [
{
action: 'cancel',
label: 'Cancel order',
tone: 'destructive',
onClick: handleCancel,
visible: (target) => target.status === 'Issued',
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Purchase Orders</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Orders placed with suppliers to restock inventory
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { PurchaseOrdersClient } from './_components/purchase-orders-client'
export default function PurchaseOrdersPage() {
return <PurchaseOrdersClient />
}
@@ -0,0 +1,356 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import {
sales as salesApi,
stores as storesApi,
rides as ridesApi,
items as itemsApi,
getApiError,
} from '@/lib/api/index'
import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api'
const saleItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitPrice: z.number().min(0.01, 'Unit price must be greater than 0'),
})
const schema = z.object({
storeId: z.string().min(1, 'Store is required'),
rideId: z.string(),
fulfillImmediately: z.boolean(),
items: z.array(saleItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface SaleSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
function createDefaultItem() {
return {
itemId: '',
quantity: 1,
unitPrice: 0,
}
}
export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) {
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [ridesList, setRidesList] = useState<RideResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
storeId: '',
rideId: '',
fulfillImmediately: false,
items: [createDefaultItem()],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
})
const watchedItems = form.watch('items')
const itemsById = useMemo(
() => new Map(itemsList.map((item) => [item.id, item])),
[itemsList]
)
const totalAmount = useMemo(
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0),
[watchedItems]
)
useEffect(() => {
if (!open) return
form.reset({
storeId: '',
rideId: '',
fulfillImmediately: false,
items: [createDefaultItem()],
})
Promise.all([
storesApi.list({ pageSize: 200 }),
ridesApi.list({ pageSize: 200, rideType: 'Sale' }),
itemsApi.list({ pageSize: 200 }),
])
.then(([stores, rides, items]) => {
setStoresList(ensureItems(stores))
setRidesList(ensureItems(rides))
setItemsList(ensureItems(items))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
}, [open])
async function onSubmit(values: FormValues) {
try {
await salesApi.create({
storeId: values.storeId,
rideId: values.rideId || null,
fulfillImmediately: values.fulfillImmediately,
items: values.items,
})
toast.add({ title: 'Sale created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create sale', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-6xl">
<DialogHeader>
<DialogTitle>Create Sale</DialogTitle>
<DialogDescription>Record a sale for a store with one or more items.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6">
<div className="grid min-h-0 gap-6 lg:grid-cols-3">
<FieldGroup className="lg:col-span-2">
<div className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="storeId"
label="Store"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select store..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select store...</SelectItem>
{storesList.map((store) => (
<SelectItem key={store.id} value={store.id}>
{store.storeName} - {store.ownerName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="rideId"
label="Ride"
render={({ field }) => (
<Select
value={(field.value as string) || '__none__'}
onValueChange={(value) => field.onChange(value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select ride..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No ride</SelectItem>
{ridesList.map((ride) => (
<SelectItem key={ride.id} value={ride.id}>
{ride.routeName} - {ride.vehicleNumberPlate}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<FormField
control={form.control}
name="fulfillImmediately"
render={({ field }) => (
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox
checked={Boolean(field.value)}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Fulfill immediately
</label>
)}
/>
<div className="rounded-2xl border p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-foreground">Sale Summary</h3>
<p className="text-sm text-muted-foreground">
Quantity and unit price are handled in the selected item&apos;s base UOM.
</p>
</div>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total</p>
<p className="text-lg font-semibold text-foreground">Rs. {totalAmount.toLocaleString()}</p>
</div>
</div>
</div>
</FieldGroup>
<div className="flex min-h-0 flex-col rounded-2xl border p-4 lg:col-span-1">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-medium text-foreground">Items</h3>
<p className="text-sm text-muted-foreground">
Only this panel scrolls when multiple items are added.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append(createDefaultItem())}>
Add Item
</Button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{fields.map((fieldItem, index) => {
const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '')
return (
<div key={fieldItem.id} className="space-y-4 rounded-2xl border p-4">
<FormField
control={form.control}
name={`items.${index}.itemId`}
label="Item"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={(value) => {
field.onChange(value)
const nextItem = value ? itemsById.get(value) : undefined
if (nextItem) {
form.setValue(`items.${index}.unitPrice`, nextItem.salePrice, { shouldValidate: true })
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem>
{itemsList.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name} - {item.sku}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
label="Quantity"
required
render={({ field, invalid }) => (
<Input
id={`items.${index}.quantity`}
name={field.name}
ref={field.ref}
type="number"
min={1}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="1"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.unitPrice`}
label="Unit Price"
required
render={({ field, invalid }) => (
<Input
id={`items.${index}.unitPrice`}
name={field.name}
ref={field.ref}
type="number"
min={0.01}
step="0.01"
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
/>
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{selectedItem
? `Quantity is in ${selectedItem.baseUomName}. Unit price is for one ${selectedItem.baseUomName.toLowerCase()}.`
: 'Quantity uses the selected item base UOM. Unit price is for one base-UOM unit.'}
</p>
{fields.length > 1 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => remove(index)}>
Remove Item
</Button>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating...' : 'Create Sale'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,160 @@
'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 { sales as salesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api'
import { SaleSheet } from './sale-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const SALE_STATUS_VARIANT: Record<SaleStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
Completed: 'default',
Cancelled: 'destructive',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyPaid: 'secondary',
Paid: 'default',
}
export function SalesClient() {
const [data, setData] = useState<SaleResponse[]>([])
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 fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await salesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load sales', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<SaleResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No sales found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'Sale ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'storeId',
title: 'Store ID',
fields: ['storeId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
),
},
{
key: 'totalAmount',
title: 'Total',
fields: ['totalAmount'],
width: 140,
cellRender: (row) => (
<span className="text-sm font-medium">{fmtAmount(row.totalAmount)}</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 140,
cellRender: (row) => (
<Badge variant={SALE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'paymentStatus',
title: 'Payment',
fields: ['paymentStatus'],
width: 160,
cellRender: (row) => (
<Badge variant={PAYMENT_STATUS_VARIANT[row.paymentStatus]}>{row.paymentStatus}</Badge>
),
},
{
key: 'saleDate',
title: 'Sale Date',
fields: [{ key: 'saleDate', transform: (v) => fmtDate(v as string) }],
width: 140,
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Sales</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Sales transactions recorded against stores
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Sale
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<SaleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,139 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { orders as ordersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { OrderResponse, OrderStatus } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const ORDER_STATUS_VARIANT: Record<OrderStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
export function OrdersClient() {
const [data, setData] = useState<OrderResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await ordersApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load orders', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<OrderResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No orders found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'Order ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'saleId',
title: 'Sale ID',
fields: ['saleId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.saleId)}</span>
),
},
{
key: 'storeId',
title: 'Store ID',
fields: ['storeId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
),
},
{
key: 'totalAmount',
title: 'Total',
fields: ['totalAmount'],
width: 140,
cellRender: (row) => (
<span className="text-sm font-medium">{fmtAmount(row.totalAmount)}</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 180,
cellRender: (row) => (
<Badge variant={ORDER_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'orderDate',
title: 'Order Date',
fields: [{ key: 'orderDate', transform: (v) => fmtDate(v as string) }],
width: 140,
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Orders</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Fulfillment status of store orders
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { OrdersClient } from './_components/orders-client'
export default function OrdersPage() {
return <OrdersClient />
}
@@ -0,0 +1,5 @@
import { SalesClient } from './_components/sales-client'
export default function SalesPage() {
return <SalesClient />
}
@@ -0,0 +1,200 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { payments as paymentsApi, getApiError } from '@/lib/api/index'
const schema = z.object({
saleId: z.string().min(1, 'Sale ID is required'),
storeId: z.string().min(1, 'Store ID is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Credit']),
collectedAt: z.string().min(1, 'Date is required'),
})
type FormValues = z.infer<typeof schema>
interface PaymentSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
saleId: '',
storeId: '',
amount: 0,
method: 'Cash',
collectedAt: '',
},
})
useEffect(() => {
if (open) {
form.reset({
saleId: '',
storeId: '',
amount: 0,
method: 'Cash',
collectedAt: '',
})
}
}, [open])
async function onSubmit(values: FormValues) {
try {
await paymentsApi.create(values)
toast.add({ title: 'Payment recorded', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to record payment', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Record Payment</DialogTitle>
<DialogDescription>Record a cash or credit payment from a store.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="saleId"
label="Sale ID"
required
render={({ field, invalid }) => (
<Input
id="saleId"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Sale ID"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeId"
label="Store ID"
required
render={({ field, invalid }) => (
<Input
id="storeId"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Store ID"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="amount"
label="Amount"
required
render={({ field, invalid }) => (
<Input
id="amount"
name={field.name}
ref={field.ref}
type="number"
step="0.01"
value={Number(field.value as string)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="method"
label="Payment Method"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Credit">Credit</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="collectedAt"
label="Collected At"
required
render={({ field, invalid }) => (
<Input
id="collectedAt"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Payment'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,148 @@
'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 { payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PaymentResponse, PaymentMethod } from '@/types/api'
import { PaymentSheet } from './payment-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = {
Cash: 'default',
Credit: 'secondary',
}
export function PaymentsClient() {
const [data, setData] = useState<PaymentResponse[]>([])
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 fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await paymentsApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load payments', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<PaymentResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No payments found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'id',
title: 'Payment ID',
fields: ['id'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span>
),
},
{
key: 'storeId',
title: 'Store ID',
fields: ['storeId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
),
},
{
key: 'amount',
title: 'Amount',
fields: ['amount'],
width: 140,
cellRender: (row) => (
<span className="text-sm font-medium">{fmtAmount(row.amount)}</span>
),
},
{
key: 'method',
title: 'Method',
fields: ['method'],
width: 120,
cellRender: (row) => (
<Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge>
),
},
{
key: 'collectedAt',
title: 'Collected At',
fields: [{ key: 'collectedAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
{
key: 'collectedBy',
title: 'Collected By',
fields: ['collectedBy'],
minWidth: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.collectedBy}</span>
),
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected from stores
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Record Payment
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<PaymentSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { PaymentsClient } from './_components/payments-client'
export default function PaymentsPage() {
return <PaymentsClient />
}
@@ -0,0 +1,116 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { stock as stockApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StockBalanceResponse, LocationType } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const LOCATION_TYPE_VARIANT: Record<LocationType, 'default' | 'secondary'> = {
Warehouse: 'default',
Vehicle: 'secondary',
}
export function StockBalancesClient() {
const [data, setData] = useState<StockBalanceResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await stockApi.balances({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<StockBalanceResponse>>(() => ({
rowKey: (row) => `${row.itemId}-${row.locationId}`,
serverSide: true,
stickyHeader: true,
emptyMessage: 'No stock balances found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'itemName',
title: 'Item',
fields: [{ key: 'itemName', primary: true }],
minWidth: 200,
},
{
key: 'locationId',
title: 'Location ID',
fields: ['locationId'],
width: 160,
cellRender: (row) => (
<span className="font-mono text-sm">{row.locationId.substring(0, 8)}</span>
),
},
{
key: 'locationType',
title: 'Location Type',
fields: ['locationType'],
width: 160,
cellRender: (row) => (
<Badge variant={LOCATION_TYPE_VARIANT[row.locationType]}>{row.locationType}</Badge>
),
},
{
key: 'quantity',
title: 'Quantity',
fields: ['quantity'],
width: 120,
cellRender: (row) => (
<span className="text-sm font-medium">{row.quantity}</span>
),
},
{
key: 'updatedAt',
title: 'Updated',
fields: [{ key: 'updatedAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Stock Balances</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Current quantity of each item across warehouses and vehicles
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,135 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast'
import { stock as stockApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StockMovementResponse, MovementType } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const MOVEMENT_TYPE_VARIANT: Record<MovementType, 'default' | 'secondary' | 'destructive'> = {
Sale: 'default',
CustomerReturn: 'secondary',
Transfer: 'secondary',
Damage: 'destructive',
Adjustment: 'secondary',
Load: 'default',
PurchaseReceipt: 'default',
PurchaseReturn: 'secondary',
}
export function StockMovementsClient() {
const [data, setData] = useState<StockMovementResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await stockApi.movements({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load stock movements', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<StockMovementResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No stock movements found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'itemName',
title: 'Item',
fields: [{ key: 'itemName', primary: true }],
minWidth: 200,
},
{
key: 'type',
title: 'Type',
fields: ['type'],
width: 160,
cellRender: (row) => (
<Badge variant={MOVEMENT_TYPE_VARIANT[row.type]}>{row.type}</Badge>
),
},
{
key: 'fromLocationType',
title: 'From',
fields: ['fromLocationType'],
width: 140,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.fromLocationType ?? '—'}
</span>
),
},
{
key: 'toLocationType',
title: 'To',
fields: ['toLocationType'],
width: 140,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.toLocationType ?? '—'}
</span>
),
},
{
key: 'quantity',
title: 'Quantity',
fields: ['quantity'],
width: 120,
cellRender: (row) => (
<span className="text-sm font-medium">{row.quantity}</span>
),
},
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
],
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Stock Movements</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Full history of stock in and out across all locations
</p>
</div>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { StockMovementsClient } from './_components/stock-movements-client'
export default function StockMovementsPage() {
return <StockMovementsClient />
}
@@ -0,0 +1,5 @@
import { StockBalancesClient } from './_components/stock-balances-client'
export default function StockBalancesPage() {
return <StockBalancesClient />
}
@@ -0,0 +1,322 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const schema = z.object({
storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'),
storeAddress: z.string().min(1, 'Store address is required'),
routeId: z.string().min(1, 'Route is required'),
latitude: z.number(),
longitude: z.number(),
creditLimit: z.number().min(0),
segment: z.string().optional(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface StoreSheetProps {
open: boolean
onClose: () => void
store: StoreResponse | null
onSuccess: () => void
}
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
return Array.isArray(value?.items) ? value.items : []
}
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
storeName: '',
ownerName: '',
storeAddress: '',
routeId: '',
latitude: 0,
longitude: 0,
creditLimit: 0,
segment: '',
isActive: true,
},
})
useEffect(() => {
if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
form.reset(
store
? {
storeName: store.storeName,
ownerName: store.ownerName,
storeAddress: store.storeAddress,
routeId: store.routeId,
latitude: store.latitude,
longitude: store.longitude,
creditLimit: store.creditLimit,
segment: store.segment ?? '',
isActive: store.isActive,
}
: {
storeName: '',
ownerName: '',
storeAddress: '',
routeId: '',
latitude: 0,
longitude: 0,
creditLimit: 0,
segment: '',
isActive: true,
}
)
}
}, [open, store])
async function onSubmit(values: FormValues) {
try {
const body = {
...values,
segment: values.segment || undefined,
}
if (isEdit) {
await storesApi.update(store!.id, body)
toast.add({ title: 'Store updated', type: 'success' })
} else {
await storesApi.create(body)
toast.add({ title: 'Store 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 Store' : 'Add Store'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="storeName"
label="Store Name"
required
render={({ field, invalid }) => (
<Input
id="storeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Sunrise Mart"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ownerName"
label="Owner Name"
required
render={({ field, invalid }) => (
<Input
id="ownerName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kamal Perera"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeAddress"
label="Store Address"
required
render={({ field, invalid }) => (
<Input
id="storeAddress"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 123 Main St, Colombo"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a route" />
</SelectTrigger>
<SelectContent>
{routes.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="latitude"
label="Latitude"
render={({ field, invalid }) => (
<Input
id="latitude"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 6.9271"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="longitude"
label="Longitude"
render={({ field, invalid }) => (
<Input
id="longitude"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 79.8612"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="creditLimit"
label="Credit Limit"
render={({ field, invalid }) => (
<Input
id="creditLimit"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="segment"
label="Segment"
render={({ field, invalid }) => (
<Input
id="segment"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Retail"
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 Store'}
</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 { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, KycStatus } from '@/types/api'
import { StoreSheet } from './store-sheet'
function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
if (status === 'Verified') return 'default'
if (status === 'Rejected') return 'destructive'
return 'secondary'
}
export function StoresClient() {
const [data, setData] = useState<StoreResponse[]>([])
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<StoreResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await storesApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load stores', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(store: StoreResponse) {
setEditing(store)
setSheetOpen(true)
}
async function toggleActive(store: StoreResponse) {
try {
await storesApi.update(store.id, { isActive: !store.isActive })
toast.add({
title: store.isActive ? 'Store deactivated' : 'Store activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update store', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<StoreResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No stores yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'storeName',
title: 'Store',
fields: ['storeName', 'ownerName'],
minWidth: 200,
cellRender: (row) => (
<div className="flex flex-col">
<span className="text-sm font-medium">{row.storeName}</span>
<span className="text-xs text-muted-foreground">{row.ownerName}</span>
</div>
),
},
{
key: 'routeId',
title: 'Route',
fields: [{ key: 'routeId' }],
minWidth: 140,
},
{
key: 'kycStatus',
title: 'KYC',
fields: ['kycStatus'],
width: 130,
cellRender: (row) => (
<Badge variant={kycBadgeVariant(row.kycStatus)}>
{row.kycStatus}
</Badge>
),
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit store',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate store',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate store',
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">Stores</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Retail store accounts, credit limits, and KYC status
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Store
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<StoreSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
store={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { StoresClient } from './_components/stores-client'
export default function StoresPage() {
return <StoresClient />
}
@@ -0,0 +1,154 @@
'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 { suppliers as suppliersApi, getApiError } from '@/lib/api/index'
import type { SupplierResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
contactInfo: z.string().optional(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface SupplierSheetProps {
open: boolean
onClose: () => void
supplier: SupplierResponse | null
onSuccess: () => void
}
export function SupplierSheet({ open, onClose, supplier, onSuccess }: SupplierSheetProps) {
const isEdit = !!supplier
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', contactInfo: '', isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
supplier
? { name: supplier.name, contactInfo: supplier.contactInfo ?? '', isActive: supplier.isActive }
: { name: '', contactInfo: '', isActive: true }
)
}
}, [open, supplier])
async function onSubmit(values: FormValues) {
try {
const body = {
...values,
contactInfo: values.contactInfo || null,
}
if (isEdit) {
await suppliersApi.update(supplier!.id, body)
toast.add({ title: 'Supplier updated', type: 'success' })
} else {
await suppliersApi.create(body)
toast.add({ title: 'Supplier 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 Supplier' : 'Add Supplier'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the supplier details below.' : 'Add a new supplier account.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Supplier 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. ABC Distributors"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="contactInfo"
label="Contact Info"
render={({ field, invalid }) => (
<Input
id="contactInfo"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Phone / email / address"
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 Supplier'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,147 @@
'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 { suppliers as suppliersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SupplierResponse } from '@/types/api'
import { SupplierSheet } from './supplier-sheet'
export function SuppliersClient() {
const [data, setData] = useState<SupplierResponse[]>([])
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<SupplierResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await suppliersApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load suppliers', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(supplier: SupplierResponse) {
setEditing(supplier)
setSheetOpen(true)
}
const tableConfig = useMemo<TableConfig<SupplierResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No suppliers yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Supplier',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'contactInfo',
title: 'Contact',
fields: ['contactInfo'],
minWidth: 180,
cellRender: (row) => (
<span className="text-sm">
{row.contactInfo ?? '—'}
</span>
),
},
{
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 supplier',
onClick: openEdit,
},
],
},
}), [pageSize])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Suppliers</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Supplier accounts used for raising purchase orders
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Supplier
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<SupplierSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
supplier={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { SuppliersClient } from './_components/suppliers-client'
export default function SuppliersPage() {
return <SuppliersClient />
}
@@ -0,0 +1,323 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 { users as usersApi, roles as rolesApi, getApiError } from '@/lib/api/index'
import type { UserResponse, RoleResponse } from '@/types/api'
const createSchema = z.object({
fullName: z.string().min(1, 'Full name is required'),
username: z.string().min(1, 'Username is required'),
roleId: z.string().min(1, 'Role is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
isActive: z.boolean(),
})
const editSchema = z.object({
fullName: z.string().min(1, 'Full name is required'),
username: z.string().min(1, 'Username is required'),
roleId: z.string().min(1, 'Role is required'),
isActive: z.boolean(),
})
type CreateFormValues = z.infer<typeof createSchema>
type EditFormValues = z.infer<typeof editSchema>
interface UserSheetProps {
open: boolean
onClose: () => void
user: UserResponse | null
onSuccess: () => void
}
export function UserSheet({ open, onClose, user, onSuccess }: UserSheetProps) {
const isEdit = !!user
const [roles, setRoles] = useState<RoleResponse[]>([])
const createForm = useForm<CreateFormValues>({
resolver: zodResolver(createSchema),
defaultValues: { fullName: '', username: '', roleId: '', password: '', isActive: true },
})
const editForm = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: { fullName: '', username: '', roleId: '', isActive: true },
})
const form = isEdit ? editForm : createForm
useEffect(() => {
if (open) {
rolesApi.list().then(setRoles).catch((err) =>
toast.add({ title: 'Failed to load roles', description: getApiError(err), type: 'error' })
)
if (isEdit && user) {
editForm.reset({
fullName: user.fullName,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
})
} else {
createForm.reset({ fullName: '', username: '', roleId: '', password: '', isActive: true })
}
}
}, [open, user])
async function onSubmitCreate(values: CreateFormValues) {
try {
await usersApi.create(values)
toast.add({ title: 'User created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
async function onSubmitEdit(values: EditFormValues) {
try {
await usersApi.update(user!.id, values)
toast.add({ title: 'User updated', 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 User' : 'Add User'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the user details below.' : 'Create a new staff account.'}
</DialogDescription>
</DialogHeader>
{isEdit ? (
<form onSubmit={editForm.handleSubmit(onSubmitEdit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={editForm.control}
name="fullName"
label="Full Name"
required
render={({ field, invalid }) => (
<Input
id="fullName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. John Smith"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={editForm.control}
name="username"
label="Username"
required
render={({ field, invalid }) => (
<Input
id="username"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. john.smith"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={editForm.control}
name="roleId"
label="Role"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={editForm.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={editForm.formState.isSubmitting}>
{editForm.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
) : (
<form onSubmit={createForm.handleSubmit(onSubmitCreate)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={createForm.control}
name="fullName"
label="Full Name"
required
render={({ field, invalid }) => (
<Input
id="fullName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. John Smith"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={createForm.control}
name="username"
label="Username"
required
render={({ field, invalid }) => (
<Input
id="username"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. john.smith"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={createForm.control}
name="roleId"
label="Role"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={createForm.control}
name="password"
label="Password"
required
render={({ field, invalid }) => (
<Input
id="password"
name={field.name}
ref={field.ref}
type="password"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Min. 6 characters"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={createForm.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={createForm.formState.isSubmitting}>
{createForm.formState.isSubmitting ? 'Saving…' : 'Create User'}
</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 { users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { UserResponse } from '@/types/api'
import { UserSheet } from './user-sheet'
export function UsersClient() {
const [data, setData] = useState<UserResponse[]>([])
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<UserResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await usersApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(user: UserResponse) {
setEditing(user)
setSheetOpen(true)
}
async function toggleActive(user: UserResponse) {
try {
await usersApi.update(user.id, { isActive: !user.isActive })
toast.add({
title: user.isActive ? 'User deactivated' : 'User activated',
type: 'success',
})
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to update user', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<UserResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
emptyMessage: 'No users yet. Add one to get started.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'fullName',
title: 'Name',
fields: ['fullName', 'username'],
minWidth: 200,
cellRender: (row) => (
<div className="flex flex-col">
<span className="text-sm font-medium">{row.fullName}</span>
<span className="text-xs text-muted-foreground">{row.username}</span>
</div>
),
},
{
key: 'roleName',
title: 'Role',
fields: [{ key: 'roleName' }],
minWidth: 140,
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit user',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate user',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate user',
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">Users</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Staff accounts and their role assignments
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add User
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/>
<UserSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
user={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { UsersClient } from './_components/users-client'
export default function UsersPage() {
return <UsersClient />
}
@@ -0,0 +1,152 @@
'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 { roles as rolesApi, getApiError } from '@/lib/api/index'
import type { RoleResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
level: z.number().int().min(1, 'Level must be at least 1'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface RoleSheetProps {
open: boolean
onClose: () => void
role: RoleResponse | null
onSuccess: () => void
}
export function RoleSheet({ open, onClose, role, onSuccess }: RoleSheetProps) {
const isEdit = !!role
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', level: 1, isActive: true },
})
useEffect(() => {
if (open) {
form.reset(
role
? { name: role.name, level: role.level, isActive: role.isActive }
: { name: '', level: 1, isActive: true }
)
}
}, [open, role])
async function onSubmit(values: FormValues) {
try {
if (isEdit) {
await rolesApi.update(role!.id, values)
toast.add({ title: 'Role updated', type: 'success' })
} else {
await rolesApi.create(values)
toast.add({ title: 'Role 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 Role' : 'Add Role'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the role details below.' : 'Add a new role to the system.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Role 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. Sales Manager"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="level"
label="Level"
required
render={({ field, invalid }) => (
<Input
id="level"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
onBlur={field.onBlur}
placeholder="e.g. 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 Role'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,149 @@
'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 { roles as rolesApi, getApiError } from '@/lib/api/index'
import type { RoleResponse } from '@/types/api'
import { RoleSheet } from './role-sheet'
export function RolesClient() {
const [data, setData] = useState<RoleResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RoleResponse | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await rolesApi.list()
setData(res)
setTotal(res.length)
} catch (err) {
toast.add({ title: 'Failed to load roles', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(role: RoleResponse) {
setEditing(role)
setSheetOpen(true)
}
async function toggleActive(role: RoleResponse) {
try {
await rolesApi.update(role.id, { isActive: !role.isActive })
toast.add({
title: role.isActive ? 'Role deactivated' : 'Role activated',
type: 'success',
})
fetchData()
} catch (err) {
toast.add({ title: 'Failed to update role', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<RoleResponse>>(() => ({
rowKey: 'id',
stickyHeader: true,
emptyMessage: 'No roles yet. Add one to get started.',
pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'name',
title: 'Role',
fields: [{ key: 'name', primary: true }],
sortable: true,
minWidth: 200,
},
{
key: 'level',
title: 'Level',
fields: [{ key: 'level', transform: (v) => `Level ${v}` }],
width: 100,
},
{
key: 'status',
title: 'Status',
fields: ['isActive'],
width: 120,
cellRender: (row) => (
<Badge variant={row.isActive ? 'default' : 'secondary'}>
{row.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
],
rowActions: {
actions: () => [
{
action: 'edit',
label: 'Edit role',
onClick: openEdit,
},
{
action: 'deactivate',
label: 'Deactivate role',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate role',
onClick: toggleActive,
visible: (target) => !target.isActive,
},
],
},
}), [openEdit, toggleActive])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Roles</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Permission sets that control what each role can see and do
</p>
</div>
<Button onClick={openCreate}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Role
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
/>
<RoleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
role={editing}
onSuccess={() => fetchData()}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { RolesClient } from './_components/roles-client'
export default function RolesPage() {
return <RolesClient />
}