frontend fully integreated with the backend
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"sessionId":"879f34f3-9715-423d-aee2-535ba3a8accd","pid":14552,"acquiredAt":1787669985270}
|
||||
@@ -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 />
|
||||
}
|
||||
+140
@@ -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 />
|
||||
}
|
||||
+172
@@ -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 />
|
||||
}
|
||||
+156
@@ -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>
|
||||
)
|
||||
}
|
||||
+150
@@ -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)} → {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)} → {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 />
|
||||
}
|
||||
+147
@@ -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'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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+135
@@ -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 />
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Geist_Mono, Outfit, Nunito_Sans } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { UiProvider } from "@/components/ui/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const nunitoSansHeading = Nunito_Sans({ subsets: ['latin'], variable: '--font-heading' })
|
||||
@@ -21,12 +21,12 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
className={cn('antialiased', fontMono.variable, 'font-sans', outfit.variable, nunitoSansHeading.variable)}
|
||||
>
|
||||
<body>
|
||||
<body suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<UiProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</UiProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft01Icon, Home01Icon, CustomerSupportIcon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-6 text-center">
|
||||
{/* Wordmark */}
|
||||
<div className="mb-10 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
D
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">Distribution</span>
|
||||
</div>
|
||||
|
||||
{/* 404 badge */}
|
||||
<div className="mb-4 inline-flex items-center rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground tracking-widest uppercase">
|
||||
Error 404
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="mb-3 font-heading text-4xl font-semibold text-foreground sm:text-5xl">
|
||||
Page not found
|
||||
</h1>
|
||||
|
||||
{/* Assurance copy */}
|
||||
<p className="mb-2 max-w-md text-base text-muted-foreground">
|
||||
This URL doesn't exist in the system — but everything else is working fine.
|
||||
</p>
|
||||
<p className="mb-10 max-w-md text-sm text-muted-foreground">
|
||||
Your data, orders, and operations are all safe and unaffected. You may have followed a broken link or typed an address that has changed.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button render={<Link href="/" />} nativeButton={false}>
|
||||
<HugeiconsIcon icon={Home01Icon} size={16} />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Support nudge */}
|
||||
<p className="mt-12 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<HugeiconsIcon icon={CustomerSupportIcon} size={13} />
|
||||
If you believe this is a mistake, contact your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ChevronRightIcon, UserIcon } from '@hugeicons/core-free-icons'
|
||||
@@ -31,6 +32,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useAuth, type Role } from '@/contexts/auth-context'
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav'
|
||||
|
||||
@@ -48,6 +54,32 @@ export function AppSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const dashboardActive = pathname === DASHBOARD_ITEM.href
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
|
||||
for (const group of NAV_GROUPS) {
|
||||
if (!isGroupVisible(group, permissions)) continue
|
||||
|
||||
const visibleItems = group.items.filter((item) => canSee(item, permissions))
|
||||
const groupActive = visibleItems.some(
|
||||
(item) =>
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href)),
|
||||
)
|
||||
|
||||
if (groupActive) {
|
||||
next[group.title] = true
|
||||
} else if (!(group.title in next)) {
|
||||
next[group.title] = false
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [pathname, permissions])
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -95,7 +127,10 @@ export function AppSidebar() {
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.title}
|
||||
defaultOpen={false}
|
||||
open={openGroups[group.title] ?? groupActive}
|
||||
onOpenChange={(open) =>
|
||||
setOpenGroups((prev) => ({ ...prev, [group.title]: open }))
|
||||
}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarGroup className="py-0">
|
||||
@@ -126,15 +161,18 @@ export function AppSidebar() {
|
||||
(item.href !== '/' &&
|
||||
pathname.startsWith(item.href))
|
||||
return (
|
||||
<SidebarMenuSubItem key={item.href}>
|
||||
<SidebarMenuSubButton
|
||||
render={<Link href={item.href} />}
|
||||
isActive={isActive}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={15} />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<Tooltip key={item.href}>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
render={<TooltipTrigger render={<Link href={item.href} />} />}
|
||||
isActive={isActive}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={15} />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<TooltipContent side="right">{item.description ?? item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RowActionButton, RowActions } from '@/components/shared/row-actions';
|
||||
|
||||
import { TableToolbar } from './table-toolbar';
|
||||
import { TablePagination } from './table-pagination';
|
||||
@@ -27,7 +28,7 @@ import { exportTable } from './hooks/use-table-export';
|
||||
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
|
||||
import type {
|
||||
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
|
||||
ServerSideParams, ExportFormat,
|
||||
ServerSideParams, ExportFormat, RowActionItem,
|
||||
} from './types';
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
@@ -48,6 +49,29 @@ function normalizeField(f: FieldConfig | string): FieldConfig {
|
||||
return typeof f === 'string' ? { key: f } : f;
|
||||
}
|
||||
|
||||
function formatTextValue(raw: unknown): string {
|
||||
if (raw == null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw);
|
||||
if (typeof raw === 'boolean') return raw ? 'Yes' : 'No';
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) return '';
|
||||
if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) {
|
||||
return raw.map(item => formatTextValue(item)).filter(Boolean).join(', ');
|
||||
}
|
||||
return `${raw.length} item${raw.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const namedValue = record.name ?? record.title ?? record.label ?? record.id;
|
||||
return namedValue == null ? '' : formatTextValue(namedValue);
|
||||
}
|
||||
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
|
||||
if (sortState.column !== colKey) {
|
||||
return (
|
||||
@@ -62,6 +86,36 @@ function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState
|
||||
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
|
||||
}
|
||||
|
||||
function DataTableRowActions<T extends object>({
|
||||
row,
|
||||
actions,
|
||||
}: {
|
||||
row: T;
|
||||
actions: RowActionItem<T>[];
|
||||
}) {
|
||||
const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true);
|
||||
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<RowActions>
|
||||
{visibleActions.map((action) => (
|
||||
<RowActionButton
|
||||
key={`${action.action}-${action.label}`}
|
||||
action={action.action}
|
||||
label={action.label}
|
||||
tone={action.tone}
|
||||
disabled={action.disabled?.(row)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCard<T extends object>({
|
||||
row,
|
||||
columns,
|
||||
@@ -73,7 +127,7 @@ function MobileCard<T extends object>({
|
||||
mobileCardCfg?: TableConfig<T>['mobileCard'];
|
||||
onClick?: (row: T) => void;
|
||||
}) {
|
||||
const actionCol = columns.find(c => !c.title && c.cellRender);
|
||||
const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender));
|
||||
const dataColumns = columns.filter(c => !!c.title);
|
||||
|
||||
const primaryCol = dataColumns[0];
|
||||
@@ -153,11 +207,34 @@ function CellContent<T extends object>({
|
||||
const fields = col.fields.map(normalizeField);
|
||||
const layout = col.layout ?? 'vertical';
|
||||
|
||||
if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') {
|
||||
const field = fields[0];
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
'text-sm',
|
||||
field.class,
|
||||
)}
|
||||
style={{ color: field.primary ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text || '—'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = fields.map((field, i) => {
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
const renderer = field.renderer ?? 'text';
|
||||
|
||||
@@ -190,17 +267,19 @@ function CellContent<T extends object>({
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn(
|
||||
'truncate block',
|
||||
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
field.class,
|
||||
i === 0 && 'text-sm',
|
||||
)}
|
||||
style={{ color: field.primary || i === 0 ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -232,6 +311,7 @@ export function DataTable<T extends object>({
|
||||
onRowClick,
|
||||
onServerSideChange,
|
||||
}: DataTableProps<T>) {
|
||||
const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
@@ -257,11 +337,51 @@ export function DataTable<T extends object>({
|
||||
return String(row[config.rowKey as keyof T]);
|
||||
}
|
||||
|
||||
const normalizedColumns = useMemo(() =>
|
||||
config.columns.map(col => ({
|
||||
const normalizedColumns = useMemo(() => {
|
||||
const baseColumns = config.columns.map(col => ({
|
||||
...col,
|
||||
fields: col.fields.map(normalizeField) as FieldConfig[],
|
||||
})), [config.columns]);
|
||||
}));
|
||||
|
||||
if (!config.rowActions) {
|
||||
return baseColumns;
|
||||
}
|
||||
|
||||
const actionsColumn: ColumnDef<T> = {
|
||||
key: '__row_actions',
|
||||
title: config.rowActions.title ?? 'Action',
|
||||
fields: [],
|
||||
width: config.rowActions.width ?? 96,
|
||||
minWidth: config.rowActions.minWidth ?? 96,
|
||||
resizable: false,
|
||||
hideable: false,
|
||||
align: config.rowActions.align ?? 'right',
|
||||
exportable: false,
|
||||
cellRender: (row: T) => (
|
||||
<DataTableRowActions row={row} actions={config.rowActions!.actions(row)} />
|
||||
),
|
||||
};
|
||||
|
||||
return [
|
||||
...baseColumns,
|
||||
actionsColumn,
|
||||
];
|
||||
}, [config.columns, config.rowActions]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder((prev) => {
|
||||
const nextKeys = normalizedColumns.map((col) => col.key);
|
||||
const kept = prev.filter((key) => nextKeys.includes(key));
|
||||
const missing = nextKeys.filter((key) => !kept.includes(key));
|
||||
const next = [...kept, ...missing];
|
||||
|
||||
if (next.length === prev.length && next.every((key, index) => key === prev[index])) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [normalizedColumns]);
|
||||
|
||||
const orderedColumns = useMemo(() =>
|
||||
columnOrder
|
||||
@@ -271,23 +391,23 @@ export function DataTable<T extends object>({
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() =>
|
||||
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
|
||||
orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)),
|
||||
[orderedColumns, hiddenColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() =>
|
||||
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[data, filterState, normalizedColumns, config.serverSide]
|
||||
config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[safeData, filterState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const sortedData = useMemo(() =>
|
||||
config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
[filteredData, sortState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const totalRecords = useMemo(() =>
|
||||
config.serverSide ? (totalCount ?? data.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, data.length, filteredData.length]
|
||||
config.serverSide ? (totalCount ?? safeData.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, safeData.length, filteredData.length]
|
||||
);
|
||||
|
||||
const rowHeightPx = useMemo(() => {
|
||||
@@ -353,7 +473,7 @@ export function DataTable<T extends object>({
|
||||
const next = new Set(selectedKeys);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
@@ -364,7 +484,7 @@ export function DataTable<T extends object>({
|
||||
selectablePageData.forEach(r => next.add(rowKey(r)));
|
||||
}
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@@ -382,6 +502,9 @@ export function DataTable<T extends object>({
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(key: string) {
|
||||
const column = orderedColumns.find(col => col.key === key);
|
||||
if (!column || column.hideable === false) return;
|
||||
|
||||
setHiddenColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TableToolbar<T>({
|
||||
onClearSelection,
|
||||
}: TableToolbarProps<T>) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const toggleableColumns = columns.filter(col => col.hideable !== false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
@@ -91,7 +92,7 @@ export function TableToolbar<T>({
|
||||
Columns
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{columns.map(col => (
|
||||
{toggleableColumns.map(col => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.key}
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
|
||||
@@ -3,6 +3,13 @@ export type ColumnLayout = 'vertical' | 'horizontal';
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type ExportFormat = 'csv' | 'excel' | 'pdf';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
export type RowActionKind =
|
||||
| 'edit'
|
||||
| 'activate'
|
||||
| 'deactivate'
|
||||
| 'confirm'
|
||||
| 'close'
|
||||
| 'cancel';
|
||||
|
||||
export interface BadgeConfig {
|
||||
colorMap?: Record<string, string>;
|
||||
@@ -49,6 +56,7 @@ export interface ColumnDef<T = object> {
|
||||
minWidth?: number;
|
||||
resizable?: boolean;
|
||||
visible?: boolean;
|
||||
hideable?: boolean;
|
||||
align?: ColumnAlign;
|
||||
group?: string;
|
||||
exportable?: boolean;
|
||||
@@ -75,6 +83,23 @@ export interface MobileCardConfig<T = object> {
|
||||
detailHref?: (row: T) => string;
|
||||
}
|
||||
|
||||
export interface RowActionItem<T = object> {
|
||||
action: RowActionKind;
|
||||
label: string;
|
||||
onClick: (row: T) => void;
|
||||
visible?: (row: T) => boolean;
|
||||
disabled?: (row: T) => boolean;
|
||||
tone?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export interface RowActionsConfig<T = object> {
|
||||
title?: string;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
align?: ColumnAlign;
|
||||
actions: (row: T) => RowActionItem<T>[];
|
||||
}
|
||||
|
||||
export interface TableConfig<T = object> {
|
||||
columns: ColumnDef<T>[];
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
@@ -93,6 +118,7 @@ export interface TableConfig<T = object> {
|
||||
isRowSelectable?: (row: T) => boolean;
|
||||
expandRender?: (row: T) => React.ReactNode;
|
||||
mobileCard?: MobileCardConfig<T>;
|
||||
rowActions?: RowActionsConfig<T>;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type FieldPath,
|
||||
type Control,
|
||||
type ControllerRenderProps,
|
||||
type FieldError,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError as FieldErrorDisplay,
|
||||
} from '@/components/ui/field'
|
||||
|
||||
interface FormFieldProps<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>> {
|
||||
control: Control<T>
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
render: (props: {
|
||||
field: ControllerRenderProps<T, TName>
|
||||
error?: FieldError
|
||||
invalid: boolean
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
export function FormField<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
render,
|
||||
}: FormFieldProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid || undefined}>
|
||||
{label && (
|
||||
<FieldLabel htmlFor={String(name)}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{render({ field, error: fieldState.error, invalid: fieldState.invalid })}
|
||||
{description && !fieldState.error && (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
)}
|
||||
{fieldState.error && (
|
||||
<FieldErrorDisplay errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ACTION_ICONS = {
|
||||
activate: CheckmarkCircle02Icon,
|
||||
cancel: Delete02Icon,
|
||||
close: Cancel01Icon,
|
||||
confirm: CheckmarkCircle02Icon,
|
||||
deactivate: Cancel01Icon,
|
||||
edit: PencilEdit01Icon,
|
||||
} as const
|
||||
|
||||
type RowActionKind = keyof typeof ACTION_ICONS
|
||||
|
||||
function RowActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActionButton({
|
||||
action,
|
||||
label,
|
||||
tone = "default",
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
|
||||
action: RowActionKind
|
||||
label: string
|
||||
tone?: "default" | "destructive"
|
||||
}) {
|
||||
const icon = ACTION_ICONS[action]
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
tone === "destructive"
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { RowActions, RowActionButton }
|
||||
@@ -47,7 +47,7 @@ function ThemeHotkey() {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
if (typeof event.key !== "string" || event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-[min(var(--radius-4xl),24px)] bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:bg-input/30 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-4",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
function UiProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { UiProvider }
|
||||
@@ -7,7 +7,64 @@ import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
function collectSelectItems(children: React.ReactNode): Array<{ value: unknown; label: string }> {
|
||||
const items: Array<{ value: unknown; label: string }> = []
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement<{ value?: unknown; label?: string; children?: React.ReactNode }>(child)) {
|
||||
return
|
||||
}
|
||||
|
||||
if ("value" in child.props) {
|
||||
items.push({
|
||||
value: child.props.value ?? null,
|
||||
label: child.props.label ?? getItemLabel(child.props.children) ?? String(child.props.value ?? ""),
|
||||
})
|
||||
}
|
||||
|
||||
if (child.props.children) {
|
||||
items.push(...collectSelectItems(child.props.children))
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function Select<Value, Multiple extends boolean | undefined = false>({
|
||||
children,
|
||||
items,
|
||||
...props
|
||||
}: SelectPrimitive.Root.Props<Value, Multiple>) {
|
||||
const derivedItems = React.useMemo(
|
||||
() => (items == null ? collectSelectItems(children) : items),
|
||||
[children, items]
|
||||
)
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root {...props} items={derivedItems}>
|
||||
{children}
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function getItemLabel(children: React.ReactNode): string | undefined {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children)
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
const parts = children
|
||||
.map(getItemLabel)
|
||||
.filter((part): part is string => Boolean(part))
|
||||
return parts.length > 0 ? parts.join(" ") : undefined
|
||||
}
|
||||
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(children)) {
|
||||
return getItemLabel(children.props.children)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
@@ -64,12 +121,13 @@ function SelectContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
alignItemWithTrigger = false,
|
||||
collisionAvoidance = { side: "shift", align: "shift", fallbackAxisSide: "none" },
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" | "collisionAvoidance"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
@@ -79,6 +137,7 @@ function SelectContent({
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
collisionAvoidance={collisionAvoidance}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
@@ -114,25 +173,28 @@ function SelectItem({
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
const label = props.label ?? getItemLabel(children)
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
data-slot="select-item"
|
||||
label={label}
|
||||
className={cn(
|
||||
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex min-h-7 w-full items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm text-popover-foreground outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
<span className="pointer-events-none flex size-4 shrink-0 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText className="block flex-1 whitespace-nowrap text-popover-foreground">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon, CheckmarkCircle02Icon, InformationCircleIcon, Alert02Icon, MultiplicationSignCircleIcon, Loading03Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const toast = ToastPrimitive.createToastManager()
|
||||
|
||||
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
||||
return <ToastPrimitive.Provider {...props} />
|
||||
}
|
||||
|
||||
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
|
||||
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
|
||||
}
|
||||
|
||||
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Viewport
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-4 bottom-4 z-[100] mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
data-slot="toast"
|
||||
className={cn(
|
||||
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl! border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
|
||||
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
|
||||
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
||||
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
|
||||
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
|
||||
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
||||
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Content
|
||||
data-slot="toast-content"
|
||||
className={cn(
|
||||
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Title
|
||||
data-slot="toast-title"
|
||||
className={cn("text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastDescription({
|
||||
className,
|
||||
...props
|
||||
}: ToastPrimitive.Description.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Description
|
||||
data-slot="toast-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastAction({
|
||||
className,
|
||||
render = <Button variant="outline" size="sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Action.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Action
|
||||
data-slot="toast-action"
|
||||
render={render}
|
||||
className={cn("shrink-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastClose({
|
||||
className,
|
||||
children,
|
||||
render = <Button variant="ghost" size="icon-sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Close.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Close
|
||||
data-slot="toast-close"
|
||||
aria-label="Close toast"
|
||||
render={render}
|
||||
className={cn(
|
||||
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</ToastPrimitive.Close>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type: string | undefined }) {
|
||||
let icon: React.ReactNode = null
|
||||
|
||||
if (type === "success") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "info") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={InformationCircleIcon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "warning") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={Alert02Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={MultiplicationSignCircleIcon} strokeWidth={2} className="text-destructive" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "loading") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} className="animate-spin" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="toast-icon"
|
||||
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastList() {
|
||||
const { toasts } = ToastPrimitive.useToastManager()
|
||||
|
||||
return toasts.map((toastItem) => (
|
||||
<Toast key={toastItem.id} toast={toastItem}>
|
||||
<ToastContent>
|
||||
<ToastIcon type={toastItem.type} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<ToastTitle />
|
||||
<ToastDescription />
|
||||
</div>
|
||||
<ToastAction />
|
||||
<ToastClose />
|
||||
</ToastContent>
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
function Toaster({
|
||||
children,
|
||||
toastManager = toast,
|
||||
...props
|
||||
}: ToastPrimitive.Provider.Props) {
|
||||
return (
|
||||
<ToastProvider toastManager={toastManager} {...props}>
|
||||
{children}
|
||||
<ToastPortal>
|
||||
<ToastViewport>
|
||||
<ToastList />
|
||||
</ToastViewport>
|
||||
</ToastPortal>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const createToastManager = ToastPrimitive.createToastManager
|
||||
const useToastManager = ToastPrimitive.useToastManager
|
||||
|
||||
export {
|
||||
Toaster,
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastContent,
|
||||
ToastDescription,
|
||||
ToastPortal,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
createToastManager,
|
||||
toast,
|
||||
useToastManager,
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon: IconSvgElement
|
||||
description?: string
|
||||
permission?: string
|
||||
}
|
||||
|
||||
@@ -49,96 +50,96 @@ export const DASHBOARD_ITEM: NavItem = {
|
||||
}
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: 'Master Data',
|
||||
icon: Building02Icon,
|
||||
items: [
|
||||
{ title: 'Regions', href: '/master/regions', icon: Location01Icon, permission: 'regions.view' },
|
||||
{ title: 'Routes', href: '/master/routes', icon: Route01Icon, permission: 'routes.view' },
|
||||
{ title: 'Vehicles', href: '/master/vehicles', icon: Car01Icon, permission: 'vehicles.view' },
|
||||
{ title: 'Warehouses', href: '/master/warehouses', icon: Building02Icon, permission: 'warehouses.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Catalogue',
|
||||
icon: CatalogueIcon,
|
||||
items: [
|
||||
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' },
|
||||
{ title: 'Categories', href: '/catalogue/categories', icon: CatalogueIcon, permission: 'categories.view' },
|
||||
{ title: 'Brands', href: '/catalogue/brands', icon: BarCode01Icon, permission: 'categories.view' },
|
||||
{ title: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, permission: 'categories.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Pricing',
|
||||
icon: Discount01Icon,
|
||||
items: [
|
||||
{ title: 'Price Lists', href: '/pricing/price-lists', icon: Discount01Icon, permission: 'pricelists.view' },
|
||||
{ title: 'Schemes', href: '/pricing/schemes', icon: DiscountTag01Icon, permission: 'schemes.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suppliers',
|
||||
icon: UserGroupIcon,
|
||||
items: [
|
||||
{ title: 'Suppliers', href: '/suppliers', icon: UserGroupIcon, permission: 'suppliers.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stores',
|
||||
icon: Store01Icon,
|
||||
items: [
|
||||
{ title: 'Stores', href: '/stores', icon: Store01Icon, permission: 'stores.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Users & Access',
|
||||
icon: UserShield01Icon,
|
||||
items: [
|
||||
{ title: 'Users', href: '/users', icon: UserShield01Icon, permission: 'users.view' },
|
||||
{ title: 'Roles', href: '/users/roles', icon: ShieldUserIcon, permission: 'roles.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Drivers',
|
||||
icon: LicenseDraftIcon,
|
||||
items: [
|
||||
{ title: 'Driver Profiles', href: '/drivers', icon: LicenseDraftIcon, permission: 'driver_profiles.view' },
|
||||
{ title: 'Compliance', href: '/drivers/compliance', icon: LicenseIcon, permission: 'compliance.manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Procurement',
|
||||
icon: ShoppingCart01Icon,
|
||||
items: [
|
||||
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, permission: 'purchase_orders.view' },
|
||||
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, permission: 'purchase_orders.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
icon: TruckIcon,
|
||||
items: [
|
||||
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' },
|
||||
{ title: 'Loading Sheets', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' },
|
||||
{ title: 'Delivery Tasks', href: '/operations/deliveries', icon: DeliveryTruck01Icon, permission: 'rides.view' },
|
||||
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, description: 'Delivery/Sales runs assigned to a driver and vehicle', permission: 'rides.view' },
|
||||
{ title: 'Loading Sheets', href: '/operations/loading', icon: DeliveryBox01Icon, description: 'Track goods loaded onto a vehicle before dispatch', permission: 'loading.view' },
|
||||
{ title: 'Delivery Tasks', href: '/operations/deliveries', icon: DeliveryTruck01Icon, description: 'Individual store drop-offs within a ride', permission: 'rides.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sales & Orders',
|
||||
icon: DollarSquareIcon,
|
||||
items: [
|
||||
{ title: 'Sales', href: '/sales', icon: DollarSquareIcon, permission: 'orders.view' },
|
||||
{ title: 'Orders', href: '/sales/orders', icon: ClipboardListIcon, permission: 'orders.view' },
|
||||
{ title: 'Payments', href: '/sales/payments', icon: BankIcon, permission: 'orders.view' },
|
||||
{ title: 'Sales', href: '/sales', icon: DollarSquareIcon, description: 'Sales transactions recorded against stores', permission: 'orders.view' },
|
||||
{ title: 'Orders', href: '/sales/orders', icon: ClipboardListIcon, description: 'Fulfillment status of store orders', permission: 'orders.view' },
|
||||
{ title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected from stores', permission: 'orders.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stores',
|
||||
icon: Store01Icon,
|
||||
items: [
|
||||
{ title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Catalogue',
|
||||
icon: CatalogueIcon,
|
||||
items: [
|
||||
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' },
|
||||
{ title: 'Categories', href: '/catalogue/categories', icon: CatalogueIcon, description: 'Product groupings and sub-categories', permission: 'categories.view' },
|
||||
{ title: 'Brands', href: '/catalogue/brands', icon: BarCode01Icon, description: 'Brands associated with catalogue items', permission: 'categories.view' },
|
||||
{ title: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, description: 'Units of measure and conversion factors between units', permission: 'categories.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stock',
|
||||
icon: PackageMoving01Icon,
|
||||
items: [
|
||||
{ title: 'Stock Balances', href: '/stock', icon: Package01Icon, permission: 'warehouses.view' },
|
||||
{ title: 'Stock Movements', href: '/stock/movements', icon: PackageMoving01Icon, permission: 'warehouses.view' },
|
||||
{ title: 'Stock Balances', href: '/stock', icon: Package01Icon, description: 'Current quantity of each item across warehouses and vehicles', permission: 'warehouses.view' },
|
||||
{ title: 'Stock Movements', href: '/stock/movements', icon: PackageMoving01Icon, description: 'Full history of stock in and out across all locations', permission: 'warehouses.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Procurement',
|
||||
icon: ShoppingCart01Icon,
|
||||
items: [
|
||||
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, description: 'Orders placed with suppliers to restock inventory', permission: 'purchase_orders.view' },
|
||||
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, description: 'Record stock received into a warehouse against a purchase order', permission: 'purchase_orders.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Pricing',
|
||||
icon: Discount01Icon,
|
||||
items: [
|
||||
{ title: 'Price Lists', href: '/pricing/price-lists', icon: Discount01Icon, description: 'Item prices scoped by region and active date range', permission: 'pricelists.view' },
|
||||
{ title: 'Schemes', href: '/pricing/schemes', icon: DiscountTag01Icon, description: 'Promotions, bundle deals, and slab discounts applied at sale', permission: 'schemes.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suppliers',
|
||||
icon: UserGroupIcon,
|
||||
items: [
|
||||
{ title: 'Suppliers', href: '/suppliers', icon: UserGroupIcon, description: 'Supplier accounts used for raising purchase orders', permission: 'suppliers.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Drivers',
|
||||
icon: LicenseDraftIcon,
|
||||
items: [
|
||||
{ title: 'Driver Profiles', href: '/drivers', icon: LicenseDraftIcon, description: 'Driver records including license and medical clearance dates', permission: 'driver_profiles.view' },
|
||||
{ title: 'Compliance', href: '/drivers/compliance', icon: LicenseIcon, description: 'Rules that enforce license and medical clearance before a ride starts', permission: 'compliance.manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Users & Access',
|
||||
icon: UserShield01Icon,
|
||||
items: [
|
||||
{ title: 'Users', href: '/users', icon: UserShield01Icon, description: 'Staff accounts and their role assignments', permission: 'users.view' },
|
||||
{ title: 'Roles', href: '/users/roles', icon: ShieldUserIcon, description: 'Permission sets that control what each role can see and do', permission: 'roles.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
icon: Building02Icon,
|
||||
items: [
|
||||
{ title: 'Regions', href: '/network/regions', icon: Location01Icon, description: 'Geographic territories the distribution network operates in', permission: 'regions.view' },
|
||||
{ title: 'Routes', href: '/network/routes', icon: Route01Icon, description: 'Delivery paths within a region that rides are assigned to', permission: 'routes.view' },
|
||||
{ title: 'Vehicles', href: '/network/vehicles', icon: Car01Icon, description: 'Fleet vehicles available for delivery rides', permission: 'vehicles.view' },
|
||||
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Distribution System — Frontend Plan
|
||||
|
||||
## Stack
|
||||
|
||||
- **Framework**: Next.js (App Router)
|
||||
- **UI**: shadcn/ui + Base UI primitives
|
||||
- **Icons**: Hugeicons (`@hugeicons/react`)
|
||||
- **Fonts**: Outfit (body), Nunito Sans (headings), Geist Mono (mono)
|
||||
- **Auth**: Mock context now → real API once backend is ready
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
| Area | Details |
|
||||
|---|---|
|
||||
| Project scaffold | Next.js + shadcn/ui initialised |
|
||||
| Layout | Dashboard shell with collapsible sidebar, mobile bottom nav |
|
||||
| Navigation | All module groups and routes defined in `config/nav.ts` |
|
||||
| Auth context | Role-based permission system (admin / warehouse / sales) with mock users |
|
||||
| Data table shell | Reusable `DataTable`, `TableToolbar`, `TablePagination` components |
|
||||
| 404 page | `app/not-found.tsx` with user assurance copy and navigation CTAs |
|
||||
| API types | Full backend contract in `types/api.ts` — all response shapes, enums, `PaginatedResponse<T>` |
|
||||
| API client | `lib/api.ts` (axios instance, auth headers, 401 redirect, error helper) + `lib/api/` module functions for all 11 domains |
|
||||
|
||||
---
|
||||
|
||||
## Module Build-out
|
||||
|
||||
Ordered by **data dependency** — each module can only work once the modules above it have data. Each needs: list page → create form → edit form.
|
||||
|
||||
### 1. Network — no dependencies
|
||||
- [x] Regions — list, create, edit
|
||||
- [x] Routes — list, create, edit *(depends on: Regions)*
|
||||
- [x] Vehicles — list, create, edit
|
||||
- [x] Warehouses — list, create, edit
|
||||
|
||||
### 2. Catalogue — no dependencies
|
||||
- [x] Categories — list, create, edit
|
||||
- [x] Brands — list, create, edit
|
||||
- [x] UOMs — list, create, edit (with conversion factors)
|
||||
- [x] Items — list, create, edit *(depends on: Categories, Brands, UOMs)*
|
||||
|
||||
### 3. Suppliers — no dependencies
|
||||
- [x] Suppliers — list, create, edit
|
||||
|
||||
### 4. Users & Access — no dependencies
|
||||
- [x] Roles — list, create, edit (with permission assignment)
|
||||
- [x] Users — list, create, edit *(depends on: Roles)*
|
||||
|
||||
### 5. Stores — depends on: Routes
|
||||
- [x] Stores — list, create, edit
|
||||
|
||||
### 6. Drivers — depends on: Users
|
||||
- [x] Driver Profiles — list, create, edit
|
||||
- [x] Compliance — manage enforcement config
|
||||
|
||||
### 7. Pricing — depends on: Regions, Items
|
||||
- [x] Price Lists — list, create, edit
|
||||
- [x] Schemes — list, create, edit
|
||||
|
||||
### 8. Procurement — depends on: Suppliers, Items, Warehouses
|
||||
- [x] Purchase Orders — list, create, view
|
||||
- [x] Goods Receipt (GRN) — list, receive against PO
|
||||
|
||||
### 9. Operations — depends on: Vehicles, Drivers, Routes, Warehouses
|
||||
- [x] Rides — list, view, manage status
|
||||
- [x] Loading Sheets — list, dispatch, receive *(depends on: Rides, Items)*
|
||||
- [x] Delivery Tasks — list, view *(depends on: Rides, Stores)*
|
||||
|
||||
### 10. Sales & Orders — depends on: Stores, Items, Rides
|
||||
- [x] Sales — list, create, view
|
||||
- [x] Orders — list, view
|
||||
- [x] Payments — list, record
|
||||
|
||||
### 11. Stock — depends on: all above
|
||||
- [x] Stock Balances — list, filter by location/item
|
||||
- [x] Stock Movements — list, filter by type
|
||||
|
||||
---
|
||||
|
||||
## Pending Infrastructure
|
||||
|
||||
- [ ] Real auth — wire login page to backend, store JWT, refresh flow
|
||||
- [x] API client — typed fetch wrapper with auth headers and error handling
|
||||
- [x] Toast / notification system — shadcn `toast` component (`Toaster` in root layout, `toast.add` / `toast.promise` API)
|
||||
- [x] Form validation library — react-hook-form + zod + @hookform/resolvers; shadcn `field` component; `lib/form.ts` barrel; `components/shared/form-field.tsx` reusable Controller wrapper
|
||||
- [ ] Dashboard — KPI cards and summary widgets (depends on backend)
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- All collection endpoints must have **pagination** — no exceptions (server-side preferred).
|
||||
- Pages live in `app/(dashboard)/<module>/page.tsx`.
|
||||
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
|
||||
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000',
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5089/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ api.interceptors.response.use(
|
||||
(error: AxiosError<{ error: string }>) => {
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
// Redirect to login when auth is implemented
|
||||
if (typeof window !== 'undefined') window.location.href = '/login'
|
||||
return new Promise(() => {}) // redirect in progress — never resolve so no error toast fires
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
ItemSummaryResponse,
|
||||
ItemDetailResponse,
|
||||
CategoryResponse,
|
||||
BrandResponse,
|
||||
UomResponse,
|
||||
SyncSource,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Items ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ItemListParams extends PageParams {
|
||||
categoryId?: string
|
||||
brandId?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface ItemBody {
|
||||
sku: string
|
||||
name: string
|
||||
description?: string | null
|
||||
categoryId: string
|
||||
subCategoryId?: string | null
|
||||
brandId?: string | null
|
||||
baseUomId: string
|
||||
salePrice: number
|
||||
syncSource?: SyncSource
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const items = {
|
||||
list: (params?: ItemListParams) =>
|
||||
api.get<PaginatedResponse<ItemSummaryResponse>>('/items', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<ItemDetailResponse>(`/items/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: ItemBody) =>
|
||||
api.post<ItemDetailResponse>('/items', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<ItemBody>) =>
|
||||
api.put<ItemDetailResponse>(`/items/${id}`, body).then(r => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/items/${id}`),
|
||||
}
|
||||
|
||||
// ─── Categories ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CategoryBody {
|
||||
name: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface SubCategoryBody {
|
||||
name: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const categories = {
|
||||
list: () =>
|
||||
api.get<CategoryResponse[]>('/categories').then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<CategoryResponse>(`/categories/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: CategoryBody) =>
|
||||
api.post<CategoryResponse>('/categories', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<CategoryBody>) =>
|
||||
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
|
||||
|
||||
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
|
||||
api.post(`/categories/${categoryId}/sub-categories`, body).then(r => r.data),
|
||||
|
||||
updateSubCategory: (categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||
api.put(`/categories/${categoryId}/sub-categories/${subId}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Brands ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BrandBody {
|
||||
name: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const brands = {
|
||||
list: (params?: PageParams) =>
|
||||
api.get<PaginatedResponse<BrandResponse>>('/brands', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<BrandResponse>(`/brands/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: BrandBody) =>
|
||||
api.post<BrandResponse>('/brands', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<BrandBody>) =>
|
||||
api.put<BrandResponse>(`/brands/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── UOMs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UomBody {
|
||||
name: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface UomConversionBody {
|
||||
toUomId: string
|
||||
factor: number
|
||||
}
|
||||
|
||||
export const uoms = {
|
||||
list: () =>
|
||||
api.get<UomResponse[]>('/uoms').then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<UomResponse>(`/uoms/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: UomBody) =>
|
||||
api.post<UomResponse>('/uoms', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<UomBody>) =>
|
||||
api.put<UomResponse>(`/uoms/${id}`, body).then(r => r.data),
|
||||
|
||||
addConversion: (id: string, body: UomConversionBody) =>
|
||||
api.post(`/uoms/${id}/conversions`, body).then(r => r.data),
|
||||
|
||||
removeConversion: (id: string, conversionId: string) =>
|
||||
api.delete(`/uoms/${id}/conversions/${conversionId}`),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
DriverProfileResponse,
|
||||
DriverComplianceConfigResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Driver Profiles ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DriverProfileBody {
|
||||
userId: string
|
||||
licenseNumber: string
|
||||
licenseExpiry: string
|
||||
medicalClearanceExpiry: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const drivers = {
|
||||
list: (params?: PageParams) =>
|
||||
api.get<PaginatedResponse<DriverProfileResponse>>('/driver-profiles', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<DriverProfileResponse>(`/driver-profiles/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: DriverProfileBody) =>
|
||||
api.post<DriverProfileResponse>('/driver-profiles', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<DriverProfileBody>) =>
|
||||
api.put<DriverProfileResponse>(`/driver-profiles/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Compliance Config ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComplianceConfigBody {
|
||||
enforceLicenseExpiry: boolean
|
||||
enforceMedicalClearance: boolean
|
||||
}
|
||||
|
||||
export const compliance = {
|
||||
get: () =>
|
||||
api.get<DriverComplianceConfigResponse>('/driver-profiles/compliance').then(r => r.data),
|
||||
|
||||
update: (body: ComplianceConfigBody) =>
|
||||
api.put<DriverComplianceConfigResponse>('/driver-profiles/compliance', body).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { regions, routes, vehicles, warehouses } from './master'
|
||||
export { items, categories, brands, uoms } from './catalogue'
|
||||
export { priceLists, schemes } from './pricing'
|
||||
export { stores } from './stores'
|
||||
export { suppliers } from './suppliers'
|
||||
export { users, roles, permissions } from './users'
|
||||
export { drivers, compliance } from './drivers'
|
||||
export { purchaseOrders, grn } from './procurement'
|
||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { sales, orders, payments } from './sales'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
export { getApiError } from '@/lib/api'
|
||||
@@ -0,0 +1,186 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
RegionResponse,
|
||||
RouteResponse,
|
||||
VehicleResponse,
|
||||
WarehouseResponse,
|
||||
VehicleType,
|
||||
WarehouseType,
|
||||
} from '@/types/api'
|
||||
|
||||
const VEHICLE_TYPE_CODES: Record<VehicleType, number> = {
|
||||
Car: 0,
|
||||
Truck: 1,
|
||||
Van: 2,
|
||||
Motorcycle: 3,
|
||||
Bicycle: 4,
|
||||
}
|
||||
|
||||
const VEHICLE_TYPE_VALUES = ['Car', 'Truck', 'Van', 'Motorcycle', 'Bicycle'] as const
|
||||
|
||||
function normalizeVehicleType(type: VehicleType | number): VehicleType {
|
||||
if (typeof type === 'number') {
|
||||
return VEHICLE_TYPE_VALUES[type] ?? 'Car'
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
function normalizeVehicle(
|
||||
vehicle: VehicleResponse | (Omit<VehicleResponse, 'type'> & { type: VehicleType | number })
|
||||
): VehicleResponse {
|
||||
return {
|
||||
...vehicle,
|
||||
type: normalizeVehicleType(vehicle.type),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVehicleListResponse(
|
||||
response: PaginatedResponse<VehicleResponse> | (Omit<VehicleResponse, 'type'> & { type: VehicleType | number })[],
|
||||
params?: VehicleListParams
|
||||
): PaginatedResponse<VehicleResponse> {
|
||||
if (Array.isArray(response)) {
|
||||
const page = params?.page ?? 1
|
||||
const pageSize = params?.pageSize ?? (response.length || 1)
|
||||
const start = (page - 1) * pageSize
|
||||
const items = response.slice(start, start + pageSize).map(normalizeVehicle)
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: response.length,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
items: response.items.map(normalizeVehicle),
|
||||
}
|
||||
}
|
||||
|
||||
function serializeVehicleBody(body: VehicleBody | Partial<VehicleBody>) {
|
||||
return {
|
||||
...body,
|
||||
...(body.type ? { type: VEHICLE_TYPE_CODES[body.type] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PageParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
// ─── Regions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegionBody {
|
||||
name: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const regions = {
|
||||
list: (params?: PageParams) =>
|
||||
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: RegionBody) =>
|
||||
api.post<RegionResponse>('/regions', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<RegionBody>) =>
|
||||
api.put<RegionResponse>(`/regions/${id}`, body).then(r => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/regions/${id}`),
|
||||
}
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RouteBody {
|
||||
regionId: string
|
||||
routeName: string
|
||||
startPoint: string
|
||||
endPoint: string
|
||||
length: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const routes = {
|
||||
list: (params?: PageParams & { regionId?: string }) =>
|
||||
api.get<PaginatedResponse<RouteResponse>>('/routes', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<RouteResponse>(`/routes/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: RouteBody) =>
|
||||
api.post<RouteResponse>('/routes', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<RouteBody>) =>
|
||||
api.put<RouteResponse>(`/routes/${id}`, body).then(r => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/routes/${id}`),
|
||||
}
|
||||
|
||||
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VehicleBody {
|
||||
regNumber: string
|
||||
numberPlate: string
|
||||
type: VehicleType
|
||||
}
|
||||
|
||||
export interface VehicleListParams extends PageParams {
|
||||
available?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const vehicles = {
|
||||
list: (params?: VehicleListParams) =>
|
||||
api.get<PaginatedResponse<VehicleResponse> | VehicleResponse[]>('/vehicles', { params }).then(r =>
|
||||
normalizeVehicleListResponse(r.data, params)
|
||||
),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<VehicleResponse>(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)),
|
||||
|
||||
create: (body: VehicleBody) =>
|
||||
api.post<VehicleResponse>('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)),
|
||||
|
||||
update: (id: string, body: Partial<VehicleBody>) =>
|
||||
api.put<VehicleResponse>(`/vehicles/${id}`, serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/vehicles/${id}`),
|
||||
}
|
||||
|
||||
// ─── Warehouses ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WarehouseBody {
|
||||
name: string
|
||||
location: string
|
||||
type: WarehouseType
|
||||
vehicleId?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const warehouses = {
|
||||
list: (params?: PageParams) =>
|
||||
api.get<PaginatedResponse<WarehouseResponse>>('/warehouses', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<WarehouseResponse>(`/warehouses/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: WarehouseBody) =>
|
||||
api.post<WarehouseResponse>('/warehouses', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<WarehouseBody>) =>
|
||||
api.put<WarehouseResponse>(`/warehouses/${id}`, body).then(r => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/warehouses/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
RideResponse,
|
||||
CancelRequestResponse,
|
||||
LoadingSheetResponse,
|
||||
DeliveryTaskResponse,
|
||||
RideType,
|
||||
RideStatus,
|
||||
CancelRequestType,
|
||||
CancelRequestStatus,
|
||||
LoadingSheetStatus,
|
||||
DeliveryTaskStatus,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Rides ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RideBody {
|
||||
rideType: RideType
|
||||
vehicleId: string
|
||||
driverProfileId: string
|
||||
routeId: string
|
||||
sourceWarehouseId: string
|
||||
meterstart: number
|
||||
}
|
||||
|
||||
export interface RideListParams extends PageParams {
|
||||
status?: RideStatus
|
||||
rideType?: RideType
|
||||
driverProfileId?: string
|
||||
vehicleId?: string
|
||||
}
|
||||
|
||||
export interface CancelRequestBody {
|
||||
typeCode: CancelRequestType
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CancelRequestReviewBody {
|
||||
status: CancelRequestStatus
|
||||
}
|
||||
|
||||
export const rides = {
|
||||
list: (params?: RideListParams) =>
|
||||
api.get<PaginatedResponse<RideResponse>>('/rides', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<RideResponse>(`/rides/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: RideBody) =>
|
||||
api.post<RideResponse>('/rides', body).then(r => r.data),
|
||||
|
||||
activate: (id: string) =>
|
||||
api.patch<RideResponse>(`/rides/${id}/activate`).then(r => r.data),
|
||||
|
||||
close: (id: string, meterend: number) =>
|
||||
api.patch<RideResponse>(`/rides/${id}/close`, { meterend }).then(r => r.data),
|
||||
|
||||
cancelRequests: {
|
||||
list: (rideId: string) =>
|
||||
api.get<CancelRequestResponse[]>(`/rides/${rideId}/cancel-requests`).then(r => r.data),
|
||||
|
||||
create: (rideId: string, body: CancelRequestBody) =>
|
||||
api.post<CancelRequestResponse>(`/rides/${rideId}/cancel-requests`, body).then(r => r.data),
|
||||
|
||||
review: (rideId: string, requestId: string, body: CancelRequestReviewBody) =>
|
||||
api.patch<CancelRequestResponse>(`/rides/${rideId}/cancel-requests/${requestId}`, body).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Loading Sheets ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoadingSheetItemBody {
|
||||
itemId: string
|
||||
quantityDispatched: number
|
||||
}
|
||||
|
||||
export interface LoadingSheetBody {
|
||||
rideId: string
|
||||
sourceWarehouseId: string
|
||||
items: LoadingSheetItemBody[]
|
||||
}
|
||||
|
||||
export interface LoadingSheetReceiptItemBody {
|
||||
itemId: string
|
||||
quantityReceived: number
|
||||
}
|
||||
|
||||
export interface LoadingSheetListParams extends PageParams {
|
||||
rideId?: string
|
||||
status?: LoadingSheetStatus
|
||||
}
|
||||
|
||||
export const loadingSheets = {
|
||||
list: (params?: LoadingSheetListParams) =>
|
||||
api.get<PaginatedResponse<LoadingSheetResponse>>('/loading-sheets', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<LoadingSheetResponse>(`/loading-sheets/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: LoadingSheetBody) =>
|
||||
api.post<LoadingSheetResponse>('/loading-sheets', body).then(r => r.data),
|
||||
|
||||
confirmDispatch: (id: string) =>
|
||||
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/dispatch`).then(r => r.data),
|
||||
|
||||
confirmReceipt: (id: string, items: LoadingSheetReceiptItemBody[]) =>
|
||||
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/receipt`, { items }).then(r => r.data),
|
||||
|
||||
resolve: (id: string) =>
|
||||
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/resolve`).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Delivery Tasks ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DeliveryTaskListParams extends PageParams {
|
||||
rideId?: string
|
||||
status?: DeliveryTaskStatus
|
||||
storeId?: string
|
||||
}
|
||||
|
||||
export const deliveryTasks = {
|
||||
listByRide: (rideId: string, params?: Omit<DeliveryTaskListParams, 'rideId'>) =>
|
||||
api.get<PaginatedResponse<DeliveryTaskResponse>>(`/rides/${rideId}/delivery-tasks`, { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data),
|
||||
|
||||
updateStatus: (id: string, status: DeliveryTaskStatus, note?: string) =>
|
||||
api.patch<DeliveryTaskResponse>(`/delivery-tasks/${id}/status`, { status, note }).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function getPaginatedItems<T>(value: T[] | { items?: T[] } | null | undefined): T[] {
|
||||
if (Array.isArray(value)) return value
|
||||
return Array.isArray(value?.items) ? value.items : []
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
PriceListResponse,
|
||||
SchemeResponse,
|
||||
SchemeRuleType,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Price Lists ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PriceListItemBody {
|
||||
itemId: string
|
||||
price: number
|
||||
minPrice?: number | null
|
||||
}
|
||||
|
||||
export interface PriceListBody {
|
||||
name: string
|
||||
regionId?: string | null
|
||||
effectiveFrom: string
|
||||
effectiveTo: string
|
||||
isActive?: boolean
|
||||
items: PriceListItemBody[]
|
||||
}
|
||||
|
||||
export interface PriceListListParams extends PageParams {
|
||||
regionId?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const priceLists = {
|
||||
list: (params?: PriceListListParams) =>
|
||||
api.get<PaginatedResponse<PriceListResponse>>('/price-lists', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<PriceListResponse>(`/price-lists/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: PriceListBody) =>
|
||||
api.post<PriceListResponse>('/price-lists', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<PriceListBody>) =>
|
||||
api.put<PriceListResponse>(`/price-lists/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Schemes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BundleComponentBody {
|
||||
itemId: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface SchemeBody {
|
||||
name: string
|
||||
ruleType: SchemeRuleType
|
||||
ruleConfig?: string | null
|
||||
triggerItemId?: string | null
|
||||
triggerQty?: number
|
||||
freeItemId?: string | null
|
||||
freeQty?: number
|
||||
bundleComponents?: BundleComponentBody[]
|
||||
regionId?: string | null
|
||||
eligibleSegment?: string | null
|
||||
dateRangeFrom: string
|
||||
dateRangeTo: string
|
||||
agentLimitExempt?: boolean
|
||||
baselineExpectedSales?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface SchemeListParams extends PageParams {
|
||||
regionId?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const schemes = {
|
||||
list: (params?: SchemeListParams) =>
|
||||
api.get<PaginatedResponse<SchemeResponse>>('/schemes', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<SchemeResponse>(`/schemes/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: SchemeBody) =>
|
||||
api.post<SchemeResponse>('/schemes', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<SchemeBody>) =>
|
||||
api.put<SchemeResponse>(`/schemes/${id}`, body).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
PurchaseOrderResponse,
|
||||
GrnResponse,
|
||||
PurchaseOrderStatus,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Purchase Orders ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PurchaseOrderItemBody {
|
||||
itemId: string
|
||||
quantity: number
|
||||
unitCost: number
|
||||
}
|
||||
|
||||
export interface PurchaseOrderBody {
|
||||
supplierId: string
|
||||
items: PurchaseOrderItemBody[]
|
||||
}
|
||||
|
||||
export interface PurchaseOrderListParams extends PageParams {
|
||||
supplierId?: string
|
||||
status?: PurchaseOrderStatus
|
||||
}
|
||||
|
||||
export const purchaseOrders = {
|
||||
list: (params?: PurchaseOrderListParams) =>
|
||||
api.get<PaginatedResponse<PurchaseOrderResponse>>('/purchase-orders', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<PurchaseOrderResponse>(`/purchase-orders/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: PurchaseOrderBody) =>
|
||||
api.post<PurchaseOrderResponse>('/purchase-orders', body).then(r => r.data),
|
||||
|
||||
cancel: (id: string, cancelReason: string) =>
|
||||
api.patch<PurchaseOrderResponse>(`/purchase-orders/${id}/cancel`, { cancelReason }).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Goods Receipts (GRN) ─────────────────────────────────────────────────────
|
||||
|
||||
export interface GrnItemBody {
|
||||
purchaseOrderItemId: string
|
||||
acceptedQuantity: number
|
||||
rejectedQuantity: number
|
||||
}
|
||||
|
||||
export interface GrnBody {
|
||||
purchaseOrderId: string
|
||||
warehouseId: string
|
||||
receivedAt: string
|
||||
items: GrnItemBody[]
|
||||
}
|
||||
|
||||
export const grn = {
|
||||
list: (params?: PageParams & { purchaseOrderId?: string }) =>
|
||||
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<GrnResponse>(`/goods-receipts/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: GrnBody) =>
|
||||
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
SaleResponse,
|
||||
OrderResponse,
|
||||
PaymentResponse,
|
||||
SaleStatus,
|
||||
OrderStatus,
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Sales ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaleItemBody {
|
||||
itemId: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
||||
export interface SaleBody {
|
||||
storeId: string
|
||||
rideId?: string | null
|
||||
fulfillImmediately?: boolean
|
||||
items: SaleItemBody[]
|
||||
}
|
||||
|
||||
export interface SaleListParams extends PageParams {
|
||||
storeId?: string
|
||||
status?: SaleStatus
|
||||
paymentStatus?: PaymentStatus
|
||||
rideId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export const sales = {
|
||||
list: (params?: SaleListParams) =>
|
||||
api.get<PaginatedResponse<SaleResponse>>('/sales', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<SaleResponse>(`/sales/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: SaleBody) =>
|
||||
api.post<SaleResponse>('/sales', body).then(r => r.data),
|
||||
|
||||
cancel: (id: string) =>
|
||||
api.patch<SaleResponse>(`/sales/${id}/cancel`).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Orders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OrderListParams extends PageParams {
|
||||
storeId?: string
|
||||
status?: OrderStatus
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export const orders = {
|
||||
list: (params?: OrderListParams) =>
|
||||
api.get<PaginatedResponse<OrderResponse>>('/orders', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<OrderResponse>(`/orders/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Payments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaymentBody {
|
||||
saleId: string
|
||||
storeId: string
|
||||
amount: number
|
||||
method: PaymentMethod
|
||||
collectedAt: string
|
||||
}
|
||||
|
||||
export interface PaymentListParams extends PageParams {
|
||||
storeId?: string
|
||||
saleId?: string
|
||||
method?: PaymentMethod
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export const payments = {
|
||||
list: (params?: PaymentListParams) =>
|
||||
api.get<PaginatedResponse<PaymentResponse>>('/payments', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<PaymentResponse>(`/payments/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: PaymentBody) =>
|
||||
api.post<PaymentResponse>('/payments', body).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
StockBalanceResponse,
|
||||
StockMovementResponse,
|
||||
LocationType,
|
||||
MovementType,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
export interface StockBalanceParams extends PageParams {
|
||||
locationId?: string
|
||||
locationType?: LocationType
|
||||
itemId?: string
|
||||
}
|
||||
|
||||
export interface StockMovementParams extends PageParams {
|
||||
itemId?: string
|
||||
locationId?: string
|
||||
type?: MovementType
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export const stock = {
|
||||
balances: (params?: StockBalanceParams) =>
|
||||
api.get<PaginatedResponse<StockBalanceResponse>>('/stock/balances', { params }).then(r => r.data),
|
||||
|
||||
movements: (params?: StockMovementParams) =>
|
||||
api.get<PaginatedResponse<StockMovementResponse>>('/stock/movements', { params }).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
StoreResponse,
|
||||
KycDocumentResponse,
|
||||
OutstandingBalanceResponse,
|
||||
StoreDashboardResponse,
|
||||
KycStatus,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
// ─── Stores ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoreBody {
|
||||
routeId: string
|
||||
ownerName: string
|
||||
storeName: string
|
||||
storeAddress: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
oneTimeAmount?: number
|
||||
creditLimit?: number
|
||||
segment?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface StoreListParams extends PageParams {
|
||||
routeId?: string
|
||||
kycStatus?: KycStatus
|
||||
search?: string
|
||||
}
|
||||
|
||||
export const stores = {
|
||||
list: (params?: StoreListParams) =>
|
||||
api.get<PaginatedResponse<StoreResponse>>('/stores', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<StoreResponse>(`/stores/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: StoreBody) =>
|
||||
api.post<StoreResponse>('/stores', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<StoreBody>) =>
|
||||
api.put<StoreResponse>(`/stores/${id}`, body).then(r => r.data),
|
||||
|
||||
outstandingBalance: (id: string) =>
|
||||
api.get<OutstandingBalanceResponse>(`/stores/${id}/outstanding-balance`).then(r => r.data),
|
||||
|
||||
dashboard: (id: string, period?: string) =>
|
||||
api.get<StoreDashboardResponse>(`/stores/${id}/dashboard`, { params: { period } }).then(r => r.data),
|
||||
|
||||
kycDocuments: (id: string) =>
|
||||
api.get<KycDocumentResponse[]>(`/stores/${id}/kyc-documents`).then(r => r.data),
|
||||
|
||||
uploadKycDocument: (id: string, documentType: string, fileReference: string) =>
|
||||
api.post<KycDocumentResponse>(`/stores/${id}/kyc-documents`, { documentType, fileReference }).then(r => r.data),
|
||||
|
||||
updateKycStatus: (id: string, kycStatus: KycStatus) =>
|
||||
api.patch<StoreResponse>(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { PaginatedResponse, SupplierResponse } from '@/types/api'
|
||||
import type { PageParams } from './master'
|
||||
|
||||
export interface SupplierBody {
|
||||
name: string
|
||||
contactInfo?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const suppliers = {
|
||||
list: (params?: PageParams) =>
|
||||
api.get<PaginatedResponse<SupplierResponse>>('/suppliers', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<SupplierResponse>(`/suppliers/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: SupplierBody) =>
|
||||
api.post<SupplierResponse>('/suppliers', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: Partial<SupplierBody>) =>
|
||||
api.put<SupplierResponse>(`/suppliers/${id}`, body).then(r => r.data),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user