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 (
<div
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,
)}
>
@@ -119,25 +119,24 @@ export function ProductCapabilitiesCard() {
return loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Card className="max-w-4xl rounded-3xl border-border/60">
<CardHeader className="border-b border-border/60 pb-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Package01Icon} size={18} />
<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={Package01Icon} size={16} />
</div>
<div>
<CardTitle className="text-base">Products</CardTitle>
<CardDescription className="text-xs">Item tracking options</CardDescription>
</div>
<CardTitle className="text-lg">Products</CardTitle>
</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>
<CardContent className="pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CardContent>
<div className="grid grid-cols-1 gap-2.5 2xl:grid-cols-2">
<CapabilityToggle
id="serialNumberTracking"
label="Serial number tracking"
description="Track individual units of an item by a unique serial number."
description="One serial per unit"
checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
@@ -146,7 +145,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle
id="batchTracking"
label="Batch / lot tracking"
description="Group stock into batches or lots for traceability."
description="Group stock by batch"
checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
@@ -155,7 +154,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle
id="expiryDateTracking"
label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock."
description="Record expiry dates"
checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
@@ -164,7 +163,7 @@ export function ProductCapabilitiesCard() {
<CapabilityToggle
id="warrantyTracking"
label="Warranty periods"
description="Attach a warranty period to items at the time of sale."
description="Warranty on sale"
checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
@@ -173,24 +172,24 @@ export function ProductCapabilitiesCard() {
{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">
Warranty defaults
</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
id="customWarrantyOverride"
label="Per-item custom warranty"
description="Allow individual items to override the default warranty period."
description="Items can override the default"
checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
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">
Default warranty period
</FieldLabel>
@@ -206,9 +205,7 @@ export function ProductCapabilitiesCard() {
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<FieldDescription className="text-xs">
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
<FieldDescription className="text-xs">Used when an item sets none</FieldDescription>
</div>
</div>
</div>