Add supplier claims management components and API integration
This commit is contained in:
@@ -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 "Issued" 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>
|
||||
)
|
||||
}
|
||||
-262
@@ -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. {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>
|
||||
)
|
||||
}
|
||||
+165
@@ -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>
|
||||
)
|
||||
}
|
||||
+27
-8
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
@@ -10,7 +11,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
|
||||
import { PurchaseOrderSheet } from './purchase-order-sheet'
|
||||
import { PurchaseOrderViewSheet } from './purchase-order-view-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
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() {
|
||||
const router = useRouter()
|
||||
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 [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) => {
|
||||
setLoading(true)
|
||||
@@ -115,9 +123,20 @@ export function PurchaseOrdersClient() {
|
||||
],
|
||||
rowActions: {
|
||||
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',
|
||||
label: 'Cancel order',
|
||||
label: 'Delete order',
|
||||
tone: 'destructive',
|
||||
onClick: handleCancel,
|
||||
visible: (target) => target.status === 'Issued',
|
||||
@@ -135,7 +154,7 @@ export function PurchaseOrdersClient() {
|
||||
Orders placed with suppliers to restock inventory
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<Button onClick={() => router.push('/procurement/orders/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
New Order
|
||||
</Button>
|
||||
@@ -152,10 +171,10 @@ export function PurchaseOrdersClient() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<PurchaseOrderSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
<PurchaseOrderViewSheet
|
||||
open={viewOpen}
|
||||
onClose={() => setViewOpen(false)}
|
||||
order={viewing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
+259
@@ -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'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>
|
||||
)
|
||||
}
|
||||
+322
@@ -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>
|
||||
)
|
||||
}
|
||||
+193
@@ -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>
|
||||
)
|
||||
}
|
||||
+92
@@ -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>
|
||||
)
|
||||
}
|
||||
+210
@@ -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 />
|
||||
}
|
||||
@@ -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: '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: '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: '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',
|
||||
icon: LicenseDraftIcon,
|
||||
|
||||
@@ -5,10 +5,11 @@ 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 { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { sales, orders, payments } from './sales'
|
||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PurchaseOrderResponse,
|
||||
GrnResponse,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseReturnResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
@@ -19,6 +20,19 @@ export interface PurchaseOrderItemBody {
|
||||
export interface PurchaseOrderBody {
|
||||
supplierId: string
|
||||
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 {
|
||||
@@ -36,8 +50,11 @@ export const purchaseOrders = {
|
||||
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),
|
||||
update: (id: string, body: UpdatePurchaseOrderBody) =>
|
||||
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) ─────────────────────────────────────────────────────
|
||||
@@ -53,6 +70,8 @@ export interface GrnBody {
|
||||
warehouseId: string
|
||||
receivedAt: string
|
||||
items: GrnItemBody[]
|
||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||
receivedByUserId: string
|
||||
}
|
||||
|
||||
export const grn = {
|
||||
@@ -65,3 +84,28 @@ export const grn = {
|
||||
create: (body: GrnBody) =>
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -27,6 +27,12 @@ export type WarehouseType =
|
||||
| 'ColdStorage' | 'BondedWarehouse' | 'AutomatedWarehouse'
|
||||
export type SchemeRuleType = 'BuyXGetY' | 'SlabDiscount' | 'FlatPercent' | 'FreeBundle'
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -411,6 +417,82 @@ export interface GrnResponse {
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StockBalanceResponse {
|
||||
|
||||
Reference in New Issue
Block a user