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
333 lines
14 KiB
TypeScript
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) },
|
|
}))
|
|
},
|
|
}))
|