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 (
|
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'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'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>
|
||||||
|
|||||||
Reference in New Issue
Block a user