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
This commit is contained in:
2026-09-28 10:04:05 -05:00
co-authored by Claude Sonnet 5
parent 00777077c2
commit 29c3ed46a3
28 changed files with 2201 additions and 320 deletions
+153 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
import type { CatalogRepository } from '../data/CatalogRepository'
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from '../data/catalogRowMapping'
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../data/catalogRowMapping'
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
import type { CatalogEntityType } from '../data/SubmissionRepository'
import { useSubmissionStore } from './submissionStore'
@@ -29,7 +29,7 @@ function saveHiddenPublicIds(ids: Set<string>): void {
}
}
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], deviceTemplates: [] }
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], manufacturers: [], deviceTemplates: [] }
interface CatalogStoreState {
catalog: Catalog
@@ -39,12 +39,23 @@ interface CatalogStoreState {
loadCatalog: () => Promise<void>
addCustomDeviceCategory: (name: string) => Promise<string>
updateCustomDeviceCategory: (id: string, name: string) => Promise<void>
removeCustomDeviceCategory: (id: string) => Promise<void>
addCustomManufacturer: (name: string) => Promise<string>
updateCustomManufacturer: (id: string, name: string) => Promise<void>
removeCustomManufacturer: (id: string) => Promise<void>
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
/** Throws (like removeCustomDeviceCategory) if the delete is rejected —
* most commonly a foreign-key violation because a device template's port
* still references it. */
removeCustomPortType: (id: string) => Promise<void>
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
removeCustomCableType: (id: string) => Promise<void>
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
@@ -53,15 +64,35 @@ interface CatalogStoreState {
hidePublicDeviceTemplate: (id: string) => void
restorePublicDeviceTemplate: (id: string) => void
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow, for authoring the shared library yourself (see
* CatalogRepository.adminAddDeviceCategory's own comment). */
adminAddDeviceCategory: (name: string) => Promise<string>
adminAddManufacturer: (name: string) => Promise<string>
adminAddPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
adminAddCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
adminAddDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
/** Admin/Super-Admin direct edit of an already-public entry, per
* organized-ideas.md §6 — bypasses the submission/review workflow
* entirely (RLS's is_admin() clause is what actually permits it). */
adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
adminUpdateCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceCategory: (id: string, name: string) => Promise<void>
adminUpdateManufacturer: (id: string, name: string) => Promise<void>
/** Unpublishes (is_public -> false) rather than deletes — see
* CatalogRepository.adminUnpublish. */
adminUnpublish: (entityType: CatalogEntityType, id: string) => Promise<void>
/** Permanent delete of a built-in entry — see
* CatalogRepository.adminRemoveDeviceCategory's own comment for the
* zero-usage contract the caller is expected to have already checked. */
adminRemoveDeviceCategory: (id: string) => Promise<void>
adminRemoveManufacturer: (id: string) => Promise<void>
adminRemovePortType: (id: string) => Promise<void>
adminRemoveCableType: (id: string) => Promise<void>
adminRemoveDeviceTemplate: (id: string) => Promise<void>
}
export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
@@ -80,11 +111,70 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
return category.id
},
updateCustomDeviceCategory: async (id, name) => {
await repository.updateDeviceCategory(id, name)
set((state) => ({
catalog: {
...state.catalog,
deviceCategories: state.catalog.deviceCategories.map((c) => (c.id === id ? { ...c, name } : c)),
},
}))
useSubmissionStore.getState().syncProposedData('device_category', id, deviceCategoryToRow(name))
},
removeCustomDeviceCategory: async (id) => {
await repository.removeDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminAddDeviceCategory: async (name) => {
const category = await repository.adminAddDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
return category.id
},
addCustomManufacturer: async (name) => {
const manufacturer = await repository.addManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
updateCustomManufacturer: async (id, name) => {
await repository.updateManufacturer(id, name)
set((state) => ({
catalog: {
...state.catalog,
manufacturers: state.catalog.manufacturers.map((m) => (m.id === id ? { ...m, name } : m)),
},
}))
useSubmissionStore.getState().syncProposedData('manufacturer', id, manufacturerToRow(name))
},
removeCustomManufacturer: async (id) => {
await repository.removeManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminAddManufacturer: async (name) => {
const manufacturer = await repository.adminAddManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
addCustomPortType: async (portType) => {
const created = await repository.addPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
adminAddPortType: async (portType) => {
const created = await repository.adminAddPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
updateCustomPortType: async (id, patch) => {
await repository.updatePortType(id, patch)
set((state) => ({
@@ -97,11 +187,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('port_type', id, portTypeToRow(updated))
},
removeCustomPortType: async (id) => {
await repository.removePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
addCustomCableType: async (cableType) => {
const created = await repository.addCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
adminAddCableType: async (cableType) => {
const created = await repository.adminAddCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
updateCustomCableType: async (id, patch) => {
await repository.updateCableType(id, patch)
set((state) => ({
@@ -114,11 +214,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('cable_type', id, cableTypeToRow(updated))
},
removeCustomCableType: async (id) => {
await repository.removeCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
addCustomDeviceTemplate: async (template) => {
const created = await repository.addDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
adminAddDeviceTemplate: async (template) => {
const created = await repository.adminAddDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
updateCustomDeviceTemplate: async (id, patch) => {
await repository.updateDeviceTemplate(id, patch)
set((state) => ({
@@ -174,8 +284,49 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
await get().loadCatalog()
},
adminUpdateDeviceCategory: async (id, name) => {
await repository.adminUpdateDeviceCategory(id, name)
await get().loadCatalog()
},
adminUpdateManufacturer: async (id, name) => {
await repository.adminUpdateManufacturer(id, name)
await get().loadCatalog()
},
adminUnpublish: async (entityType, id) => {
await repository.adminUnpublish(entityType, id)
await get().loadCatalog()
},
adminRemoveDeviceCategory: async (id) => {
await repository.adminRemoveDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminRemoveManufacturer: async (id) => {
await repository.adminRemoveManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminRemovePortType: async (id) => {
await repository.adminRemovePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
adminRemoveCableType: async (id) => {
await repository.adminRemoveCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
adminRemoveDeviceTemplate: async (id) => {
await repository.adminRemoveDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
}))