sku and item fix

This commit is contained in:
2026-08-11 21:55:04 +05:30
parent 15ddac178c
commit a7ba3d3e04
15 changed files with 557 additions and 96 deletions
@@ -10,6 +10,7 @@ import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -33,6 +34,7 @@ export default function ItemTypesPage() {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<ItemType | null>(null)
const [name, setName] = useState("")
const [isMeasurable, setIsMeasurable] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
@@ -50,6 +52,7 @@ export default function ItemTypesPage() {
function openCreateDialog() {
setEditing(null)
setName("")
setIsMeasurable(false)
setErrors({})
setOpen(true)
}
@@ -57,6 +60,7 @@ export default function ItemTypesPage() {
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setIsMeasurable(itemType.isMeasurable)
setErrors({})
setOpen(true)
}
@@ -71,13 +75,16 @@ export default function ItemTypesPage() {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
// isMeasurable must travel on every PUT: the server preserves it when omitted, so a
// name-only body would leave the switch the user just flipped unsaved.
await itemTypesApi.update(editing.itemTypeId, { name, isMeasurable }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
await itemTypesApi.create({ name, isMeasurable })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setIsMeasurable(false)
setEditing(null)
setErrors({})
load()
@@ -112,6 +119,7 @@ export default function ItemTypesPage() {
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item&apos;s content size.
</p>
</div>
</div>
@@ -135,6 +143,23 @@ export default function ItemTypesPage() {
/>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<div className="flex items-start gap-4">
<Switch
checked={isMeasurable}
onCheckedChange={setIsMeasurable}
aria-label="Values are measurements"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Values are measurements</span>
<span className="text-sm text-muted-foreground">
Values are entered as a number plus a unit (500 ml, 1 L) and become each item&apos;s
content size. Leave off for plain labels like Red or Small.
</span>
</div>
</div>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -173,6 +198,7 @@ export default function ItemTypesPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Values</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -183,6 +209,9 @@ export default function ItemTypesPage() {
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
@@ -13,7 +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 { contentPairErrors, validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import {
contentPairErrors,
validateVariantContent,
validateVariantItemForm,
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
@@ -36,15 +41,43 @@ function skuSegment(text: string, maxLen: number): string {
return cleaned.slice(0, maxLen) || "GEN"
}
function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
/**
* A value on a builder dimension. `qty`/`unit` are set only on a measurable dimension;
* `label` is the single source of truth for the chip, the item name and the SKU segment.
*/
type DimensionValue = { label: string; qty: number | null; unit: MeasureUnit | null }
const isMeasured = (v: DimensionValue): v is DimensionValue & { qty: number; unit: MeasureUnit } =>
v.qty !== null && v.unit !== null
/** Canonical casing, so 1.5 L renders as "1.5L" and not "1.5l". */
const UNIT_LABEL: Record<MeasureUnit, string> = { Ml: "ml", L: "L", G: "g", Kg: "kg" }
const measuredLabel = (qty: number, unit: MeasureUnit) => `${qty}${UNIT_LABEL[unit]}`
/**
* Normalised identity of a measurement, mirroring the server's `ItemContent.Normalize`
* (L/Kg ×1000 → ml/g). Used for BOTH the SKU segment and value dedupe, so the client's
* notion of "same size" is the server's.
*
* The decimal point becomes `P` rather than being stripped: `skuSegment` deletes it, which
* would make 2.5ml and 25ml — and 1.5L and 15L — produce identical SKUs and fail the
* create loop partway with SKU_DUPLICATE.
*/
function measureKey(qty: number, unit: MeasureUnit): string {
const factor = unit === "L" || unit === "Kg" ? 1000 : 1
const base = unit === "L" || unit === "Ml" ? "ML" : "G"
const n = Math.round(qty * factor * 10000) / 10000
const digits = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")
return `${digits.replace(".", "P")}${base}`
}
/** The item builder only ever offers these two dimensions, regardless of what else exists
* in the Item Types master list. */
const BUILDER_ITEM_TYPES = ["color", "size"]
function isBuilderItemType(name: string): boolean {
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
function buildVariantSku(categoryLabel: string, values: DimensionValue[]): string {
return [
skuSegment(categoryLabel, 3),
// Measurement segments are deliberately NOT truncated — truncation is what collides.
...values.map((v) => (isMeasured(v) ? measureKey(v.qty, v.unit) : skuSegment(v.label, 3))),
].join("-")
}
export default function NewItemPage() {
@@ -63,6 +96,7 @@ export default function NewItemPage() {
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
@@ -73,8 +107,12 @@ export default function NewItemPage() {
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
// Measurable dimensions enter a number + unit instead of free text.
const [qtyByCategory, setQtyByCategory] = useState<Record<number, string>>({})
const [unitByCategory, setUnitByCategory] = useState<Record<number, MeasureUnit | null>>({})
const [valueErrors, setValueErrors] = useState<Record<number, string>>({})
// Lets a specific generated combination be dropped from the preview table before
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
@@ -128,11 +166,15 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
* item and supplies the SKU's leading segment.
*
* Category and subcategory still classify the item and are both sent to the server; they just
* no longer name it, which is what produced labels like "Beverages - 500ml".
*/
const productLabel = productName.trim()
function handleCategoryChange(value: number | null) {
setCategoryId(value)
@@ -145,22 +187,60 @@ export default function NewItemPage() {
)
}
/**
* Add is the real validation gate for a measurement — rejecting here means a half or
* duplicate pair never reaches state, so nothing downstream has to defend against one.
*/
function addValue(itemTypeId: number) {
const value = (inputByCategory[itemTypeId] ?? "").trim()
if (value) {
setValuesByCategory((prev) => {
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, value] }
})
}
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
}
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) {
const label = (inputByCategory[itemTypeId] ?? "").trim()
if (label) {
setValuesByCategory((prev) => {
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.label.toLowerCase() === label.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, { label, qty: null, unit: null }] }
})
}
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
return
}
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
const unit = unitByCategory[itemTypeId] ?? null
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
return
}
if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
return
}
// Dedupe on the NORMALISED size, not the label: 500 ml and 0.5 L read differently but
// store identically, so a label-only check would create two items of the same size.
const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
return
}
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
[itemTypeId]: [...(prev[itemTypeId] ?? []), { label: measuredLabel(qty, unit), qty, unit }],
}))
setQtyByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(itemTypeId: number, label: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v.label !== label),
}))
}
@@ -175,13 +255,13 @@ export default function NewItemPage() {
const allVariants = useMemo(() => {
if (activeCategories.length === 0) return []
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
let combinations: { key: string; parts: { name: string; value: DimensionValue }[] }[] = [{ key: "", parts: [] }]
for (const cat of activeCategories) {
const next: typeof combinations = []
for (const combo of combinations) {
for (const value of cat.values) {
next.push({
key: combo.key ? `${combo.key}::${value}` : value,
key: combo.key ? `${combo.key}::${value.label}` : value.label,
parts: [...combo.parts, { name: cat.name, value }],
})
}
@@ -190,9 +270,9 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
sku: buildVariantSku(productLabel, c.parts.map((p) => p.value)),
}))
}, [activeCategories, effectiveLabel])
}, [activeCategories, productLabel])
const variants = useMemo(
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
@@ -203,12 +283,76 @@ export default function NewItemPage() {
setRemovedVariantKeys((prev) => new Set(prev).add(key))
}
/**
* True once a checked dimension supplies each variant's content size. The form-level
* content field is then hidden and cleared, so the two can never disagree.
*/
const measurableCheckedCount = (itemTypes ?? []).filter(
(t) => checkedItemTypeIds.includes(t.itemTypeId) && t.isMeasurable
).length
// Keyed off *checked*, not "checked and already has values": the moment a measurement
// dimension is ticked the form-level pair is irrelevant, so it should not linger while the
// first value is being typed.
const hasMeasurableDimension = measurableCheckedCount > 0
/**
* The shared content pair is offered only when it is the ONLY way to set a content size:
* dimensions are chosen, and none of them measures anything (a Colour-only product, whose
* variants really do all hold the same amount).
*
* Deliberately hidden on the untouched page. Showing it there invites someone to fill in one
* size before discovering that ticking Size would have captured a size per value — which
* reads as though sizes still apply to every variant at once.
*/
const showSharedContent = checkedItemTypeIds.length > 0 && !hasMeasurableDimension
useEffect(() => {
if (!hasMeasurableDimension) return
setContentQty("")
setContentUnit(null)
}, [hasMeasurableDimension])
/** A variant's own measured part, else the form-level fallback pair. */
function contentForVariant(variant: { parts: { value: DimensionValue }[] }) {
const measured = variant.parts.find((p) => isMeasured(p.value))
if (measured && isMeasured(measured.value)) {
return { qty: measured.value.qty, unit: measured.value.unit }
}
// Only fall back to the shared pair when that pair is the one on offer, so a value left
// behind by an earlier selection can never be applied invisibly.
if (!showSharedContent || !contentQty.trim() || !contentUnit) return null
return { qty: Number(contentQty), unit: contentUnit }
}
function contentLabelFor(variant: { parts: { value: DimensionValue }[] }) {
const content = contentForVariant(variant)
return content ? measuredLabel(content.qty, content.unit) : "—"
}
async function handleSubmit() {
setSubmitError(null)
const nextErrors = {
...validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }),
...contentPairErrors(contentQty, contentUnit),
const nextErrors: Record<string, string> = {
...validateVariantItemForm({ productName, categoryId, hasVariants: variants.length > 0 }),
// Validate the shared pair exactly when it is on screen. Validating it while hidden
// would block submit with a message the user cannot see.
...(showSharedContent ? contentPairErrors(contentQty, contentUnit) : {}),
}
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
(key) => {
const variant = variants.find((v) => v.key === key)
const content = variant ? contentForVariant(variant) : null
return content
? { qty: String(content.qty), unit: content.unit }
: { qty: "", unit: null }
},
)
const sweepMessage = Object.values(contentSweep)[0]
if (sweepMessage) nextErrors.variantContent = sweepMessage
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
@@ -224,9 +368,10 @@ export default function NewItemPage() {
let created = 0
try {
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
@@ -237,8 +382,11 @@ 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,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
contentQty: content?.qty ?? null,
contentUnit: content?.unit ?? null,
})
created += 1
}
@@ -290,6 +438,22 @@ export default function NewItemPage() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Product name</Label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="e.g. Coca Cola"
aria-invalid={!!errors.productName}
className="h-12! text-base"
/>
<FieldError errors={[errors.productName ? { message: errors.productName } : undefined]} />
<p className="text-sm text-muted-foreground">
Names every variant and starts its SKU
{productLabel ? ` — "${brandLabel ? brandLabel + " " : ""}${productLabel} - 500ml", SKU ${skuSegment(productLabel, 3)}-…` : "."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -394,7 +558,10 @@ export default function NewItemPage() {
</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. */}
or a label has none. Litres/kilograms are normalised to ml/g by the server.
Hidden once a measurement dimension is in play, because that dimension then
gives each variant its own size and this pair would only contradict it. */}
{showSharedContent && (
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
@@ -432,9 +599,11 @@ export default function NewItemPage() {
]}
/>
<p className="text-sm text-muted-foreground">
Leave blank for items with no measurable content. Stock is still counted in the base UOM.
Applies to every variant. Leave blank for items with no measurable content stock is
counted in the base UOM either way.
</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)}>
@@ -512,23 +681,54 @@ export default function NewItemPage() {
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
measurement out of the box, so the builder silently falls back to one shared
content size and the user has no reason to suspect another page is involved. */}
{(itemTypes ?? []).length > 0 && !(itemTypes ?? []).some((t) => t.isMeasurable) && (
<p className="mt-2 text-sm text-muted-foreground">
Selling the same product in several sizes (500 ml, 1 L)?{" "}
<Link href="/dashboard/products/item-types" className="font-medium underline">
Mark that item type as a measurement
</Link>{" "}
and each size will capture its own number and unit, giving every variant its own content size.
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
{(itemTypes ?? [])
.filter((t) => isBuilderItemType(t.name))
.map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two.
const blocked = t.isMeasurable && !checked && measurableCheckedCount > 0
return (
<label
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
<Checkbox
checked={checkedItemTypeIds.includes(t.itemTypeId)}
checked={checked}
disabled={blocked}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{t.name}</span>
{t.isMeasurable && (
<Badge variant="outline" className="text-xs">measurement</Badge>
)}
</label>
))}
)
})}
</div>
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
<FieldError
errors={[
errors.variants ? { message: errors.variants } : undefined,
errors.measurable ? { message: errors.measurable } : undefined,
]}
/>
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -540,33 +740,79 @@ export default function NewItemPage() {
return (
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
<div className="flex gap-2">
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(t.itemTypeId)
}
}}
placeholder={t.name}
className="h-11 text-base"
/>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add {t.name}
</Button>
</div>
{t.isMeasurable ? (
// A measurement is entered as a number + unit; the chip label, the SKU
// segment and the item's stored content size all derive from this pair.
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={qtyByCategory[t.itemTypeId] ?? ""}
onChange={(e) => setQtyByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(t.itemTypeId)
}
}}
placeholder="e.g. 500"
aria-invalid={!!valueErrors[t.itemTypeId]}
className="h-11 text-base"
/>
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(t.itemTypeId)
}
}}
placeholder={t.name}
className="h-11 text-base"
/>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add {t.name}
</Button>
</div>
)}
<FieldError
errors={[valueErrors[t.itemTypeId] ? { message: valueErrors[t.itemTypeId] } : undefined]}
/>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v}
<Badge key={v.label} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v.label}
<button
type="button"
onClick={() => removeValue(t.itemTypeId, v)}
onClick={() => removeValue(t.itemTypeId, v.label)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${v}`}
aria-label={`Remove ${v.label}`}
>
<X className="size-3" />
</button>
@@ -579,6 +825,10 @@ export default function NewItemPage() {
</div>
)}
{/* Form-level, not per-row: a variant's content is derived, so there is no cell to
attach this to. Should never appear — addValue rejects a bad pair at entry. */}
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
@@ -588,6 +838,7 @@ export default function NewItemPage() {
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
)}
@@ -603,10 +854,15 @@ export default function NewItemPage() {
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value}
{part.value.label}
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5 text-muted-foreground">
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
@@ -81,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
@@ -95,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,
+14 -3
View File
@@ -223,13 +223,20 @@ export interface UpdateBrandRequest {
* Item type master (docs/11 §2.7) — a dimension *name* such as Color, Size or Material.
* Formerly `VariantCategory` in this app.
*
* Nothing links an item to one of these: it exists only to populate the builder's
* dropdown. The chosen values are baked into the SKU client-side. Not to be confused with
* {@link StockNature}, which is what the old `itemType` enum became.
* Nothing links an item to one of these, and the chosen values are baked into the SKU
* client-side. It is no longer *only* a dropdown source, though: `isMeasurable` changes how
* the builder captures values. Not to be confused with {@link StockNature}, which is what
* the old `itemType` enum became.
*/
export interface ItemType {
itemTypeId: number
name: string
/**
* True ⇒ this dimension's values are content measurements (500 ml, 1 L), so the builder
* captures a number + unit per value and writes it to each generated item's content size.
* False ⇒ plain labels (Red, S) — which is what an apparel "Size" wants.
*/
isMeasurable: boolean
status: EntityStatus
createdAt: string
updatedAt: string | null
@@ -237,10 +244,14 @@ export interface ItemType {
export interface CreateItemTypeRequest {
name: string
/** Omitted ⇒ false. */
isMeasurable?: boolean
}
export interface UpdateItemTypeRequest {
name: string
/** Omitted ⇒ the stored value is preserved. Always send it from a form that shows it. */
isMeasurable?: boolean
}
/**