Refactor production and stock tests to remove UOM dependency

- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
This commit is contained in:
2026-08-11 11:32:40 +05:30
parent d37824cecc
commit 15ddac178c
108 changed files with 1221 additions and 987 deletions
@@ -8,6 +8,7 @@ import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
@@ -28,7 +29,6 @@ interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
@@ -72,7 +72,6 @@ export default function PurchaseOrderDetailPage() {
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: String(l.qty),
unitPrice: String(l.unitPrice),
@@ -111,9 +110,6 @@ 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 warehouseCode(warehouseId: number) {
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
}
@@ -141,7 +137,6 @@ export default function PurchaseOrderDetailPage() {
for (const line of lines) {
const errors = validatePoLine({
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
@@ -157,7 +152,6 @@ export default function PurchaseOrderDetailPage() {
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
uomId: l.uomId as number,
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
@@ -347,7 +341,7 @@ export default function PurchaseOrderDetailPage() {
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Plus className="size-5" />
Add line
</Button>
@@ -376,7 +370,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">{baseUomLabel(items, uoms, line.itemId)}</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>
@@ -403,19 +397,9 @@ export default function PurchaseOrderDetailPage() {
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
@@ -12,6 +12,7 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
@@ -39,7 +40,6 @@ import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
@@ -57,7 +57,7 @@ function newKey() {
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
// from its negotiated price (below) but stays editable.
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
}
function NewPurchaseOrderContent() {
@@ -169,7 +169,6 @@ function NewPurchaseOrderContent() {
(l): DraftLine => ({
key: newKey(),
itemId: l.itemId,
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: "0",
@@ -195,7 +194,6 @@ function NewPurchaseOrderContent() {
return {
key: newKey(),
itemId: l.itemId,
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: cell ? String(cell.unitPrice) : "0",
@@ -242,7 +240,6 @@ function NewPurchaseOrderContent() {
for (const line of lines) {
const errors = validatePoLine({
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
@@ -258,7 +255,6 @@ function NewPurchaseOrderContent() {
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
uomId: l.uomId as number,
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
@@ -449,19 +445,9 @@ function NewPurchaseOrderContent() {
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
@@ -18,6 +18,7 @@ import {
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
@@ -154,10 +155,24 @@ export function StageDrawer({
return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`)
}, [items])
const uomName = useMemo(() => {
const byId = new Map(uoms.map((u) => [u.uomId, u.name]))
return (id: number) => byId.get(id) ?? `#${id}`
}, [uoms])
// Nothing on a run line carries a unit of its own any more. An output that references a
// real item shows that item's base UOM; only intermediate WIP falls back to its own label.
const outputUnit = useMemo(
() => (output: RunStageOutput) =>
output.itemId !== null ? baseUomLabel(items, uoms, output.itemId) : uomLabel(uoms, output.uomId),
[items, uoms],
)
// An input's unit follows how its quantity was expressed: ml/g for a content formula,
// otherwise a count of the item's packs. Upstream inputs are WIP from a parent stage.
const inputUnit = useMemo(
() => (input: RunStageInput) => {
if (input.source === "Upstream") return "WIP"
if (input.qtyUnit === "Content") return contentUnitLabel(items, input.itemId) ?? "—"
return baseUomLabel(items, uoms, input.itemId)
},
[items, uoms],
)
const stageName = useMemo(() => {
const byId = new Map(run.stages.map((s) => [s.runStageId, s.name]))
@@ -323,7 +338,7 @@ export function StageDrawer({
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
uomName={uomName}
unitLabel={inputUnit}
/>
))}
</section>
@@ -337,7 +352,7 @@ export function StageDrawer({
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{outputUnit(output)}</span>
</div>
{output.itemId !== null && (
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
@@ -522,7 +537,7 @@ export function StageDrawer({
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
<Row label="Good quantity">{fmt(goodQty)} {outputUnit(terminalOutput)}</Row>
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
<Row label="Leftovers returned">{money(run.costPool.returned)}</Row>
<Separator className="my-1" />
@@ -653,14 +668,14 @@ function InputCard({
value,
onValueChange,
itemName,
uomName,
unitLabel,
}: {
input: RunStageInput
editable: boolean
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
uomName: (id: number) => string
unitLabel: (input: RunStageInput) => string
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
@@ -671,7 +686,7 @@ function InputCard({
<p className="min-w-0 truncate text-sm font-medium text-foreground">
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<div className="mt-2 flex flex-col gap-1">
@@ -698,9 +713,10 @@ function InputCard({
)}
{/*
Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the
input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36.
Labelled explicitly so the two are never read as the same unit.
Consumed/returned figures are always a count of the item's packs, while `plannedQty`
above is in the input's declared unit — a formula written as 2000 ml against a 500 ml
bottle shows planned 2000 and consumed 4. Labelled explicitly so the two are never
read as the same unit.
*/}
{input.consumedQty > 0 && (
<>
@@ -1,9 +1,11 @@
"use client"
import type { ReactNode } from "react"
import { Plus, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { CustomFieldType, StageInputSource } from "@/types/production"
import { baseUomLabel, contentUnitLabel } from "@/lib/uom-label"
import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import {
BuilderFieldDef,
@@ -35,21 +37,24 @@ export interface UpstreamOutputOption {
outputName: string
}
/** One row per input/output quantity — UOM select plus qty, used three times below. */
/**
* One quantity row: the number, plus whatever names its unit.
*
* The unit is no longer a free choice. An input's is decided by its item (and, when that item
* has a content size, by the Pack/Content toggle); an output's comes from its item, or from a
* WIP label when it has none. So each caller supplies its own `unit` control and this row only
* owns the number.
*/
function QtyRow({
qty,
uomId,
uoms,
readOnly,
onQtyChange,
onUomChange,
unit,
}: {
qty: number
uomId: number | null
uoms: Uom[]
readOnly: boolean
onQtyChange: (qty: number) => void
onUomChange: (uomId: number) => void
unit: ReactNode
}) {
return (
<div className="flex items-center gap-2">
@@ -64,22 +69,59 @@ function QtyRow({
placeholder="Qty per batch"
aria-label="Quantity per batch"
/>
<Select<number> value={uomId} onValueChange={(v) => v && onUomChange(v)}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="UOM">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
{unit}
</div>
)
}
/** Static unit name, for the rows whose unit is derived and therefore not editable. */
function UnitLabel({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-24 shrink-0 items-center text-sm text-muted-foreground">{children}</span>
)
}
/**
* Names the unit of a stage input's quantity.
*
* Only one case is a choice: a Stock input whose item declares a content size can be written
* either as an amount of that content (2000 ml) or as a pack count (4 bottles). Everything
* else has exactly one possible unit, so it renders as a label rather than a control.
*/
function InputUnitControl({
input,
items,
uoms,
readOnly,
onChange,
}: {
input: BuilderInput
items: ItemListItem[]
uoms: Uom[]
readOnly: boolean
onChange: (unit: StageQtyUnit) => void
}) {
if (input.source === "Upstream") return <UnitLabel>WIP</UnitLabel>
const item = items.find((candidate) => candidate.itemId === input.itemId)
const packName = baseUomLabel(items, uoms, input.itemId)
const contentName = contentUnitLabel(items, input.itemId)
if (!item?.contentBaseQty || !contentName) return <UnitLabel>{packName}</UnitLabel>
return (
<Select<string> value={input.qtyUnit} onValueChange={(v) => v && onChange(v as StageQtyUnit)}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="Quantity unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Content" className="text-sm">{contentName}</SelectItem>
<SelectItem value="Pack" className="text-sm">{packName}</SelectItem>
</SelectContent>
</Select>
)
}
export function StageEditorPanel({
data,
isTerminal,
@@ -108,7 +150,7 @@ export function StageEditorPanel({
onChange({
inputs: [
...data.inputs,
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 },
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, qtyUnit: "Pack", qtyPerBatch: 1 },
],
})
}
@@ -122,13 +164,20 @@ export function StageEditorPanel({
* the validator rejects an input that carries both.
*/
function changeInputSource(localId: string, source: StageInputSource) {
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
updateInput(
localId,
// WIP has no content size, so an Upstream input can only ever be counted in whole units.
source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null, qtyUnit: "Pack" },
)
}
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
/**
* Default to content units when the item has a content size — a recipe is far more often
* written as "2000 ml of syrup" than "4 bottles" — and to packs otherwise. Still switchable.
*/
function pickInputItem(input: BuilderInput, itemId: number) {
const item = items.find((i) => i.itemId === itemId)
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
updateInput(input.localId, { itemId, qtyUnit: item?.contentBaseQty ? "Content" : "Pack" })
}
function updateOutput(key: string, patch: Partial<BuilderOutput>) {
@@ -287,11 +336,15 @@ export function StageEditorPanel({
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
unit={<InputUnitControl
input={input}
items={items}
uoms={uoms}
readOnly={readOnly}
onChange={(qtyUnit) => updateInput(input.localId, { qtyUnit })}
/>}
/>
</div>
</div>
@@ -347,11 +400,27 @@ export function StageEditorPanel({
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
unit={
// A finished good is counted in its item's own unit; only WIP names one.
output.itemId !== null ? (
<UnitLabel>{baseUomLabel(items, uoms, output.itemId)}</UnitLabel>
) : (
<Select<number> value={output.uomId} onValueChange={(v) => v && updateOutput(output.key, { uomId: v })}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="WIP unit">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
/>
</div>
))}
@@ -77,7 +77,7 @@ function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Ed
source: i.source,
itemId: i.itemId,
fromOutputKey: i.fromOutputKey,
uomId: i.uomId,
qtyUnit: i.qtyUnit,
qtyPerBatch: i.qtyPerBatch,
})),
outputs: s.outputs.map((o) => ({
@@ -414,14 +414,21 @@ function TemplateBuilderContent() {
const where = `Input ${i + 1} of "${label}"`
if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`)
if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`)
if (input.uomId === null) list.push(`${where} needs a UOM.`)
// Content quantities divide by the item's content size, so the item must declare one.
if (input.qtyUnit === "Content") {
const item = items.find((candidate) => candidate.itemId === input.itemId)
if (item && !item.contentBaseQty) {
list.push(`${where} is in content units, but ${item.sku} has no content size.`)
}
}
if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
})
data.outputs.forEach((output, i) => {
const where = `Output ${i + 1} of "${label}"`
if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`)
if (output.uomId === null) list.push(`${where} needs a UOM.`)
// Only work-in-progress declares its own unit; a finished good takes its item's.
if (!isTerminal && output.uomId === null) list.push(`${where} needs a UOM.`)
if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
})
@@ -502,7 +509,7 @@ function TemplateBuilderContent() {
source: i.source,
itemId: i.source === "Stock" ? i.itemId : null,
fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null,
uomId: i.uomId!,
qtyUnit: i.source === "Upstream" ? "Pack" : i.qtyUnit,
qtyPerBatch: i.qtyPerBatch,
})),
// Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was*
@@ -513,7 +520,8 @@ function TemplateBuilderContent() {
key: o.key,
itemId: isTerminal ? o.itemId : null,
name: o.name.trim(),
uomId: o.uomId!,
// An item-bearing output takes its unit from the item, so it must send none.
uomId: isTerminal ? null : o.uomId,
qtyPerBatch: o.qtyPerBatch,
})),
}
@@ -7,15 +7,15 @@
// the contract (only outputs do, because Upstream inputs reference them by key). Rendering
// them by array index would make React reuse the wrong <input> when a row is removed, so
// every editable row carries a throwaway `localId` that is stripped on save.
// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the
// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set,
// which is what makes the non-null assertions in its payload builder sound.
// * **Half-filled rows.** A row the user just added has nothing picked yet, so `itemId` and a
// WIP output's `uomId` are nullable here. `page.tsx` blocks the save until the required ones
// are set, which is what makes the non-null assertions in its payload builder sound.
//
// A stage's identity IS its React Flow node id, which is its server key — the stringified
// stage id, or `tmp-<uuid>` for a stage drawn in this session. That is why edges need no
// translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`.
import { CustomFieldType, StageInputSource } from "@/types/production"
import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production"
/** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */
export function newKey(): string {
@@ -34,7 +34,11 @@ export interface BuilderInput {
itemId: number | null
/** Upstream inputs only — an output key belonging to a *direct* parent stage. */
fromOutputKey: string | null
uomId: number | null
/**
* Whether `qtyPerBatch` is a pack count or an amount of the item's content (ml/g).
* `Content` is only offered for a Stock input whose item declares a content size.
*/
qtyUnit: StageQtyUnit
qtyPerBatch: number
}
@@ -44,6 +48,7 @@ export interface BuilderOutput {
/** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */
itemId: number | null
name: string
/** WIP label — required when `itemId` is null, and must stay null when it is set. */
uomId: number | null
qtyPerBatch: number
}
@@ -12,7 +12,10 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, StockNature, TrackingMode } from "@/types/master-data"
import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -52,6 +55,9 @@ export default function ItemDetailPage() {
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
// isn't sent on save — nothing to wire it to server-side.
const [warehouseId, setWarehouseId] = useState<number | null>(null)
@@ -75,6 +81,8 @@ export default function ItemDetailPage() {
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "")
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
}
function load() {
@@ -105,7 +113,7 @@ export default function ItemDetailPage() {
async function handleSave() {
if (!item || !etag) return
setSaveError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -117,6 +125,8 @@ export default function ItemDetailPage() {
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
taxClass: taxClass || null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
},
etag
)
@@ -273,6 +283,53 @@ export default function ItemDetailPage() {
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
{/* Content size (FR-MD-02): how much one pack holds. Optional, and independent of the
base UOM above — stock is counted in packs either way. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={contentQty}
disabled={conflict}
onChange={(e) => setContentQty(e.target.value)}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
disabled={conflict}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{u.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError
errors={[
errors.contentQty ? { message: errors.contentQty } : undefined,
errors.contentUnit ? { message: errors.contentUnit } : undefined,
]}
/>
{/* The stored value, so the ×1000 normalisation is never a surprise. */}
<p className="text-sm text-muted-foreground">
{item.contentBaseQty !== null && item.contentBaseUnit
? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.`
: "No content size — leave blank for items with nothing measurable to hold."}
</p>
</div>
<div className="flex flex-col gap-2">
{/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */}
@@ -321,7 +378,8 @@ 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)} is the base UOM every transaction records quantities as a count of it (FR-MD-03).
There are no conversions: a differently sized pack is a different item.
</p>
</div>
)
@@ -13,9 +13,12 @@ import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import { contentPairErrors, validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -54,6 +57,9 @@ export default function NewItemPage() {
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
/** Defaults to the first UOM once loaded; null only means none exist yet. */
const [baseUomId, setBaseUomId] = useState<number | null>(null)
// Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [loadError, setLoadError] = useState<string | null>(null)
@@ -199,7 +205,10 @@ export default function NewItemPage() {
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
const nextErrors = {
...validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }),
...contentPairErrors(contentQty, contentUnit),
}
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
@@ -228,6 +237,8 @@ export default function NewItemPage() {
stockNature,
trackingMode: "None",
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
})
created += 1
}
@@ -382,6 +393,48 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
or a label has none. Litres/kilograms are normalised to ml/g by the server. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={contentQty}
onChange={(e) => setContentQty(e.target.value)}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{u.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError
errors={[
errors.contentQty ? { message: errors.contentQty } : undefined,
errors.contentUnit ? { message: errors.contentUnit } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
Leave blank for items with no measurable content. Stock is still counted in the base UOM.
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -59,7 +59,7 @@ export default function UomsPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
</div>
</div>
@@ -9,6 +9,7 @@ import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
@@ -56,9 +57,6 @@ export default function GrnDetailPage() {
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function uomFor(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
function binFor(binId: number | null) {
if (!binId) return "—"
return bins.find((b) => b.binId === binId)?.code ?? `#${binId}`
@@ -179,7 +177,7 @@ export default function GrnDetailPage() {
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
@@ -11,6 +11,7 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
@@ -33,7 +34,6 @@ interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
binId: number | null
qty: string
unitCost: string
@@ -70,7 +70,6 @@ function emptyLine(): DraftLine {
key: newKey(),
poLineId: null,
itemId: null,
uomId: null,
binId: null,
qty: "",
unitCost: "",
@@ -168,7 +167,6 @@ export default function NewGrnPage() {
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: null,
qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice),
@@ -246,7 +244,6 @@ export default function NewGrnPage() {
for (const line of lines) {
const errors = validateLine({
itemId: line.itemId,
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
@@ -268,7 +265,6 @@ export default function NewGrnPage() {
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
uomId: l.uomId as number,
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
@@ -486,27 +482,9 @@ export default function NewGrnPage() {
)}
</TableCell>
<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}`}
</div>
) : (
<>
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</>
)}
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
@@ -8,6 +8,7 @@ import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
@@ -30,7 +31,6 @@ const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine =>
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
@@ -107,7 +107,6 @@ export default function BundleSaleDetailPage() {
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
@@ -377,7 +376,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)
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
@@ -391,19 +390,8 @@ export default function BundleSaleDetailPage() {
</SelectContent>
</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>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</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-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>
@@ -9,6 +9,7 @@ import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -31,7 +32,6 @@ const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine =>
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
@@ -97,7 +97,6 @@ function NewBundleSaleContent() {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
uomId: item?.baseUomId ?? line.uomId,
})
})
: [createBlankLine()]
@@ -261,7 +260,6 @@ function NewBundleSaleContent() {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
uomId: item?.baseUomId ?? line.uomId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
@@ -277,19 +275,8 @@ function NewBundleSaleContent() {
</SelectContent>
</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>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</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-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>
@@ -14,6 +14,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast"
@@ -40,7 +41,6 @@ type FreeIssueRow = {
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
@@ -78,7 +78,6 @@ export default function NewFreeIssuePage() {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = items.find((x) => x.itemId === firstLine?.itemId)
const uom = uoms.find((x) => x.uomId === firstLine?.uomId)
const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
@@ -88,7 +87,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: baseUomLabel(items, uoms, firstLine?.itemId),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
@@ -118,7 +117,6 @@ export default function NewFreeIssuePage() {
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
},
])
@@ -145,20 +143,17 @@ export default function NewFreeIssuePage() {
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
updateLine(key, { itemId })
}
function selectEditingItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
updateEditingLine(key, { itemId })
}
async function submit() {
const activeLines = editingRowId ? editingLines : lines
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.")
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
setSaving(true)
@@ -170,7 +165,6 @@ export default function NewFreeIssuePage() {
cashierUserId,
lines: activeLines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
@@ -219,7 +213,6 @@ export default function NewFreeIssuePage() {
{
key: "edit-line-1",
itemId: detailLine?.itemId ?? 0,
uomId: detailLine?.uomId ?? 0,
warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId,
qty: detailLine?.qty ?? 1,
freeQty: detailLine?.freeQty ?? 0,
@@ -319,19 +312,8 @@ export default function NewFreeIssuePage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateEditingLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
@@ -385,18 +367,7 @@ export default function NewFreeIssuePage() {
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
@@ -9,6 +9,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
@@ -30,7 +31,6 @@ const money = new Intl.NumberFormat("en-LK", {
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
@@ -103,7 +103,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
doc.data.lines.map((line) => ({
key: String(line.salesInvoiceLineId),
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
freeQty: line.freeQty,
@@ -152,7 +151,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
@@ -172,7 +170,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
async function save() {
if (!customerId || !warehouseId || !etag) return
if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) {
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
return
}
@@ -188,7 +186,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
@@ -353,7 +350,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">{baseUomLabel(items, uoms, line.itemId)}</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>
@@ -496,19 +493,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
))}
</select>
</td>
<td className="px-4 py-3 min-w-40">
<select
value={line.uomId ? String(line.uomId) : ""}
onChange={(e) => updateLine(line.key, { uomId: Number(e.target.value) })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">UOM</option>
{uoms.map((u) => (
<option key={u.uomId} value={String(u.uomId)}>
{u.name}
</option>
))}
</select>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</td>
<td className="px-4 py-3 w-28">
<input
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -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>{baseUomLabel(items, uoms, line.itemId)}</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>
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -36,7 +37,6 @@ type ActiveFocScheme = {
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
@@ -92,7 +92,6 @@ export default function NewSalesInvoicePage() {
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: defaultWarehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
@@ -131,7 +130,6 @@ export default function NewSalesInvoicePage() {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
@@ -181,7 +179,6 @@ export default function NewSalesInvoicePage() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.")
const payload: CreateSalesInvoiceRequest = {
customerId,
@@ -189,7 +186,6 @@ export default function NewSalesInvoicePage() {
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
@@ -357,19 +353,8 @@ export default function NewSalesInvoicePage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -37,7 +38,6 @@ const money = new Intl.NumberFormat("en-LK", {
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
@@ -101,7 +101,6 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
doc.data.lines.map((line) => ({
key: String(line.salesSlipLineId),
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
freeQty: line.freeQty,
@@ -179,7 +178,6 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
@@ -346,7 +344,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">{baseUomLabel(items, uoms, line.itemId)}</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>
@@ -454,12 +452,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
<SelectContent>{items.map((i) => <SelectItem key={i.itemId} value={String(i.itemId)}>{i.sku} - {i.name}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<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>
</Select>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</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.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>
@@ -18,6 +18,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
@@ -31,7 +32,6 @@ type Line = CreateSalesSlipLineRequest & { key: string }
const blankLine = (key: string): Line => ({
key,
itemId: 0,
uomId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
@@ -88,7 +88,6 @@ export default function NewSalesSlipPage() {
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
@@ -106,7 +105,6 @@ export default function NewSalesSlipPage() {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
@@ -155,7 +153,6 @@ export default function NewSalesSlipPage() {
if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.")
if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.")
if (lines.some((line) => Number(line.uomId) === 0)) return setSubmitError("Select a valid UOM for every line.")
const payload: CreateSalesSlipRequest = {
customerId,
@@ -163,7 +160,6 @@ export default function NewSalesSlipPage() {
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
@@ -328,19 +324,8 @@ export default function NewSalesSlipPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -7,6 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
@@ -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>{baseUomLabel(items, uoms, line.itemId)}</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>
@@ -7,6 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -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>{baseUomLabel(items, uoms, line.itemId)}</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>
-9
View File
@@ -10,8 +10,6 @@ import {
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
UpdateUomConversionsRequest,
UpdateUomConversionsResponse,
} from "@/types/master-data"
export interface ListItemsParams {
@@ -59,11 +57,4 @@ export const itemsApi = {
body: request,
})
},
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
method: "PUT",
body: request,
})
},
}
+46
View File
@@ -0,0 +1,46 @@
// Document lines carry no UOM of their own: every quantity in the system is a count of the
// item's base UOM (FR-MD-02/03). Screens that used to render a per-line UOM picker now show
// the unit as a derived, read-only label — the user still needs to know that "12" means
// 12 bottles, they just cannot change it.
import { ItemListItem, Uom } from "@/types/master-data"
/** Minimal shapes so this works with both `Item` and `ItemListItem`. */
type ItemLike = Pick<ItemListItem, "itemId" | "baseUomId">
/**
* Display name of an item's base UOM, e.g. "BOTTLE". Returns an em dash when no item is
* selected yet, and falls back to the raw id if the UOM list has not loaded.
*/
export function baseUomLabel(
items: readonly ItemLike[],
uoms: readonly Uom[],
itemId: number | null | undefined,
): string {
if (!itemId) return "—"
const item = items.find((candidate) => candidate.itemId === itemId)
if (!item) return "—"
return uoms.find((u) => u.uomId === item.baseUomId)?.name ?? `#${item.baseUomId}`
}
/**
* Display name for a UOM id that may be absent — an intermediate production output's WIP
* label, which is null on any output that references a real item.
*/
export function uomLabel(uoms: readonly Uom[], uomId: number | null | undefined): string {
if (!uomId) return "—"
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
/**
* The unit an item's content is measured in — "ml" or "g" — or null when the item has no
* content size. Used to label a production input entered in content units.
*/
export function contentUnitLabel(
items: readonly Pick<ItemListItem, "itemId" | "contentBaseUnit">[],
itemId: number | null | undefined,
): string | null {
if (!itemId) return null
const unit = items.find((candidate) => candidate.itemId === itemId)?.contentBaseUnit
return unit ? unit.toLowerCase() : null
}
@@ -13,7 +13,6 @@ export const grnHeaderSchema = z.object({
export function validateLine(input: {
itemId: number | null
uomId: number | null
qty: string
unitCost: string
discountPct: string
@@ -25,7 +24,6 @@ export function validateLine(input: {
const errors: Record<string, string> = {}
if (!input.itemId) errors.itemId = "Select an item"
if (!input.uomId) errors.uomId = "Select a UOM"
const qty = Number(input.qty)
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
@@ -10,12 +10,38 @@ export function validateItemForm(input: {
name: string
categoryId: number | null
baseUomId: number | null
/** Raw field value; empty means "no measurable content", which is valid. */
contentQty?: string
contentUnit?: string | null
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.sku.trim()) errors.sku = "SKU is required"
if (!input.name.trim()) errors.name = "Item name is required"
if (!input.categoryId) errors.categoryId = "Select a category"
if (!input.baseUomId) errors.baseUomId = "Select a base UOM"
Object.assign(errors, contentPairErrors(input.contentQty, input.contentUnit))
return errors
}
/**
* An item's content size is optional, but half of it is not: the server rejects a lone
* quantity or a lone unit with a 422. Shared because the variant builder validates its own
* shape and so cannot go through `validateItemForm`.
*/
export function contentPairErrors(
contentQty: string | undefined,
contentUnit: string | null | undefined,
): Record<string, string> {
const errors: Record<string, string> = {}
const rawQty = (contentQty ?? "").trim()
const unit = contentUnit ?? null
if (rawQty && !unit) errors.contentUnit = "Select a unit for the content size"
if (!rawQty && unit) errors.contentQty = "Enter a content size, or clear the unit"
if (rawQty) {
const qty = Number(rawQty)
if (Number.isNaN(qty) || qty <= 0) errors.contentQty = "Content size must be greater than 0"
}
return errors
}
@@ -29,16 +55,6 @@ export function validateReorderLine(input: { warehouseId: number | null; reorder
return errors
}
export function validateConversionLine(input: { fromUom: number | null; toUom: number | null; factor: string }): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.fromUom) errors.fromUom = "Select a UOM"
if (!input.toUom) errors.toUom = "Select a UOM"
if (input.fromUom && input.toUom && input.fromUom === input.toUom) errors.toUom = "From and to UOM must differ"
const factor = Number(input.factor)
if (!input.factor || Number.isNaN(factor) || factor <= 0) errors.factor = "Factor must be greater than 0"
return errors
}
export function validateUomName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "UOM name is required"
@@ -32,7 +32,6 @@ export function validateQuotationLine(input: { unitPrice: string; leadDays: stri
export function validatePoLine(input: {
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
@@ -40,7 +39,6 @@ export function validatePoLine(input: {
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.itemId) errors.itemId = "Select an item"
if (!input.uomId) errors.uomId = "Select a UOM"
if (!input.warehouseId) errors.warehouseId = "Select a warehouse"
const qty = Number(input.qty)
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
-2
View File
@@ -5,7 +5,6 @@ export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled"
export interface BundleSaleTemplateLine {
bundleSaleTemplateLineId: number
itemId: number
uomId: number
warehouseId: number
qty: number
unitPrice: number
@@ -40,7 +39,6 @@ export interface BundleSaleLine {
itemId: number
description: string
qty: number
uomId: number
warehouseId: number
unitPrice: number
lineTotal: number
-2
View File
@@ -26,7 +26,6 @@ export interface BatchInput {
export interface CreateGrnLineInput {
poLineId?: number | null
itemId: number
uomId: number
binId?: number | null
qty: number
/**
@@ -55,7 +54,6 @@ export interface GrnLine {
grnLineId: number
poLineId: number | null
itemId: number
uomId: number
binId: number | null
qty: number
/** Gross unit cost received at. */
+27 -20
View File
@@ -10,7 +10,26 @@ import { EntityStatus } from "@/types/common"
export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export interface ItemListItem {
/**
* Unit of an item's content size — how much one stocked pack holds.
*
* Not a stocking unit: stock is always counted in packs (`baseUomId`). Only `Ml` and `G`
* are ever returned as a *base* content unit; `L` and `Kg` are entry conveniences the
* server normalises ×1000 on write.
*/
export type MeasureUnit = "Ml" | "L" | "G" | "Kg"
/** The four content fields, which are always all set or all null. */
export interface ItemContent {
/** Content per pack as entered, e.g. 500 with `Ml`, or 1.5 with `L`. */
contentQty: number | null
contentUnit: MeasureUnit | null
/** Server-derived normalisation of the pair above; never sent on write. */
contentBaseQty: number | null
contentBaseUnit: MeasureUnit | null
}
export interface ItemListItem extends ItemContent {
itemId: number
sku: string
name: string
@@ -33,13 +52,6 @@ export interface ItemReorderSetting {
reorderQty: number
}
export interface UomConversion {
conversionId: number
fromUom: number
toUom: number
factor: number
}
/**
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
*
@@ -47,7 +59,7 @@ export interface UomConversion {
* client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the
* only record of which colour/size an item is.
*/
export interface Item {
export interface Item extends ItemContent {
itemId: number
sku: string
name: string
@@ -65,7 +77,6 @@ export interface Item {
salePrice: number | null
status: EntityStatus
reorder: ItemReorderSetting[]
conversions: UomConversion[]
createdAt: string
updatedAt: string | null
}
@@ -87,6 +98,12 @@ export interface CreateItemRequest {
taxClass?: string | null
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
salePrice?: number | null
/**
* Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is
* derived server-side and is deliberately not accepted here.
*/
contentQty?: number | null
contentUnit?: MeasureUnit | null
}
export type UpdateItemRequest = CreateItemRequest
@@ -95,16 +112,6 @@ export interface UpdateItemReorderRequest {
settings: ItemReorderSetting[]
}
export interface UpdateUomConversionsRequest {
conversions: { fromUom: number; toUom: number; factor: number }[]
}
export interface UpdateUomConversionsResponse {
itemId: number
baseUomId: number
conversions: UomConversion[]
}
export interface Warehouse {
warehouseId: number
code: string
-2
View File
@@ -136,7 +136,6 @@ export type PurchaseOrderStatus =
export interface PoLine {
poLineId: number
itemId: number
uomId: number
warehouseId: number
qty: number
unitPrice: number
@@ -178,7 +177,6 @@ export interface PurchaseOrderSummary {
export interface CreatePoLineInput {
itemId: number
uomId: number
warehouseId: number
qty: number
unitPrice: number
+28 -11
View File
@@ -4,7 +4,9 @@
// Three things changed when the real backend landed, and they are worth knowing if you are
// reading old code or docs/21-FRONTEND-PHASE2.md:
// * a template is identified by `code`, not `docNo` (only runs carry a document number)
// * quantities reference `itemId`/`uomId` numeric FKs with `qtyPerBatch` — not free text
// * quantities reference an `itemId` numeric FK with `qtyPerBatch` — not free text. Lines
// carry no UOM: an input's unit is `qtyUnit` (packs or the item's content unit) and an
// output's is its item's base UOM, or `uomId` when it is intermediate WIP
// * stages carry `posX`/`posY`, so canvas layout round-trips through the server
//
// The `key` vocabulary (§D.1): every stage and output has a client-facing string key
@@ -17,6 +19,16 @@ export type TemplateStatus = "Active" | "Inactive"
export type ProductionRunStatus = "InProgress" | "Completed" | "Cancelled"
export type RunStageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved"
export type StageInputSource = "Stock" | "Upstream"
/**
* What a stage input's quantity is expressed in.
*
* `Pack` is a count of the item's base UOM. `Content` is an amount of the item's content in
* its base content unit (ml or g), which the server divides by the item's content size to get
* packs — 2000 ml of a 500 ml bottle consumes 4, and 300 ml consumes 0.6. `Content` requires
* the item to have a content size, and Upstream inputs must always be `Pack`.
*/
export type StageQtyUnit = "Pack" | "Content"
export type CustomFieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
export type RunStageEventType =
@@ -50,7 +62,7 @@ export interface StageInput {
/** Set when `source` is `Upstream` — an output of a *direct* parent stage. */
fromOutputId: number | null
fromOutputKey: string | null
uomId: number
qtyUnit: StageQtyUnit
qtyPerBatch: number
}
@@ -60,7 +72,8 @@ export interface StageOutput {
/** Null on intermediate (WIP) outputs; required on the terminal stage's single output. */
itemId: number | null
name: string
uomId: number
/** WIP display label — set exactly when `itemId` is null; the unit otherwise comes from the item. */
uomId: number | null
qtyPerBatch: number
}
@@ -137,7 +150,7 @@ export interface SaveStageInputInput {
source: StageInputSource
itemId?: number | null
fromOutputKey?: string | null
uomId: number
qtyUnit: StageQtyUnit
qtyPerBatch: number
}
@@ -145,7 +158,8 @@ export interface SaveStageOutputInput {
key: string
itemId?: number | null
name: string
uomId: number
/** Required when `itemId` is null (WIP); must be null when it is set. */
uomId?: number | null
qtyPerBatch: number
}
@@ -200,13 +214,14 @@ export interface RunStageInput {
source: StageInputSource
itemId: number | null
fromRunOutputId: number | null
uomId: number
/** In the input's *declared* UOM. Editable until the stage starts. */
qtyUnit: StageQtyUnit
/** Expressed in `qtyUnit`. Editable until the stage starts. */
plannedQty: number
/**
* The four figures below are in the item's *base* UOM — the only unit the FIFO engine and
* the ledger speak. A stage input declared in "box of 12" therefore shows plannedQty 3 and
* consumedQty 36. Do not compare them to plannedQty without converting.
* The four figures below are always a count of the item's *base* UOM — the only unit the
* FIFO engine and the ledger speak. For a `Content` input they are therefore in different
* units from plannedQty: 2000 ml planned against a 500 ml bottle shows consumedQty 4. Do
* not compare them to plannedQty without dividing by the item's content size.
*/
consumedQty: number
consumedValue: number
@@ -220,7 +235,9 @@ export interface RunStageOutput {
runOutputId: number
itemId: number | null
name: string
uomId: number
/** WIP display label; null on the terminal output, whose unit is the finished item's base UOM. */
uomId: number | null
/** Always a pack count — scrap is recorded in whole units, never in ml or g. */
plannedQty: number
producedQty: number
scrappedQty: number
-5
View File
@@ -11,7 +11,6 @@ export interface SalesInvoiceLine {
description: string
qty: number
freeQty: number
uomId: number
warehouseId: number
unitPrice: number
baseCost: number
@@ -101,7 +100,6 @@ export interface SalesInvoice extends SalesInvoiceSummary {
export interface CreateSalesInvoiceLineRequest {
itemId: number
uomId: number
warehouseId: number
qty: number
freeQty: number
@@ -131,7 +129,6 @@ export interface SalesSlipLine {
description: string
qty: number
freeQty: number
uomId: number
warehouseId: number
unitPrice: number
baseCost: number
@@ -186,7 +183,6 @@ export interface SalesSlip {
export interface CreateSalesSlipLineRequest {
itemId: number
uomId: number
warehouseId: number
qty: number
freeQty: number
@@ -257,7 +253,6 @@ export interface FreeIssueSummary {
itemId: number
itemSku: string
itemName: string
uomId: number
uomName: string
qty: number
freeQty: number