Files
av-planner/src/state/catalogStore.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

333 lines
14 KiB
TypeScript

import { create } from 'zustand'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
import type { CatalogRepository } from '../data/CatalogRepository'
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../data/catalogRowMapping'
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
import type { CatalogEntityType } from '../data/SubmissionRepository'
import { useSubmissionStore } from './submissionStore'
const repository: CatalogRepository = new SupabaseCatalogRepository()
// Which public device templates you've hidden from your own palette — a
// per-user UI preference, not catalog data, so it's not a Supabase row: hiding
// a public entry only affects your view of it (see domain/diagram.ts's
// allDeviceTemplates/hiddenDeviceTemplates), never the shared entry itself.
const HIDDEN_PUBLIC_TEMPLATES_KEY = 'av-planner:hiddenPublicDeviceTemplateIds'
function loadHiddenPublicIds(): Set<string> {
try {
const raw = window.localStorage.getItem(HIDDEN_PUBLIC_TEMPLATES_KEY)
return raw ? new Set(JSON.parse(raw)) : new Set()
} catch {
return new Set()
}
}
function saveHiddenPublicIds(ids: Set<string>): void {
try {
window.localStorage.setItem(HIDDEN_PUBLIC_TEMPLATES_KEY, JSON.stringify([...ids]))
} catch {
// Not essential — worst case a hidden template reappears next launch.
}
}
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], manufacturers: [], deviceTemplates: [] }
interface CatalogStoreState {
catalog: Catalog
isLoaded: boolean
hiddenPublicDeviceTemplateIds: Set<string>
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>
removeCustomDeviceTemplate: (id: string) => Promise<void>
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) => ({
catalog: emptyCatalog,
isLoaded: false,
hiddenPublicDeviceTemplateIds: loadHiddenPublicIds(),
loadCatalog: async () => {
const catalog = await repository.load()
set({ catalog, isLoaded: true })
},
addCustomDeviceCategory: async (name) => {
const category = await repository.addDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
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) => ({
catalog: {
...state.catalog,
portTypes: state.catalog.portTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
},
}))
const updated = get().catalog.portTypes.find((pt) => pt.id === id)
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) => ({
catalog: {
...state.catalog,
cableTypes: state.catalog.cableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
},
}))
const updated = get().catalog.cableTypes.find((ct) => ct.id === id)
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) => ({
catalog: {
...state.catalog,
deviceTemplates: state.catalog.deviceTemplates.map((t) => (t.id === id ? { ...t, ...patch } : t)),
},
}))
const updated = get().catalog.deviceTemplates.find((t) => t.id === id)
if (updated) useSubmissionStore.getState().syncProposedData('device_template', id, deviceTemplateToRow(updated))
},
removeCustomDeviceTemplate: async (id) => {
await repository.removeDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
hidePublicDeviceTemplate: (id) => {
const next = new Set(get().hiddenPublicDeviceTemplateIds)
next.add(id)
saveHiddenPublicIds(next)
set({ hiddenPublicDeviceTemplateIds: next })
},
restorePublicDeviceTemplate: (id) => {
const next = new Set(get().hiddenPublicDeviceTemplateIds)
next.delete(id)
saveHiddenPublicIds(next)
set({ hiddenPublicDeviceTemplateIds: next })
},
// Re-fetch the whole catalog after each of these rather than patching
// locally — device_template edits also touch device_template_ports, and
// unpublish changes which rows even show up (custom flips true for the
// Admin who did it, since is_admin() is the only thing still granting
// them visibility — see adminUnpublish's own comment). Simplest correct
// thing, and Admin actions here are infrequent enough that the extra
// round trip doesn't matter.
adminUpdatePortType: async (id, patch) => {
await repository.adminUpdatePortType(id, patch)
await get().loadCatalog()
},
adminUpdateCableType: async (id, patch) => {
await repository.adminUpdateCableType(id, patch)
await get().loadCatalog()
},
adminUpdateDeviceTemplate: async (id, patch) => {
await repository.adminUpdateDeviceTemplate(id, patch)
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) },
}))
},
}))