Add smoke tests for UOM conversions and enhance frontend UOM management

- Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated.
- Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses.
- Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items.
- Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms.
- Developed utility functions for consistent UOM formatting and conversion handling across the application.
This commit is contained in:
2026-08-10 15:12:51 +05:30
parent d37824cecc
commit 8d5a05a419
63 changed files with 1539 additions and 136 deletions
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
@@ -111,8 +112,8 @@ export default function PurchaseOrderDetailPage() {
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
function uomLabel(uomId: number) {
return uomName(uomId, uoms)
}
function warehouseCode(warehouseId: number) {
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
@@ -376,7 +377,7 @@ export default function PurchaseOrderDetailPage() {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.uomId ? uomLabel(line.uomId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
@@ -14,6 +14,8 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
@@ -81,6 +83,7 @@ function NewPurchaseOrderContent() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const allowedUoms = useAllowedUoms()
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
@@ -102,7 +105,8 @@ function NewPurchaseOrderContent() {
useEffect(() => {
Promise.all([
loadItems(),
uomsApi.list().then((uo) => setUoms(uo.items)),
// pageSize: the default page would silently truncate the unit list as the master grows.
uomsApi.list({ pageSize: 200 }).then((uo) => setUoms(uo.items)),
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
loadVendors(),
]).catch((err) => setLoadError(errorMessage(err)))
@@ -428,7 +432,17 @@ function NewPurchaseOrderContent() {
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku}${item.name}` : undefined}>{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<Select<number | null>
value={line.itemId}
onValueChange={(v) => {
// Reset to the item's base unit and load its allowed units.
allowedUoms.load(v)
updateLine(line.key, {
itemId: v,
uomId: (items ?? []).find((i) => i.itemId === v)?.baseUomId ?? null,
})
}}
>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
@@ -454,7 +468,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
@@ -3,6 +3,8 @@
import { Plus, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { CustomFieldType, StageInputSource } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import {
@@ -46,7 +48,8 @@ function QtyRow({
}: {
qty: number
uomId: number | null
uoms: Uom[]
/** Already narrowed to the row's item by the caller — see `pickerOptions`. */
uoms: { uomId: number; name: string }[]
readOnly: boolean
onQtyChange: (qty: number) => void
onUomChange: (uomId: number) => void
@@ -125,9 +128,12 @@ export function StageEditorPanel({
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
}
const allowedUoms = useAllowedUoms()
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
function pickInputItem(input: BuilderInput, itemId: number) {
const item = items.find((i) => i.itemId === itemId)
allowedUoms.load(itemId)
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
}
@@ -146,6 +152,7 @@ export function StageEditorPanel({
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
function pickOutputItem(output: BuilderOutput, itemId: number) {
const item = items.find((i) => i.itemId === itemId)
allowedUoms.load(itemId)
updateOutput(output.key, {
itemId,
name: item?.name ?? output.name,
@@ -288,7 +295,7 @@ export function StageEditorPanel({
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
uoms={pickerOptions(allowedUoms.get(input.itemId), uoms)}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
@@ -348,7 +355,7 @@ export function StageEditorPanel({
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
uoms={pickerOptions(allowedUoms.get(output.itemId), uoms)}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
@@ -0,0 +1,193 @@
"use client"
// Per-item UOM conversion editor (FR-MD-02).
//
// Until this panel existed, `PUT /items/{id}/uom-conversions` had no caller anywhere in the
// app: conversions were typed, and had an API client method, but no screen could create one.
// That made every non-base UOM unusable — a user could pick "Box-12" on an invoice line and
// only discover at post time that the item had no conversion for it.
//
// Direction is fixed and not user-editable: a row always converts **into** the item's base
// UOM. The server enforces that (`toUom` must equal `baseUomId`), because the conversion
// engine only ever looks up `<other> → base` and never inverts a factor. Rendering `toUom`
// as fixed text rather than a second picker is what keeps the two in step.
import { useState } from "react"
import { Plus, Save, Trash2 } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateConversionLine } from "@/lib/validations/master-data"
import { UomConversion } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
interface DraftRow {
key: string
fromUom: number | null
factor: string
}
interface Props {
itemId: number
baseUomId: number
conversions: UomConversion[]
uoms: { uomId: number; name: string }[]
/** Lets the parent refresh the item so `conversions` stays in sync after a save. */
onSaved: () => void
}
let rowSeq = 0
const nextKey = () => `conv-${rowSeq++}`
export function UomConversionsPanel({ itemId, baseUomId, conversions, uoms, onSaved }: Props) {
const [rows, setRows] = useState<DraftRow[]>(() =>
conversions.map((c) => ({ key: nextKey(), fromUom: c.fromUom, factor: String(c.factor) })),
)
const [errors, setErrors] = useState<Record<string, Record<string, string>>>({})
const [saving, setSaving] = useState(false)
const baseName = uoms.find((u) => u.uomId === baseUomId)?.name ?? "base UOM"
// The base UOM converts to itself implicitly; offering it here would only produce a 422.
const selectableUoms = uoms.filter((u) => u.uomId !== baseUomId)
function updateRow(key: string, patch: Partial<DraftRow>) {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)))
}
function addRow() {
setRows((prev) => [...prev, { key: nextKey(), fromUom: null, factor: "" }])
}
function removeRow(key: string) {
setRows((prev) => prev.filter((r) => r.key !== key))
}
async function handleSave() {
const nextErrors: Record<string, Record<string, string>> = {}
for (const row of rows) {
const rowErrors = validateConversionLine({ fromUom: row.fromUom, toUom: baseUomId, factor: row.factor })
if (Object.keys(rowErrors).length > 0) nextErrors[row.key] = rowErrors
}
// The unique index is on (item, from, to); catching it here beats a 400 from the server.
const chosen = rows.map((r) => r.fromUom).filter((u): u is number => u !== null)
const duplicates = chosen.filter((u, i) => chosen.indexOf(u) !== i)
for (const row of rows) {
if (row.fromUom !== null && duplicates.includes(row.fromUom)) {
nextErrors[row.key] = { ...nextErrors[row.key], fromUom: "One conversion per unit" }
}
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
// A full replace, matching the server's upsert semantics: an empty list clears them all,
// which is also how a user detaches conversions before changing the base UOM.
await itemsApi.updateUomConversions(itemId, {
conversions: rows.map((r) => ({ fromUom: r.fromUom as number, toUom: baseUomId, factor: Number(r.factor) })),
})
toast.success("Conversions saved", `${rows.length} conversion(s) against ${baseName}`)
onSaved()
} catch (err) {
toast.error("Could not save conversions", errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5">
<div className="flex flex-col gap-1">
<Label className="text-base font-semibold">UOM conversions</Label>
<p className="text-sm text-muted-foreground">
Units this item can be bought, sold, or produced in besides {baseName}. Each row says how many{" "}
{baseName} one of that unit is worth a Box of 12 pieces is a factor of 12. Stock is always stored in{" "}
{baseName}.
</p>
</div>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
No conversions this item can only be transacted in {baseName}.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-64">Unit</TableHead>
<TableHead className="w-40 text-right">Factor</TableHead>
<TableHead>Converts to</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.key}>
<TableCell>
<Select<number | null>
value={row.fromUom}
onValueChange={(v) => updateRow(row.key, { fromUom: v })}
items={selectableUoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors[row.key]?.fromUom}>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
{selectableUoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors[row.key]?.fromUom ? { message: errors[row.key].fromUom } : undefined]} />
</TableCell>
<TableCell className="text-right">
<Input
type="number"
min="0"
step="0.000001"
value={row.factor}
onChange={(e) => updateRow(row.key, { factor: e.target.value })}
aria-invalid={!!errors[row.key]?.factor}
className="text-right"
/>
<FieldError errors={[errors[row.key]?.factor ? { message: errors[row.key].factor } : undefined]} />
</TableCell>
<TableCell className="text-base text-muted-foreground">
{row.factor && Number(row.factor) > 0
? `1 ${uoms.find((u) => u.uomId === row.fromUom)?.name ?? "unit"} = ${Number(row.factor)} ${baseName}`
: baseName}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => removeRow(row.key)} aria-label="Remove conversion">
<Trash2 className="size-5 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-between gap-3">
<Button variant="outline" onClick={addRow}>
<Plus className="size-5" />
Add conversion
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save conversions"}
</Button>
</div>
</div>
)
}
@@ -11,9 +11,12 @@ import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { uomName } from "@/lib/uom"
import { cn } from "@/lib/utils"
import { Item, StockNature, TrackingMode } from "@/types/master-data"
import { UomConversionsPanel } from "./UomConversionsPanel"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -92,7 +95,9 @@ export default function ItemDetailPage() {
useEffect(() => {
if (!Number.isFinite(itemId)) return
load()
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
// pageSize matters here: the default page would silently truncate the UOM list as the
// master grows, hiding units from the base-UOM picker and the conversions editor.
Promise.all([categoriesApi.list(), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cat, uo, wh]) => {
setCategories(cat.items)
setUoms(uo.items)
@@ -155,9 +160,6 @@ export default function ItemDetailPage() {
}
}
function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
if (loadError && !item) {
return (
@@ -321,8 +323,19 @@ export default function ItemDetailPage() {
</div>
<p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM every transaction converts to and stores quantities in base UOM (FR-MD-03).
{uomName(item.baseUomId, uoms)} is the base UOM every transaction converts to and stores quantities in base UOM (FR-MD-03).
</p>
<UomConversionsPanel
// Remount when the saved set changes so the editor's draft rows re-seed from the
// server's response rather than keeping stale local state after a save or reload.
key={item.conversions.map((c) => `${c.conversionId}:${c.factor}`).join("|")}
itemId={item.itemId}
baseUomId={item.baseUomId}
conversions={item.conversions}
uoms={uoms}
onSaved={load}
/>
</div>
)
}
@@ -8,6 +8,7 @@ import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "luci
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
@@ -57,7 +58,7 @@ export default function GrnDetailPage() {
return items.find((i) => i.itemId === itemId)
}
function uomFor(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
return uomName(uomId, uoms)
}
function binFor(binId: number | null) {
if (!binId) return "—"
@@ -13,6 +13,8 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
@@ -108,12 +110,14 @@ export default function NewGrnPage() {
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [refreshingItems, setRefreshingItems] = useState(false)
const allowedUoms = useAllowedUoms()
useEffect(() => {
Promise.all([
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
// pageSize: without it the default page silently truncates the unit list.
uomsApi.list({ pageSize: 200 }),
vendorsApi.list({ pageSize: 200, status: "Active" }),
purchaseOrdersApi.list({ pageSize: 200 }),
])
@@ -155,13 +159,18 @@ export default function NewGrnPage() {
setHeaderError(null)
try {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
// Open-ness is decided on the base pair, matching the server's close condition —
// qty/qtyReceived are PO-UOM display figures derived by division and can drift.
const openLines = po.lines.filter((l) => l.qtyReceivedBase < l.qtyBase)
if (openLines.length === 0) {
setHeaderError("This purchase order has no open (unreceived) lines.")
setLines([])
return
}
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
// The receiving lines keep the PO's UOM, so preload each item's allowed units for the
// few lines a user may switch to a different pack size.
openLines.forEach((l) => allowedUoms.load(l.itemId))
setLines(
openLines.map(
(l): DraftLine => ({
@@ -469,7 +478,16 @@ export default function NewGrnPage() {
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<Select<number | null>
value={line.itemId}
onValueChange={(v) => {
// Reset the unit to the item's base and load its allowed
// units — a UOM carried over from the previous item would
// usually have no conversion for the new one.
allowedUoms.load(v)
updateLine(line.key, { itemId: v, uomId: itemFor(v)?.baseUomId ?? null })
}}
>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
@@ -488,7 +506,7 @@ export default function NewGrnPage() {
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
{uomName(line.uomId, uoms ?? [])}
</div>
) : (
<>
@@ -497,7 +515,7 @@ export default function NewGrnPage() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
@@ -532,6 +550,12 @@ export default function NewGrnPage() {
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
{/* Shows what will actually hit stock when receiving in a pack unit. */}
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? [])}
</p>
)}
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
@@ -9,6 +9,8 @@ import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { basePreview, pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
@@ -55,6 +57,7 @@ export default function BundleSaleDetailPage() {
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const allowedUoms = useAllowedUoms()
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
@@ -102,12 +105,16 @@ export default function BundleSaleDetailPage() {
setTemplateId(data.bundleSaleTemplateId)
setBundleName(data.bundleName)
setBundlePrice(data.bundlePrice)
data.lines.forEach((line) => allowedUoms.load(line.itemId))
setLines(
data.lines.map((line) => ({
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
// Keep the UOM the line was saved with. This used to be forced back to the
// item's base because the server rewrote it that way; it now preserves what
// was entered, so overriding here would discard the user's choice.
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
@@ -377,6 +384,7 @@ export default function BundleSaleDetailPage() {
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
allowedUoms.load(itemId)
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
@@ -392,12 +400,14 @@ export default function BundleSaleDetailPage() {
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
{/* Enabled now that bundle lines keep the entered UOM (the server
snapshots QtyBase beside it instead of overwriting Qty). */}
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft || !line.itemId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
@@ -405,7 +415,12 @@ export default function BundleSaleDetailPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-28">
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" />
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
)}
</TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">
{editing && isDraft ? (
@@ -12,6 +12,8 @@ import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { basePreview, pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -61,6 +63,7 @@ function NewBundleSaleContent() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const allowedUoms = useAllowedUoms()
useEffect(() => {
Promise.all([
@@ -94,11 +97,12 @@ function NewBundleSaleContent() {
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
uomId: item?.baseUomId ?? line.uomId,
})
// Load each component's allowed units so the picker narrows for template-seeded rows too.
allowedUoms.load(line.itemId)
// Keep the template's own UOM. Forcing it to the item's base while keeping the
// template's unitPrice would leave qty and price in different units, and
// componentSubtotal below multiplies the two.
return createBlankLine({ ...line, uomId: line.uomId })
})
: [createBlankLine()]
)
@@ -259,6 +263,7 @@ function NewBundleSaleContent() {
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
allowedUoms.load(itemId)
updateLine(line.key, {
itemId,
uomId: item?.baseUomId ?? line.uomId,
@@ -278,12 +283,14 @@ function NewBundleSaleContent() {
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
{/* Enabled now that bundle lines keep the entered UOM: the server
snapshots the base quantity beside it instead of overwriting qty. */}
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!line.itemId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
@@ -291,7 +298,14 @@ function NewBundleSaleContent() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-28">
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" />
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
</p>
)}
</TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">
<button
@@ -10,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { pickerOptions, uomName as resolveUomName } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
@@ -70,6 +72,7 @@ export default function NewFreeIssuePage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const allowedUoms = useAllowedUoms()
async function refreshRows() {
const list = await salesApi.listFreeIssues({ pageSize: 50 })
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
uomName: uom?.name ?? resolveUomName(firstLine?.uomId, uoms),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
@@ -146,11 +149,13 @@ export default function NewFreeIssuePage() {
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
allowedUoms.load(itemId)
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
}
function selectEditingItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
allowedUoms.load(itemId)
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
}
@@ -325,7 +330,7 @@ export default function NewFreeIssuePage() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
@@ -390,7 +395,7 @@ export default function NewFreeIssuePage() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
@@ -11,6 +11,8 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
@@ -75,6 +77,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
const allowedUoms = useAllowedUoms()
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -99,6 +102,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId)
setInvoiceType(doc.data.invoiceType)
// Preload allowed units for the items already on the document, so editing an
// existing line offers the narrowed list immediately.
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
setLines(
doc.data.lines.map((line) => ({
key: String(line.salesInvoiceLineId),
@@ -150,6 +156,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
allowedUoms.load(itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
@@ -353,7 +360,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</td>
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
<td className="px-4 py-3">{uomName(line.uomId, uoms)}</td>
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
@@ -503,7 +510,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">UOM</option>
{uoms.map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
<option key={u.uomId} value={String(u.uomId)}>
{u.name}
</option>
@@ -519,6 +526,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
</p>
)}
</td>
<td className="px-4 py-3 w-28">
<input
@@ -7,6 +7,7 @@ import { ArrowLeft, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { basePreview, pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
@@ -71,6 +73,7 @@ export default function NewSalesInvoicePage() {
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const allowedUoms = useAllowedUoms()
useEffect(() => {
Promise.all([
@@ -129,6 +132,9 @@ export default function NewSalesInvoicePage() {
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
// Load the units this item can actually be sold in, so the UOM picker below narrows from
// the global list to base + defined conversions.
allowedUoms.load(itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
@@ -363,7 +369,7 @@ export default function NewSalesInvoicePage() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
@@ -380,6 +386,12 @@ export default function NewSalesInvoicePage() {
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
{/* Makes the conversion visible while editing instead of at post time. */}
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
</p>
)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -7,6 +7,8 @@ import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lu
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
@@ -71,6 +73,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
const allowedUoms = useAllowedUoms()
useEffect(() => {
if (!Number.isFinite(slipId)) {
@@ -97,6 +100,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
setWarehouseId(doc.data.warehouseId)
setCashierUserId(doc.data.cashierUserId)
setPromotionSuggestion(null)
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
setLines(
doc.data.lines.map((line) => ({
key: String(line.salesSlipLineId),
@@ -149,8 +153,12 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
}
function selectItem(key: string, itemId: number) {
allowedUoms.load(itemId)
updateLine(key, {
itemId,
// Reset to the new item's base unit: a UOM carried over from the previous item
// usually has no conversion for this one, and would be refused on save.
uomId: items.find((i) => i.itemId === itemId)?.baseUomId ?? 0,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
@@ -346,7 +354,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="px-4 py-3">{uomName(line.uomId, uoms)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
@@ -457,10 +465,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
<TableCell>
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
<SelectContent>{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell>
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} />
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
)}
</TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { basePreview, pickerOptions } from "@/lib/uom"
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
@@ -65,6 +67,7 @@ export default function NewSalesSlipPage() {
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const allowedUoms = useAllowedUoms()
const [saving, setSaving] = useState(false)
useEffect(() => {
@@ -104,6 +107,8 @@ export default function NewSalesSlipPage() {
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
// Narrow the UOM picker to units this item has a conversion for.
allowedUoms.load(itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
@@ -334,7 +339,7 @@ export default function NewSalesSlipPage() {
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
@@ -351,6 +356,12 @@ export default function NewSalesSlipPage() {
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
{/* Makes the conversion visible while editing instead of at post time. */}
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
<p className="mt-1 text-xs text-muted-foreground">
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
</p>
)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -7,6 +7,7 @@ import { PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { formatQtyValue, formatQtyWithName } from "@/lib/uom"
import { errorMessage } from "@/lib/error-map"
import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
@@ -135,11 +136,12 @@ export default function StockEnquiryPage() {
<div className="text-sm text-muted-foreground">{item?.name}</div>
</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
{/* Every figure here is base UOM; the label is what tells the user which. */}
<TableCell className="px-3 py-3.5">{formatQtyWithName(row.onHand, row.baseUomName)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(row.available, row.baseUomName)}</TableCell>
<TableCell className="px-3 py-3.5">{formatQtyValue(row.onHold)}</TableCell>
<TableCell className="px-3 py-3.5">{formatQtyValue(row.inTransit)}</TableCell>
<TableCell className="px-3 py-3.5">{formatQtyValue(row.reserved)}</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { formatQtyWithName } from "@/lib/uom"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { LedgerEntry } from "@/types/stock"
@@ -189,10 +190,10 @@ export default function StockLedgerPage() {
{entry.direction}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
<TableCell className="px-3 py-3.5">{formatQtyWithName(entry.qtyBase, entry.baseUomName)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(entry.runningBalance, entry.baseUomName)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
@@ -6,9 +6,11 @@ import { AlertTriangle, CheckCircle2 } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { formatQty } from "@/lib/uom"
import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -19,16 +21,23 @@ export default function ReorderAlertsPage() {
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [uoms, setUoms] = useState<Uom[]>([])
const [error, setError] = useState<string | null>(null)
const [requesting, setRequesting] = useState<string | null>(null)
const [requested, setRequested] = useState<Set<string>>(new Set())
useEffect(() => {
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([a, it, wh]) => {
Promise.all([
stockApi.reorderAlerts(),
itemsApi.list({ pageSize: 200 }),
warehousesApi.list(),
uomsApi.list({ pageSize: 200 }),
])
.then(([a, it, wh, uo]) => {
setAlerts(a.items)
setItems(it.items)
setWarehouses(wh.items)
setUoms(uo.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
@@ -44,7 +53,10 @@ export default function ReorderAlertsPage() {
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
setRequested((prev) => new Set(prev).add(key))
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
// Requisitions are base-UOM only, so name the item's actual base unit rather than
// the placeholder word "units".
const uom = itemsById.get(alert.itemId)?.baseUomId
toast.success("Requisition created", `${res.docNo} for ${formatQty(qty, uom ?? null, uoms)}.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
@@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
@@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>