Implement GRN tax settings UI and calculations for VAT and NBT
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+22
-25
@@ -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'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't set their own warranty period.
|
||||
</FieldDescription>
|
||||
<FieldDescription className="text-xs">Used when an item sets none</FieldDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user