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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user