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 ( + { if (!v) onClose() }}> + + + Record Goods Receipt + + Record stock received into a warehouse against a purchase order. + + + +
+ + ( + + )} + /> + +
+ ( + + )} + /> + + ( + + )} + /> +
+
+ + {/* Line items — populated from the selected PO */} + {selectedPO && ( + <> + +
+

Line Items

+ + {loadingPO ? ( +

Loading items…

+ ) : poItems.length === 0 ? ( +

No receivable items on this order.

+ ) : ( + <> + {/* Header row */} +
+

Item

+

Accepted

+

Rejected

+
+ + {fields.map((f, index) => { + const poItem = poItems[index] + return ( +
+
+ {poItem?.itemName} + + {poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity} + +
+ + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> +
+ ) + })} + + )} +
+ + )} + + + + + + +
+
+ ) +} 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 ( + { 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-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' }) )