Files
av-planner/src/data/catalogRowMapping.ts
T
aarbitandClaude Sonnet 5 29c3ed46a3 Add manufacturer catalog, category/manufacturer library management, and built-in cable flag
Normalizes manufacturer as a shared catalog entity (like device categories)
instead of free text on each device, giving the admin duplicate-detection
nudge a reliable signal. Adds full CRUD (including Admin direct-publish,
bypassing the submission queue) for categories, manufacturers, port types,
and cable types, plus a Categories & Manufacturers library modal and a
browse-by-manufacturer/search view in the device palette. Adds
Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB
lead, a budget AVR's power cord) can be flagged and excluded from the BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:05 -05:00

127 lines
4.6 KiB
TypeScript

import type { CatalogEntityType } from './SubmissionRepository'
import type { CableType, DeviceTemplate, PortType } from '../domain/types'
/**
* Maps domain objects to the shape of their Supabase table row (snake_case
* columns, no id/is_public/owner_id). Shared by two call sites that both
* need a row-shaped payload: SupabaseCatalogRepository's inserts, and
* catalog submissions' `proposed_data` (see SubmissionRepository) — a
* submission's proposed_data is deliberately row-shaped so approving it is
* just writing that object onto the row at `entity_id`, not a translation
* step an Admin's approval action would otherwise have to duplicate.
*/
/** Which table backs each catalog entity type — shared by
* SupabaseAdminSubmissionRepository (approving into the right table) and
* SupabaseCatalogRepository (direct Admin unpublish). */
export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
device_template: 'device_templates',
port_type: 'port_types',
cable_type: 'cable_types',
device_category: 'device_categories',
manufacturer: 'manufacturers',
}
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: portType.name,
category: portType.category,
family: portType.family,
compatible_family_ids: portType.compatibleFamilyIds ?? [],
max_connections: portType.maxConnections ?? null,
}
}
export function cableTypeToRow(cableType: Omit<CableType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: cableType.name,
family: cableType.family,
family2: cableType.family2 ?? null,
unit: cableType.unit,
cost_per_unit: cableType.costPerUnit ?? null,
}
}
export function deviceCategoryToRow(name: string): Record<string, unknown> {
return { name }
}
export function manufacturerToRow(name: string): Record<string, unknown> {
return { name }
}
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
return {
name: template.name,
category_id: template.category,
manufacturer_id: template.manufacturerId ?? null,
model: template.model ?? null,
cost: template.cost ?? null,
ports: template.ports.map((port, index) => ({
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
}
}
/**
* The reverse direction: turns a `catalog_submissions.proposed_data` row
* back into domain-shaped fields, so a rejected/pending submission can be
* reopened in the same form component that created it (see MySubmissionsModal)
* instead of needing a bespoke "edit a raw JSON blob" UI. Safe because
* proposed_data is always a *complete* row projection (every field of the
* type, per the `*ToRow` functions above), never a partial patch.
*/
export function rowToPortTypeFields(row: Record<string, unknown>): Omit<PortType, 'id' | 'custom'> {
const compatibleFamilyIds = row.compatible_family_ids as string[] | undefined
return {
name: row.name as string,
category: row.category as PortType['category'],
family: row.family as string,
compatibleFamilyIds: compatibleFamilyIds && compatibleFamilyIds.length > 0 ? compatibleFamilyIds : undefined,
maxConnections: (row.max_connections as number | null) ?? undefined,
}
}
export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableType, 'id' | 'custom'> {
return {
name: row.name as string,
family: row.family as string,
family2: (row.family2 as string | null) ?? undefined,
unit: row.unit as CableType['unit'],
costPerUnit: (row.cost_per_unit as number | null) ?? undefined,
}
}
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
const ports =
(row.ports as Array<{
name: string
direction: DeviceTemplate['ports'][number]['direction']
port_type_id: string
built_in_cable?: boolean
}>) ?? []
return {
name: row.name as string,
category: row.category_id as string,
manufacturerId: (row.manufacturer_id as string | null) ?? undefined,
model: (row.model as string | null) ?? undefined,
cost: (row.cost as number | null) ?? undefined,
ports: ports.map((port, index) => ({
id: `draft-port-${index}`,
name: port.name,
direction: port.direction,
portTypeId: port.port_type_id,
builtInCable: port.built_in_cable ?? false,
})),
}
}
export function rowToDeviceCategoryName(row: Record<string, unknown>): string {
return row.name as string
}