diff --git a/distribution_fe/app/(dashboard)/procurement/orders/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/procurement/orders/[id]/edit/page.tsx new file mode 100644 index 0000000..f5ce354 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/[id]/edit/page.tsx @@ -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 + +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(null) + const [itemsList, setItemsList] = useState([]) + const [loading, setLoading] = useState(true) + + const form = useForm({ + 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

Loading…

+ } + + if (!order) { + return

Purchase order not found.

+ } + + if (order.status !== 'Issued') { + return ( +
+
+ +
+

Edit Purchase Order

+
+
+

+ Only a purchase order in the "Issued" status can be edited. This order is currently{' '} + {order.status}. +

+
+ ) + } + + return ( +
+
+ +
+

Edit Purchase Order

+

{order.supplierName}

+
+
+ +
+
+
+
+

Items

+

+ Lines that already received stock are locked and cannot be edited or removed. +

+
+ +
+ +
+ + + + + + + + + + + + {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 ( + + + + + + + + + + + + ) + })} + +
ItemQuantityUnit CostLine Total
+ ( + + )} + /> + {selectedItem && ( +

+ In {selectedItem.baseUomName} +

+ )} + {locked && ( +

Already received — locked

+ )} +
+ ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + disabled={locked} + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + disabled={locked} + aria-invalid={invalid} + /> + )} + /> + {fmtAmount(lineTotal)} + +
+
+
+ +
+
+

Order Summary

+

+ {watchedItems.length} line item{watchedItems.length === 1 ? '' : 's'} on this order. +

+ + + +
+

Total Cost

+

{fmtAmount(totalCost)}

+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-sheet.tsx b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-sheet.tsx deleted file mode 100644 index 8e599c3..0000000 --- a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-sheet.tsx +++ /dev/null @@ -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 - -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([]) - const [itemsList, setItemsList] = useState([]) - - const form = useForm({ - 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 ( - { if (!v) onClose() }}> - - - Create Purchase Order - Issue a new purchase order to a supplier. - - -
-
- - {/* Left — supplier + summary */} - - ( - - )} - /> - -
-
-
-

Order Summary

-

{fields.length} line item{fields.length !== 1 ? 's' : ''}

-
-
-

Total Cost

-

- Rs. {totalCost.toLocaleString('en-LK', { minimumFractionDigits: 2 })} -

-
-
-
-
- - {/* Right — scrollable items */} -
-
-
-

Line Items

-

Add items to order

-
- -
- -
- {fields.map((f, index) => { - const selected = itemsById.get(watchedItems[index]?.itemId ?? '') - return ( -
- ( - - )} - /> - -
- ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> -
- - {selected && ( -

UOM: {selected.baseUomName}

- )} - - {fields.length > 1 && ( -
- -
- )} -
- ) - })} -
-
-
- - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-view-sheet.tsx b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-view-sheet.tsx new file mode 100644 index 0000000..b91a573 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-view-sheet.tsx @@ -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 = { + 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 ( +
+ {label} + {value} +
+ ) +} + +export function PurchaseOrderViewSheet({ open, onClose, order }: PurchaseOrderViewSheetProps) { + const [returns, setReturns] = useState([]) + 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 ( + !v && onClose()}> + + + Purchase Order Details + + +
+ + {order.status}} /> + {order.cancelReason && } + + +
+ + + +
+

Line Items

+
+ + + + + + + + + + + + + {order.items.map((item) => ( + + + + + + + + + ))} + +
ItemQtyUnit CostReceivedCancelledLine Total
+ {item.itemName} + ({item.sku}) + {item.isCancelled && Cancelled} + {item.quantity}{fmtAmount(item.unitCost)}{item.receivedQuantity}{item.cancelledQuantity}{fmtAmount(item.quantity * item.unitCost)}
+
+
+ + + +
+
+

Purchase Returns

