From 25b21d6fa846f2102b9202e0a0e49f5fcee20926 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 12:57:48 +0530 Subject: [PATCH] Refactor GRN configuration page layout --- .../app/(dashboard)/configuration/page.tsx | 11 +- .../(dashboard)/procurement/grn/new/page.tsx | 124 ++++++++++++++---- distribution_fe/lib/api/index.ts | 2 +- distribution_fe/lib/api/procurement.ts | 3 + distribution_fe/lib/api/stock.ts | 6 + distribution_fe/lib/api/sys-config.ts | 50 +++++++ distribution_fe/types/api.ts | 6 +- 7 files changed, 176 insertions(+), 26 deletions(-) diff --git a/distribution_fe/app/(dashboard)/configuration/page.tsx b/distribution_fe/app/(dashboard)/configuration/page.tsx index b787920..fb8c58d 100644 --- a/distribution_fe/app/(dashboard)/configuration/page.tsx +++ b/distribution_fe/app/(dashboard)/configuration/page.tsx @@ -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() {

System-wide settings, grouped by area

- + {/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */} +
+
+ +
+
+ +
+
) } diff --git a/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx index 0ee05bd..c1ec526 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx @@ -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(null) const [loadingPO, setLoadingPO] = useState(false) + // Which taxes apply to a GRN, and whether each is mandatory (Configuration → GRN Taxes). + const [tax, setTax] = useState(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>({}) @@ -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)} /> - applyToAllLines('vatPercent', v)} - /> + {nbtOn && ( + setTaxBlank(b => ({ ...b, nbt: blank }))} + onChange={v => applyToAllLines('nbtPercent', v)} + /> + )} + + {vatOn && ( + setTaxBlank(b => ({ ...b, vat: blank }))} + onChange={v => applyToAllLines('vatPercent', v)} + /> + )} @@ -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 }) + } > Add Item @@ -505,7 +556,8 @@ export default function NewGrnPage() { Free Qty Unit Cost Disc % - VAT % + {nbtOn && NBT %} + {vatOn && VAT %} Line Total @@ -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 ( @@ -596,9 +649,16 @@ export default function NewGrnPage() { - - - + {nbtOn && ( + + + + )} + {vatOn && ( + + + + )} {money(amounts.total)} @@ -619,11 +679,14 @@ export default function NewGrnPage() { {info && v && ( - + + 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() { - + ({ + ...v, + nbtPercent: nbtOn ? v.nbtPercent : 0, + vatPercent: vatOn ? v.vatPercent : 0, + }))} + showNbt={nbtOn} + showVat={vatOn} + /> )} @@ -648,7 +719,14 @@ export default function NewGrnPage() { diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index ed4c5f6..08ff4ca 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -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' diff --git a/distribution_fe/lib/api/procurement.ts b/distribution_fe/lib/api/procurement.ts index 560ff38..b1efefe 100644 --- a/distribution_fe/lib/api/procurement.ts +++ b/distribution_fe/lib/api/procurement.ts @@ -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 } diff --git a/distribution_fe/lib/api/stock.ts b/distribution_fe/lib/api/stock.ts index bdfbebe..b663843 100644 --- a/distribution_fe/lib/api/stock.ts +++ b/distribution_fe/lib/api/stock.ts @@ -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 `` 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), } diff --git a/distribution_fe/lib/api/sys-config.ts b/distribution_fe/lib/api/sys-config.ts index d68b953..6699096 100644 --- a/distribution_fe/lib/api/sys-config.ts +++ b/distribution_fe/lib/api/sys-config.ts @@ -22,3 +22,53 @@ export const sysConfig = { set: (key: string, value: string, userId?: string | null) => api.put(`/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): 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])))), +} diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 90c0766..2e6a440 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -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 {