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:
colortable:id,name,hex_value,color_code,business_idsizetable:id,name,size_code,business_idproducttable: hascolor_id,size_idFK fields (single values, not variants)inventorytable: 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 moduleapps/backend/src/inventories/infrastructure/inventories.repository.tsapps/frontend-pwa/src/components/forms/product/ProductPage.tsxapps/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_varianttable (see schema below) - Alter
inventorytable: addvariant_idcolumn + 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(includingbulkCreateVariants) - 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
ProductVariantsModuleinapps/backend/src/app.module.ts - Extend
inventories.repository.ts— queries optionally filter byvariantId - 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.tsxif needed
Verification
- Migration applies cleanly
- Types regenerated
- Backend builds without TS errors
-
POST /product-variants/bulkcreates 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 IDsselectedColors: string[]— color IDsmatrix: Record<\${sizeId}-${colorId}`, VariantMatrixCell>` — cell dataexistingVariants: 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)}
/>
)}