From f4db83654c93a3f20816443480abe60520e668e8 Mon Sep 17 00:00:00 2001
From: "Kalana B. Thilakarathna"
Date: Sat, 29 Aug 2026 21:03:31 +0530
Subject: [PATCH] Implement GRN and Purchase Order sheets with form handling
and data fetching
---
.../rides/_components/ride-sheet.tsx | 12 +-
.../grn/_components/grn-client.tsx | 16 +-
.../procurement/grn/_components/grn-sheet.tsx | 298 ++++++++++++++++++
.../_components/purchase-order-sheet.tsx | 262 +++++++++++++++
.../_components/purchase-orders-client.tsx | 15 +
.../sales/_components/sale-sheet.tsx | 10 +-
.../stores/_components/store-sheet.tsx | 7 +-
7 files changed, 601 insertions(+), 19 deletions(-)
create mode 100644 distribution_fe/app/(dashboard)/procurement/grn/_components/grn-sheet.tsx
create mode 100644 distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-sheet.tsx
diff --git a/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx b/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx
index 04ef51c..840f79c 100644
--- a/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx
+++ b/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx
@@ -29,6 +29,7 @@ import {
routes as routesApi,
warehouses as warehousesApi,
getApiError,
+ getPaginatedItems,
} from '@/lib/api/index'
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api'
@@ -49,9 +50,6 @@ interface RideSheetProps {
onSuccess: () => void
}
-function ensureItems(value: { items?: T[] } | null | undefined): T[] {
- return Array.isArray(value?.items) ? value.items : []
-}
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
const [vehiclesList, setVehiclesList] = useState([])
@@ -91,10 +89,10 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
warehousesApi.list({ pageSize: 200 }),
])
.then(([v, d, r, w]) => {
- setVehiclesList(ensureItems(v))
- setDriversList(ensureItems(d))
- setRoutesList(ensureItems(r))
- setWarehousesList(ensureItems(w))
+ setVehiclesList(getPaginatedItems(v))
+ setDriversList(getPaginatedItems(d))
+ setRoutesList(getPaginatedItems(r))
+ setWarehousesList(getPaginatedItems(w))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx
index 821ee32..e5a69f4 100644
--- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx
+++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx
@@ -1,12 +1,15 @@
'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 { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
+import { GrnSheet } from './grn-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -19,6 +22,7 @@ export function GrnClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
+ const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -101,6 +105,10 @@ export function GrnClient() {
Stock received into a warehouse against a purchase order
+
+
+ setSheetOpen(false)}
+ onSuccess={() => fetchData(page, pageSize)}
+ />
>
)
}
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-sheet.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-sheet.tsx
new file mode 100644
index 0000000..e0cc8c8
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-sheet.tsx
@@ -0,0 +1,298 @@
+'use client'
+
+import { useEffect, 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 { Separator } from '@/components/ui/separator'
+import { toast } from '@/components/ui/toast'
+import {
+ grn as grnApi,
+ purchaseOrders as purchaseOrdersApi,
+ warehouses as warehousesApi,
+ getApiError,
+ getPaginatedItems,
+} from '@/lib/api/index'
+import type { PurchaseOrderResponse, WarehouseResponse } from '@/types/api'
+
+const grnItemSchema = z.object({
+ purchaseOrderItemId: z.string().min(1),
+ acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
+ rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
+})
+
+const schema = z.object({
+ purchaseOrderId: z.string().min(1, 'Purchase order is required'),
+ warehouseId: z.string().min(1, 'Warehouse is required'),
+ receivedAt: z.string().min(1, 'Received date is required'),
+ items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
+})
+
+type FormValues = z.infer
+
+interface GrnSheetProps {
+ open: boolean
+ onClose: () => void
+ onSuccess: () => void
+}
+
+function todayIso() {
+ return new Date().toISOString().slice(0, 10)
+}
+
+export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
+ const [eligiblePOs, setEligiblePOs] = useState([])
+ const [warehousesList, setWarehousesList] = useState([])
+ const [selectedPO, setSelectedPO] = useState(null)
+ const [loadingPO, setLoadingPO] = useState(false)
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ purchaseOrderId: '',
+ warehouseId: '',
+ receivedAt: todayIso(),
+ items: [],
+ },
+ })
+
+ const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
+
+ // Load reference data when sheet opens
+ useEffect(() => {
+ if (!open) return
+ form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), items: [] })
+ setSelectedPO(null)
+ Promise.all([
+ purchaseOrdersApi.list({ pageSize: 200 }),
+ warehousesApi.list({ pageSize: 200 }),
+ ])
+ .then(([pos, ws]) => {
+ // Only POs that can still receive goods
+ const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
+ setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
+ setWarehousesList(getPaginatedItems(ws))
+ })
+ .catch(err =>
+ toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
+ )
+ }, [open])
+
+ // When PO changes, fetch its details and populate items
+ async function handlePOChange(poId: string) {
+ form.setValue('purchaseOrderId', poId, { shouldValidate: true })
+ if (!poId) {
+ setSelectedPO(null)
+ replace([])
+ return
+ }
+ setLoadingPO(true)
+ try {
+ const po = await purchaseOrdersApi.get(poId)
+ setSelectedPO(po)
+ // Pre-fill items: remaining quantity for each non-cancelled line
+ const lineItems = (po.items ?? [])
+ .filter(item => !item.isCancelled)
+ .map(item => ({
+ purchaseOrderItemId: item.id,
+ acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
+ rejectedQuantity: 0,
+ }))
+ replace(lineItems)
+ } catch (err) {
+ toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
+ } finally {
+ setLoadingPO(false)
+ }
+ }
+
+ async function onSubmit(values: FormValues) {
+ try {
+ await grnApi.create({
+ purchaseOrderId: values.purchaseOrderId,
+ warehouseId: values.warehouseId,
+ receivedAt: `${values.receivedAt}T00:00:00.000Z`,
+ items: values.items,
+ })
+ toast.add({ title: 'Goods receipt recorded', type: 'success' })
+ onSuccess()
+ onClose()
+ } catch (err) {
+ toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
+ }
+ }
+
+ const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
+
+ return (
+
+ )
+}
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
new file mode 100644
index 0000000..8e599c3
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-order-sheet.tsx
@@ -0,0 +1,262 @@
+'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 (
+
+ )
+}
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 26ad5a9..eefdbe8 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,12 +1,16 @@
'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 { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api'
+import { PurchaseOrderSheet } from './purchase-order-sheet'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -27,6 +31,7 @@ export function PurchaseOrdersClient() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
+ const [sheetOpen, setSheetOpen] = useState(false)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -130,6 +135,10 @@ export function PurchaseOrdersClient() {
Orders placed with suppliers to restock inventory
+
+
+ setSheetOpen(false)}
+ onSuccess={() => fetchData(page, pageSize)}
+ />
>
)
}
diff --git a/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx
index 5451b75..c696c20 100644
--- a/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx
+++ b/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx
@@ -30,6 +30,7 @@ import {
rides as ridesApi,
items as itemsApi,
getApiError,
+ getPaginatedItems,
} from '@/lib/api/index'
import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api'
@@ -54,9 +55,6 @@ interface SaleSheetProps {
onSuccess: () => void
}
-function ensureItems(value: { items?: T[] } | null | undefined): T[] {
- return Array.isArray(value?.items) ? value.items : []
-}
function createDefaultItem() {
return {
@@ -112,9 +110,9 @@ export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) {
itemsApi.list({ pageSize: 200 }),
])
.then(([stores, rides, items]) => {
- setStoresList(ensureItems(stores))
- setRidesList(ensureItems(rides))
- setItemsList(ensureItems(items))
+ setStoresList(getPaginatedItems(stores))
+ setRidesList(getPaginatedItems(rides))
+ setItemsList(getPaginatedItems(items))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
index 96d17dc..d51fbf2 100644
--- a/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
+++ b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
@@ -23,7 +23,7 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
-import { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index'
+import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const schema = z.object({
@@ -47,9 +47,6 @@ interface StoreSheetProps {
onSuccess: () => void
}
-function ensureItems(value: { items?: T[] } | null | undefined): T[] {
- return Array.isArray(value?.items) ? value.items : []
-}
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
@@ -72,7 +69,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
useEffect(() => {
if (open) {
- routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) =>
+ routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)