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
}
/**