Saltar al contenido principal

Feature 01 — Variant Matrix Editor

Phase: 1 (Foundation) Priority: 🔴 Critical — most important missing piece for apparel retail Status: ⏳ Pending

Context

Products currently use a single-SKU model. The product table has direct FKs to color, size, and style, but cannot express "Red / Size M" as a distinct traceable unit. Apparel requires each size × color combination to have its own SKU, barcode, and per-location inventory level.

Existing foundation:

  • color table: id, name, hex_value, color_code, business_id
  • size table: id, name, size_code, business_id
  • product table: has color_id, size_id FK fields (single values, not variants)
  • inventory table: tracks stock per (location_id, product_id) — must be extended to (location_id, product_id, variant_id)

Relevant existing files:

  • apps/backend/src/products/ — Products module
  • apps/backend/src/inventories/infrastructure/inventories.repository.ts
  • apps/frontend-pwa/src/components/forms/product/ProductPage.tsx
  • apps/frontend-pwa/src/components/forms/product/ProductAttributes.tsx (reference pattern)
  • apps/frontend-pwa/src/services/productService.ts

Task Checklist

Database

  • Create migration file: packages/backend/database/src/migrations/2026-03-XX-product-variants.mjs
  • Create product_variant table (see schema below)
  • Alter inventory table: add variant_id column + update unique constraint
  • Add indexes
  • Run pnpm run migration:local:push
  • Run pnpm run generate:types

Backend

  • Create module folder: apps/backend/src/product-variants/
  • Create product-variants.module.ts
  • Create domain/product-variants-repository.domain.ts
  • Create infrastructure/product-variants.repository.ts
  • Create application/product-variants.service.ts (including bulkCreateVariants)
  • Create interfaces/product-variants.controller.ts
  • Create interfaces/dtos/create-product-variant.dto.ts
  • Create interfaces/dtos/update-product-variant.dto.ts
  • Create interfaces/dtos/bulk-create-product-variants.dto.ts
  • Create interfaces/query/paginate-product-variants.query.ts
  • Register ProductVariantsModule in apps/backend/src/app.module.ts
  • Extend inventories.repository.ts — queries optionally filter by variantId
  • Extend products.service.ts — include variant count in product response

PWA Frontend

  • Create apps/frontend-pwa/src/types/productVariant.ts
  • Create apps/frontend-pwa/src/services/productVariantService.ts
  • Create apps/frontend-pwa/src/components/forms/product/VariantMatrixEditor.tsx
  • Extend ProductPage.tsx — add "Manage Variants" tab/button
  • Register form route in apps/frontend-pwa/src/pages/MainPage.tsx if needed

Verification

  • Migration applies cleanly
  • Types regenerated
  • Backend builds without TS errors
  • POST /product-variants/bulk creates all size×color combinations
  • GET /product-variants?parentProductId=<id> returns variant list
  • Matrix editor renders in ProductPage
  • Saving variants creates correct DB rows (verify in pgAdmin)

Database Schema

New Table: product_variant

// packages/backend/database/src/migrations/2026-03-XX-product-variants.mjs
import { Kysely, sql } from "kysely";

