Implement GRN tax settings UI and calculations for VAT and NBT

This commit is contained in:
2026-10-08 12:55:45 +05:30
parent 0451a0f418
commit 106ed57897
5 changed files with 279 additions and 45 deletions
@@ -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 (
<Field>
<FieldLabel>{label}</FieldLabel>
<FieldLabel>
{label}
{required && <span className="text-destructive"> *</span>}
</FieldLabel>
<div className="relative">
<Input
type="number"
@@ -112,6 +138,7 @@ export function PercentSelect({ label, value, options, onChange }: PercentSelect
value={text}
onChange={e => handleChange(e.target.value)}
onBlur={handleBlur}
aria-invalid={required && blank ? true : undefined}
className="pr-8"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
@@ -123,6 +150,7 @@ export function PercentSelect({ label, value, options, onChange }: PercentSelect
))}
</datalist>
</div>
{required && blank && <p className="text-xs text-destructive">{label} is required</p>}
</Field>
)
}
@@ -157,13 +185,14 @@ export function NumberCell<T extends FieldValues>({ 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 (
<div className="flex justify-end">
@@ -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
<Button
type="button"
variant="outline"
disabled={accepted - progress.done <= 0}
disabled={accepted - progress.done <= 0 || generating}
onClick={generateSerials}
>
Auto-generate {Math.max(0, accepted - progress.done)}
@@ -35,6 +35,8 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
if (!grn) return null
// NBT only appears for receipts that were taxed with it, so older GRNs keep the same layout.
const hasNbt = grn.nbtPercent > 0 || grn.nbtAmount > 0 || grn.items.some(i => i.nbtPercent > 0)
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@@ -67,6 +69,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<th className="px-3 py-2 text-right font-medium">Free</th>
<th className="px-3 py-2 text-right font-medium">Unit Cost</th>
<th className="px-3 py-2 text-right font-medium">Disc %</th>
{hasNbt && <th className="px-3 py-2 text-right font-medium">NBT %</th>}
<th className="px-3 py-2 text-right font-medium">VAT %</th>
<th className="px-3 py-2 text-right font-medium">Line Total</th>
</tr>
@@ -103,6 +106,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<td className="px-3 py-2 text-right">{item.freeQuantity}</td>
<td className="px-3 py-2 text-right">{money(item.unitCost)}</td>
<td className="px-3 py-2 text-right">{item.discountPercent}%</td>
{hasNbt && <td className="px-3 py-2 text-right">{item.nbtPercent}%</td>}
<td className="px-3 py-2 text-right">{item.vatPercent}%</td>
<td className="px-3 py-2 text-right font-medium">{money(item.lineTotal)}</td>
</tr>
@@ -115,6 +119,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<Row label="Subtotal" value={money(grn.subTotal)} />
<Row label={`Discount (GRN ${grn.discountPercent}%)`} value={`- ${money(grn.discountAmount)}`} />
<Row label="Taxable amount" value={money(grn.taxableAmount)} />
{hasNbt && <Row label={`NBT (GRN ${grn.nbtPercent}%)`} value={money(grn.nbtAmount)} />}
<Row label={`VAT (GRN ${grn.vatPercent}%)`} value={money(grn.vatAmount)} />
<Row label="Grand total" value={<span className="text-base font-semibold">{money(grn.totalAmount)}</span>} />
</div>