feat: add sales return functionality

- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
This commit is contained in:
2026-08-12 10:18:56 +05:30
parent d37824cecc
commit d16a227b54
18 changed files with 1005 additions and 6 deletions
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -619,8 +619,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
</>
) : (
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
This invoice is {invoice.status.toLowerCase()} and cannot be edited.
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>This invoice is {invoice.status.toLowerCase()} and cannot be edited.</span>
{invoice.status === "Posted" ? (
<Link
href={`/dashboard/sales/sales-returns/new?invoiceId=${invoice.salesInvoiceId}`}
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-black bg-white px-4 text-sm font-medium text-foreground shadow-sm hover:bg-muted sm:self-auto"
>
<Undo2 className="size-4" />
Return items
</Link>
) : null}
</div>
)}
</div>
@@ -0,0 +1,307 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { salesReturnsApi } from "@/lib/api/sales-returns"
import { salesApi } from "@/lib/api/sales"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales"
import { cn } from "@/lib/utils"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
}
function NewSalesReturnContent() {
const router = useRouter()
const searchParams = useSearchParams()
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
const presetLineId = Number(searchParams.get("lineId")) || null
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [remainingLoading, setRemainingLoading] = useState(false)
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lineState, setLineState] = useState<Record<number, LineState>>({})
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
.then(([invoiceList, it, rc]) => {
// Only Posted invoices have stock movements to return against.
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
useEffect(() => {
if (!selectedInvoice) {
setLineState({})
setRemainingByLine({})
return
}
const next: Record<number, LineState> = {}
for (const line of selectedInvoice.lines) {
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
}
setLineState(next)
setRemainingLoading(true)
salesReturnsApi
.getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => {
const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
setRemainingByLine(map)
if (presetLineId) {
const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
}
})
.catch((err) => setLoadError(errorMessage(err)))
.finally(() => setRemainingLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvoice?.salesInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function remainingFor(lineId: number, fallback: number) {
return remainingByLine[lineId] ?? fallback
}
function toggleLine(line: SalesInvoiceLine) {
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
setLineState((prev) => ({
...prev,
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
}))
}
function setQty(lineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (!selectedInvoice) {
setHeaderError("Select an invoice to return against.")
return
}
if (!reasonCodeId) {
setHeaderError("Select a reason code.")
return
}
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
if (selectedLines.length === 0) {
setSubmitError("Select at least one line to return.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) {
const errors = validateSalesReturnLine({
salesInvoiceLineId: line.salesInvoiceLineId,
qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
})
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
}
setLineErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty),
}))
setSubmitting(true)
try {
const salesReturn = await salesReturnsApi.create({
customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId,
reasonCodeId,
lines: payloadLines,
})
toast.success("Sales return posted", `${salesReturn.docNo}${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
router.push("/dashboard/sales/sales-returns")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post sales return", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !invoices
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Sales Return</h1>
<p className="text-base text-muted-foreground">Return sold goods from a customer; posts an inbound ledger entry immediately.</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Sales Invoice</Label>
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a posted invoice" />
</SelectTrigger>
<SelectContent>
{(invoices ?? []).map((inv) => (
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
{inv.invoiceNo} {inv.customerSnapshotName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{reasonCodes.map((rc) => (
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
{rc.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
{selectedInvoice && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoiced qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Remaining qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedInvoice.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
const fullyReturned = !remainingLoading && remaining <= 0
return (
<TableRow key={line.salesInvoiceLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : line.description}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
{remainingLoading ? "…" : fullyReturned ? <span className="text-muted-foreground">Fully returned</span> : remaining}
</TableCell>
<TableCell className="px-3 py-3.5">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected || fullyReturned}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/sales-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewSalesReturnPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewSalesReturnContent />
</Suspense>
)
}
@@ -0,0 +1,121 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Undo2, Plus } from "lucide-react"
import { salesReturnsApi } from "@/lib/api/sales-returns"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { SalesReturnSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function SalesReturnsListPage() {
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([r, c, w, rc]) => {
setReturns(r.items)
setCustomers(c.items)
setWarehouses(w.items)
setReasonCodes(rc.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
function customerLabel(id: number) {
const customer = customers.find((c) => c.customerId === id)
return customer ? (customer.displayName ?? customer.name) : `#${id}`
}
function warehouseCode(id: number) {
return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}`
}
function reasonLabel(id: number) {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
</div>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && returns === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Undo2 className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No sales returns yet.</p>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
)}
{!error && returns !== null && returns.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<TableRow key={r.returnId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -8,9 +8,10 @@ import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReasonCode, StockAdjustment } from "@/types/stock"
import { OnHand, ReasonCode, StockAdjustment } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -34,6 +35,10 @@ export default function NewWastagePage() {
const [qty, setQty] = useState("")
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [onHandLoading, setOnHandLoading] = useState(false)
const [onHandError, setOnHandError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
@@ -57,12 +62,31 @@ export default function NewWastagePage() {
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
useEffect(() => {
if (!itemId || !warehouseId) {
setOnHand(null)
setOnHandError(null)
return
}
setOnHandLoading(true)
setOnHandError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setOnHandError(errorMessage(err))
})
.finally(() => setOnHandLoading(false))
}, [itemId, warehouseId])
async function handleSubmit() {
setSubmitError(null)
const nextErrors: Record<string, string> = {}
if (!warehouseId) nextErrors.warehouseId = "Select a warehouse"
if (!itemId) nextErrors.itemId = "Select an item"
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
else if (onHand && Number(qty) > onHand.available) nextErrors.qty = `Insufficient quantity — only ${onHand.available} available`
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -92,6 +116,8 @@ export default function NewWastagePage() {
setQty("")
setReasonCodeId(null)
setSubmitError(null)
setOnHand(null)
setOnHandError(null)
}
const loading = !warehouses || !items || !reasonCodes
@@ -192,7 +218,20 @@ export default function NewWastagePage() {
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Quantity wasted</Label>
<div className="flex items-center justify-between">
<Label className="text-base">Quantity wasted</Label>
{itemId && warehouseId && (
<span className="text-sm text-muted-foreground">
{onHandLoading
? "Checking available stock…"
: onHandError
? "Available stock unknown"
: onHand
? `Available: ${onHand.available}`
: null}
</span>
)}
</div>
<Input
type="number"
min="0"