feat: enhance stage quantity management for running stock inputs

- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
This commit is contained in:
2026-08-17 14:30:55 +05:30
parent ee9fa40edf
commit 1c5b05e315
6 changed files with 400 additions and 84 deletions
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
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 { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 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">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}