Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -338,6 +338,8 @@ export interface CreateRunRequest {
|
||||
export interface StageQuantityLine {
|
||||
id: number
|
||||
plannedQty: number
|
||||
/** Required only when this lowers a running Stock input below what it has already consumed. */
|
||||
reasonCodeId?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateStageQuantitiesRequest {
|
||||
|
||||
Reference in New Issue
Block a user