Implement GRN tax settings UI and calculations for VAT and NBT
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user