Refactor GRN configuration page layout
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||||
|
import { GrnTaxCard } from './_components/grn-tax-card'
|
||||||
|
|
||||||
export default function ConfigurationPage() {
|
export default function ConfigurationPage() {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +11,15 @@ export default function ConfigurationPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductCapabilitiesCard />
|
{/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
|
||||||
|
<div className="grid grid-cols-12 items-stretch gap-6">
|
||||||
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
|
<ProductCapabilitiesCard />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
|
||||||
|
<GrnTaxCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import type { GrnItemBody } from '@/lib/api/procurement'
|
import type { GrnItemBody } from '@/lib/api/procurement'
|
||||||
|
import type { GrnTaxSettings } from '@/lib/api/sys-config'
|
||||||
import {
|
import {
|
||||||
grn as grnApi,
|
grn as grnApi,
|
||||||
purchaseOrders as purchaseOrdersApi,
|
purchaseOrders as purchaseOrdersApi,
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
users as usersApi,
|
users as usersApi,
|
||||||
suppliers as suppliersApi,
|
suppliers as suppliersApi,
|
||||||
items as itemsApi,
|
items as itemsApi,
|
||||||
|
grnTaxSettings,
|
||||||
|
DEFAULT_GRN_TAX_SETTINGS,
|
||||||
getApiError,
|
getApiError,
|
||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
@@ -37,10 +40,11 @@ import type {
|
|||||||
SupplierResponse,
|
SupplierResponse,
|
||||||
ItemSummaryResponse,
|
ItemSummaryResponse,
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
import { LineTracking, trackingPayload, trackingProgress } from '../_components/grn-tracking'
|
import { LineTracking, parseSerials, trackingPayload, trackingProgress } from '../_components/grn-tracking'
|
||||||
import {
|
import {
|
||||||
DISCOUNT_OPTIONS,
|
DISCOUNT_OPTIONS,
|
||||||
VAT_OPTIONS,
|
VAT_OPTIONS,
|
||||||
|
NBT_OPTIONS,
|
||||||
GrnTotals,
|
GrnTotals,
|
||||||
NumberCell,
|
NumberCell,
|
||||||
PercentSelect,
|
PercentSelect,
|
||||||
@@ -64,6 +68,7 @@ const lineSchema = z.object({
|
|||||||
freeQuantity: z.number().min(0, 'Must be 0 or more'),
|
freeQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||||
unitCost: z.number().min(0, 'Must be 0 or more'),
|
unitCost: z.number().min(0, 'Must be 0 or more'),
|
||||||
discountPercent: percent,
|
discountPercent: percent,
|
||||||
|
nbtPercent: percent,
|
||||||
vatPercent: percent,
|
vatPercent: percent,
|
||||||
// One serial number per line (newline or comma separated) — used for serial-tracked items.
|
// One serial number per line (newline or comma separated) — used for serial-tracked items.
|
||||||
serialText: z.string(),
|
serialText: z.string(),
|
||||||
@@ -77,6 +82,7 @@ const schema = z.object({
|
|||||||
receivedAt: z.string().min(1, 'Received date is required'),
|
receivedAt: z.string().min(1, 'Received date is required'),
|
||||||
receivedByUserId: z.string().min(1, 'Received by is required'),
|
receivedByUserId: z.string().min(1, 'Received by is required'),
|
||||||
discountPercent: percent,
|
discountPercent: percent,
|
||||||
|
nbtPercent: percent,
|
||||||
vatPercent: percent,
|
vatPercent: percent,
|
||||||
items: z.array(lineSchema).min(1, 'Add at least one item'),
|
items: z.array(lineSchema).min(1, 'Add at least one item'),
|
||||||
})
|
})
|
||||||
@@ -91,6 +97,7 @@ const emptyLine = {
|
|||||||
freeQuantity: 0,
|
freeQuantity: 0,
|
||||||
unitCost: 0,
|
unitCost: 0,
|
||||||
discountPercent: 0,
|
discountPercent: 0,
|
||||||
|
nbtPercent: 0,
|
||||||
vatPercent: 0,
|
vatPercent: 0,
|
||||||
serialText: '',
|
serialText: '',
|
||||||
batches: [],
|
batches: [],
|
||||||
@@ -116,6 +123,9 @@ export default function NewGrnPage() {
|
|||||||
const [poId, setPoId] = useState('')
|
const [poId, setPoId] = useState('')
|
||||||
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
|
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
|
||||||
const [loadingPO, setLoadingPO] = useState(false)
|
const [loadingPO, setLoadingPO] = useState(false)
|
||||||
|
// Which taxes apply to a GRN, and whether each is mandatory (Configuration → GRN Taxes).
|
||||||
|
const [tax, setTax] = useState<GrnTaxSettings>(DEFAULT_GRN_TAX_SETTINGS)
|
||||||
|
const [taxBlank, setTaxBlank] = useState({ vat: false, nbt: false })
|
||||||
// Tracking flags for items loaded from a PO, keyed by itemId (the PO line response doesn't carry them).
|
// Tracking flags for items loaded from a PO, keyed by itemId (the PO line response doesn't carry them).
|
||||||
const [poItemInfo, setPoItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
|
const [poItemInfo, setPoItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
|
||||||
|
|
||||||
@@ -127,6 +137,7 @@ export default function NewGrnPage() {
|
|||||||
receivedAt: todayIso(),
|
receivedAt: todayIso(),
|
||||||
receivedByUserId: '',
|
receivedByUserId: '',
|
||||||
discountPercent: 0,
|
discountPercent: 0,
|
||||||
|
nbtPercent: 0,
|
||||||
vatPercent: 0,
|
vatPercent: 0,
|
||||||
items: [{ ...emptyLine }],
|
items: [{ ...emptyLine }],
|
||||||
},
|
},
|
||||||
@@ -154,9 +165,25 @@ export default function NewGrnPage() {
|
|||||||
.catch(err =>
|
.catch(err =>
|
||||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New GRNs start from the configured default rates; a disabled tax stays at 0.
|
||||||
|
grnTaxSettings
|
||||||
|
.get()
|
||||||
|
.then(t => {
|
||||||
|
setTax(t)
|
||||||
|
applyToAllLines('vatPercent', t.vatMode === 'Disabled' ? 0 : t.vatDefaultRate)
|
||||||
|
applyToAllLines('nbtPercent', t.nbtMode === 'Disabled' ? 0 : t.nbtDefaultRate)
|
||||||
|
})
|
||||||
|
.catch(err =>
|
||||||
|
toast.add({ title: 'Failed to load GRN tax settings', description: getApiError(err), type: 'error' })
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isPoMode = mode === 'po'
|
const isPoMode = mode === 'po'
|
||||||
|
const vatOn = tax.vatMode !== 'Disabled'
|
||||||
|
const nbtOn = tax.nbtMode !== 'Disabled'
|
||||||
|
const colCount = 9 + (vatOn ? 1 : 0) + (nbtOn ? 1 : 0)
|
||||||
const infoFor = (itemId: string) => poItemInfo[itemId] ?? itemsList.find(i => i.id === itemId)
|
const infoFor = (itemId: string) => poItemInfo[itemId] ?? itemsList.find(i => i.id === itemId)
|
||||||
const poLines = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
|
const poLines = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
|
||||||
const poLine = (poItemId: string) => poLines.find(i => i.id === poItemId)
|
const poLine = (poItemId: string) => poLines.find(i => i.id === poItemId)
|
||||||
@@ -164,6 +191,7 @@ export default function NewGrnPage() {
|
|||||||
const watchedItems = form.watch('items')
|
const watchedItems = form.watch('items')
|
||||||
const headerDiscount = form.watch('discountPercent')
|
const headerDiscount = form.watch('discountPercent')
|
||||||
const headerVat = form.watch('vatPercent')
|
const headerVat = form.watch('vatPercent')
|
||||||
|
const headerNbt = form.watch('nbtPercent')
|
||||||
// Block Save until every tracked line has its serials/batches fully entered (accepted + free units).
|
// Block Save until every tracked line has its serials/batches fully entered (accepted + free units).
|
||||||
const trackingIncomplete = watchedItems.some(v => {
|
const trackingIncomplete = watchedItems.some(v => {
|
||||||
const p = trackingProgress(infoFor(v.itemId), v.acceptedQuantity + v.freeQuantity, v.serialText, v.batches)
|
const p = trackingProgress(infoFor(v.itemId), v.acceptedQuantity + v.freeQuantity, v.serialText, v.batches)
|
||||||
@@ -171,9 +199,9 @@ export default function NewGrnPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// The GRN-wide percentage is a default: choosing one fills every line, and a line can still be edited.
|
// The GRN-wide percentage is a default: choosing one fills every line, and a line can still be edited.
|
||||||
function applyToAllLines(field: 'discountPercent' | 'vatPercent', value: number) {
|
function applyToAllLines(field: 'discountPercent' | 'vatPercent' | 'nbtPercent', value: number) {
|
||||||
form.setValue(field, value, { shouldDirty: true })
|
form.setValue(field, value, { shouldDirty: true })
|
||||||
fields.forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
|
form.getValues('items').forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeMode(next: GrnMode) {
|
function changeMode(next: GrnMode) {
|
||||||
@@ -183,9 +211,10 @@ export default function NewGrnPage() {
|
|||||||
setSelectedPO(null)
|
setSelectedPO(null)
|
||||||
const discountPercent = form.getValues('discountPercent')
|
const discountPercent = form.getValues('discountPercent')
|
||||||
const vatPercent = form.getValues('vatPercent')
|
const vatPercent = form.getValues('vatPercent')
|
||||||
|
const nbtPercent = form.getValues('nbtPercent')
|
||||||
// Direct starts with one blank line; Against PO stays empty until an order is picked.
|
// Direct starts with one blank line; Against PO stays empty until an order is picked.
|
||||||
form.setValue('supplierId', NO_SUPPLIER)
|
form.setValue('supplierId', NO_SUPPLIER)
|
||||||
replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent }] : [])
|
replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent, nbtPercent }] : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A line in PO mode points at one of the order's lines; re-picking resets the cost and tracking.
|
// A line in PO mode points at one of the order's lines; re-picking resets the cost and tracking.
|
||||||
@@ -203,6 +232,7 @@ export default function NewGrnPage() {
|
|||||||
setPoId(nextId)
|
setPoId(nextId)
|
||||||
const discountPercent = form.getValues('discountPercent')
|
const discountPercent = form.getValues('discountPercent')
|
||||||
const vatPercent = form.getValues('vatPercent')
|
const vatPercent = form.getValues('vatPercent')
|
||||||
|
const nbtPercent = form.getValues('nbtPercent')
|
||||||
|
|
||||||
setLoadingPO(true)
|
setLoadingPO(true)
|
||||||
try {
|
try {
|
||||||
@@ -225,6 +255,7 @@ export default function NewGrnPage() {
|
|||||||
unitCost: item.unitCost,
|
unitCost: item.unitCost,
|
||||||
discountPercent,
|
discountPercent,
|
||||||
vatPercent,
|
vatPercent,
|
||||||
|
nbtPercent,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -266,7 +297,8 @@ export default function NewGrnPage() {
|
|||||||
freeQuantity: v.freeQuantity,
|
freeQuantity: v.freeQuantity,
|
||||||
unitCost: v.unitCost,
|
unitCost: v.unitCost,
|
||||||
discountPercent: v.discountPercent,
|
discountPercent: v.discountPercent,
|
||||||
vatPercent: v.vatPercent,
|
...(vatOn ? { vatPercent: v.vatPercent } : {}),
|
||||||
|
...(nbtOn ? { nbtPercent: v.nbtPercent } : {}),
|
||||||
...payload,
|
...payload,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -279,7 +311,8 @@ export default function NewGrnPage() {
|
|||||||
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
|
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
|
||||||
items,
|
items,
|
||||||
discountPercent: values.discountPercent,
|
discountPercent: values.discountPercent,
|
||||||
vatPercent: values.vatPercent,
|
...(vatOn ? { vatPercent: values.vatPercent } : {}),
|
||||||
|
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
|
||||||
receivedByUserId: values.receivedByUserId,
|
receivedByUserId: values.receivedByUserId,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -288,7 +321,8 @@ export default function NewGrnPage() {
|
|||||||
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
|
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
|
||||||
receivedByUserId: values.receivedByUserId,
|
receivedByUserId: values.receivedByUserId,
|
||||||
discountPercent: values.discountPercent,
|
discountPercent: values.discountPercent,
|
||||||
vatPercent: values.vatPercent,
|
...(vatOn ? { vatPercent: values.vatPercent } : {}),
|
||||||
|
...(nbtOn ? { nbtPercent: values.nbtPercent } : {}),
|
||||||
items,
|
items,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -429,12 +463,27 @@ export default function NewGrnPage() {
|
|||||||
onChange={v => applyToAllLines('discountPercent', v)}
|
onChange={v => applyToAllLines('discountPercent', v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PercentSelect
|
{nbtOn && (
|
||||||
label="GRN VAT %"
|
<PercentSelect
|
||||||
value={headerVat}
|
label="GRN NBT %"
|
||||||
options={VAT_OPTIONS}
|
value={headerNbt}
|
||||||
onChange={v => applyToAllLines('vatPercent', v)}
|
options={NBT_OPTIONS}
|
||||||
/>
|
required={tax.nbtMode === 'Mandatory'}
|
||||||
|
onBlankChange={blank => setTaxBlank(b => ({ ...b, nbt: blank }))}
|
||||||
|
onChange={v => applyToAllLines('nbtPercent', v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vatOn && (
|
||||||
|
<PercentSelect
|
||||||
|
label="GRN VAT %"
|
||||||
|
value={headerVat}
|
||||||
|
options={VAT_OPTIONS}
|
||||||
|
required={tax.vatMode === 'Mandatory'}
|
||||||
|
onBlankChange={blank => setTaxBlank(b => ({ ...b, vat: blank }))}
|
||||||
|
onChange={v => applyToAllLines('vatPercent', v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -468,7 +517,7 @@ export default function NewGrnPage() {
|
|||||||
{isPoMode
|
{isPoMode
|
||||||
? 'Lines start from the purchase order. Add Item adds another line from the same order (e.g. a second batch). Unit cost starts at the PO price; change it if the supplier invoiced differently.'
|
? 'Lines start from the purchase order. Add Item adds another line from the same order (e.g. a second batch). Unit cost starts at the PO price; change it if the supplier invoiced differently.'
|
||||||
: 'Add each received item as its own line.'}{' '}
|
: 'Add each received item as its own line.'}{' '}
|
||||||
GRN discount % and VAT % fill every line; edit a line to override it. Only accepted units are costed;
|
GRN discount %{nbtOn ? ', NBT %' : ''}{vatOn ? ' and VAT %' : ''} fill every line; edit a line to override it. Only accepted units are costed;
|
||||||
free units are stocked at no cost.
|
free units are stocked at no cost.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,7 +526,9 @@ export default function NewGrnPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPoMode && !selectedPO}
|
disabled={isPoMode && !selectedPO}
|
||||||
onClick={() => append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat })}
|
onClick={() =>
|
||||||
|
append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat, nbtPercent: headerNbt })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
Add Item
|
Add Item
|
||||||
@@ -505,7 +556,8 @@ export default function NewGrnPage() {
|
|||||||
<th className="w-24 px-3 py-2 text-left font-medium">Free Qty</th>
|
<th className="w-24 px-3 py-2 text-left font-medium">Free Qty</th>
|
||||||
<th className="w-28 px-3 py-2 text-left font-medium">Unit Cost</th>
|
<th className="w-28 px-3 py-2 text-left font-medium">Unit Cost</th>
|
||||||
<th className="w-24 px-3 py-2 text-left font-medium">Disc %</th>
|
<th className="w-24 px-3 py-2 text-left font-medium">Disc %</th>
|
||||||
<th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>
|
{nbtOn && <th className="w-24 px-3 py-2 text-left font-medium">NBT %</th>}
|
||||||
|
{vatOn && <th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>}
|
||||||
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
|
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
|
||||||
<th className="w-16 px-3 py-2"></th>
|
<th className="w-16 px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -519,7 +571,8 @@ export default function NewGrnPage() {
|
|||||||
acceptedQuantity: v?.acceptedQuantity ?? 0,
|
acceptedQuantity: v?.acceptedQuantity ?? 0,
|
||||||
unitCost: v?.unitCost ?? 0,
|
unitCost: v?.unitCost ?? 0,
|
||||||
discountPercent: v?.discountPercent ?? 0,
|
discountPercent: v?.discountPercent ?? 0,
|
||||||
vatPercent: v?.vatPercent ?? 0,
|
nbtPercent: nbtOn ? (v?.nbtPercent ?? 0) : 0,
|
||||||
|
vatPercent: vatOn ? (v?.vatPercent ?? 0) : 0,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Fragment key={f.id}>
|
<Fragment key={f.id}>
|
||||||
@@ -596,9 +649,16 @@ export default function NewGrnPage() {
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<NumberCell control={form.control} name={`items.${index}.discountPercent`} step={0.01} max={100} />
|
<NumberCell control={form.control} name={`items.${index}.discountPercent`} step={0.01} max={100} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
{nbtOn && (
|
||||||
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
|
<td className="px-3 py-2">
|
||||||
</td>
|
<NumberCell control={form.control} name={`items.${index}.nbtPercent`} step={0.01} max={100} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{vatOn && (
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
<td className="px-3 py-3 text-right font-medium">{money(amounts.total)}</td>
|
<td className="px-3 py-3 text-right font-medium">{money(amounts.total)}</td>
|
||||||
|
|
||||||
@@ -619,11 +679,14 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
{info && v && (
|
{info && v && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="px-3 pb-3">
|
<td colSpan={colCount} className="px-3 pb-3">
|
||||||
<LineTracking
|
<LineTracking
|
||||||
info={info}
|
info={info}
|
||||||
name={info.name}
|
name={info.name}
|
||||||
accepted={v.acceptedQuantity + v.freeQuantity}
|
accepted={v.acceptedQuantity + v.freeQuantity}
|
||||||
|
otherSerials={watchedItems.flatMap((o, j) =>
|
||||||
|
j !== index && o.itemId === v.itemId ? parseSerials(o.serialText) : [],
|
||||||
|
)}
|
||||||
serialText={v.serialText}
|
serialText={v.serialText}
|
||||||
batches={v.batches}
|
batches={v.batches}
|
||||||
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
|
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
|
||||||
@@ -639,7 +702,15 @@ export default function NewGrnPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GrnTotals lines={watchedItems} />
|
<GrnTotals
|
||||||
|
lines={watchedItems.map(v => ({
|
||||||
|
...v,
|
||||||
|
nbtPercent: nbtOn ? v.nbtPercent : 0,
|
||||||
|
vatPercent: vatOn ? v.vatPercent : 0,
|
||||||
|
}))}
|
||||||
|
showNbt={nbtOn}
|
||||||
|
showVat={vatOn}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +719,14 @@ export default function NewGrnPage() {
|
|||||||
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={form.formState.isSubmitting || loadingPO || fields.length === 0 || trackingIncomplete}
|
disabled={
|
||||||
|
form.formState.isSubmitting ||
|
||||||
|
loadingPO ||
|
||||||
|
fields.length === 0 ||
|
||||||
|
trackingIncomplete ||
|
||||||
|
(tax.vatMode === 'Mandatory' && taxBlank.vat) ||
|
||||||
|
(tax.nbtMode === 'Mandatory' && taxBlank.nbt)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export { stock } from './stock'
|
|||||||
export { productReports } from './reports'
|
export { productReports } from './reports'
|
||||||
export { shopPos, invoices, payments } from './sales'
|
export { shopPos, invoices, payments } from './sales'
|
||||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||||
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config'
|
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
||||||
export { getPaginatedItems } from './paginated'
|
export { getPaginatedItems } from './paginated'
|
||||||
|
|
||||||
export type { PageParams } from './master'
|
export type { PageParams } from './master'
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface GrnItemBody {
|
|||||||
/** Omitted falls back to the GRN-wide percentage. */
|
/** Omitted falls back to the GRN-wide percentage. */
|
||||||
discountPercent?: number
|
discountPercent?: number
|
||||||
vatPercent?: number
|
vatPercent?: number
|
||||||
|
nbtPercent?: number
|
||||||
/** Free-issue units: stocked but not costed. Serials/batches must cover accepted + free. */
|
/** Free-issue units: stocked but not costed. Serials/batches must cover accepted + free. */
|
||||||
freeQuantity?: number
|
freeQuantity?: number
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,7 @@ export interface GrnBody {
|
|||||||
/** GRN-wide defaults for lines that don't set their own. */
|
/** GRN-wide defaults for lines that don't set their own. */
|
||||||
discountPercent?: number
|
discountPercent?: number
|
||||||
vatPercent?: number
|
vatPercent?: number
|
||||||
|
nbtPercent?: number
|
||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,7 @@ export interface DirectGrnBody {
|
|||||||
items: GrnItemBody[]
|
items: GrnItemBody[]
|
||||||
discountPercent?: number
|
discountPercent?: number
|
||||||
vatPercent?: number
|
vatPercent?: number
|
||||||
|
nbtPercent?: number
|
||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export const stock = {
|
|||||||
adjust: (body: AdjustmentBody) =>
|
adjust: (body: AdjustmentBody) =>
|
||||||
api.post('/stock/adjustment', body).then(r => r.data),
|
api.post('/stock/adjustment', body).then(r => r.data),
|
||||||
|
|
||||||
|
/** Next free number for auto-generated serials: one past the highest `<prefix><digits>` already saved for the item. */
|
||||||
|
nextSerialSequence: (itemId: string, prefix: string) =>
|
||||||
|
api
|
||||||
|
.get<{ next: number }>('/stock/serials/next-sequence', { params: { itemId, prefix } })
|
||||||
|
.then(r => r.data.next),
|
||||||
|
|
||||||
updateMovementReason: (id: string, reasonCode: string) =>
|
updateMovementReason: (id: string, reasonCode: string) =>
|
||||||
api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data),
|
api.patch(`/stock/movements/${id}/reason`, { reasonCode }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,53 @@ export const sysConfig = {
|
|||||||
set: (key: string, value: string, userId?: string | null) =>
|
set: (key: string, value: string, userId?: string | null) =>
|
||||||
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
|
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GRN tax settings (must match Distribution.Features.SysConfig.SysConfigService) ──────────
|
||||||
|
|
||||||
|
export type TaxMode = 'Disabled' | 'Optional' | 'Mandatory'
|
||||||
|
|
||||||
|
export const GRN_TAX_KEYS = {
|
||||||
|
vatMode: 'Grn.Tax.Vat.Mode',
|
||||||
|
vatRate: 'Grn.Tax.Vat.DefaultRate',
|
||||||
|
nbtMode: 'Grn.Tax.Nbt.Mode',
|
||||||
|
nbtRate: 'Grn.Tax.Nbt.DefaultRate',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export interface GrnTaxSettings {
|
||||||
|
vatMode: TaxMode
|
||||||
|
vatDefaultRate: number
|
||||||
|
nbtMode: TaxMode
|
||||||
|
nbtDefaultRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAX_MODES: TaxMode[] = ['Disabled', 'Optional', 'Mandatory']
|
||||||
|
|
||||||
|
/** Unconfigured, VAT is Optional (as GRNs behaved before the setting existed) and NBT is off. */
|
||||||
|
export const DEFAULT_GRN_TAX_SETTINGS: GrnTaxSettings = {
|
||||||
|
vatMode: 'Optional',
|
||||||
|
vatDefaultRate: 0,
|
||||||
|
nbtMode: 'Disabled',
|
||||||
|
nbtDefaultRate: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGrnTaxSettings(byKey: Record<string, string>): GrnTaxSettings {
|
||||||
|
const mode = (raw: string | undefined, fallback: TaxMode): TaxMode =>
|
||||||
|
TAX_MODES.includes(raw as TaxMode) ? (raw as TaxMode) : fallback
|
||||||
|
const rate = (raw: string | undefined) => {
|
||||||
|
const n = Number(raw)
|
||||||
|
return raw !== undefined && Number.isFinite(n) && n >= 0 && n <= 100 ? n : 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
vatMode: mode(byKey[GRN_TAX_KEYS.vatMode], DEFAULT_GRN_TAX_SETTINGS.vatMode),
|
||||||
|
vatDefaultRate: rate(byKey[GRN_TAX_KEYS.vatRate]),
|
||||||
|
nbtMode: mode(byKey[GRN_TAX_KEYS.nbtMode], DEFAULT_GRN_TAX_SETTINGS.nbtMode),
|
||||||
|
nbtDefaultRate: rate(byKey[GRN_TAX_KEYS.nbtRate]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const grnTaxSettings = {
|
||||||
|
get: () =>
|
||||||
|
sysConfig
|
||||||
|
.getAll()
|
||||||
|
.then(all => parseGrnTaxSettings(Object.fromEntries(all.map(c => [c.key, c.value])))),
|
||||||
|
}
|
||||||
|
|||||||
@@ -429,6 +429,8 @@ export interface GrnItemResponse {
|
|||||||
lineTotal: number
|
lineTotal: number
|
||||||
/** Free-issue units received at no charge (in stock, not costed). */
|
/** Free-issue units received at no charge (in stock, not costed). */
|
||||||
freeQuantity: number
|
freeQuantity: number
|
||||||
|
nbtPercent: number
|
||||||
|
nbtAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrnResponse {
|
export interface GrnResponse {
|
||||||
@@ -447,10 +449,12 @@ export interface GrnResponse {
|
|||||||
vatPercent: number
|
vatPercent: number
|
||||||
subTotal: number
|
subTotal: number
|
||||||
discountAmount: number
|
discountAmount: number
|
||||||
/** Subtotal - discount; the base VAT is charged on. */
|
/** Subtotal - discount; the base NBT is charged on (VAT is charged on this plus NBT). */
|
||||||
taxableAmount: number
|
taxableAmount: number
|
||||||
vatAmount: number
|
vatAmount: number
|
||||||
totalAmount: number
|
totalAmount: number
|
||||||
|
nbtPercent: number
|
||||||
|
nbtAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseReturnItemResponse {
|
export interface PurchaseReturnItemResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user