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
@@ -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<TaxMode, string> = {
Disabled: 'Not applied',
Optional: 'Optional',
Mandatory: 'Mandatory',
}
const MODE_HINTS: Record<TaxMode, string> = {
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 (
<div className="grid gap-2.5 rounded-xl border border-border/60 bg-background px-3 py-2.5 sm:grid-cols-2">
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{title} <span className="text-xs font-normal text-muted-foreground">· {description}</span>
</FieldLabel>
<FieldDescription className="text-xs">{MODE_HINTS[mode]}</FieldDescription>
</div>
<div className="flex flex-col gap-1">
<FieldLabel className="text-xs text-muted-foreground">Applies on GRN</FieldLabel>
<Select value={mode} onValueChange={v => v && onModeChange(v as TaxMode)} disabled={pending}>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(MODE_LABELS) as TaxMode[]).map(m => (
<SelectItem key={m} value={m}>{MODE_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<FieldLabel className="text-xs text-muted-foreground">Default rate</FieldLabel>
<div className="relative">
<Input
type="number"
inputMode="decimal"
min={0}
max={100}
step={0.01}
value={text}
disabled={mode === 'Disabled' || pending}
onChange={e => setText(e.target.value)}
onBlur={commit}
className="pr-8"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">%</span>
</div>
</div>
</div>
)
}
/** 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<GrnTaxSettings>(DEFAULT_GRN_TAX_SETTINGS)
const [pending, setPending] = useState<Record<string, boolean>>({})
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 <p className="text-sm text-muted-foreground">Loading...</p>
return (
<Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Discount01Icon} size={16} />
</div>
<div>
<CardTitle className="text-base">GRN Taxes</CardTitle>
<CardDescription className="text-xs">VAT and NBT on goods receipts</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
<TaxRow
id="grnVatMode"
title="VAT"
description="Value added tax"
mode={settings.vatMode}
rate={settings.vatDefaultRate}
pending={!!pending[GRN_TAX_KEYS.vatMode] || !!pending[GRN_TAX_KEYS.vatRate]}
onModeChange={m => save('vatMode', GRN_TAX_KEYS.vatMode, m)}
onRateCommit={r => save('vatDefaultRate', GRN_TAX_KEYS.vatRate, r)}
/>
<TaxRow
id="grnNbtMode"
title="NBT"
description="Nation building tax"
mode={settings.nbtMode}
rate={settings.nbtDefaultRate}
pending={!!pending[GRN_TAX_KEYS.nbtMode] || !!pending[GRN_TAX_KEYS.nbtRate]}
onModeChange={m => save('nbtMode', GRN_TAX_KEYS.nbtMode, m)}
onRateCommit={r => save('nbtDefaultRate', GRN_TAX_KEYS.nbtRate, r)}
/>
</CardContent>
</Card>
)
}
@@ -45,7 +45,7 @@ function CapabilityToggle({ id, label, description, checked, disabled, onChecked
return ( return (
<div <div
className={cn( className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4', 'flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-background px-3 py-2.5',
className, className,
)} )}
> >
@@ -119,25 +119,24 @@ export function ProductCapabilitiesCard() {
return loading ? ( return loading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
) : ( ) : (
<Card className="max-w-4xl rounded-3xl border-border/60"> <Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
<CardHeader className="border-b border-border/60 pb-5"> <CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Package01Icon} size={18} /> <HugeiconsIcon icon={Package01Icon} size={16} />
</div>
<div>
<CardTitle className="text-base">Products</CardTitle>
<CardDescription className="text-xs">Item tracking options</CardDescription>
</div> </div>
<CardTitle className="text-lg">Products</CardTitle>
</div> </div>
<CardDescription className="mt-3">
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
items then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-5"> <CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
<CapabilityToggle <CapabilityToggle
id="serialNumberTracking" id="serialNumberTracking"
label="Serial number tracking" label="Serial number tracking"
description="Track individual units of an item by a unique serial number." description="One serial per unit"
checked={form.serialNumberTracking} checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
@@ -146,7 +145,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="batchTracking" id="batchTracking"
label="Batch / lot tracking" label="Batch / lot tracking"
description="Group stock into batches or lots for traceability." description="Group stock by batch"
checked={form.batchTracking} checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
@@ -155,7 +154,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="expiryDateTracking" id="expiryDateTracking"
label="Expiry date tracking" label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock." description="Record expiry dates"
checked={form.expiryDateTracking} checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
@@ -164,7 +163,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle <CapabilityToggle
id="warrantyTracking" id="warrantyTracking"
label="Warranty periods" label="Warranty periods"
description="Attach a warranty period to items at the time of sale." description="Warranty on sale"
checked={form.warrantyTracking} checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]} disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
@@ -173,24 +172,24 @@ export function ProductCapabilitiesCard() {
{form.warrantyTracking && ( {form.warrantyTracking && (
<> <>
<Separator className="my-5" /> <Separator className="my-3" />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2.5">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults Warranty defaults
</p> </p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
<CapabilityToggle <CapabilityToggle
id="customWarrantyOverride" id="customWarrantyOverride"
label="Per-item custom warranty" label="Per-item custom warranty"
description="Allow individual items to override the default warranty period." description="Items can override the default"
checked={form.customWarrantyOverride} checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]} disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/> />
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4"> <div className="flex flex-col justify-center gap-1.5 rounded-xl border border-border/60 bg-background px-3 py-2.5">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground"> <FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period Default warranty period
</FieldLabel> </FieldLabel>
@@ -206,9 +205,7 @@ export function ProductCapabilitiesCard() {
/> />
<span className="text-sm text-muted-foreground">days</span> <span className="text-sm text-muted-foreground">days</span>
</div> </div>
<FieldDescription className="text-xs"> <FieldDescription className="text-xs">Used when an item sets none</FieldDescription>
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div> </div>
</div> </div>
</div> </div>
@@ -13,6 +13,7 @@ import type { BatchRow } from './grn-tracking'
/** Preset GRN-wide percentages. Any line can still be typed to a different value. */ /** 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 DISCOUNT_OPTIONS = [0, 2.5, 5, 10, 15, 20, 25]
export const VAT_OPTIONS = [0, 5, 8, 12, 15, 18] 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. // 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 const round2 = (n: number) => Math.round(Number((n * 100).toPrecision(12))) / 100
@@ -21,23 +22,30 @@ export interface CostedLine {
acceptedQuantity: number acceptedQuantity: number
unitCost: number unitCost: number
discountPercent: number discountPercent: number
nbtPercent: number
vatPercent: 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) { export function lineAmounts(l: CostedLine) {
const gross = round2((l.acceptedQuantity || 0) * (l.unitCost || 0)) const gross = round2((l.acceptedQuantity || 0) * (l.unitCost || 0))
const discount = round2((gross * (l.discountPercent || 0)) / 100) const discount = round2((gross * (l.discountPercent || 0)) / 100)
const vat = round2(((gross - discount) * (l.vatPercent || 0)) / 100) const net = gross - discount
return { gross, discount, vat, total: gross - discount + vat } 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[]) { export function grnTotals(lines: CostedLine[]) {
const amounts = lines.map(lineAmounts) const amounts = lines.map(lineAmounts)
const subTotal = amounts.reduce((s, a) => s + a.gross, 0) const subTotal = amounts.reduce((s, a) => s + a.gross, 0)
const discount = amounts.reduce((s, a) => s + a.discount, 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) 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) => export const money = (n: number) =>
@@ -69,6 +77,10 @@ interface PercentSelectProps {
value: number value: number
options: number[] options: number[]
onChange: (value: number) => void 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 * locally while typing so "2." or "" don't get rewritten under the cursor; the number is only pushed
* up when it is valid. * 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 listId = useId()
const [text, setText] = useState(String(value)) const [text, setText] = useState(String(value))
// Follow outside changes (e.g. a reset) without fighting what the user is typing. // Follow outside changes (e.g. a reset) without fighting what the user is typing.
useEffect(() => { 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]) }, [value])
const blank = text.trim() === ''
function handleChange(raw: string) { function handleChange(raw: string) {
setText(raw) setText(raw)
const n = Number(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() { function handleBlur() {
const n = Number(text) 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 ( return (
<Field> <Field>
<FieldLabel>{label}</FieldLabel> <FieldLabel>
{label}
{required && <span className="text-destructive"> *</span>}
</FieldLabel>
<div className="relative"> <div className="relative">
<Input <Input
type="number" type="number"
@@ -112,6 +138,7 @@ export function PercentSelect({ label, value, options, onChange }: PercentSelect
value={text} value={text}
onChange={e => handleChange(e.target.value)} onChange={e => handleChange(e.target.value)}
onBlur={handleBlur} onBlur={handleBlur}
aria-invalid={required && blank ? true : undefined}
className="pr-8" className="pr-8"
/> />
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground"> <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> </datalist>
</div> </div>
{required && blank && <p className="text-xs text-destructive">{label} is required</p>}
</Field> </Field>
) )
} }
@@ -157,13 +185,14 @@ export function NumberCell<T extends FieldValues>({ control, name, step = 1, max
} }
/** Totals block under the items table. */ /** 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 t = grnTotals(lines)
const rows: [string, string][] = [ const rows: [string, string][] = [
['Subtotal', money(t.subTotal)], ['Subtotal', money(t.subTotal)],
['Discount', `- ${money(t.discount)}`], ['Discount', `- ${money(t.discount)}`],
['Taxable amount', money(t.taxable)], ['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 ( return (
<div className="flex justify-end"> <div className="flex justify-end">
@@ -12,6 +12,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { stock as stockApi } from '@/lib/api/stock'
import type { GrnBatchLineBody } from '@/lib/api/procurement' import type { GrnBatchLineBody } from '@/lib/api/procurement'
import type { ItemSummaryResponse } from '@/types/api' import type { ItemSummaryResponse } from '@/types/api'
@@ -71,6 +72,8 @@ interface LineTrackingProps {
batches: BatchRow[] batches: BatchRow[]
onSerialText: (v: string) => void onSerialText: (v: string) => void
onBatches: (v: BatchRow[]) => 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 * 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. * 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 [panelOpen, setPanelOpen] = useState(false)
const [scanValue, setScanValue] = useState('') const [scanValue, setScanValue] = useState('')
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`) const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
const [genStart, setGenStart] = useState(1) const [genStart, setGenStart] = useState(1)
const [generating, setGenerating] = useState(false)
const progress = trackingProgress(info, accepted, serialText, batches) const progress = trackingProgress(info, accepted, serialText, batches)
if (!progress) return null if (!progress) return null
@@ -98,19 +111,38 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
setScanValue('') setScanValue('')
} }
// Appends generated serials (prefix + zero-padded sequence) for the units still missing, // Appends generated serials (prefix + zero-padded sequence) for the units still missing. Numbering
// skipping any value already in the list. They stay editable in the list below. // continues after the highest number already saved for this item (asked from the server), so a
const generateSerials = () => { // 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 existing = parseSerials(serialText)
const taken = new Set(existing) const taken = new Set([...existing, ...otherSerials])
const missing = Math.max(0, accepted - existing.length) 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[] = [] 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')}` const sn = `${genPrefix}${String(n).padStart(4, '0')}`
if (!taken.has(sn)) generated.push(sn) if (!taken.has(sn)) generated.push(sn)
n++
} }
onSerialText([...existing, ...generated].join('\n')) onSerialText([...existing, ...generated].join('\n'))
setGenStart(genStart + missing) setGenStart(n)
} }
const isSerial = progress.kind === 'serial' const isSerial = progress.kind === 'serial'
@@ -180,7 +212,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={accepted - progress.done <= 0} disabled={accepted - progress.done <= 0 || generating}
onClick={generateSerials} onClick={generateSerials}
> >
Auto-generate {Math.max(0, accepted - progress.done)} 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) { export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
if (!grn) return null 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <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">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">Unit Cost</th>
<th className="px-3 py-2 text-right font-medium">Disc %</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">VAT %</th>
<th className="px-3 py-2 text-right font-medium">Line Total</th> <th className="px-3 py-2 text-right font-medium">Line Total</th>
</tr> </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">{item.freeQuantity}</td>
<td className="px-3 py-2 text-right">{money(item.unitCost)}</td> <td className="px-3 py-2 text-right">{money(item.unitCost)}</td>
<td className="px-3 py-2 text-right">{item.discountPercent}%</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">{item.vatPercent}%</td>
<td className="px-3 py-2 text-right font-medium">{money(item.lineTotal)}</td> <td className="px-3 py-2 text-right font-medium">{money(item.lineTotal)}</td>
</tr> </tr>
@@ -115,6 +119,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<Row label="Subtotal" value={money(grn.subTotal)} /> <Row label="Subtotal" value={money(grn.subTotal)} />
<Row label={`Discount (GRN ${grn.discountPercent}%)`} value={`- ${money(grn.discountAmount)}`} /> <Row label={`Discount (GRN ${grn.discountPercent}%)`} value={`- ${money(grn.discountAmount)}`} />
<Row label="Taxable amount" value={money(grn.taxableAmount)} /> <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={`VAT (GRN ${grn.vatPercent}%)`} value={money(grn.vatAmount)} />
<Row label="Grand total" value={<span className="text-base font-semibold">{money(grn.totalAmount)}</span>} /> <Row label="Grand total" value={<span className="text-base font-semibold">{money(grn.totalAmount)}</span>} />
</div> </div>