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
@@ -1,6 +1,7 @@
'use client'
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
import { GrnTaxCard } from './_components/grn-tax-card'
export default function ConfigurationPage() {
return (
@@ -10,7 +11,15 @@ export default function ConfigurationPage() {
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div>
<ProductCapabilitiesCard />
{/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
<div className="grid grid-cols-12 items-stretch gap-6">
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<ProductCapabilitiesCard />
</div>
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<GrnTaxCard />
</div>
</div>
</div>
)
}
@@ -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>
+1 -1
View File
@@ -11,7 +11,7 @@ export { stock } from './stock'
export { productReports } from './reports'
export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config'
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
export { getPaginatedItems } from './paginated'
export type { PageParams } from './master'
+3
View File
@@ -82,6 +82,7 @@ export interface GrnItemBody {
/** Omitted falls back to the GRN-wide percentage. */
discountPercent?: number
vatPercent?: number
nbtPercent?: number
/** Free-issue units: stocked but not costed. Serials/batches must cover accepted + free. */
freeQuantity?: number
}
@@ -94,6 +95,7 @@ export interface GrnBody {
/** GRN-wide defaults for lines that don't set their own. */
discountPercent?: number
vatPercent?: number
nbtPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
}
@@ -104,6 +106,7 @@ export interface DirectGrnBody {
items: GrnItemBody[]
discountPercent?: number
vatPercent?: number
nbtPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
}
+6
View File
@@ -47,6 +47,12 @@ export const stock = {
adjust: (body: AdjustmentBody) =>
api.post('/stock/adjustment', body).then(r => r.data),
/** Next free number for auto-generated serials: one past the highest `<prefix><digits>` already saved for the item. */
nextSerialSequence: (itemId: string, prefix: string) =>
api
.get<{ next: number }>('/stock/serials/next-sequence', { params: { itemId, prefix } })
.then(r => r.data.next),
updateMovementReason: (id: string, reasonCode: string) =>
api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data),
}
+50
View File
@@ -22,3 +22,53 @@ export const sysConfig = {
set: (key: string, value: string, userId?: string | null) =>
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
}
// ─── GRN tax settings (must match Distribution.Features.SysConfig.SysConfigService) ──────────
export type TaxMode = 'Disabled' | 'Optional' | 'Mandatory'
export const GRN_TAX_KEYS = {
vatMode: 'Grn.Tax.Vat.Mode',
vatRate: 'Grn.Tax.Vat.DefaultRate',
nbtMode: 'Grn.Tax.Nbt.Mode',
nbtRate: 'Grn.Tax.Nbt.DefaultRate',
} as const
export interface GrnTaxSettings {
vatMode: TaxMode
vatDefaultRate: number
nbtMode: TaxMode
nbtDefaultRate: number
}
const TAX_MODES: TaxMode[] = ['Disabled', 'Optional', 'Mandatory']
/** Unconfigured, VAT is Optional (as GRNs behaved before the setting existed) and NBT is off. */
export const DEFAULT_GRN_TAX_SETTINGS: GrnTaxSettings = {
vatMode: 'Optional',
vatDefaultRate: 0,
nbtMode: 'Disabled',
nbtDefaultRate: 0,
}
export function parseGrnTaxSettings(byKey: Record<string, string>): GrnTaxSettings {
const mode = (raw: string | undefined, fallback: TaxMode): TaxMode =>
TAX_MODES.includes(raw as TaxMode) ? (raw as TaxMode) : fallback
const rate = (raw: string | undefined) => {
const n = Number(raw)
return raw !== undefined && Number.isFinite(n) && n >= 0 && n <= 100 ? n : 0
}
return {
vatMode: mode(byKey[GRN_TAX_KEYS.vatMode], DEFAULT_GRN_TAX_SETTINGS.vatMode),
vatDefaultRate: rate(byKey[GRN_TAX_KEYS.vatRate]),
nbtMode: mode(byKey[GRN_TAX_KEYS.nbtMode], DEFAULT_GRN_TAX_SETTINGS.nbtMode),
nbtDefaultRate: rate(byKey[GRN_TAX_KEYS.nbtRate]),
}
}
export const grnTaxSettings = {
get: () =>
sysConfig
.getAll()
.then(all => parseGrnTaxSettings(Object.fromEntries(all.map(c => [c.key, c.value])))),
}
+5 -1
View File
@@ -429,6 +429,8 @@ export interface GrnItemResponse {
lineTotal: number
/** Free-issue units received at no charge (in stock, not costed). */
freeQuantity: number
nbtPercent: number
nbtAmount: number
}
export interface GrnResponse {
@@ -447,10 +449,12 @@ export interface GrnResponse {
vatPercent: number
subTotal: number
discountAmount: number
/** Subtotal - discount; the base VAT is charged on. */
/** Subtotal - discount; the base NBT is charged on (VAT is charged on this plus NBT). */
taxableAmount: number
vatAmount: number
totalAmount: number
nbtPercent: number
nbtAmount: number
}
export interface PurchaseReturnItemResponse {