Refactor GRN configuration page layout

This commit is contained in:
2026-10-08 12:57:48 +05:30
parent 106ed57897
commit 25b21d6fa8
7 changed files with 176 additions and 26 deletions
@@ -20,6 +20,7 @@ import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import type { GrnTaxSettings } from '@/lib/api/sys-config'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
@@ -27,6 +28,8 @@ import {
users as usersApi,
suppliers as suppliersApi,
items as itemsApi,
grnTaxSettings,
DEFAULT_GRN_TAX_SETTINGS,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
@@ -37,10 +40,11 @@ import type {
SupplierResponse,
ItemSummaryResponse,
} from '@/types/api'
import { LineTracking, trackingPayload, trackingProgress } from '../_components/grn-tracking'
import { LineTracking, parseSerials, trackingPayload, trackingProgress } from '../_components/grn-tracking'
import {
DISCOUNT_OPTIONS,
VAT_OPTIONS,
NBT_OPTIONS,
GrnTotals,
NumberCell,
PercentSelect,
@@ -64,6 +68,7 @@ const lineSchema = z.object({
freeQuantity: z.number().min(0, 'Must be 0 or more'),
unitCost: z.number().min(0, 'Must be 0 or more'),
discountPercent: percent,
nbtPercent: percent,
vatPercent: percent,
// One serial number per line (newline or comma separated) — used for serial-tracked items.
serialText: z.string(),
@@ -77,6 +82,7 @@ const schema = z.object({
receivedAt: z.string().min(1, 'Received date is required'),
receivedByUserId: z.string().min(1, 'Received by is required'),
discountPercent: percent,
nbtPercent: percent,
vatPercent: percent,
items: z.array(lineSchema).min(1, 'Add at least one item'),
})
@@ -91,6 +97,7 @@ const emptyLine = {
freeQuantity: 0,
unitCost: 0,
discountPercent: 0,
nbtPercent: 0,
vatPercent: 0,
serialText: '',
batches: [],
@@ -116,6 +123,9 @@ export default function NewGrnPage() {
const [poId, setPoId] = useState('')
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
// Which taxes apply to a GRN, and whether each is mandatory (Configuration → GRN Taxes).
const [tax, setTax] = useState<GrnTaxSettings>(DEFAULT_GRN_TAX_SETTINGS)
const [taxBlank, setTaxBlank] = useState({ vat: false, nbt: false })
// Tracking flags for items loaded from a PO, keyed by itemId (the PO line response doesn't carry them).
const [poItemInfo, setPoItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
@@ -127,6 +137,7 @@ export default function NewGrnPage() {
receivedAt: todayIso(),
receivedByUserId: '',
discountPercent: 0,
nbtPercent: 0,
vatPercent: 0,
items: [{ ...emptyLine }],
},
@@ -154,9 +165,25 @@ export default function NewGrnPage() {
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
// New GRNs start from the configured default rates; a disabled tax stays at 0.
grnTaxSettings
.get()
.then(t => {
setTax(t)
applyToAllLines('vatPercent', t.vatMode === 'Disabled' ? 0 : t.vatDefaultRate)
applyToAllLines('nbtPercent', t.nbtMode === 'Disabled' ? 0 : t.nbtDefaultRate)
})
.catch(err =>
toast.add({ title: 'Failed to load GRN tax settings', description: getApiError(err), type: 'error' })
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isPoMode = mode === 'po'
const vatOn = tax.vatMode !== 'Disabled'
const nbtOn = tax.nbtMode !== 'Disabled'
const colCount = 9 + (vatOn ? 1 : 0) + (nbtOn ? 1 : 0)
const infoFor = (itemId: string) => poItemInfo[itemId] ?? itemsList.find(i => i.id === itemId)
const poLines = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
const poLine = (poItemId: string) => poLines.find(i => i.id === poItemId)
@@ -164,6 +191,7 @@ export default function NewGrnPage() {
const watchedItems = form.watch('items')
const headerDiscount = form.watch('discountPercent')
const headerVat = form.watch('vatPercent')
const headerNbt = form.watch('nbtPercent')
// Block Save until every tracked line has its serials/batches fully entered (accepted + free units).
const trackingIncomplete = watchedItems.some(v => {
const p = trackingProgress(infoFor(v.itemId), v.acceptedQuantity + v.freeQuantity, v.serialText, v.batches)
@@ -171,9 +199,9 @@ export default function NewGrnPage() {
})
// The GRN-wide percentage is a default: choosing one fills every line, and a line can still be edited.
function applyToAllLines(field: 'discountPercent' | 'vatPercent', value: number) {
function applyToAllLines(field: 'discountPercent' | 'vatPercent' | 'nbtPercent', value: number) {
form.setValue(field, value, { shouldDirty: true })
fields.forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
form.getValues('items').forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
}
function changeMode(next: GrnMode) {
@@ -183,9 +211,10 @@ export default function NewGrnPage() {
setSelectedPO(null)
const discountPercent = form.getValues('discountPercent')
const vatPercent = form.getValues('vatPercent')
const nbtPercent = form.getValues('nbtPercent')
// Direct starts with one blank line; Against PO stays empty until an order is picked.
form.setValue('supplierId', NO_SUPPLIER)
replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent }] : [])
replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent, nbtPercent }] : [])
}
// A line in PO mode points at one of the order's lines; re-picking resets the cost and tracking.
@@ -203,6 +232,7 @@ export default function NewGrnPage() {
setPoId(nextId)
const discountPercent = form.getValues('discountPercent')
const vatPercent = form.getValues('vatPercent')
const nbtPercent = form.getValues('nbtPercent')
setLoadingPO(true)
try {
@@ -225,6 +255,7 @@ export default function NewGrnPage() {
unitCost: item.unitCost,
discountPercent,
vatPercent,
nbtPercent,
})),
)
} catch (err) {
@@ -266,7 +297,8 @@ export default function NewGrnPage() {
freeQuantity: v.freeQuantity,
unitCost: v.unitCost,
discountPercent: v.discountPercent,
vatPercent: v.vatPercent,
...(vatOn ? { vatPercent: v.vatPercent } : {}),
...(nbtOn ? { nbtPercent: v.nbtPercent } : {}),
...payload,
})
}
@@ -279,7 +311,8 @@ export default function NewGrnPage() {
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items,
discountPercent: values.discountPercent,
vatPercent: values.vatPercent,
...(vatOn ? { vatPercent: values.vatPercent } : {}),
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
receivedByUserId: values.receivedByUserId,
})
} else {
@@ -288,7 +321,8 @@ export default function NewGrnPage() {
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
receivedByUserId: values.receivedByUserId,
discountPercent: values.discountPercent,
vatPercent: values.vatPercent,
...(vatOn ? { vatPercent: values.vatPercent } : {}),
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
items,
})
}
@@ -429,12 +463,27 @@ export default function NewGrnPage() {
onChange={v => applyToAllLines('discountPercent', v)}
/>
<PercentSelect
label="GRN VAT %"
value={headerVat}
options={VAT_OPTIONS}
onChange={v => applyToAllLines('vatPercent', v)}
/>
{nbtOn && (
<PercentSelect
label="GRN NBT %"
value={headerNbt}
options={NBT_OPTIONS}
required={tax.nbtMode === 'Mandatory'}
onBlankChange={blank => setTaxBlank(b => ({ ...b, nbt: blank }))}
onChange={v => applyToAllLines('nbtPercent', v)}
/>
)}
{vatOn && (
<PercentSelect
label="GRN VAT %"
value={headerVat}
options={VAT_OPTIONS}
required={tax.vatMode === 'Mandatory'}
onBlankChange={blank => setTaxBlank(b => ({ ...b, vat: blank }))}
onChange={v => applyToAllLines('vatPercent', v)}
/>
)}
</div>
<FormField
@@ -468,7 +517,7 @@ export default function NewGrnPage() {
{isPoMode
? 'Lines start from the purchase order. Add Item adds another line from the same order (e.g. a second batch). Unit cost starts at the PO price; change it if the supplier invoiced differently.'
: 'Add each received item as its own line.'}{' '}
GRN discount % and VAT % fill every line; edit a line to override it. Only accepted units are costed;
GRN discount %{nbtOn ? ', NBT %' : ''}{vatOn ? ' and VAT %' : ''} fill every line; edit a line to override it. Only accepted units are costed;
free units are stocked at no cost.
</p>
</div>
@@ -477,7 +526,9 @@ export default function NewGrnPage() {
variant="outline"
size="sm"
disabled={isPoMode && !selectedPO}
onClick={() => append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat })}
onClick={() =>
append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat, nbtPercent: headerNbt })
}
>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
@@ -505,7 +556,8 @@ export default function NewGrnPage() {
<th className="w-24 px-3 py-2 text-left font-medium">Free Qty</th>
<th className="w-28 px-3 py-2 text-left font-medium">Unit Cost</th>
<th className="w-24 px-3 py-2 text-left font-medium">Disc %</th>
<th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>
{nbtOn && <th className="w-24 px-3 py-2 text-left font-medium">NBT %</th>}
{vatOn && <th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>}
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
<th className="w-16 px-3 py-2"></th>
</tr>
@@ -519,7 +571,8 @@ export default function NewGrnPage() {
acceptedQuantity: v?.acceptedQuantity ?? 0,
unitCost: v?.unitCost ?? 0,
discountPercent: v?.discountPercent ?? 0,
vatPercent: v?.vatPercent ?? 0,
nbtPercent: nbtOn ? (v?.nbtPercent ?? 0) : 0,
vatPercent: vatOn ? (v?.vatPercent ?? 0) : 0,
})
return (
<Fragment key={f.id}>
@@ -596,9 +649,16 @@ export default function NewGrnPage() {
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.discountPercent`} step={0.01} max={100} />
</td>
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
</td>
{nbtOn && (
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.nbtPercent`} step={0.01} max={100} />
</td>
)}
{vatOn && (
<td className="px-3 py-2">
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
</td>
)}
<td className="px-3 py-3 text-right font-medium">{money(amounts.total)}</td>
@@ -619,11 +679,14 @@ export default function NewGrnPage() {
{info && v && (
<tr>
<td colSpan={10} className="px-3 pb-3">
<td colSpan={colCount} className="px-3 pb-3">
<LineTracking
info={info}
name={info.name}
accepted={v.acceptedQuantity + v.freeQuantity}
otherSerials={watchedItems.flatMap((o, j) =>
j !== index && o.itemId === v.itemId ? parseSerials(o.serialText) : [],
)}
serialText={v.serialText}
batches={v.batches}
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
@@ -639,7 +702,15 @@ export default function NewGrnPage() {
</table>
</div>
<GrnTotals lines={watchedItems} />
<GrnTotals
lines={watchedItems.map(v => ({
...v,
nbtPercent: nbtOn ? v.nbtPercent : 0,
vatPercent: vatOn ? v.vatPercent : 0,
}))}
showNbt={nbtOn}
showVat={vatOn}
/>
</>
)}
</div>
@@ -648,7 +719,14 @@ export default function NewGrnPage() {
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting || loadingPO || fields.length === 0 || trackingIncomplete}
disabled={
form.formState.isSubmitting ||
loadingPO ||
fields.length === 0 ||
trackingIncomplete ||
(tax.vatMode === 'Mandatory' && taxBlank.vat) ||
(tax.nbtMode === 'Mandatory' && taxBlank.nbt)
}
>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>