feat(grn): add update functionality for draft GRNs and support document-level discounts

- Implemented Update method in GrnsController to allow editing of draft GRNs.
- Enhanced CreateGrnRequest to include an optional totalDiscount property.
- Updated GrnService to handle GRN updates, including validation and line item processing.
- Modified NewGrnPage to support editing existing GRNs and applying document-level discounts.
- Improved UI in NewItemPage and GrnDetailPage for better user experience.
- Added search functionality to Select component for improved item selection.
This commit is contained in:
2026-08-11 11:00:31 +05:30
parent 8e9974b735
commit bb6d939059
13 changed files with 450 additions and 76 deletions
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Plus, X } from "lucide-react"
import { ExternalLink, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
@@ -44,6 +44,11 @@ function isBuilderItemType(name: string): boolean {
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
}
/** Opens a master-data management page in a new tab so the in-progress form isn't lost. */
function openInNewTab(path: string) {
window.open(path, "_blank", "noopener,noreferrer")
}
export default function NewItemPage() {
const router = useRouter()
@@ -280,7 +285,20 @@ export default function NewItemPage() {
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<div className="flex items-center gap-2">
<Label className="text-base">Category</Label>
<Button
type="button"
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 rounded-full"
onClick={() => openInNewTab("/dashboard/products/categories")}
aria-label="Add category"
title="Add category"
>
<Plus className="size-3" />
</Button>
</div>
<Select<number | null>
value={categoryId}
onValueChange={handleCategoryChange}
@@ -303,7 +321,22 @@ export default function NewItemPage() {
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<div className="flex items-center gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Button
type="button"
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 rounded-full"
onClick={() =>
openInNewTab(categoryId ? `/dashboard/products/categories/${categoryId}` : "/dashboard/products/categories")
}
aria-label="Add subcategory"
title="Add subcategory"
>
<Plus className="size-3" />
</Button>
</div>
<Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
@@ -325,7 +358,20 @@ export default function NewItemPage() {
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<div className="flex items-center gap-2">
<Label className="text-base">Brand (optional)</Label>
<Button
type="button"
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 rounded-full"
onClick={() => openInNewTab("/dashboard/products/brands")}
aria-label="Add brand"
title="Add brand"
>
<Plus className="size-3" />
</Button>
</div>
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
@@ -345,7 +391,20 @@ export default function NewItemPage() {
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<div className="flex items-center gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Button
type="button"
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 rounded-full"
onClick={() => openInNewTab("/dashboard/warehouse")}
aria-label="Add warehouse"
title="Add warehouse"
>
<Plus className="size-3" />
</Button>
</div>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
@@ -364,7 +423,20 @@ export default function NewItemPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<div className="flex items-center gap-2">
<Label className="text-base">Base UOM</Label>
<Button
type="button"
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 rounded-full"
onClick={() => openInNewTab("/dashboard/products/uoms")}
aria-label="Add UOM"
title="Add UOM"
>
<Plus className="size-3" />
</Button>
</div>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
@@ -454,11 +526,22 @@ export default function NewItemPage() {
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openInNewTab("/dashboard/products/item-types")}
>
<ExternalLink className="size-4" />
Manage item types
</Button>
</div>
<div className="flex flex-wrap items-center gap-4">
@@ -130,10 +130,15 @@ export default function GrnDetailPage() {
</div>
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
<div className="flex items-center gap-2">
<Link href={`/dashboard/receiving/grn/new?grnId=${grn.grnId}`} className={cn(buttonVariants({ size: "lg", variant: "outline" }))}>
Edit
</Link>
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
@@ -86,6 +86,8 @@ function emptyLine(): DraftLine {
export default function NewGrnPage() {
const router = useRouter()
const search = useSearchParams()
const editingGrnId = Number(search?.get("grnId")) || null
const [mode, setMode] = useState<Mode>("po")
@@ -102,6 +104,7 @@ export default function NewGrnPage() {
const [poId, setPoId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [totalDiscount, setTotalDiscount] = useState<string>("")
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
@@ -127,6 +130,37 @@ export default function NewGrnPage() {
.catch((err) => setLoadError(errorMessage(err)))
}, [])
// If editing an existing draft GRN, load and populate the form.
useEffect(() => {
if (!editingGrnId) return
grnsApi
.get(editingGrnId)
.then((g) => {
setPoId(g.poId ?? null)
setVendorId(g.vendorId ?? null)
setWarehouseId(g.warehouseId)
setLines(
g.lines.map((ln) => ({
key: newKey(),
poLineId: ln.poLineId,
itemId: ln.itemId,
uomId: ln.uomId,
binId: ln.binId,
qty: String(ln.qty),
unitCost: String(ln.unitCost),
poUnitPrice: ln.poUnitPrice,
discountPct: String(ln.discountPct),
vatPct: String(ln.vatPct),
holdStatus: ln.holdStatus,
batchNo: "",
expiryDate: "",
serialNumbersText: "",
}))
)
})
.catch(() => {})
}, [editingGrnId])
useEffect(() => {
if (!warehouseId) {
setBins([])
@@ -208,6 +242,15 @@ export default function NewGrnPage() {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
// When a document-level total discount is entered, clear any per-line discounts.
function updateTotalDiscount(next: string) {
setTotalDiscount(next)
const pct = Number(next) || 0
if (pct > 0) {
setLines((prev) => prev.map((l) => ({ ...l, discountPct: "0" })))
}
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key))
}
@@ -272,7 +315,8 @@ export default function NewGrnPage() {
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
// Use document-level discount if supplied, otherwise per-line discount.
discountPct: (Number(totalDiscount) || 0) > 0 ? (Number(totalDiscount) || 0) : Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
@@ -282,13 +326,26 @@ export default function NewGrnPage() {
setSubmitting(true)
try {
const grn = await grnsApi.create({
poId: mode === "po" ? poId : null,
vendorId: mode === "direct" ? vendorId : null,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
let grn
if (editingGrnId) {
grn = await grnsApi.update(editingGrnId, {
poId: mode === "po" ? poId : null,
vendorId: mode === "direct" ? vendorId : null,
warehouseId: warehouseId as number,
lines: payloadLines,
totalDiscount: Number(totalDiscount) || undefined,
})
toast.success("GRN updated", `${grn.docNo} is ready to confirm.`)
} else {
grn = await grnsApi.create({
poId: mode === "po" ? poId : null,
vendorId: mode === "direct" ? vendorId : null,
warehouseId: warehouseId as number,
lines: payloadLines,
totalDiscount: Number(totalDiscount) || undefined,
})
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
}
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
@@ -473,7 +530,7 @@ export default function NewGrnPage() {
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-md max-w-[80vw]" align="start">
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
@@ -557,10 +614,11 @@ export default function NewGrnPage() {
min="0"
max="100"
step="any"
value={line.discountPct}
value={(Number(totalDiscount) || 0) > 0 ? totalDiscount : line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
disabled={(Number(totalDiscount) || 0) > 0}
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
@@ -653,12 +711,37 @@ export default function NewGrnPage() {
)}
{!poLoading && lines.length > 0 && (
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="font-semibold tabular-nums">
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
</span>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 text-base">
<div className="flex items-center gap-3 justify-end">
<label className="text-sm text-muted-foreground">Total discount %</label>
<Input
type="number"
min="0"
max="100"
step="any"
value={totalDiscount}
onChange={(e) => updateTotalDiscount(e.target.value)}
className="w-28 text-sm"
/>
</div>
<div className="flex items-center gap-3 justify-end">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="font-semibold tabular-nums">
{(() => {
const totalReceived = lines.reduce((s, l) => s + computeLine(l).receivedValue, 0)
const totalVat = lines.reduce((s, l) => s + computeLine(l).vatAmount, 0)
const pct = Number(totalDiscount) || 0
if (pct > 0) {
const discountedBase = totalReceived * (1 - pct / 100)
const discountedVat = lines.reduce((s, l) => s + computeLine(l).vatAmount * (1 - pct / 100), 0)
return (discountedBase + discountedVat).toFixed(2)
}
return (totalReceived + totalVat).toFixed(2)
})()}
</span>
</div>
</div>
)}
</div>
@@ -671,7 +754,7 @@ export default function NewGrnPage() {
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
{submitting ? (editingGrnId ? "Saving…" : "Creating…") : editingGrnId ? "Save Changes" : "Create GRN"}
</Button>
</div>
</>
+36 -38
View File
@@ -129,47 +129,45 @@
--sidebar-ring: oklch(0.68 0.186 265.215);
}
/* Vibrant — the "System" toggle option. Light content area (background,
cards, header, table panels) paired with a dark sidebar — the same split
Linear/Vercel/Notion use in their light themes. One violet accent drives
every interactive state; the sidebar keeps its own dark token family
(applied via .sidebar-surface below) so it stays dark regardless. */
/* Vibrant — the "System" toggle option. Light gray background and sidebar
throughout (no light content split), with one violet accent driving every
interactive state. */
.vibrant {
--background: oklch(0.97 0.004 265);
--foreground: oklch(0.2 0.02 265);
--card: oklch(0.995 0.002 265);
--card-foreground: oklch(0.2 0.02 265);
--popover: oklch(0.995 0.002 265);
--popover-foreground: oklch(0.2 0.02 265);
--primary: oklch(0.55 0.2 275);
--background: oklch(0.82 0 0);
--foreground: oklch(0.2 0 0);
--card: oklch(0.99 0 0);
--card-foreground: oklch(0.2 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0.2 0 0);
--primary: oklch(0.5 0.2 275);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.93 0.02 275);
--secondary-foreground: oklch(0.32 0.15 275);
--muted: oklch(0.94 0.006 265);
--muted-foreground: oklch(0.48 0.02 265);
--accent: oklch(0.55 0.14 210);
--secondary: oklch(0.82 0.02 275);
--secondary-foreground: oklch(0.35 0.15 275);
--muted: oklch(0.85 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.55 0.13 210);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.58 0.22 25);
--border: oklch(0.88 0.012 265);
--input: oklch(0.92 0.01 265);
--ring: oklch(0.55 0.2 275);
--success: oklch(0.55 0.15 150);
--warning: oklch(0.72 0.15 80);
--error: oklch(0.58 0.22 25);
--info: oklch(0.55 0.14 210);
--chart-1: oklch(0.55 0.2 275);
--chart-2: oklch(0.55 0.14 210);
--chart-3: oklch(0.55 0.15 150);
--chart-4: oklch(0.72 0.15 80);
--chart-5: oklch(0.58 0.22 25);
--sidebar: oklch(0.18 0.02 265);
--sidebar-foreground: oklch(0.96 0.005 265);
--sidebar-primary: oklch(0.64 0.19 275);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.78 0 0);
--input: oklch(0.8 0 0);
--ring: oklch(0.5 0.2 275);
--success: oklch(0.5 0.15 142.495);
--warning: oklch(0.65 0.15 72.031);
--error: oklch(0.55 0.22 25);
--info: oklch(0.55 0.13 210);
--chart-1: oklch(0.5 0.2 275);
--chart-2: oklch(0.55 0.13 210);
--chart-3: oklch(0.5 0.15 142.495);
--chart-4: oklch(0.65 0.15 72.031);
--chart-5: oklch(0.55 0.22 25);
--sidebar: oklch(0.82 0 0);
--sidebar-foreground: oklch(0.2 0 0);
--sidebar-primary: oklch(0.5 0.19 275);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.28 0.03 265);
--sidebar-accent-foreground: oklch(0.96 0.005 265);
--sidebar-border: oklch(0.26 0.025 265);
--sidebar-ring: oklch(0.64 0.19 275);
--sidebar-accent: oklch(0.75 0 0);
--sidebar-accent-foreground: oklch(0.2 0 0);
--sidebar-border: oklch(0.7 0 0);
--sidebar-ring: oklch(0.5 0.19 275);
}
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
@@ -186,7 +184,7 @@
--popover-foreground: var(--sidebar-foreground);
--foreground: var(--sidebar-foreground);
--muted: var(--sidebar-accent);
--muted-foreground: oklch(0.72 0.015 265);
--muted-foreground: oklch(0.4 0.015 265);
--primary: var(--sidebar-primary);
--primary-foreground: var(--sidebar-primary-foreground);
--border: var(--sidebar-border);