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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user