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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user