feat: Implement fixed sale price functionality for items

- Added a toggle for fixed sale price vs stock value in the item creation form.
- Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected.
- Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger.
- Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page.
- Updated documentation to reflect changes in item pricing and GRN handling.
This commit is contained in:
2026-07-23 12:12:32 +05:30
parent 5cf9588728
commit a1b3985469
15 changed files with 245 additions and 25 deletions
@@ -13,7 +13,7 @@ import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { validateVariantItemForm } from "@/lib/validations/master-data"
import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
@@ -73,10 +73,21 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock")
const [fixValue, setFixValue] = useState<string>("")
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// A row shows its own override if set, otherwise it follows the shared fix value.
const priceFor = (key: string) => pricesByKey[key] ?? fixValue
useEffect(() => {
Promise.all([
categoriesApi.list({ pageSize: 200, status: "Active" }),
@@ -190,7 +201,11 @@ export default function NewItemPage() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
return
@@ -212,6 +227,7 @@ export default function NewItemPage() {
baseUomId,
stockNature,
trackingMode: "None",
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
})
created += 1
}
@@ -384,6 +400,59 @@ export default function NewItemPage() {
</div>
</div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2>
<p className="text-sm text-muted-foreground">
Choose a fixed selling price, or leave it to the item&apos;s stock value.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
{priceMode === "fixed" && (
<div className="flex max-w-xs flex-col gap-2">
<Label className="text-base">Fix value (applies to all variants)</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={fixValue}
onChange={(e) => setFixValue(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
<p className="text-sm text-muted-foreground">
Edit any row below to give that variant a different price.
</p>
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
@@ -469,6 +538,9 @@ export default function NewItemPage() {
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
@@ -485,6 +557,31 @@ export default function NewItemPage() {
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
type="button"
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -107,6 +107,7 @@ export default function NewGrnPage() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [refreshingItems, setRefreshingItems] = useState(false)
useEffect(() => {
Promise.all([
@@ -188,6 +189,21 @@ export default function NewGrnPage() {
}
}
// Re-pull the active items list so an item created in the other tab (via "New item")
// becomes selectable without reloading the whole screen and losing the in-progress GRN.
async function refreshItems() {
setRefreshingItems(true)
try {
const res = await itemsApi.list({ pageSize: 200, status: "Active" })
setItems(res.items)
toast.success("Items refreshed", `${res.items.length} active item${res.items.length === 1 ? "" : "s"} loaded.`)
} catch (err) {
toast.error("Could not refresh items", errorMessage(err))
} finally {
setRefreshingItems(false)
}
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -384,14 +400,43 @@ export default function NewGrnPage() {
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{mode === "direct" && (
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{mode === "po" && (
<p className="text-sm text-muted-foreground">
PO lines are prefilled. Use Add line to receive an item that isnt on the PO.
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
)}
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
<Button
type="button"
variant="outline"
onClick={() => window.open("/dashboard/products/new", "_blank", "noopener,noreferrer")}
>
<ExternalLink className="size-5" />
New item
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={refreshItems}
disabled={refreshingItems}
aria-label="Refresh items"
title="Refresh items"
>
<RefreshCw className={cn("size-5", refreshingItems && "animate-spin")} />
</Button>
</div>
</div>
{poLoading && <Skeleton className="h-24 w-full" />}