Merge branch 'Dev' into sales-return
This commit is contained in:
@@ -10,8 +10,6 @@ import {
|
||||
TrackingMode,
|
||||
UpdateItemReorderRequest,
|
||||
UpdateItemRequest,
|
||||
UpdateUomConversionsRequest,
|
||||
UpdateUomConversionsResponse,
|
||||
} from "@/types/master-data"
|
||||
|
||||
export interface ListItemsParams {
|
||||
@@ -59,11 +57,4 @@ export const itemsApi = {
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Document lines carry no UOM of their own: every quantity in the system is a count of the
|
||||
// item's base UOM (FR-MD-02/03). Screens that used to render a per-line UOM picker now show
|
||||
// the unit as a derived, read-only label — the user still needs to know that "12" means
|
||||
// 12 bottles, they just cannot change it.
|
||||
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
|
||||
/** Minimal shapes so this works with both `Item` and `ItemListItem`. */
|
||||
type ItemLike = Pick<ItemListItem, "itemId" | "baseUomId">
|
||||
|
||||
/**
|
||||
* Display name of an item's base UOM, e.g. "BOTTLE". Returns an em dash when no item is
|
||||
* selected yet, and falls back to the raw id if the UOM list has not loaded.
|
||||
*/
|
||||
export function baseUomLabel(
|
||||
items: readonly ItemLike[],
|
||||
uoms: readonly Uom[],
|
||||
itemId: number | null | undefined,
|
||||
): string {
|
||||
if (!itemId) return "—"
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
if (!item) return "—"
|
||||
return uoms.find((u) => u.uomId === item.baseUomId)?.name ?? `#${item.baseUomId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a UOM id that may be absent — an intermediate production output's WIP
|
||||
* label, which is null on any output that references a real item.
|
||||
*/
|
||||
export function uomLabel(uoms: readonly Uom[], uomId: number | null | undefined): string {
|
||||
if (!uomId) return "—"
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit an item's content is measured in — "ml" or "g" — or null when the item has no
|
||||
* content size. Used to label a production input entered in content units.
|
||||
*/
|
||||
export function contentUnitLabel(
|
||||
items: readonly Pick<ItemListItem, "itemId" | "contentBaseUnit">[],
|
||||
itemId: number | null | undefined,
|
||||
): string | null {
|
||||
if (!itemId) return null
|
||||
const unit = items.find((candidate) => candidate.itemId === itemId)?.contentBaseUnit
|
||||
return unit ? unit.toLowerCase() : null
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export const grnHeaderSchema = z.object({
|
||||
|
||||
export function validateLine(input: {
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
discountPct: string
|
||||
@@ -25,7 +24,6 @@ export function validateLine(input: {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
if (!input.uomId) errors.uomId = "Select a UOM"
|
||||
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
|
||||
@@ -10,12 +10,38 @@ export function validateItemForm(input: {
|
||||
name: string
|
||||
categoryId: number | null
|
||||
baseUomId: number | null
|
||||
/** Raw field value; empty means "no measurable content", which is valid. */
|
||||
contentQty?: string
|
||||
contentUnit?: string | null
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.sku.trim()) errors.sku = "SKU is required"
|
||||
if (!input.name.trim()) errors.name = "Item name is required"
|
||||
if (!input.categoryId) errors.categoryId = "Select a category"
|
||||
if (!input.baseUomId) errors.baseUomId = "Select a base UOM"
|
||||
Object.assign(errors, contentPairErrors(input.contentQty, input.contentUnit))
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* An item's content size is optional, but half of it is not: the server rejects a lone
|
||||
* quantity or a lone unit with a 422. Shared because the variant builder validates its own
|
||||
* shape and so cannot go through `validateItemForm`.
|
||||
*/
|
||||
export function contentPairErrors(
|
||||
contentQty: string | undefined,
|
||||
contentUnit: string | null | undefined,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
const rawQty = (contentQty ?? "").trim()
|
||||
const unit = contentUnit ?? null
|
||||
|
||||
if (rawQty && !unit) errors.contentUnit = "Select a unit for the content size"
|
||||
if (!rawQty && unit) errors.contentQty = "Enter a content size, or clear the unit"
|
||||
if (rawQty) {
|
||||
const qty = Number(rawQty)
|
||||
if (Number.isNaN(qty) || qty <= 0) errors.contentQty = "Content size must be greater than 0"
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -29,16 +55,6 @@ export function validateReorderLine(input: { warehouseId: number | null; reorder
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateConversionLine(input: { fromUom: number | null; toUom: number | null; factor: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.fromUom) errors.fromUom = "Select a UOM"
|
||||
if (!input.toUom) errors.toUom = "Select a UOM"
|
||||
if (input.fromUom && input.toUom && input.fromUom === input.toUom) errors.toUom = "From and to UOM must differ"
|
||||
const factor = Number(input.factor)
|
||||
if (!input.factor || Number.isNaN(factor) || factor <= 0) errors.factor = "Factor must be greater than 0"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateUomName(name: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!name.trim()) errors.name = "UOM name is required"
|
||||
@@ -65,10 +81,14 @@ export function validateItemTypeName(name: string): Record<string, string> {
|
||||
}
|
||||
|
||||
export function validateVariantItemForm(input: {
|
||||
productName: string
|
||||
categoryId: number | null
|
||||
hasVariants: boolean
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
// Required: it names every generated item and leads its SKU, and neither reads sensibly
|
||||
// when derived from the category instead ("Beverages - 500ml").
|
||||
if (!input.productName.trim()) errors.productName = "Enter a product name"
|
||||
if (!input.categoryId) errors.categoryId = "Select a category"
|
||||
if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values"
|
||||
return errors
|
||||
@@ -79,6 +99,29 @@ export function validateVariantItemForm(input: {
|
||||
* (docs/20 §3.1). Returns a map keyed by variant key → message; empty when valid.
|
||||
* In "stock" mode there is nothing to validate (prices are sent as null).
|
||||
*/
|
||||
/**
|
||||
* Belt-and-braces sweep over the generated variants: each one's resolved content pair must be
|
||||
* whole or wholly absent. It should never fire — `addValue` rejects a half or duplicate pair at
|
||||
* entry — so it exists to catch stale state, not to guide the user.
|
||||
*
|
||||
* Keyed by variant key to mirror {@link validateVariantPrices}, but surfaced as ONE form-level
|
||||
* message: content is *derived*, so unlike a price there is no per-row control to attach an
|
||||
* error to.
|
||||
*/
|
||||
export function validateVariantContent(
|
||||
variantKeys: string[],
|
||||
contentFor: (key: string) => { qty: string; unit: string | null },
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
for (const key of variantKeys) {
|
||||
const { qty, unit } = contentFor(key)
|
||||
const pair = contentPairErrors(qty, unit)
|
||||
const message = pair.contentQty ?? pair.contentUnit
|
||||
if (message) errors[key] = message
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateVariantPrices(
|
||||
variantKeys: string[],
|
||||
priceFor: (key: string) => string,
|
||||
|
||||
@@ -32,7 +32,6 @@ export function validateQuotationLine(input: { unitPrice: string; leadDays: stri
|
||||
|
||||
export function validatePoLine(input: {
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
@@ -40,7 +39,6 @@ export function validatePoLine(input: {
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
if (!input.uomId) errors.uomId = "Select a UOM"
|
||||
if (!input.warehouseId) errors.warehouseId = "Select a warehouse"
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
|
||||
Reference in New Issue
Block a user