Add supplier claims management components and API integration

This commit is contained in:
2026-09-08 16:14:33 +05:30
parent e53db290c8
commit 58d67495d2
16 changed files with 2191 additions and 274 deletions
@@ -0,0 +1,319 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useFieldArray, useWatch } from 'react-hook-form'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
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 { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import {
purchaseOrders as purchaseOrdersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, PurchaseOrderResponse } from '@/types/api'
const poLineSchema = z.object({
purchaseOrderItemId: z.string().optional(),
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
isReceived: z.boolean(),
})
const schema = z.object({
items: z.array(poLineSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
function createDefaultLine() {
return { purchaseOrderItemId: undefined, itemId: '', quantity: 1, unitCost: 0, isReceived: false }
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
export default function EditPurchaseOrderPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const [order, setOrder] = useState<PurchaseOrderResponse | null>(null)
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [loading, setLoading] = useState(true)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { items: [createDefaultLine()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const watchedItems = useWatch({ control: form.control, name: 'items' })
const itemsById = useMemo(() => new Map(itemsList.map((item) => [item.id, item])), [itemsList])
const totalCost = useMemo(
() => watchedItems.reduce((sum, i) => sum + (Number(i.quantity) || 0) * (Number(i.unitCost) || 0), 0),
[watchedItems]
)
useEffect(() => {
Promise.all([
purchaseOrdersApi.get(params.id),
itemsApi.list({ pageSize: 200 }),
])
.then(([po, itemsRes]) => {
setOrder(po)
setItemsList(getPaginatedItems(itemsRes))
form.reset({
items: po.items
.filter((i) => !i.isCancelled)
.map((i) => ({
purchaseOrderItemId: i.id,
itemId: i.itemId,
quantity: i.quantity,
unitCost: i.unitCost,
isReceived: i.receivedQuantity > 0,
})),
})
})
.catch((err) => {
toast.add({ title: 'Failed to load purchase order', description: getApiError(err), type: 'error' })
})
.finally(() => setLoading(false))
}, [params.id])
async function onSubmit(values: FormValues) {
try {
await purchaseOrdersApi.update(params.id, {
items: values.items.map((i) => ({
purchaseOrderItemId: i.purchaseOrderItemId,
itemId: i.itemId,
quantity: i.quantity,
unitCost: i.unitCost,
})),
})
toast.add({ title: 'Purchase order updated', type: 'success' })
router.push('/procurement/orders')
} catch (err) {
toast.add({ title: 'Failed to update purchase order', description: getApiError(err), type: 'error' })
}
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading…</p>
}
if (!order) {
return <p className="text-sm text-muted-foreground">Purchase order not found.</p>
}
if (order.status !== 'Issued') {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/procurement/orders')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Edit Purchase Order</h1>
</div>
</div>
<p className="text-sm text-muted-foreground">
Only a purchase order in the &quot;Issued&quot; status can be edited. This order is currently{' '}
<span className="font-medium text-foreground">{order.status}</span>.
</p>
</div>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/procurement/orders')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Edit Purchase Order</h1>
<p className="text-sm text-muted-foreground">{order.supplierName}</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<div className="rounded-2xl border p-6">
<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">
Lines that already received stock are locked and cannot be edited or removed.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append(createDefaultLine())}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
</Button>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-left font-medium">Quantity</th>
<th className="w-32 px-3 py-2 text-left font-medium">Unit Cost</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{fields.map((fieldItem, index) => {
const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '')
const lineTotal = (Number(watchedItems[index]?.quantity) || 0) * (Number(watchedItems[index]?.unitCost) || 0)
const locked = Boolean(watchedItems[index]?.isReceived)
return (
<tr key={fieldItem.id} className="border-t align-top">
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.itemId`}
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}.unitCost`, nextItem.salePrice, { shouldValidate: true })
}
}}
disabled={locked}
>
<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>
)}
/>
{selectedItem && (
<p className="mt-1 text-xs text-muted-foreground">
In {selectedItem.baseUomName}
</p>
)}
{locked && (
<p className="mt-1 text-xs text-amber-600">Already received — locked</p>
)}
</td>
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
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}
disabled={locked}
aria-invalid={invalid}
/>
)}
/>
</td>
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.unitCost`}
render={({ field, invalid }) => (
<Input
id={`items.${index}.unitCost`}
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}
disabled={locked}
aria-invalid={invalid}
/>
)}
/>
</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(lineTotal)}</td>
<td className="px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={fields.length === 1 || locked}
onClick={() => remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
<div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
<p className="mt-1 text-sm text-muted-foreground">
{watchedItems.length} line item{watchedItems.length === 1 ? '' : 's'} on this order.
</p>
<Separator className="my-4" />
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Total Cost</p>
<p className="text-xl font-semibold text-foreground">{fmtAmount(totalCost)}</p>
</div>
<div className="mt-6 flex flex-col gap-2">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/procurement/orders')}>
Cancel
</Button>
</div>
</div>
</div>
</form>
</div>
)
}
@@ -1,262 +0,0 @@
'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 { 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 { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import {
purchaseOrders as purchaseOrdersApi,
suppliers as suppliersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, SupplierResponse } from '@/types/api'
const poItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
})
const schema = z.object({
supplierId: z.string().min(1, 'Supplier is required'),
items: z.array(poItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface PurchaseOrderSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function defaultItem() {
return { itemId: '', quantity: 1, unitCost: 0 }
}
export function PurchaseOrderSheet({ open, onClose, onSuccess }: PurchaseOrderSheetProps) {
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { supplierId: '', items: [defaultItem()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const watchedItems = form.watch('items')
const itemsById = useMemo(() => new Map(itemsList.map(i => [i.id, i])), [itemsList])
const totalCost = useMemo(
() => watchedItems.reduce((sum, i) => sum + (Number(i.quantity) || 0) * (Number(i.unitCost) || 0), 0),
[watchedItems]
)
useEffect(() => {
if (!open) return
form.reset({ supplierId: '', items: [defaultItem()] })
Promise.all([
suppliersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
])
.then(([s, it]) => {
setSuppliersList(getPaginatedItems(s))
setItemsList(getPaginatedItems(it))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
async function onSubmit(values: FormValues) {
try {
await purchaseOrdersApi.create(values)
toast.add({ title: 'Purchase order created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create purchase order', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Create Purchase Order</DialogTitle>
<DialogDescription>Issue a new purchase order to a supplier.</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">
{/* Left — supplier + summary */}
<FieldGroup className="lg:col-span-2">
<FormField
control={form.control}
name="supplierId"
label="Supplier"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{suppliersList.map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="rounded-2xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Order Summary</p>
<p className="text-sm text-muted-foreground">{fields.length} line item{fields.length !== 1 ? 's' : ''}</p>
</div>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total Cost</p>
<p className="text-lg font-semibold text-foreground">
Rs.&nbsp;{totalCost.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
</p>
</div>
</div>
</div>
</FieldGroup>
{/* Right — scrollable items */}
<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>
<p className="text-sm font-medium text-foreground">Line Items</p>
<p className="text-sm text-muted-foreground">Add items to order</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append(defaultItem())}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add
</Button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{fields.map((f, index) => {
const selected = itemsById.get(watchedItems[index]?.itemId ?? '')
return (
<div key={f.id} className="space-y-3 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={val => {
field.onChange(val ?? '')
const item = val ? itemsById.get(val) : undefined
if (item) form.setValue(`items.${index}.unitCost`, item.salePrice, { shouldValidate: true })
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.map(i => (
<SelectItem key={i.id} value={i.id}>{i.name} — {i.sku}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
label="Qty"
required
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.unitCost`}
label="Unit Cost"
required
render={({ field, invalid }) => (
<Input
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}
aria-invalid={invalid}
/>
)}
/>
</div>
{selected && (
<p className="text-xs text-muted-foreground">UOM: {selected.baseUomName}</p>
)}
{fields.length > 1 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => remove(index)}>
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
Remove
</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 Order'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,165 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { purchaseReturns as purchaseReturnsApi, getApiError } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus, PurchaseReturnResponse } from '@/types/api'
import { PurchaseReturnDialog } from './purchase-return-dialog'
const PO_STATUS_VARIANT: Record<PurchaseOrderStatus, 'default' | 'secondary' | 'destructive'> = {
Issued: 'default',
PartiallyReceived: 'secondary',
Received: 'secondary',
Closed: 'secondary',
Cancelled: 'destructive',
}
const fmtDateTime = (v: string) =>
new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
interface PurchaseOrderViewSheetProps {
open: boolean
onClose: () => void
order: PurchaseOrderResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function PurchaseOrderViewSheet({ open, onClose, order }: PurchaseOrderViewSheetProps) {
const [returns, setReturns] = useState<PurchaseReturnResponse[]>([])
const [returnDialogOpen, setReturnDialogOpen] = useState(false)
const fetchReturns = () => {
if (!order) return
purchaseReturnsApi.listByPurchaseOrder(order.id)
.then(setReturns)
.catch((err) => toast.add({ title: 'Failed to load purchase returns', description: getApiError(err), type: 'error' }))
}
useEffect(() => {
if (!open || !order) return
fetchReturns()
}, [open, order?.id])
if (!order) return null
const totalCost = order.items.reduce((sum, i) => sum + i.quantity * i.unitCost, 0)
const hasReceivedStock = order.items.some((i) => i.receivedQuantity > 0)
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Purchase Order Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Supplier" value={order.supplierName} />
<Row label="Status" value={<Badge variant={PO_STATUS_VARIANT[order.status]}>{order.status}</Badge>} />
{order.cancelReason && <Row label="Cancel Reason" value={order.cancelReason} />}
<Row label="Created" value={fmtDateTime(order.createdAt)} />
<Row label="Total Cost" value={fmtAmount(totalCost)} />
</div>
<Separator className="my-2" />
<div>
<p className="mb-2 text-sm font-medium text-foreground">Line Items</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-right font-medium">Qty</th>
<th className="px-3 py-2 text-right font-medium">Unit Cost</th>
<th className="px-3 py-2 text-right font-medium">Received</th>
<th className="px-3 py-2 text-right font-medium">Cancelled</th>
<th className="px-3 py-2 text-right font-medium">Line Total</th>
</tr>
</thead>
<tbody>
{order.items.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">
{item.itemName}
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
{item.isCancelled && <Badge variant="destructive" className="ml-2">Cancelled</Badge>}
</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2 text-right">{fmtAmount(item.unitCost)}</td>
<td className="px-3 py-2 text-right">{item.receivedQuantity}</td>
<td className="px-3 py-2 text-right">{item.cancelledQuantity}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitCost)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Separator className="my-2" />
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Purchase Returns</p>
{hasReceivedStock && (
<Button type="button" variant="outline" size="sm" onClick={() => setReturnDialogOpen(true)}>
Return Items
</Button>
)}
</div>
{returns.length === 0 ? (
<p className="text-sm text-muted-foreground">No items have been returned to the supplier.</p>
) : (
<div className="flex flex-col gap-3">
{returns.map((ret) => (
<div key={ret.id} className="rounded-xl border p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{ret.reason}</p>
<span className="text-xs text-muted-foreground">{fmtDateTime(ret.createdAt)}</span>
</div>
<ul className="mt-2 flex flex-col gap-1">
{ret.items.map((item) => (
<li key={item.id} className="text-sm text-muted-foreground">
{item.itemName} ({item.sku}) — {item.quantity}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
</DialogContent>
<PurchaseReturnDialog
open={returnDialogOpen}
onClose={() => setReturnDialogOpen(false)}
onSuccess={fetchReturns}
order={order}
existingReturns={returns}
/>
</Dialog>
)
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table' import { DataTable } from '@/components/shared/data-table'
@@ -10,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api' import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
import { PurchaseOrderSheet } from './purchase-order-sheet' import { PurchaseOrderViewSheet } from './purchase-order-view-sheet'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -26,12 +27,19 @@ const PO_STATUS_VARIANT: Record<PurchaseOrderStatus, 'default' | 'secondary' | '
} }
export function PurchaseOrdersClient() { export function PurchaseOrdersClient() {
const router = useRouter()
const [data, setData] = useState<PurchaseOrderResponse[]>([]) const [data, setData] = useState<PurchaseOrderResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false) const [viewing, setViewing] = useState<PurchaseOrderResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
function openView(row: PurchaseOrderResponse) {
setViewing(row)
setViewOpen(true)
}
const fetchData = useCallback(async (p: number, ps: number) => { const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true) setLoading(true)
@@ -115,9 +123,20 @@ export function PurchaseOrdersClient() {
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
{
action: 'view',
label: 'View order',
onClick: openView,
},
{
action: 'edit',
label: 'Edit order',
onClick: (row: PurchaseOrderResponse) => router.push(`/procurement/orders/${row.id}/edit`),
visible: (target) => target.status === 'Issued',
},
{ {
action: 'cancel', action: 'cancel',
label: 'Cancel order', label: 'Delete order',
tone: 'destructive', tone: 'destructive',
onClick: handleCancel, onClick: handleCancel,
visible: (target) => target.status === 'Issued', visible: (target) => target.status === 'Issued',
@@ -135,7 +154,7 @@ export function PurchaseOrdersClient() {
Orders placed with suppliers to restock inventory Orders placed with suppliers to restock inventory
</p> </p>
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <Button onClick={() => router.push('/procurement/orders/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} /> <HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
New Order New Order
</Button> </Button>
@@ -152,10 +171,10 @@ export function PurchaseOrdersClient() {
}} }}
/> />
<PurchaseOrderSheet <PurchaseOrderViewSheet
open={sheetOpen} open={viewOpen}
onClose={() => setSheetOpen(false)} onClose={() => setViewOpen(false)}
onSuccess={() => fetchData(page, pageSize)} order={viewing}
/> />
</> </>
) )
@@ -0,0 +1,259 @@
'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 { 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 { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import { purchaseReturns as purchaseReturnsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseReturnResponse, UserResponse } from '@/types/api'
const returnItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
})
const schema = z.object({
reason: z.string().min(1, 'Reason is required'),
createdByUserId: z.string().min(1, 'Created by is required'),
items: z.array(returnItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface PurchaseReturnDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
order: PurchaseOrderResponse | null
existingReturns: PurchaseReturnResponse[]
}
function defaultItem() {
return { itemId: '', quantity: 1 }
}
export function PurchaseReturnDialog({ open, onClose, onSuccess, order, existingReturns }: PurchaseReturnDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { reason: '', createdByUserId: '', items: [defaultItem()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
useEffect(() => {
if (!open) return
form.reset({ reason: '', createdByUserId: '', items: [defaultItem()] })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
// Quantity already returned per item, across all existing returns on this Po
const returnedByItem = useMemo(() => {
const map = new Map<string, number>()
for (const ret of existingReturns) {
for (const item of ret.items) {
map.set(item.itemId, (map.get(item.itemId) ?? 0) + item.quantity)
}
}
return map
}, [existingReturns])
// Only items that actually received stock can be returned
const returnableItems = useMemo(() => {
if (!order) return []
return order.items
.filter((i) => i.receivedQuantity > 0)
.map((i) => ({
itemId: i.itemId,
itemName: i.itemName,
sku: i.sku,
available: i.receivedQuantity - (returnedByItem.get(i.itemId) ?? 0),
}))
.filter((i) => i.available > 0)
}, [order, returnedByItem])
if (!order) return null
async function onSubmit(values: FormValues) {
for (const line of values.items) {
const available = returnableItems.find((i) => i.itemId === line.itemId)?.available ?? 0
if (line.quantity > available) {
toast.add({ title: `Return quantity exceeds the receivable amount for this item (max ${available})`, type: 'error' })
return
}
}
try {
await purchaseReturnsApi.create(order!.id, {
reason: values.reason,
items: values.items,
createdByUserId: values.createdByUserId,
})
toast.add({ title: 'Purchase return recorded', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to record purchase return', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Return Items to Supplier</DialogTitle>
<DialogDescription>
Return previously received stock for this purchase order back to {order.supplierName}.
</DialogDescription>
</DialogHeader>
{returnableItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
No items on this order have any receivable-and-not-already-returned quantity.
</p>
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
<FieldGroup>
<FormField
control={form.control}
name="reason"
label="Reason"
required
render={({ field, invalid }) => (
<Input
id="reason"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Damaged on arrival"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="createdByUserId"
label="Created By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Items to Return</p>
<Button type="button" variant="outline" size="sm" onClick={() => append(defaultItem())}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add
</Button>
</div>
{fields.map((f, index) => (
<div key={f.id} className="flex items-start gap-3 rounded-xl border p-3">
<div className="flex-1">
<FormField
control={form.control}
name={`items.${index}.itemId`}
label="Item"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem>
{returnableItems.map((i) => (
<SelectItem key={i.itemId} value={i.itemId}>
{i.itemName} — {i.sku} (max {i.available})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-28">
<FormField
control={form.control}
name={`items.${index}.quantity`}
label="Qty"
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</div>
{fields.length > 1 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-6"
onClick={() => remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
</Button>
)}
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Return'}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,329 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useFieldArray, useWatch } from 'react-hook-form'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
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 { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import {
purchaseOrders as purchaseOrdersApi,
suppliers as suppliersApi,
items as itemsApi,
users as usersApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, SupplierResponse, UserResponse } from '@/types/api'
const poItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitCost: z.number().min(0.01, 'Unit cost must be greater than 0'),
})
const schema = z.object({
supplierId: z.string().min(1, 'Supplier is required'),
createdByUserId: z.string().min(1, 'Created by is required'),
items: z.array(poItemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
function createDefaultItem() {
return { itemId: '', quantity: 1, unitCost: 0 }
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
export default function NewPurchaseOrderPage() {
const router = useRouter()
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { supplierId: '', createdByUserId: '', items: [createDefaultItem()] },
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
})
const watchedItems = useWatch({ control: form.control, name: 'items' })
const itemsById = useMemo(
() => new Map(itemsList.map((item) => [item.id, item])),
[itemsList]
)
const totalCost = useMemo(
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitCost) || 0), 0),
[watchedItems]
)
useEffect(() => {
Promise.all([
suppliersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([suppliersRes, itemsRes, usersRes]) => {
setSuppliersList(getPaginatedItems(suppliersRes))
setItemsList(getPaginatedItems(itemsRes))
setUsersList(getPaginatedItems(usersRes))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
}, [])
async function onSubmit(values: FormValues) {
try {
await purchaseOrdersApi.create(values)
toast.add({ title: 'Purchase order created', type: 'success' })
router.push('/procurement/orders')
} catch (err) {
toast.add({ title: 'Failed to create purchase order', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/procurement/orders')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Create Purchase Order</h1>
<p className="text-sm text-muted-foreground">Issue a new purchase order to a supplier.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Order Details</h3>
<FieldGroup>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<FormField
control={form.control}
name="supplierId"
label="Supplier"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select supplier...</SelectItem>
{suppliersList.map((supplier) => (
<SelectItem key={supplier.id} value={supplier.id}>
{supplier.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="createdByUserId"
label="Created By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</FieldGroup>
</div>
<div className="rounded-2xl border p-6">
<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">
Quantity and unit cost use the selected item&apos;s base UOM.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append(createDefaultItem())}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
</Button>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-left font-medium">Quantity</th>
<th className="w-32 px-3 py-2 text-left font-medium">Unit Cost</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{fields.map((fieldItem, index) => {
const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '')
const lineTotal = (Number(watchedItems[index]?.quantity) || 0) * (Number(watchedItems[index]?.unitCost) || 0)
return (
<tr key={fieldItem.id} className="border-t align-top">
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.itemId`}
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}.unitCost`, 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>
)}
/>
{selectedItem && (
<p className="mt-1 text-xs text-muted-foreground">
In {selectedItem.baseUomName}
</p>
)}
</td>
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
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}
/>
)}
/>
</td>
<td className="px-3 py-2">
<FormField
control={form.control}
name={`items.${index}.unitCost`}
render={({ field, invalid }) => (
<Input
id={`items.${index}.unitCost`}
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}
/>
)}
/>
</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(lineTotal)}</td>
<td className="px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
<div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
<p className="mt-1 text-sm text-muted-foreground">
{watchedItems.length} line item{watchedItems.length === 1 ? '' : 's'} on this order.
</p>
<Separator className="my-4" />
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Total Cost</p>
<p className="text-xl font-semibold text-foreground">{fmtAmount(totalCost)}</p>
</div>
<div className="mt-6 flex flex-col gap-2">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating...' : 'Create Order'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/procurement/orders')}>
Cancel
</Button>
</div>
</div>
</div>
</form>
</div>
)
}
@@ -0,0 +1,322 @@
'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 { 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 { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import {
supplierClaims as supplierClaimsApi,
purchaseOrders as purchaseOrdersApi,
users as usersApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { PurchaseOrderResponse, UserResponse, SupplierClaimIssueType, SupplierClaimResponse } from '@/types/api'
const ISSUE_TYPES: SupplierClaimIssueType[] = ['Damaged', 'Short', 'Expired', 'WrongItem', 'Other']
const itemSchema = z.object({
itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
issueType: z.enum(['Damaged', 'Short', 'Expired', 'WrongItem', 'Other']),
})
const schema = z.object({
purchaseOrderId: z.string().min(1, 'Purchase order is required'),
details: z.string().min(1, 'Details are required'),
raisedByUserId: z.string(),
items: z.array(itemSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface SupplierClaimDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
claim?: SupplierClaimResponse | null
}
function defaultItem() {
return { itemId: '', quantity: 1, issueType: 'Damaged' as SupplierClaimIssueType }
}
export function SupplierClaimDialog({ open, onClose, onSuccess, claim }: SupplierClaimDialogProps) {
const [ordersList, setOrdersList] = useState<PurchaseOrderResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedOrder, setSelectedOrder] = useState<PurchaseOrderResponse | null>(null)
const isEdit = Boolean(claim)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { purchaseOrderId: '', details: '', raisedByUserId: '', items: [defaultItem()] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const receivedItems = useMemo(
() => selectedOrder?.items.filter((i) => i.receivedQuantity > 0) ?? [],
[selectedOrder]
)
useEffect(() => {
if (!open) return
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([posRes, usersRes]) => {
const orderList = getPaginatedItems(posRes)
setOrdersList(orderList)
setUsersList(getPaginatedItems(usersRes))
if (claim) {
form.reset({
purchaseOrderId: claim.purchaseOrderId,
details: claim.details,
raisedByUserId: '',
items: claim.items.map((i) => ({ itemId: i.itemId, quantity: i.quantity, issueType: i.issueType })),
})
setSelectedOrder(orderList.find((o) => o.id === claim.purchaseOrderId) ?? null)
} else {
form.reset({ purchaseOrderId: '', details: '', raisedByUserId: '', items: [defaultItem()] })
setSelectedOrder(null)
}
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open, claim])
function handleOrderChange(id: string | null) {
form.setValue('purchaseOrderId', id ?? '', { shouldValidate: true })
setSelectedOrder(id ? ordersList.find((o) => o.id === id) ?? null : null)
}
async function onSubmit(values: FormValues) {
if (isEdit) {
try {
await supplierClaimsApi.update(claim!.id, {
details: values.details,
evidenceUrl: undefined,
items: values.items,
})
toast.add({ title: 'Supplier claim updated', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to update supplier claim', description: getApiError(err), type: 'error' })
}
return
}
if (!values.raisedByUserId) {
toast.add({ title: 'Raised by is required', type: 'error' })
return
}
try {
await supplierClaimsApi.create(values.purchaseOrderId, {
details: values.details,
items: values.items,
raisedByUserId: values.raisedByUserId,
})
toast.add({ title: 'Supplier claim raised', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to raise supplier claim', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit' : 'Raise'} Supplier Claim</DialogTitle>
<DialogDescription>
{isEdit
? 'Only claims that are still Open can be edited.'
: 'Raise a claim against a supplier for damaged, short, expired, or wrong stock received.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
<FieldGroup>
<FormField
control={form.control}
name="purchaseOrderId"
label="Purchase Order"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={handleOrderChange} disabled={isEdit}>
<SelectTrigger>
<SelectValue placeholder="Select purchase order..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select purchase order...</SelectItem>
{ordersList.map((po) => (
<SelectItem key={po.id} value={po.id}>
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className={isEdit ? '' : 'grid gap-4 sm:grid-cols-2'}>
<FormField
control={form.control}
name="details"
label="Details"
required
render={({ field, invalid }) => (
<Input
id="details"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Describe the issue..."
aria-invalid={invalid}
/>
)}
/>
{!isEdit && (
<FormField
control={form.control}
name="raisedByUserId"
label="Raised By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
</div>
</FieldGroup>
{selectedOrder && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Claimed Items</p>
<Button type="button" variant="outline" size="sm" onClick={() => append(defaultItem())}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add
</Button>
</div>
{receivedItems.length === 0 ? (
<p className="text-sm text-muted-foreground">This order has no received items to claim against.</p>
) : (
fields.map((f, index) => (
<div key={f.id} className="grid grid-cols-[1fr_100px_140px_auto] items-start gap-3 rounded-xl border p-3">
<FormField
control={form.control}
name={`items.${index}.itemId`}
label="Item"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem>
{receivedItems.map((oi) => (
<SelectItem key={oi.itemId} value={oi.itemId}>
{oi.itemName} — {oi.sku}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${index}.quantity`}
label="Qty"
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.issueType`}
label="Issue"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Issue..." />
</SelectTrigger>
<SelectContent>
{ISSUE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{fields.length > 1 && (
<Button type="button" variant="outline" size="sm" className="mt-6" onClick={() => remove(index)}>
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
</Button>
)}
</div>
))
)}
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || receivedItems.length === 0}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Raise Claim'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,193 @@
'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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { supplierClaims as supplierClaimsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SupplierClaimResponse, UserResponse, SupplierClaimResolutionType } from '@/types/api'
const RESOLUTION_TYPES: SupplierClaimResolutionType[] = ['Replacement', 'Credit', 'Refund', 'PriceAdjustment']
const schema = z.object({
resolutionType: z.enum(['Replacement', 'Credit', 'Refund', 'PriceAdjustment']),
resolutionAmount: z.number().min(0).optional(),
resolutionNotes: z.string(),
userId: z.string().min(1, 'This field is required'),
})
type FormValues = z.infer<typeof schema>
interface SupplierClaimResolveDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
claim: SupplierClaimResponse | null
mode: 'approve' | 'reject'
}
export function SupplierClaimResolveDialog({ open, onClose, onSuccess, claim, mode }: SupplierClaimResolveDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' },
})
const resolutionType = form.watch('resolutionType')
useEffect(() => {
if (!open) return
form.reset({ resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open, mode])
if (!claim) return null
async function onSubmit(values: FormValues) {
try {
if (mode === 'approve') {
await supplierClaimsApi.approve(claim!.id, {
resolutionType: values.resolutionType,
resolutionAmount: values.resolutionType === 'Replacement' ? undefined : values.resolutionAmount,
resolutionNotes: values.resolutionNotes || undefined,
approvedByUserId: values.userId,
})
toast.add({ title: 'Claim approved', type: 'success' })
} else {
await supplierClaimsApi.reject(claim!.id, {
resolutionNotes: values.resolutionNotes || undefined,
rejectedByUserId: values.userId,
})
toast.add({ title: 'Claim rejected', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: `Failed to ${mode} claim`, description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{mode === 'approve' ? 'Approve' : 'Reject'} Supplier Claim</DialogTitle>
<DialogDescription>{claim.details}</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
{mode === 'approve' && (
<>
<FormField
control={form.control}
name="resolutionType"
label="Resolution Type"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select resolution..." />
</SelectTrigger>
<SelectContent>
{RESOLUTION_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{resolutionType !== 'Replacement' && (
<FormField
control={form.control}
name="resolutionAmount"
label="Resolution Amount"
required
render={({ field, invalid }) => (
<Input
type="number"
min={0}
step="0.01"
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
)}
</>
)}
<FormField
control={form.control}
name="resolutionNotes"
label="Notes"
render={({ field }) => (
<Input
id="resolutionNotes"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Optional notes..."
/>
)}
/>
<FormField
control={form.control}
name="userId"
label={mode === 'approve' ? 'Approved By' : 'Rejected By'}
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</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…' : mode === 'approve' ? 'Approve' : 'Reject'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,92 @@
'use client'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { SupplierClaimResponse, SupplierClaimStatus } from '@/types/api'
const STATUS_VARIANT: Record<SupplierClaimStatus, 'default' | 'secondary' | 'destructive'> = {
Open: 'default',
Approved: 'secondary',
Rejected: 'destructive',
}
const fmtDateTime = (v: string) =>
new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
interface SupplierClaimViewSheetProps {
open: boolean
onClose: () => void
claim: SupplierClaimResponse | null
supplierName?: string
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function SupplierClaimViewSheet({ open, onClose, claim, supplierName }: SupplierClaimViewSheetProps) {
if (!claim) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Supplier Claim Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{supplierName && <Row label="Supplier" value={supplierName} />}
<Row label="Status" value={<Badge variant={STATUS_VARIANT[claim.status]}>{claim.status}</Badge>} />
<Row label="Details" value={claim.details} />
{claim.evidenceUrl && <Row label="Evidence" value={claim.evidenceUrl} />}
{claim.resolutionType && <Row label="Resolution Type" value={claim.resolutionType} />}
{claim.resolutionAmount != null && <Row label="Resolution Amount" value={fmtAmount(claim.resolutionAmount)} />}
{claim.resolutionNotes && <Row label="Resolution Notes" value={claim.resolutionNotes} />}
{claim.resolvedAt && <Row label="Resolved At" value={fmtDateTime(claim.resolvedAt)} />}
<Row label="Created" value={fmtDateTime(claim.createdAt)} />
</div>
<div>
<p className="mb-2 text-sm font-medium text-foreground">Claimed Items</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-right font-medium">Qty</th>
<th className="px-3 py-2 text-left font-medium">Issue</th>
</tr>
</thead>
<tbody>
{claim.items.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">
{item.itemName}
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2">{item.issueType}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,210 @@
'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 { supplierClaims as supplierClaimsApi, purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SupplierClaimResponse, SupplierClaimStatus } from '@/types/api'
import { SupplierClaimViewSheet } from './supplier-claim-view-sheet'
import { SupplierClaimDialog } from './supplier-claim-dialog'
import { SupplierClaimResolveDialog } from './supplier-claim-resolve-dialog'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const STATUS_VARIANT: Record<SupplierClaimStatus, 'default' | 'secondary' | 'destructive'> = {
Open: 'default',
Approved: 'secondary',
Rejected: 'destructive',
}
export function SupplierClaimsClient() {
const [data, setData] = useState<SupplierClaimResponse[]>([])
const [loading, setLoading] = useState(true)
const [pageSize, setPageSize] = useState(20)
const [supplierNameByPoId, setSupplierNameByPoId] = useState<Map<string, string>>(new Map())
const [createOpen, setCreateOpen] = useState(false)
const [viewing, setViewing] = useState<SupplierClaimResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
const [resolving, setResolving] = useState<SupplierClaimResponse | null>(null)
const [resolveMode, setResolveMode] = useState<'approve' | 'reject'>('approve')
const [resolveOpen, setResolveOpen] = useState(false)
const [editing, setEditing] = useState<SupplierClaimResponse | null>(null)
const [editOpen, setEditOpen] = useState(false)
function openView(row: SupplierClaimResponse) {
setViewing(row)
setViewOpen(true)
}
function openResolve(row: SupplierClaimResponse, mode: 'approve' | 'reject') {
setResolving(row)
setResolveMode(mode)
setResolveOpen(true)
}
function openEdit(row: SupplierClaimResponse) {
setEditing(row)
setEditOpen(true)
}
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [claimsList, posRes] = await Promise.all([
supplierClaimsApi.list(),
purchaseOrdersApi.list({ pageSize: 200 }),
])
setSupplierNameByPoId(new Map(getPaginatedItems(posRes).map((po) => [po.id, po.supplierName])))
setData(claimsList)
} catch (err) {
toast.add({ title: 'Failed to load supplier claims', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const tableConfig = useMemo<TableConfig<SupplierClaimResponse>>(() => ({
rowKey: 'id',
stickyHeader: true,
emptyMessage: 'No supplier claims raised yet.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'supplier',
title: 'Supplier',
fields: ['purchaseOrderId'],
minWidth: 180,
cellRender: (row) => (
<span className="text-sm font-medium">{supplierNameByPoId.get(row.purchaseOrderId) ?? '—'}</span>
),
},
{
key: 'details',
title: 'Details',
fields: ['details'],
minWidth: 220,
cellRender: (row) => (
<span className="text-sm text-muted-foreground line-clamp-2">{row.details}</span>
),
},
{
key: 'itemCount',
title: 'Items',
fields: ['items'],
width: 100,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.items.length}</span>
),
},
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 120,
cellRender: (row) => (
<Badge variant={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: 'view',
label: 'View claim',
onClick: openView,
},
{
action: 'edit',
label: 'Edit claim',
onClick: openEdit,
visible: (target) => target.status === 'Open',
},
{
action: 'confirm',
label: 'Approve claim',
onClick: (row: SupplierClaimResponse) => openResolve(row, 'approve'),
visible: (target) => target.status === 'Open',
},
{
action: 'cancel',
label: 'Reject claim',
tone: 'destructive',
onClick: (row: SupplierClaimResponse) => openResolve(row, 'reject'),
visible: (target) => target.status === 'Open',
},
],
},
}), [pageSize, supplierNameByPoId])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Supplier Claims</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Claims raised against a supplier for damaged, short, or wrong stock
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Raise Claim
</Button>
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={data.length}
onServerSideChange={({ page: p }) => {
setPageSize(p.pageSize)
}}
/>
<SupplierClaimViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
claim={viewing}
supplierName={viewing ? supplierNameByPoId.get(viewing.purchaseOrderId) : undefined}
/>
<SupplierClaimDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={fetchData}
/>
<SupplierClaimDialog
open={editOpen}
onClose={() => setEditOpen(false)}
onSuccess={fetchData}
claim={editing}
/>
<SupplierClaimResolveDialog
open={resolveOpen}
onClose={() => setResolveOpen(false)}
onSuccess={fetchData}
claim={resolving}
mode={resolveMode}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { SupplierClaimsClient } from './_components/supplier-claims-client'
export default function SupplierClaimsPage() {
return <SupplierClaimsClient />
}
+10 -1
View File
@@ -83,7 +83,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' }, { 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: '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: '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: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, description: 'Units of measure and conversion factors between units', permission: 'categories.view' },
], ],
}, },
{ {
@@ -117,6 +117,15 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Suppliers', href: '/suppliers', icon: UserGroupIcon, description: 'Supplier accounts used for raising purchase orders', permission: 'suppliers.view' }, { title: 'Suppliers', href: '/suppliers', icon: UserGroupIcon, description: 'Supplier accounts used for raising purchase orders', permission: 'suppliers.view' },
], ],
}, },
{
title: 'Returns & Claims',
icon: ClipboardListIcon,
items: [
{ title: 'Order Returns', href: '/returns-claims/order-returns', icon: ClipboardListIcon, description: 'Items a store returned during a sale visit', permission: 'orders.view' },
{ title: 'Agency Claims', href: '/returns-claims/claims', icon: ClipboardListIcon, description: 'Claims raised against a regional agency and their settlement', permission: 'orders.view' },
{ title: 'Supplier Claims', href: '/returns-claims/supplier-claims', icon: ClipboardListIcon, description: 'Claims raised against a supplier for damaged, short, or wrong stock', permission: 'purchase_orders.view' },
],
},
{ {
title: 'Drivers', title: 'Drivers',
icon: LicenseDraftIcon, icon: LicenseDraftIcon,
+2 -1
View File
@@ -5,10 +5,11 @@ export { stores } from './stores'
export { suppliers } from './suppliers' export { suppliers } from './suppliers'
export { users, roles, permissions } from './users' export { users, roles, permissions } from './users'
export { drivers, compliance } from './drivers' export { drivers, compliance } from './drivers'
export { purchaseOrders, grn } from './procurement' export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations' export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock' export { stock } from './stock'
export { sales, orders, payments } from './sales' export { sales, orders, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { getPaginatedItems } from './paginated' export { getPaginatedItems } from './paginated'
export type { PageParams } from './master' export type { PageParams } from './master'
+46 -2
View File
@@ -4,6 +4,7 @@ import type {
PurchaseOrderResponse, PurchaseOrderResponse,
GrnResponse, GrnResponse,
PurchaseOrderStatus, PurchaseOrderStatus,
PurchaseReturnResponse,
} from '@/types/api' } from '@/types/api'
import type { PageParams } from './master' import type { PageParams } from './master'
@@ -19,6 +20,19 @@ export interface PurchaseOrderItemBody {
export interface PurchaseOrderBody { export interface PurchaseOrderBody {
supplierId: string supplierId: string
items: PurchaseOrderItemBody[] items: PurchaseOrderItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
createdByUserId: string
}
export interface UpdatePurchaseOrderLineBody {
purchaseOrderItemId?: string
itemId: string
quantity: number
unitCost: number
}
export interface UpdatePurchaseOrderBody {
items: UpdatePurchaseOrderLineBody[]
} }
export interface PurchaseOrderListParams extends PageParams { export interface PurchaseOrderListParams extends PageParams {
@@ -36,8 +50,11 @@ export const purchaseOrders = {
create: (body: PurchaseOrderBody) => create: (body: PurchaseOrderBody) =>
api.post<PurchaseOrderResponse>('/purchase-orders', body).then(r => r.data), api.post<PurchaseOrderResponse>('/purchase-orders', body).then(r => r.data),
cancel: (id: string, cancelReason: string) => update: (id: string, body: UpdatePurchaseOrderBody) =>
api.patch<PurchaseOrderResponse>(`/purchase-orders/${id}/cancel`, { cancelReason }).then(r => r.data), api.put<PurchaseOrderResponse>(`/purchase-orders/${id}`, body).then(r => r.data),
cancel: (id: string, reason: string) =>
api.post<PurchaseOrderResponse>(`/purchase-orders/${id}/cancel`, { reason }).then(r => r.data),
} }
// ─── Goods Receipts (GRN) ───────────────────────────────────────────────────── // ─── Goods Receipts (GRN) ─────────────────────────────────────────────────────
@@ -53,6 +70,8 @@ export interface GrnBody {
warehouseId: string warehouseId: string
receivedAt: string receivedAt: string
items: GrnItemBody[] items: GrnItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
} }
export const grn = { export const grn = {
@@ -65,3 +84,28 @@ export const grn = {
create: (body: GrnBody) => create: (body: GrnBody) =>
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data), api.post<GrnResponse>('/goods-receipts', body).then(r => r.data),
} }
// ─── Purchase Returns ─────────────────────────────────────────────────────────
export interface PurchaseReturnItemBody {
itemId: string
quantity: number
}
export interface PurchaseReturnBody {
reason: string
items: PurchaseReturnItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
createdByUserId: string
}
export const purchaseReturns = {
get: (id: string) =>
api.get<PurchaseReturnResponse>(`/purchase-returns/${id}`).then(r => r.data),
listByPurchaseOrder: (purchaseOrderId: string) =>
api.get<PurchaseReturnResponse[]>(`/purchase-orders/${purchaseOrderId}/returns`).then(r => r.data),
create: (purchaseOrderId: string, body: PurchaseReturnBody) =>
api.post<PurchaseReturnResponse>(`/purchase-orders/${purchaseOrderId}/returns`, body).then(r => r.data),
}
+130
View File
@@ -0,0 +1,130 @@
import { api } from '@/lib/api'
import type {
SaleReturnResponse,
ReturnMode,
ReturnReasonCode,
ClaimResponse,
SupplierClaimResponse,
SupplierClaimIssueType,
SupplierClaimResolutionType,
} from '@/types/api'
// ─── Sale Returns (order returns) ──────────────────────────────────────────────
export interface SaleReturnItemBody {
itemId: string
quantity: number
reasonCode: ReturnReasonCode
}
export interface SaleReturnBody {
mode: ReturnMode
items: SaleReturnItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
createdByUserId: string
}
export interface UpdateSaleReturnReasonsBody {
items: { itemId: string; reasonCode: ReturnReasonCode }[]
}
export const saleReturns = {
list: () =>
api.get<SaleReturnResponse[]>('/returns').then(r => r.data),
get: (id: string) =>
api.get<SaleReturnResponse>(`/returns/${id}`).then(r => r.data),
listByOrder: (orderId: string) =>
api.get<SaleReturnResponse[]>(`/orders/${orderId}/returns`).then(r => r.data),
create: (orderId: string, body: SaleReturnBody) =>
api.post<SaleReturnResponse>(`/orders/${orderId}/returns`, body).then(r => r.data),
updateReasons: (id: string, body: UpdateSaleReturnReasonsBody) =>
api.patch<SaleReturnResponse>(`/returns/${id}/reasons`, body).then(r => r.data),
}
// ─── Claims (agency claims) ────────────────────────────────────────────────────
export interface ClaimBody {
agencyUserId: string
details: string
}
export interface SettleClaimBody {
settlementAmount: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
settledByUserId: string
}
export const claims = {
list: () =>
api.get<ClaimResponse[]>('/claims').then(r => r.data),
get: (id: string) =>
api.get<ClaimResponse>(`/claims/${id}`).then(r => r.data),
create: (body: ClaimBody) =>
api.post<ClaimResponse>('/claims', body).then(r => r.data),
update: (id: string, body: ClaimBody) =>
api.put<ClaimResponse>(`/claims/${id}`, body).then(r => r.data),
settle: (id: string, body: SettleClaimBody) =>
api.post<ClaimResponse>(`/claims/${id}/settle`, body).then(r => r.data),
}
// ─── Supplier Claims ────────────────────────────────────────────────────────────
export interface SupplierClaimItemBody {
itemId: string
quantity: number
issueType: SupplierClaimIssueType
}
export interface SupplierClaimBody {
goodsReceiptId?: string
details: string
evidenceUrl?: string
items: SupplierClaimItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
raisedByUserId: string
}
export interface ApproveSupplierClaimBody {
resolutionType: SupplierClaimResolutionType
resolutionAmount?: number
resolutionNotes?: string
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
approvedByUserId: string
}
export interface RejectSupplierClaimBody {
resolutionNotes?: string
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
rejectedByUserId: string
}
export const supplierClaims = {
list: () =>
api.get<SupplierClaimResponse[]>('/supplier-claims').then(r => r.data),
get: (id: string) =>
api.get<SupplierClaimResponse>(`/supplier-claims/${id}`).then(r => r.data),
listByPurchaseOrder: (purchaseOrderId: string) =>
api.get<SupplierClaimResponse[]>(`/purchase-orders/${purchaseOrderId}/claims`).then(r => r.data),
create: (purchaseOrderId: string, body: SupplierClaimBody) =>
api.post<SupplierClaimResponse>(`/purchase-orders/${purchaseOrderId}/claims`, body).then(r => r.data),
update: (id: string, body: { details: string; evidenceUrl?: string; items: SupplierClaimItemBody[] }) =>
api.put<SupplierClaimResponse>(`/supplier-claims/${id}`, body).then(r => r.data),
approve: (id: string, body: ApproveSupplierClaimBody) =>
api.post<SupplierClaimResponse>(`/supplier-claims/${id}/approve`, body).then(r => r.data),
reject: (id: string, body: RejectSupplierClaimBody) =>
api.post<SupplierClaimResponse>(`/supplier-claims/${id}/reject`, body).then(r => r.data),
}
+82
View File
@@ -27,6 +27,12 @@ export type WarehouseType =
| 'ColdStorage' | 'BondedWarehouse' | 'AutomatedWarehouse' | 'ColdStorage' | 'BondedWarehouse' | 'AutomatedWarehouse'
export type SchemeRuleType = 'BuyXGetY' | 'SlabDiscount' | 'FlatPercent' | 'FreeBundle' export type SchemeRuleType = 'BuyXGetY' | 'SlabDiscount' | 'FlatPercent' | 'FreeBundle'
export type SyncSource = 'Manual' | 'Erp' export type SyncSource = 'Manual' | 'Erp'
export type ReturnMode = 'CreditDeduction' | 'ItemExchange'
export type ReturnReasonCode = 'Expired' | 'Damaged' | 'OrderError' | 'Other'
export type ClaimStatus = 'Open' | 'Settled'
export type SupplierClaimStatus = 'Open' | 'Approved' | 'Rejected'
export type SupplierClaimIssueType = 'Damaged' | 'Short' | 'Expired' | 'WrongItem' | 'Other'
export type SupplierClaimResolutionType = 'Replacement' | 'Credit' | 'Refund' | 'PriceAdjustment'
// ─── Pagination ─────────────────────────────────────────────────────────────── // ─── Pagination ───────────────────────────────────────────────────────────────
@@ -411,6 +417,82 @@ export interface GrnResponse {
items: GrnItemResponse[] items: GrnItemResponse[]
} }
export interface PurchaseReturnItemResponse {
id: string
itemId: string
sku: string
itemName: string
quantity: number
}
export interface PurchaseReturnResponse {
id: string
purchaseOrderId: string
reason: string
isActive: boolean
createdAt: string
items: PurchaseReturnItemResponse[]
}
// ─── Returns & Claims ─────────────────────────────────────────────────────────
export interface SaleReturnItemResponse {
id: string
itemId: string
sku: string
itemName: string
quantity: number
reasonCode: ReturnReasonCode
unitPrice: number
}
export interface SaleReturnResponse {
id: string
orderId: string
mode: ReturnMode
totalCreditAmount: number
orderTotalAmountAfter: number
isActive: boolean
createdAt: string
items: SaleReturnItemResponse[]
}
export interface ClaimResponse {
id: string
agencyUserId: string
details: string
status: ClaimStatus
settlementAmount: number | null
settledAt: string | null
isActive: boolean
createdAt: string
}
export interface SupplierClaimItemResponse {
id: string
itemId: string
sku: string
itemName: string
quantity: number
issueType: SupplierClaimIssueType
}
export interface SupplierClaimResponse {
id: string
purchaseOrderId: string
goodsReceiptId: string | null
details: string
evidenceUrl: string | null
status: SupplierClaimStatus
resolutionType: SupplierClaimResolutionType | null
resolutionAmount: number | null
resolutionNotes: string | null
resolvedAt: string | null
isActive: boolean
createdAt: string
items: SupplierClaimItemResponse[]
}
// ─── Stock ──────────────────────────────────────────────────────────────────── // ─── Stock ────────────────────────────────────────────────────────────────────
export interface StockBalanceResponse { export interface StockBalanceResponse {