feat(admin): implement variant management — list, create, edit, preview, activate/deactivate, delete (Plan 05)

- Extend addVariant with dimension fields and SKU uniqueness check; expand updateVariant to full field set; update getByIdForAdmin to return all variants (active + inactive)
- Add generateSku utility to @repo/utils; auto-generates SKU from brand, product name, attributes, and weight with manual-override support
- Move ProductSearchSection to components/shared and fix nav link /variants → /variant
- Variants page: product search, loading skeleton, variants table, toolbar with create button
- VariantsTable: 8 columns, activate/deactivate toggle, delete with AlertDialog confirmation
- VariantPreviewDialog: read-only full variant details with sections for pricing, inventory, shipping, attributes
- VariantForm: zod schema with superRefine for dimension and on-sale validation, auto-SKU generation
- CreateVariantDialog and EditVariantDialog wiring dollarsToCents on submit
- Install sonner and add Toaster to root layout; install ShadCN Switch component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:32:32 +03:00
parent 1ea527ca1f
commit 8e4309892c
17 changed files with 1818 additions and 8 deletions

View File

@@ -561,7 +561,24 @@ export const getByIdForAdmin = query({
args: { id: v.id("products") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
return getProductWithRelations(ctx, id);
const product = await ctx.db.get(id);
if (!product) return null;
const [imagesRaw, variants, category] = await Promise.all([
ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", id))
.collect(),
// Admin sees ALL variants (active and inactive)
ctx.db
.query("productVariants")
.withIndex("by_product", (q) => q.eq("productId", id))
.collect(),
ctx.db.get(product.categoryId),
]);
const images = imagesRaw.sort((a, b) => a.position - b.position);
return { ...product, images, variants, category };
},
});
@@ -906,11 +923,20 @@ export const addVariant = mutation({
v.literal("oz"),
),
),
length: v.optional(v.number()),
width: v.optional(v.number()),
height: v.optional(v.number()),
dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in"))),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
const product = await ctx.db.get(args.productId);
if (!product) throw new Error("Product not found");
const existingSku = await ctx.db
.query("productVariants")
.withIndex("by_sku", (q) => q.eq("sku", args.sku))
.unique();
if (existingSku) throw new Error(`SKU "${args.sku}" is already in use`);
return await ctx.db.insert("productVariants", {
productId: args.productId,
name: args.name,
@@ -922,6 +948,10 @@ export const addVariant = mutation({
isActive: args.isActive,
weight: args.weight ?? 0,
weightUnit: args.weightUnit ?? "g",
...(args.length !== undefined && { length: args.length }),
...(args.width !== undefined && { width: args.width }),
...(args.height !== undefined && { height: args.height }),
...(args.dimensionUnit !== undefined && { dimensionUnit: args.dimensionUnit }),
});
},
});
@@ -929,16 +959,45 @@ export const addVariant = mutation({
export const updateVariant = mutation({
args: {
id: v.id("productVariants"),
name: v.optional(v.string()),
sku: v.optional(v.string()),
price: v.optional(v.number()),
compareAtPrice: v.optional(v.number()),
stockQuantity: v.optional(v.number()),
isActive: v.optional(v.boolean()),
weight: v.optional(v.number()),
weightUnit: v.optional(
v.union(
v.literal("g"),
v.literal("kg"),
v.literal("lb"),
v.literal("oz"),
),
),
attributes: variantAttributesValidator,
length: v.optional(v.number()),
width: v.optional(v.number()),
height: v.optional(v.number()),
dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in"))),
},
handler: async (ctx, { id, ...updates }) => {
handler: async (ctx, { id, sku, ...updates }) => {
await Users.requireAdmin(ctx);
const variant = await ctx.db.get(id);
if (!variant) throw new Error("Variant not found");
// Enforce SKU uniqueness if sku is being changed
if (sku !== undefined && sku !== variant.sku) {
const existing = await ctx.db
.query("productVariants")
.withIndex("by_sku", (q) => q.eq("sku", sku))
.unique();
if (existing && existing._id !== id) {
throw new Error(`SKU "${sku}" is already in use by another variant`);
}
}
const fields: Record<string, unknown> = {};
if (sku !== undefined) fields.sku = sku;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) fields[key] = value;
}