export async function up(db) {
await db.schema
.createTable("product_variant")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("parent_product_id", "uuid", (col) => col.notNull().references("product.id").onDelete("cascade"))
.addColumn("name", "varchar", (col) => col.notNull()) // e.g. "Red / M"
.addColumn("sku", "varchar")
.addColumn("barcode", "varchar")
.addColumn("color_id", "uuid", (col) => col.references("color.id").onDelete("set null"))
.addColumn("size_id", "uuid", (col) => col.references("size.id").onDelete("set null"))
.addColumn("price", "numeric")
.addColumn("cost", "numeric")
.addColumn("currency_id", "uuid", (col) => col.references("currency.id").onDelete("set null"))
.addColumn("is_active", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("sort_order", "integer", (col) => col.defaultTo(0))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("created_by", "uuid", (col) => col.notNull().references("user.id").onDelete("cascade"))
.addColumn("updated_at", "timestamptz")
.addColumn("updated_by", "uuid", (col) => col.references("user.id").onDelete("set null"))
.execute();

// Indexes
await db.schema.createIndex("idx_product_variant_parent").on("product_variant").columns(["business_id", "parent_product_id"]).execute();
await db.schema.createIndex("idx_product_variant_sku").on("product_variant").columns(["business_id", "sku"]).where(sql`sku IS NOT NULL`).unique().execute();
await db.schema.createIndex("idx_product_variant_barcode").on("product_variant").columns(["business_id", "barcode"]).where(sql`barcode IS NOT NULL`).unique().execute();

// Extend inventory table
await db.schema.alterTable("inventory")
.addColumn("variant_id", "uuid", (col) => col.references("product_variant.id").onDelete("cascade"))
.execute();
}

export async function down(db) {
await db.schema.alterTable("inventory").dropColumn("variant_id").execute();
await db.schema.dropIndex("idx_product_variant_barcode").execute();
await db.schema.dropIndex("idx_product_variant_sku").execute();
await db.schema.dropIndex("idx_product_variant_parent").execute();
await db.schema.dropTable("product_variant").execute();
}

Backend Implementation

Domain Interface: product-variants-repository.domain.ts

export interface ProductVariant {
id: string;
businessId: string;
parentProductId: string;
name: string;
sku: string | null;
barcode: string | null;
colorId: string | null;
sizeId: string | null;
price: number | null;
cost: number | null;
currencyId: string | null;
isActive: boolean;
sortOrder: number;
createdAt: Date;
createdBy: string;
updatedAt: Date | null;
updatedBy: string | null;
}

export interface IProductVariantsRepository {
create(data: Insertable<ProductVariant>): Promise<ProductVariant>;
bulkCreate(data: Insertable<ProductVariant>[]): Promise<ProductVariant[]>;
findByParentProduct(parentProductId: string, businessId: string): Promise<ProductVariant[]>;
findById(id: string): Promise<ProductVariant | undefined>;
update(id: string, data: Partial<ProductVariant>): Promise<ProductVariant>;
softDelete(id: string): Promise<void>;
}

Service: product-variants.service.ts

@Injectable()
export class ProductVariantsService {
constructor(
private readonly repository: ProductVariantsRepository,
private readonly inventoriesRepository: InventoriesRepository,
) {}

async bulkCreateVariants(parentProductId: string, variants: BulkCreateVariantDTO[], businessId: string, createdBy: string) {
// 1. Validate parent product exists in this business
// 2. Build variant name from color + size labels
// 3. Bulk insert product_variant rows
// 4. For each existing inventory location of the parent product:
// create an inventory row for each variant (quantity = 0)
// 5. Return created variants
}

async getVariantsByProduct(parentProductId: string, businessId: string): Promise<ProductVariant[]> {
return this.repository.findByParentProduct(parentProductId, businessId);
}
}

Controller: product-variants.controller.ts

@Controller("product-variants")
export class ProductVariantsController {
@Post("bulk")
async bulkCreate(@Body() dto: BulkCreateProductVariantsDTO) { ... }

@Post()
async create(@Body() dto: CreateProductVariantDTO) { ... }

@Get()
async findAll(@Query() query: PaginateProductVariantsQuery) { ... }
// query.parentProductId is required filter

@Get(":id")
async findOne(@Param("id") id: string) { ... }

@Patch(":id")
async update(@Param("id") id: string, @Body() dto: UpdateProductVariantDTO) { ... }

@Delete(":id")
async remove(@Param("id") id: string, @Query("businessId") businessId: string) { ... }
}

DTOs

// create-product-variant.dto.ts
export class CreateProductVariantDTO {
@IsUUID() businessId: string;
@IsUUID() parentProductId: string;
@IsUUID() createdBy: string;
@IsString() @IsNotEmpty() name: string;
@IsOptional() @IsString() sku?: string;
@IsOptional() @IsString() barcode?: string;
@IsOptional() @IsUUID() colorId?: string;
@IsOptional() @IsUUID() sizeId?: string;
@IsOptional() @IsNumber() price?: number;
@IsOptional() @IsNumber() cost?: number;
@IsOptional() @IsUUID() currencyId?: string;
@IsOptional() @IsNumber() sortOrder?: number;
}

// bulk-create-product-variants.dto.ts
export class BulkCreateProductVariantsDTO {
@IsUUID() businessId: string;
@IsUUID() parentProductId: string;
@IsUUID() createdBy: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => VariantCellDTO)
variants: VariantCellDTO[];
}