+ {hasReceivedStock && ( + + )} +
+ + {returns.length === 0 ? ( +

No items have been returned to the supplier.

+ ) : ( +
+ {returns.map((ret) => ( +
+
+

{ret.reason}

+ {fmtDateTime(ret.createdAt)} +
+
    + {ret.items.map((item) => ( +
  • + {item.itemName} ({item.sku}) — {item.quantity} +
  • + ))} +
+
+ ))} +
+ )} +
+
+ + setReturnDialogOpen(false)} + onSuccess={fetchReturns} + order={order} + existingReturns={returns} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx index eefdbe8..5f36470 100644 --- a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx +++ b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx @@ -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([]) 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(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

- @@ -152,10 +171,10 @@ export function PurchaseOrdersClient() { }} /> - setSheetOpen(false)} - onSuccess={() => fetchData(page, pageSize)} + setViewOpen(false)} + order={viewing} /> ) diff --git a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-return-dialog.tsx b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-return-dialog.tsx new file mode 100644 index 0000000..f5d6c2f --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-return-dialog.tsx @@ -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 + +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([]) + + const form = useForm({ + 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() + 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 ( + { if (!v) onClose() }}> + + + Return Items to Supplier + + Return previously received stock for this purchase order back to {order.supplierName}. + + + + {returnableItems.length === 0 ? ( +

+ No items on this order have any receivable-and-not-already-returned quantity. +

+ ) : ( +
+ + ( + + )} + /> + + ( + + )} + /> + + +
+
+

Items to Return

+ +
+ + {fields.map((f, index) => ( +
+
+ ( + + )} + /> +
+ +
+ ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> +
+ + {fields.length > 1 && ( + + )} +
+ ))} +
+ + + + + +
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx new file mode 100644 index 0000000..b97233b --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx @@ -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 + +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([]) + const [itemsList, setItemsList] = useState([]) + const [usersList, setUsersList] = useState([]) + + const form = useForm({ + 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 ( +
+
+ +
+

Create Purchase Order

+

Issue a new purchase order to a supplier.

+
+
+ +
+
+

Order Details

+ +
+ ( + + )} + /> + + ( + + )} + /> +
+
+
+ +
+
+
+

Items

+

+ Quantity and unit cost use the selected item's base UOM. +

+
+ +
+ +
+ + + + + + + + + + + + {fields.map((fieldItem, index) => { + const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '') + const lineTotal = (Number(watchedItems[index]?.quantity) || 0) * (Number(watchedItems[index]?.unitCost) || 0) + + return ( + + + + + + + + + + + + ) + })} + +
ItemQuantityUnit CostLine Total
+ ( + + )} + /> + {selectedItem && ( +

+ In {selectedItem.baseUomName} +

+ )} +
+ ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="1" + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + {fmtAmount(lineTotal)} + +
+
+
+ +
+
+

Order Summary

+

+ {watchedItems.length} line item{watchedItems.length === 1 ? '' : 's'} on this order. +

+ + + +
+

Total Cost

+

{fmtAmount(totalCost)}

