sku and item fix
This commit is contained in:
@@ -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'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'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
|
||||
|
||||
Reference in New Issue
Block a user