diff --git a/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-dialog.tsx b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-dialog.tsx new file mode 100644 index 0000000..05bfb64 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-dialog.tsx @@ -0,0 +1,293 @@ +'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 { + saleReturns as saleReturnsApi, + orders as ordersApi, + stores as storesApi, + items as itemsApi, + users as usersApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { OrderResponse, ItemSummaryResponse, UserResponse, ReturnReasonCode } from '@/types/api' + +const REASON_CODES: ReturnReasonCode[] = ['Expired', 'Damaged', 'OrderError', 'Other'] + +const returnItemSchema = z.object({ + itemId: z.string().min(1, 'Item is required'), + quantity: z.number().min(1, 'Quantity must be at least 1'), + reasonCode: z.enum(['Expired', 'Damaged', 'OrderError', 'Other']), +}) + +const schema = z.object({ + orderId: z.string().min(1, 'Order is required'), + mode: z.enum(['CreditDeduction', 'ItemExchange']), + 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 OrderReturnDialogProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +function defaultItem() { + return { itemId: '', quantity: 1, reasonCode: 'Damaged' as ReturnReasonCode } +} + +export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialogProps) { + const [ordersList, setOrdersList] = useState([]) + const [storeNameById, setStoreNameById] = useState>(new Map()) + const [itemsList, setItemsList] = useState([]) + const [usersList, setUsersList] = useState([]) + const [selectedOrder, setSelectedOrder] = useState(null) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { orderId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] }, + }) + + const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' }) + const itemsById = useMemo(() => new Map(itemsList.map((i) => [i.id, i])), [itemsList]) + + const eligibleOrders = useMemo( + () => ordersList.filter((o) => o.status !== 'Cancelled' && o.status !== 'Fulfilled'), + [ordersList] + ) + + useEffect(() => { + if (!open) return + form.reset({ orderId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] }) + setSelectedOrder(null) + Promise.all([ + ordersApi.list(), + storesApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([orderList, storeList, itemsRes, usersRes]) => { + setOrdersList(orderList) + setStoreNameById(new Map(getPaginatedItems(storeList).map((s) => [s.id, s.storeName]))) + setItemsList(getPaginatedItems(itemsRes)) + setUsersList(getPaginatedItems(usersRes)) + }) + .catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })) + }, [open]) + + function handleOrderChange(orderId: string | null) { + form.setValue('orderId', orderId ?? '', { shouldValidate: true }) + const order = orderId ? ordersList.find((o) => o.id === orderId) ?? null : null + setSelectedOrder(order) + } + + async function onSubmit(values: FormValues) { + try { + await saleReturnsApi.create(values.orderId, { + mode: values.mode, + items: values.items, + createdByUserId: values.createdByUserId, + }) + toast.add({ title: 'Order return recorded', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to record return', description: getApiError(err), type: 'error' }) + } + } + + return ( + { if (!v) onClose() }}> + + + Record Order Return + + Record items a store returned against an open order. Only items the store has previously + ordered can be returned. + + + +
+ + ( + + )} + /> + +
+ ( + + )} + /> + + ( + + )} + /> +
+
+ + {selectedOrder && ( +
+
+

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)/returns-claims/order-returns/_components/order-return-edit-dialog.tsx b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-edit-dialog.tsx new file mode 100644 index 0000000..59a8c14 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-edit-dialog.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useEffect } from 'react' +import { useFieldArray } from 'react-hook-form' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +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 { saleReturns as saleReturnsApi, getApiError } from '@/lib/api/index' +import type { SaleReturnResponse, ReturnReasonCode } from '@/types/api' + +const REASON_CODES: ReturnReasonCode[] = ['Expired', 'Damaged', 'OrderError', 'Other'] + +const schema = z.object({ + items: z.array(z.object({ + itemId: z.string(), + itemName: z.string(), + reasonCode: z.enum(['Expired', 'Damaged', 'OrderError', 'Other']), + })), +}) + +type FormValues = z.infer + +interface OrderReturnEditDialogProps { + open: boolean + onClose: () => void + onSuccess: () => void + ret: SaleReturnResponse | null +} + +export function OrderReturnEditDialog({ open, onClose, onSuccess, ret }: OrderReturnEditDialogProps) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { items: [] }, + }) + + const { fields } = useFieldArray({ control: form.control, name: 'items' }) + + useEffect(() => { + if (!open || !ret) return + form.reset({ + items: ret.items.map((i) => ({ itemId: i.itemId, itemName: i.itemName, reasonCode: i.reasonCode })), + }) + }, [open, ret]) + + if (!ret) return null + + async function onSubmit(values: FormValues) { + try { + await saleReturnsApi.updateReasons(ret!.id, { + items: values.items.map((i) => ({ itemId: i.itemId, reasonCode: i.reasonCode })), + }) + toast.add({ title: 'Return updated', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to update return', description: getApiError(err), type: 'error' }) + } + } + + return ( + { if (!v) onClose() }}> + + + Edit Order Return + + The return was already applied to stock and the order total when it was recorded, so + quantity and mode can't be changed. Only each line's reason can be corrected. + + + +
+ + {fields.map((f, index) => ( +
+ {f.itemName} +
+ ( + + )} + /> +
+
+ ))} +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-view-sheet.tsx b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-view-sheet.tsx new file mode 100644 index 0000000..4ba1660 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-return-view-sheet.tsx @@ -0,0 +1,90 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import type { SaleReturnResponse, ReturnMode } from '@/types/api' + +const MODE_LABEL: Record = { + CreditDeduction: 'Credit Deduction', + ItemExchange: 'Item Exchange', +} + +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 OrderReturnViewSheetProps { + open: boolean + onClose: () => void + ret: SaleReturnResponse | null + storeName?: string +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +export function OrderReturnViewSheet({ open, onClose, ret, storeName }: OrderReturnViewSheetProps) { + if (!ret) return null + + return ( + !v && onClose()}> + + + Order Return Details + + +
+ {storeName && } + {ret.orderId}} /> + {MODE_LABEL[ret.mode]}} /> + + + +
+ +
+

Returned Items

+
+ + + + + + + + + + + {ret.items.map((item) => ( + + + + + + + ))} + +
ItemQtyReasonUnit Price
+ {item.itemName} + ({item.sku}) + {item.quantity}{item.reasonCode}{fmtAmount(item.unitPrice)}
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-returns-client.tsx b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-returns-client.tsx new file mode 100644 index 0000000..d11ff3d --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/order-returns/_components/order-returns-client.tsx @@ -0,0 +1,183 @@ +'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 { saleReturns as saleReturnsApi, orders as ordersApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { SaleReturnResponse, ReturnMode } from '@/types/api' +import { OrderReturnViewSheet } from './order-return-view-sheet' +import { OrderReturnDialog } from './order-return-dialog' +import { OrderReturnEditDialog } from './order-return-edit-dialog' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` + +const MODE_VARIANT: Record = { + CreditDeduction: 'default', + ItemExchange: 'secondary', +} + +export function OrderReturnsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [pageSize, setPageSize] = useState(20) + const [storeNameByOrderId, setStoreNameByOrderId] = useState>(new Map()) + const [viewing, setViewing] = useState(null) + const [viewOpen, setViewOpen] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [editOpen, setEditOpen] = useState(false) + + function openView(row: SaleReturnResponse) { + setViewing(row) + setViewOpen(true) + } + + function openEdit(row: SaleReturnResponse) { + setEditing(row) + setEditOpen(true) + } + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const [returns, orderList, storeList] = await Promise.all([ + saleReturnsApi.list(), + ordersApi.list(), + storesApi.list({ pageSize: 200 }), + ]) + const storeNameById = new Map(getPaginatedItems(storeList).map((s) => [s.id, s.storeName])) + const map = new Map(orderList.map((o) => [o.id, storeNameById.get(o.storeId) ?? '—'])) + setStoreNameByOrderId(map) + setData(returns) + } catch (err) { + toast.add({ title: 'Failed to load order returns', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + stickyHeader: true, + emptyMessage: 'No order returns recorded yet.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'store', + title: 'Store', + fields: ['orderId'], + minWidth: 200, + cellRender: (row) => ( + {storeNameByOrderId.get(row.orderId) ?? '—'} + ), + }, + { + key: 'mode', + title: 'Mode', + fields: ['mode'], + width: 160, + cellRender: (row) => ( + {row.mode === 'CreditDeduction' ? 'Credit Deduction' : 'Item Exchange'} + ), + }, + { + key: 'itemCount', + title: 'Items', + fields: ['items'], + width: 100, + cellRender: (row) => ( + {row.items.length} + ), + }, + { + key: 'totalCreditAmount', + title: 'Credit Amount', + fields: ['totalCreditAmount'], + width: 140, + cellRender: (row) => ( + {fmtAmount(row.totalCreditAmount)} + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'view', + label: 'View return', + onClick: openView, + }, + { + action: 'edit', + label: 'Edit return', + onClick: openEdit, + }, + ], + }, + }), [pageSize, storeNameByOrderId]) + + return ( + <> +
+
+

Order Returns

+

+ Items a store returned during a sale visit +

+
+ +
+ + { + setPageSize(p.pageSize) + }} + /> + + setViewOpen(false)} + ret={viewing} + storeName={viewing ? storeNameByOrderId.get(viewing.orderId) : undefined} + /> + + setCreateOpen(false)} + onSuccess={fetchData} + /> + + setEditOpen(false)} + onSuccess={fetchData} + ret={editing} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/returns-claims/order-returns/page.tsx b/distribution_fe/app/(dashboard)/returns-claims/order-returns/page.tsx new file mode 100644 index 0000000..cd12842 --- /dev/null +++ b/distribution_fe/app/(dashboard)/returns-claims/order-returns/page.tsx @@ -0,0 +1,5 @@ +import { OrderReturnsClient } from './_components/order-returns-client' + +export default function OrderReturnsPage() { + return +}