responsiveness and ui fixers
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
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 { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { Button } 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
|
||||
}
|
||||
|
||||
export interface SalesReturnFormProps {
|
||||
presetInvoiceId?: number | null
|
||||
presetLineId?: number | null
|
||||
onCancel: () => void
|
||||
onSuccess: (docNo: string) => void
|
||||
}
|
||||
|
||||
/** Shared form body behind both the standalone /sales-returns/new page (deep-linked from an
|
||||
* invoice's "Return this line" action, which needs presetInvoiceId/presetLineId) and the
|
||||
* "New Return" popup on the sales-returns list. */
|
||||
export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, onCancel, onSuccess }: SalesReturnFormProps) {
|
||||
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.`)
|
||||
onSuccess(salesReturn.docNo)
|
||||
} 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">
|
||||
{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="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-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>
|
||||
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{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 (
|
||||
<div key={line.salesInvoiceLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
||||
{item ? `${item.sku} — ${item.name}` : line.description}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Invoiced</p>
|
||||
<p className="font-medium text-foreground">{line.qty}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Remaining</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{remainingLoading ? "…" : fullyReturned ? "Returned" : remaining}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit price</p>
|
||||
<p className="font-medium text-foreground">{line.unitPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm">Return qty</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
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]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Invoiced qty</TableHead>
|
||||
<TableHead>Remaining qty</TableHead>
|
||||
<TableHead>Unit price</TableHead>
|
||||
<TableHead className="w-36">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} className="align-top hover:bg-muted/20">
|
||||
<TableCell>
|
||||
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
|
||||
</TableCell>
|
||||
<TableCell>{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" type="button" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Return"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user