export class VariantCellDTO {
@IsOptional() @IsUUID() colorId?: string;
@IsOptional() @IsUUID() sizeId?: string;
@IsOptional() @IsString() sku?: string;
@IsOptional() @IsString() barcode?: string;
@IsOptional() @IsNumber() price?: number;
@IsOptional() @IsNumber() cost?: number;
@IsBoolean() active: boolean;
}

PWA Frontend

Types: types/productVariant.ts

export interface ProductVariant {
id?: string;
businessId?: string;
parentProductId: string;
name: string;
sku?: string;
barcode?: string;
colorId?: string;
colorName?: string;
sizeId?: string;
sizeName?: string;
price?: number;
cost?: number;
currencyId?: string;
isActive: boolean;
sortOrder?: number;
}

export interface VariantMatrixCell {
colorId: string;
sizeId: string;
sku?: string;
barcode?: string;
price?: number;
cost?: number;
active: boolean;
}

Service: productVariantService.ts

const BASE_URL = `${API_URL}/product-variants`;

export async function getVariantsByProduct(token: string, businessId: string, parentProductId: string): Promise<ProductVariant[]>

export async function bulkCreateVariants(token: string, data: {
businessId: string;
parentProductId: string;
createdBy: string;
variants: VariantMatrixCell[];
}): Promise<ProductVariant[]>

export async function updateVariant(token: string, id: string, data: Partial<ProductVariant>): Promise<ProductVariant>

export async function deleteVariant(token: string, id: string, businessId: string): Promise<void>

Component: VariantMatrixEditor.tsx

UI Layout:

┌─────────────────────────────────────────────────────────┐
│ Select Sizes: [S] [M] [L] [XL] [XXL] │
│ Select Colors: [Red] [Blue] [Black] [White] │
│ [Generate Matrix] │
├────────────┬──────────┬──────────┬──────────┬──────────┤
│ │ Red │ Blue │ Black │ White │
├────────────┼──────────┼──────────┼──────────┼──────────┤
│ S │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │
│ M │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │
│ L │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │
│ XL │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │ ✓ [sku] │
└────────────┴──────────┴──────────┴──────────┴──────────┘
[Save All Variants]

Props:

interface VariantMatrixEditorProps {
parentProductId: string;
parentPrice?: number;
parentCost?: number;
currencyId: string;
onClose: () => void;
}

State:

  • selectedSizes: string[] — size IDs
  • selectedColors: string[] — color IDs
  • matrix: Record<\${sizeId}-${colorId}`, VariantMatrixCell>` — cell data
  • existingVariants: ProductVariant[] — already saved variants

Integration into ProductPage.tsx:

// Add alongside "Manage Attributes" button:
<Button onClick={() => setManagingVariantsFor(product.id)}>
Manage Variants ({product.variantCount ?? 0})
</Button>

{managingVariantsFor && (
<VariantMatrixEditor
parentProductId={managingVariantsFor}
parentPrice={selectedProduct?.price}
currencyId={selectedProduct?.currencyId}
onClose={() => setManagingVariantsFor(null)}
/>
)}