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