+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-dialog.tsx b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-dialog.tsx new file mode 100644 index 0000000..cedc12c --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-dialog.tsx @@ -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 + +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([]) + const [usersList, setUsersList] = useState([]) + const [selectedOrder, setSelectedOrder] = useState(null) + const isEdit = Boolean(claim) + + const form = useForm({ + 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 ( + { if (!v) onClose() }}> + + + {isEdit ? 'Edit' : 'Raise'} Supplier Claim + + {isEdit + ? 'Only claims that are still Open can be edited.' + : 'Raise a claim against a supplier for damaged, short, expired, or wrong stock received.'} + + + +
+ + ( + + )} + /> + +
+ ( + + )} + /> + + {!isEdit && ( + ( + + )} + /> + )} +
+
+ + {selectedOrder && ( +
+
+

Claimed Items

+ +
+ + {receivedItems.length === 0 ? ( +

This order has no received items to claim against.

+ ) : ( + fields.map((f, index) => ( +
+ ( + + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + + ( + + )} + /> + + {fields.length > 1 && ( + + )} +
+ )) + )} +
+ )} + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-resolve-dialog.tsx b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-resolve-dialog.tsx new file mode 100644 index 0000000..9c18fdd --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-resolve-dialog.tsx @@ -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 + +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([]) + + const form = useForm({ + 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 ( + { if (!v) onClose() }}> + + + {mode === 'approve' ? 'Approve' : 'Reject'} Supplier Claim + {claim.details} + + +
+ + {mode === 'approve' && ( + <> + ( + + )} + /> + + {resolutionType !== 'Replacement' && ( + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + )} + + )} + + ( + + )} + /> + + ( + + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-view-sheet.tsx b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-view-sheet.tsx new file mode 100644 index 0000000..3ac9c52 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claim-view-sheet.tsx @@ -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 = { + 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 ( +
+ {label} + {value} +
+ ) +} + +export function SupplierClaimViewSheet({ open, onClose, claim, supplierName }: SupplierClaimViewSheetProps) { + if (!claim) return null + + return ( + !v && onClose()}> + + + Supplier Claim Details + + +
+ {supplierName && } + {claim.status}} /> + + {claim.evidenceUrl && } + {claim.resolutionType && } + {claim.resolutionAmount != null && } + {claim.resolutionNotes && } + {claim.resolvedAt && } + +
+ +
+

Claimed Items

+
+ + + + + + + + + + {claim.items.map((item) => ( + + + + + + ))} + +
ItemQtyIssue
+ {item.itemName} + ({item.sku}) + {item.quantity}{item.issueType}
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claims-client.tsx b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claims-client.tsx new file mode 100644 index 0000000..30edc53 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/_components/supplier-claims-client.tsx @@ -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 = { + Open: 'default', + Approved: 'secondary', + Rejected: 'destructive', +} + +export function SupplierClaimsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [pageSize, setPageSize] = useState(20) + const [supplierNameByPoId, setSupplierNameByPoId] = useState>(new Map()) + const [createOpen, setCreateOpen] = useState(false) + const [viewing, setViewing] = useState(null) + const [viewOpen, setViewOpen] = useState(false) + const [resolving, setResolving] = useState(null) + const [resolveMode, setResolveMode] = useState<'approve' | 'reject'>('approve') + const [resolveOpen, setResolveOpen] = useState(false) + const [editing, setEditing] = useState(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>(() => ({ + 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) => ( + {supplierNameByPoId.get(row.purchaseOrderId) ?? '—'} + ), + }, + { + key: 'details', + title: 'Details', + fields: ['details'], + minWidth: 220, + cellRender: (row) => ( + {row.details} + ), + }, + { + key: 'itemCount', + title: 'Items', + fields: ['items'], + width: 100, + cellRender: (row) => ( + {row.items.length} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 120, + cellRender: (row) => ( + {row.status} + ), + }, + { + 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 ( + <> +
+
+

Supplier Claims

+

+ Claims raised against a supplier for damaged, short, or wrong stock +

+
+ +
+ + { + setPageSize(p.pageSize) + }} + /> + + setViewOpen(false)} + claim={viewing} + supplierName={viewing ? supplierNameByPoId.get(viewing.purchaseOrderId) : undefined} + /> + + setCreateOpen(false)} + onSuccess={fetchData} + /> + + setEditOpen(false)} + onSuccess={fetchData} + claim={editing} + /> + + setResolveOpen(false)} + onSuccess={fetchData} + claim={resolving} + mode={resolveMode} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/page.tsx b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/page.tsx new file mode 100644 index 0000000..d6e747b --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/supplier-claims/page.tsx @@ -0,0 +1,5 @@ +import { SupplierClaimsClient } from './_components/supplier-claims-client' + +export default function SupplierClaimsPage() { + return +} diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 24e8398..315219c 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -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, diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 397f23a..8b6a06b 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -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' diff --git a/distribution_fe/lib/api/procurement.ts b/distribution_fe/lib/api/procurement.ts index 7a4c66c..9aadc3f 100644 --- a/distribution_fe/lib/api/procurement.ts +++ b/distribution_fe/lib/api/procurement.ts @@ -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('/purchase-orders', body).then(r => r.data), - cancel: (id: string, cancelReason: string) => - api.patch(`/purchase-orders/${id}/cancel`, { cancelReason }).then(r => r.data), + update: (id: string, body: UpdatePurchaseOrderBody) => + api.put(`/purchase-orders/${id}`, body).then(r => r.data), + + cancel: (id: string, reason: string) => + api.post(`/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('/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(`/purchase-returns/${id}`).then(r => r.data), + + listByPurchaseOrder: (purchaseOrderId: string) => + api.get(`/purchase-orders/${purchaseOrderId}/returns`).then(r => r.data), + + create: (purchaseOrderId: string, body: PurchaseReturnBody) => + api.post(`/purchase-orders/${purchaseOrderId}/returns`, body).then(r => r.data), +} diff --git a/distribution_fe/lib/api/returns-claims.ts b/distribution_fe/lib/api/returns-claims.ts new file mode 100644 index 0000000..f7cea52 --- /dev/null +++ b/distribution_fe/lib/api/returns-claims.ts @@ -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('/returns').then(r => r.data), + + get: (id: string) => + api.get(`/returns/${id}`).then(r => r.data), + + listByOrder: (orderId: string) => + api.get(`/orders/${orderId}/returns`).then(r => r.data), + + create: (orderId: string, body: SaleReturnBody) => + api.post(`/orders/${orderId}/returns`, body).then(r => r.data), + + updateReasons: (id: string, body: UpdateSaleReturnReasonsBody) => + api.patch(`/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('/claims').then(r => r.data), + + get: (id: string) => + api.get(`/claims/${id}`).then(r => r.data), + + create: (body: ClaimBody) => + api.post('/claims', body).then(r => r.data), + + update: (id: string, body: ClaimBody) => + api.put(`/claims/${id}`, body).then(r => r.data), + + settle: (id: string, body: SettleClaimBody) => + api.post(`/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('/supplier-claims').then(r => r.data), + + get: (id: string) => + api.get(`/supplier-claims/${id}`).then(r => r.data), + + listByPurchaseOrder: (purchaseOrderId: string) => + api.get(`/purchase-orders/${purchaseOrderId}/claims`).then(r => r.data), + + create: (purchaseOrderId: string, body: SupplierClaimBody) => + api.post(`/purchase-orders/${purchaseOrderId}/claims`, body).then(r => r.data), + + update: (id: string, body: { details: string; evidenceUrl?: string; items: SupplierClaimItemBody[] }) => + api.put(`/supplier-claims/${id}`, body).then(r => r.data), + + approve: (id: string, body: ApproveSupplierClaimBody) => + api.post(`/supplier-claims/${id}/approve`, body).then(r => r.data), + + reject: (id: string, body: RejectSupplierClaimBody) => + api.post(`/supplier-claims/${id}/reject`, body).then(r => r.data), +} diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index f1c11cc..6a93ac8 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -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 {