From 106ed5789786f8e083429a279d91a64d9dc03660 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 12:55:45 +0530 Subject: [PATCH] Implement GRN tax settings UI and calculations for VAT and NBT --- .../_components/grn-tax-card.tsx | 171 ++++++++++++++++++ .../_components/product-capabilities-card.tsx | 47 +++-- .../grn/_components/grn-costing.tsx | 53 ++++-- .../grn/_components/grn-tracking.tsx | 48 ++++- .../grn/_components/grn-view-sheet.tsx | 5 + 5 files changed, 279 insertions(+), 45 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/configuration/_components/grn-tax-card.tsx diff --git a/distribution_fe/app/(dashboard)/configuration/_components/grn-tax-card.tsx b/distribution_fe/app/(dashboard)/configuration/_components/grn-tax-card.tsx new file mode 100644 index 0000000..c4b104e --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/_components/grn-tax-card.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useEffect, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Discount01Icon } from '@hugeicons/core-free-icons' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { FieldLabel, FieldDescription } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { + sysConfig, + grnTaxSettings, + GRN_TAX_KEYS, + DEFAULT_GRN_TAX_SETTINGS, + getApiError, +} from '@/lib/api/index' +import type { GrnTaxSettings, TaxMode } from '@/lib/api/sys-config' +import { useAuth } from '@/contexts/auth-context' + +const MODE_LABELS: Record = { + Disabled: 'Not applied', + Optional: 'Optional', + Mandatory: 'Mandatory', +} + +const MODE_HINTS: Record = { + Disabled: 'Hidden on GRN', + Optional: 'Shown, can be 0', + Mandatory: 'Shown, must be filled', +} + +interface TaxRowProps { + id: string + title: string + description: string + mode: TaxMode + rate: number + pending: boolean + onModeChange: (mode: TaxMode) => void + onRateCommit: (rate: number) => void +} + +function TaxRow({ id, title, description, mode, rate, pending, onModeChange, onRateCommit }: TaxRowProps) { + // Edit the rate as text and only save when it is valid and the field loses focus. + const [text, setText] = useState(String(rate)) + useEffect(() => setText(String(rate)), [rate]) + + function commit() { + const n = Number(text) + if (text.trim() === '' || !Number.isFinite(n) || n < 0 || n > 100) { + setText(String(rate)) + return + } + if (n !== rate) onRateCommit(n) + } + + return ( +
+
+ + {title} ยท {description} + + {MODE_HINTS[mode]} +
+ +
+ Applies on GRN + +
+ +
+ Default rate +
+ setText(e.target.value)} + onBlur={commit} + className="pr-8" + /> + % +
+
+
+ ) +} + +/** GRN taxes: whether VAT and/or NBT apply to goods receipts, whether each is mandatory, and its default rate. */ +export function GrnTaxCard() { + const { user } = useAuth() + const [loading, setLoading] = useState(true) + const [settings, setSettings] = useState(DEFAULT_GRN_TAX_SETTINGS) + const [pending, setPending] = useState>({}) + + useEffect(() => { + grnTaxSettings.get() + .then(setSettings) + .catch(err => toast.add({ title: 'Failed to load GRN tax settings', description: getApiError(err), type: 'error' })) + .finally(() => setLoading(false)) + }, []) + + // Saves one key immediately and rolls the field back if the server refuses it. + async function save(field: keyof GrnTaxSettings, configKey: string, value: TaxMode | number) { + const previous = settings[field] + setSettings(s => ({ ...s, [field]: value })) + setPending(p => ({ ...p, [configKey]: true })) + try { + await sysConfig.set(configKey, String(value), user.id) + } catch (err) { + setSettings(s => ({ ...s, [field]: previous })) + toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) + } finally { + setPending(p => ({ ...p, [configKey]: false })) + } + } + + if (loading) return

Loading...

+ + return ( + + +
+
+ +
+
+ GRN Taxes + VAT and NBT on goods receipts +
+
+
+ + save('vatMode', GRN_TAX_KEYS.vatMode, m)} + onRateCommit={r => save('vatDefaultRate', GRN_TAX_KEYS.vatRate, r)} + /> + save('nbtMode', GRN_TAX_KEYS.nbtMode, m)} + onRateCommit={r => save('nbtDefaultRate', GRN_TAX_KEYS.nbtRate, r)} + /> + +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx index 85e4a3c..42136d7 100644 --- a/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx +++ b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx @@ -45,7 +45,7 @@ function CapabilityToggle({ id, label, description, checked, disabled, onChecked return (
@@ -119,25 +119,24 @@ export function ProductCapabilitiesCard() { return loading ? (

Loading...

) : ( - - -
-
- + + +
+
+ +
+
+ Products + Item tracking options
- Products
- - Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately; - items then opt into the enabled capabilities from the item's edit form. -
- -
+ +
updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} @@ -146,7 +145,7 @@ export function ProductCapabilitiesCard() { updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} @@ -155,7 +154,7 @@ export function ProductCapabilitiesCard() { updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} @@ -164,7 +163,7 @@ export function ProductCapabilitiesCard() { updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} @@ -173,24 +172,24 @@ export function ProductCapabilitiesCard() { {form.warrantyTracking && ( <> - + -
+

Warranty defaults

-
+
updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} /> -
+
Default warranty period @@ -206,9 +205,7 @@ export function ProductCapabilitiesCard() { /> days
- - Applied to items that don't set their own warranty period. - + Used when an item sets none
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-costing.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-costing.tsx index dcd3563..92ab378 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-costing.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-costing.tsx @@ -13,6 +13,7 @@ import type { BatchRow } from './grn-tracking' /** Preset GRN-wide percentages. Any line can still be typed to a different value. */ export const DISCOUNT_OPTIONS = [0, 2.5, 5, 10, 15, 20, 25] export const VAT_OPTIONS = [0, 5, 8, 12, 15, 18] +export const NBT_OPTIONS = [0, 2] // Same rule as the server (GrnPricing): 2 dp, halves round away from zero, rounded per line. const round2 = (n: number) => Math.round(Number((n * 100).toPrecision(12))) / 100 @@ -21,23 +22,30 @@ export interface CostedLine { acceptedQuantity: number unitCost: number discountPercent: number + nbtPercent: number vatPercent: number } -/** gross = qty x cost; discount on gross; VAT on (gross - discount); total = gross - discount + VAT. */ +/** + * gross = qty x cost; discount on gross; NBT on (gross - discount); VAT on (gross - discount + NBT); + * total = gross - discount + NBT + VAT. Mirrors the server (GrnPricing.Line). + */ export function lineAmounts(l: CostedLine) { const gross = round2((l.acceptedQuantity || 0) * (l.unitCost || 0)) const discount = round2((gross * (l.discountPercent || 0)) / 100) - const vat = round2(((gross - discount) * (l.vatPercent || 0)) / 100) - return { gross, discount, vat, total: gross - discount + vat } + const net = gross - discount + const nbt = round2((net * (l.nbtPercent || 0)) / 100) + const vat = round2(((net + nbt) * (l.vatPercent || 0)) / 100) + return { gross, discount, nbt, vat, total: net + nbt + vat } } export function grnTotals(lines: CostedLine[]) { const amounts = lines.map(lineAmounts) const subTotal = amounts.reduce((s, a) => s + a.gross, 0) const discount = amounts.reduce((s, a) => s + a.discount, 0) + const nbt = amounts.reduce((s, a) => s + a.nbt, 0) const vat = amounts.reduce((s, a) => s + a.vat, 0) - return { subTotal, discount, taxable: subTotal - discount, vat, total: subTotal - discount + vat } + return { subTotal, discount, taxable: subTotal - discount, nbt, vat, total: subTotal - discount + nbt + vat } } export const money = (n: number) => @@ -69,6 +77,10 @@ interface PercentSelectProps { value: number options: number[] onChange: (value: number) => void + /** A required percentage can't be left blank (0 is fine); optional ones fall back to the last value. */ + required?: boolean + /** Reports whether a required field is currently blank, so the form can block saving. */ + onBlankChange?: (blank: boolean) => void } /** @@ -76,30 +88,44 @@ interface PercentSelectProps { * locally while typing so "2." or "" don't get rewritten under the cursor; the number is only pushed * up when it is valid. */ -export function PercentSelect({ label, value, options, onChange }: PercentSelectProps) { +export function PercentSelect({ label, value, options, onChange, required, onBlankChange }: PercentSelectProps) { const listId = useId() const [text, setText] = useState(String(value)) // Follow outside changes (e.g. a reset) without fighting what the user is typing. useEffect(() => { - setText(prev => (Number(prev) === value && prev !== '' ? prev : String(value))) + setText(prev => (prev.trim() !== '' && Number(prev) === value ? prev : String(value))) + onBlankChange?.(false) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]) + const blank = text.trim() === '' + function handleChange(raw: string) { setText(raw) const n = Number(raw) - if (raw.trim() !== '' && Number.isFinite(n) && n >= 0 && n <= 100) onChange(n) + if (raw.trim() === '') { + onBlankChange?.(!!required) + } else if (Number.isFinite(n) && n >= 0 && n <= 100) { + onChange(n) + onBlankChange?.(false) + } } - // An empty or out-of-range entry falls back to the last valid value on blur. + // An out-of-range entry falls back to the last valid value on blur; a blank one does too, unless + // the field is required (then it stays blank so the "Required" message and blocked save are visible). function handleBlur() { const n = Number(text) - if (text.trim() === '' || !Number.isFinite(n) || n < 0 || n > 100) setText(String(value)) + if (blank && required) return + if (blank || !Number.isFinite(n) || n < 0 || n > 100) setText(String(value)) } return ( - {label} + + {label} + {required && *} +
handleChange(e.target.value)} onBlur={handleBlur} + aria-invalid={required && blank ? true : undefined} className="pr-8" /> @@ -123,6 +150,7 @@ export function PercentSelect({ label, value, options, onChange }: PercentSelect ))}
+ {required && blank &&

{label} is required

}
) } @@ -157,13 +185,14 @@ export function NumberCell({ control, name, step = 1, max } /** Totals block under the items table. */ -export function GrnTotals({ lines }: { lines: CostedLine[] }) { +export function GrnTotals({ lines, showNbt, showVat }: { lines: CostedLine[]; showNbt: boolean; showVat: boolean }) { const t = grnTotals(lines) const rows: [string, string][] = [ ['Subtotal', money(t.subTotal)], ['Discount', `- ${money(t.discount)}`], ['Taxable amount', money(t.taxable)], - ['VAT', money(t.vat)], + ...(showNbt ? ([['NBT', money(t.nbt)]] as [string, string][]) : []), + ...(showVat ? ([['VAT', money(t.vat)]] as [string, string][]) : []), ] return (
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx index 0922e12..48691e2 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { toast } from '@/components/ui/toast' +import { stock as stockApi } from '@/lib/api/stock' import type { GrnBatchLineBody } from '@/lib/api/procurement' import type { ItemSummaryResponse } from '@/types/api' @@ -71,6 +72,8 @@ interface LineTrackingProps { batches: BatchRow[] onSerialText: (v: string) => void onBatches: (v: BatchRow[]) => void + /** Serials already typed on other lines of this GRN for the same item, so generation never repeats them. */ + otherSerials?: string[] } /** @@ -78,11 +81,21 @@ interface LineTrackingProps { * entry, so long serial lists don't stretch the form. Serial tracking takes precedence over * batch/expiry (same as the server). Warranty is not entered here โ€” its clock starts at sale. */ -export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) { +export function LineTracking({ + info, + name, + accepted, + serialText, + batches, + onSerialText, + onBatches, + otherSerials = [], +}: LineTrackingProps) { const [panelOpen, setPanelOpen] = useState(false) const [scanValue, setScanValue] = useState('') const [genPrefix, setGenPrefix] = useState(`${info.sku}-`) const [genStart, setGenStart] = useState(1) + const [generating, setGenerating] = useState(false) const progress = trackingProgress(info, accepted, serialText, batches) if (!progress) return null @@ -98,19 +111,38 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri setScanValue('') } - // Appends generated serials (prefix + zero-padded sequence) for the units still missing, - // skipping any value already in the list. They stay editable in the list below. - const generateSerials = () => { + // Appends generated serials (prefix + zero-padded sequence) for the units still missing. Numbering + // continues after the highest number already saved for this item (asked from the server), so a + // second receipt of the same item gets 0006โ€“0010 instead of repeating 0001โ€“0005. Values already in + // this list, or on other lines of this GRN, are skipped. They stay editable in the list below. + const generateSerials = async () => { const existing = parseSerials(serialText) - const taken = new Set(existing) + const taken = new Set([...existing, ...otherSerials]) const missing = Math.max(0, accepted - existing.length) + + setGenerating(true) + let start = genStart + try { + start = Math.max(genStart, await stockApi.nextSerialSequence(info.id, genPrefix)) + } catch { + toast.add({ + title: 'Could not check existing serial numbers', + description: 'Numbering starts from the value shown; the server will reject any serial that already exists.', + type: 'error', + }) + } finally { + setGenerating(false) + } + const generated: string[] = [] - for (let n = genStart; generated.length < missing; n++) { + let n = start + while (generated.length < missing) { const sn = `${genPrefix}${String(n).padStart(4, '0')}` if (!taken.has(sn)) generated.push(sn) + n++ } onSerialText([...existing, ...generated].join('\n')) - setGenStart(genStart + missing) + setGenStart(n) } const isSerial = progress.kind === 'serial' @@ -180,7 +212,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri