@@ -131,11 +158,15 @@ export default function PortTypesPanel() {
{customTypes.map((pt) =>
editingId === pt.id ? (
-
setEditingId(null)}
+ onSave={(fields) => {
+ updateCustomPortType(pt.id, fields)
+ setEditingId(null)
+ }}
/>
) : (
-
+
+ {pendingEntityIds.has(pt.id) ? (
+
+ Pending review
+
+ ) : (
+
+ )}
+
+
),
)}
@@ -166,17 +212,43 @@ export default function PortTypesPanel() {
Built-in ({builtInTypes.length})
-
- {builtInTypes.map((pt) => (
-
- {pt.name}
- {describeCompatibility(pt) && (
- · also fits: {describeCompatibility(pt)}
- )}
-
- ))}
+
+ {builtInTypes.map((pt) =>
+ suggestingId === pt.id ? (
+
setSuggestingId(null)}
+ onSave={(fields) => handleSuggestEdit(pt.id, fields)}
+ />
+ ) : (
+
+
+ {pt.name}
+ {describeCompatibility(pt) && (
+ · also fits: {describeCompatibility(pt)}
+ )}
+
+ {pendingEntityIds.has(pt.id) ? (
+
+ Edit pending review
+
+ ) : (
+
+ )}
+
+ ),
+ )}
-
Built-in connector types can't be edited or removed.
+
+ Built-in connector types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.
+
)
diff --git a/src/components/palette/cableTypeFormFields.ts b/src/components/palette/cableTypeFormFields.ts
new file mode 100644
index 0000000..df4bfb8
--- /dev/null
+++ b/src/components/palette/cableTypeFormFields.ts
@@ -0,0 +1,29 @@
+import type { CableType, PortType } from '../../domain/types'
+
+export interface CableTypeFormFields {
+ name: string
+ endAPortTypeId: string
+ endBPortTypeId: string
+ unit: CableType['unit']
+ costPerUnit?: number
+}
+
+/** Resolves the form's port-type-based picks into the family/family2 shape
+ * a CableType actually stores. Shared by every save path (create, direct
+ * edit, submit-for-public, suggest-edit) so they can't drift apart.
+ * Split out of CableTypeManager.tsx (a plain function alongside a
+ * component there defeats fast refresh) rather than folded into
+ * data/catalogRowMapping.ts since this is form-input resolution, not a
+ * domain-object-to-row mapping. */
+export function resolveCableTypeFields(fields: CableTypeFormFields, portTypes: PortType[]): Omit
| undefined {
+ const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
+ const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
+ if (!endA || !endB) return undefined
+ return {
+ name: fields.name,
+ family: endA.family,
+ family2: endA.family === endB.family ? undefined : endB.family,
+ unit: fields.unit,
+ costPerUnit: fields.costPerUnit,
+ }
+}
diff --git a/src/components/submissions/MySubmissionsModal.tsx b/src/components/submissions/MySubmissionsModal.tsx
new file mode 100644
index 0000000..e743e38
--- /dev/null
+++ b/src/components/submissions/MySubmissionsModal.tsx
@@ -0,0 +1,321 @@
+import { useState } from 'react'
+import {
+ cableTypeToRow,
+ deviceCategoryToRow,
+ deviceTemplateToRow,
+ portTypeToRow,
+ rowToCableTypeFields,
+ rowToDeviceTemplateFields,
+ rowToPortTypeFields,
+} from '../../data/catalogRowMapping'
+import type { CatalogSubmission } from '../../data/SubmissionRepository'
+import { allPortTypes } from '../../domain/project'
+import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
+import { useCatalogStore } from '../../state/catalogStore'
+import { useSubmissionStore } from '../../state/submissionStore'
+import { resolveCableTypeFields } from '../palette/cableTypeFormFields'
+import { CableTypeForm } from '../palette/CableTypeManager'
+import DeviceTemplateEditor from '../palette/DeviceTemplateEditor'
+import { PortTypeForm } from '../palette/PortTypeManager'
+import Modal from '../common/Modal'
+
+const ENTITY_TYPE_LABELS: Record = {
+ device_template: 'Device',
+ port_type: 'Port type',
+ cable_type: 'Cable type',
+ device_category: 'Category',
+}
+
+/** The submission's entity, as it currently exists in the catalog (public
+ * or private), if it still exists at all. */
+function findEntity(catalog: Catalog, submission: CatalogSubmission) {
+ switch (submission.entityType) {
+ case 'port_type':
+ return catalog.portTypes.find((pt) => pt.id === submission.entityId)
+ case 'cable_type':
+ return catalog.cableTypes.find((ct) => ct.id === submission.entityId)
+ case 'device_template':
+ return catalog.deviceTemplates.find((dt) => dt.id === submission.entityId)
+ case 'device_category':
+ return catalog.deviceCategories.find((c) => c.id === submission.entityId)
+ }
+}
+
+/** Best-effort display name: the entity's current live name if it still
+ * exists in the catalog, else whatever name was proposed (it always will
+ * exist in this app's flow — entity_id always points at a real row — but
+ * falling back is cheap insurance). */
+function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): string {
+ const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : undefined
+ return findEntity(catalog, submission)?.name ?? proposedName ?? submission.entityId
+}
+
+/** True when this submission is about an entry you still privately own —
+ * i.e. "promote my own entry" rather than "suggest an edit to a public
+ * entry you don't own". For that case the library entry (editable via the
+ * normal palette/managers) is the single source of truth; catalogStore
+ * keeps proposed_data synced to it automatically (see syncProposedData),
+ * so there's no separate draft to edit here. */
+function isOwnPrivateEntry(catalog: Catalog, submission: CatalogSubmission): boolean {
+ return findEntity(catalog, submission)?.custom === true
+}
+
+/** Row-shaped snapshot of the live entity, for the "Resubmit" action on an
+ * own-private-entry submission — re-confirms the (already-synced)
+ * proposed_data and flips status back to pending in one explicit click. */
+function liveEntityRow(catalog: Catalog, submission: CatalogSubmission): Record | undefined {
+ const entity = findEntity(catalog, submission)
+ if (!entity) return undefined
+ switch (submission.entityType) {
+ case 'port_type':
+ return portTypeToRow(entity as PortType)
+ case 'cable_type':
+ return cableTypeToRow(entity as CableType)
+ case 'device_template':
+ return deviceTemplateToRow(entity as DeviceTemplate)
+ case 'device_category':
+ return deviceCategoryToRow(entity.name)
+ }
+}
+
+function statusBadge(submission: CatalogSubmission) {
+ switch (submission.status) {
+ case 'pending':
+ return Pending
+ case 'approved':
+ return Approved
+ case 'rejected':
+ return Rejected
+ }
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+}
+
+export default function MySubmissionsModal({ onClose }: { onClose: () => void }) {
+ const catalog = useCatalogStore((s) => s.catalog)
+ const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
+ const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
+ const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
+ const withdraw = useSubmissionStore((s) => s.withdraw)
+ const resubmit = useSubmissionStore((s) => s.resubmit)
+ const [editingId, setEditingId] = useState(null)
+ const [categoryDraft, setCategoryDraft] = useState('')
+
+ const allPortTypesList = allPortTypes(catalog)
+
+ const handleWithdraw = (submission: CatalogSubmission) => {
+ const confirmed = window.confirm(`Withdraw this submission for "${entityDisplayName(catalog, submission)}"?`)
+ if (confirmed) withdraw(submission.id)
+ }
+
+ const startEditing = (submission: CatalogSubmission) => {
+ setCategoryDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '')
+ setEditingId(submission.id)
+ }
+
+ return (
+
+
+ {mySubmissions.length === 0 && (
+
+ No submissions yet — use "Submit" or "Suggest edit" on a catalog entry to propose it for the public
+ catalog.
+
+ )}
+ {mySubmissions.map((submission) => {
+ const isEditing = editingId === submission.id
+ const ownEntry = isOwnPrivateEntry(catalog, submission)
+ // For an own-private-entry submission, the library entry is the
+ // only place to edit — this "revise" form only ever applies to
+ // suggesting an edit to a public entry you don't own.
+ const canRevise = !ownEntry && (submission.status === 'pending' || submission.status === 'rejected')
+
+ // Own-private-entry editing: the exact same form + save action as
+ // the device library's own "Edit" button (direct catalogStore
+ // update, not a submission-specific save path) — catalogStore's
+ // update actions already keep this submission's proposed_data in
+ // sync afterward (see syncProposedData), so nothing extra is
+ // needed here beyond editing the live entry itself.
+ if (isEditing && ownEntry && submission.entityType === 'port_type') {
+ const entity = findEntity(catalog, submission) as PortType
+ return (
+
setEditingId(null)}
+ onSave={(fields) => {
+ updateCustomPortType(submission.entityId, fields)
+ setEditingId(null)
+ }}
+ />
+ )
+ }
+ if (isEditing && ownEntry && submission.entityType === 'cable_type') {
+ const entity = findEntity(catalog, submission) as CableType
+ return (
+ setEditingId(null)}
+ onSave={(fields) => {
+ const resolved = resolveCableTypeFields(fields, allPortTypesList)
+ if (!resolved) return
+ updateCustomCableType(submission.entityId, resolved)
+ setEditingId(null)
+ }}
+ />
+ )
+ }
+ if (isEditing && ownEntry && submission.entityType === 'device_template') {
+ const entity = findEntity(catalog, submission) as DeviceTemplate
+ return setEditingId(null)} />
+ }
+
+ // Suggest-an-edit-to-a-public-entry editing: a separate draft
+ // (proposed_data) since there's no live private row to defer to —
+ // saving here revises the submission itself.
+ if (isEditing && submission.entityType === 'port_type') {
+ return (
+ setEditingId(null)}
+ onSave={(fields) => {
+ resubmit(submission.id, portTypeToRow(fields))
+ setEditingId(null)
+ }}
+ />
+ )
+ }
+ if (isEditing && submission.entityType === 'cable_type') {
+ return (
+ setEditingId(null)}
+ onSave={(fields) => {
+ const resolved = resolveCableTypeFields(fields, allPortTypesList)
+ if (!resolved) return
+ resubmit(submission.id, cableTypeToRow(resolved))
+ setEditingId(null)
+ }}
+ />
+ )
+ }
+ if (isEditing && submission.entityType === 'device_template') {
+ return (
+ setEditingId(null)}
+ onSaved={() => setEditingId(null)}
+ />
+ )
+ }
+ if (isEditing && submission.entityType === 'device_category') {
+ return (
+
+ setCategoryDraft(e.target.value)}
+ className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
+ />
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {entityDisplayName(catalog, submission)}
+ {statusBadge(submission)}
+
+
+ {ENTITY_TYPE_LABELS[submission.entityType]} · submitted {formatDate(submission.createdAt)}
+
+
+
+ {ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
+
+ )}
+ {ownEntry && submission.status === 'rejected' && (
+
+ )}
+ {canRevise && (
+
+ )}
+ {submission.status === 'pending' && (
+
+ )}
+
+
+ {ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
+
+ Tracks your library entry — editing it here (or from the palette) keeps this submission up to date automatically.
+
+ )}
+ {submission.status === 'rejected' && submission.reviewReason && (
+
+ Reason:
+ {submission.reviewReason}
+
+ )}
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/src/data/SubmissionRepository.ts b/src/data/SubmissionRepository.ts
new file mode 100644
index 0000000..2fb2a89
--- /dev/null
+++ b/src/data/SubmissionRepository.ts
@@ -0,0 +1,59 @@
+/** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s
+ * check constraint — `manufacturer` is omitted since the app doesn't wire up the
+ * normalized manufacturer catalog yet (see organized-ideas.md §3, deferred alongside
+ * device_templates.manufacturer staying free text for now). */
+export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category'
+
+/**
+ * A user's request to change the public catalog — either promoting one of
+ * their own private entries to public, or proposing an edit to an existing
+ * public entry. Regular users can never write a public catalog row directly
+ * (RLS only allows Admins to), so this is the only path either kind of
+ * change can take; an Admin review queue (not yet built — organized-ideas.md
+ * §9's next sub-phase) is what actually applies an approved one.
+ */
+export interface CatalogSubmission {
+ id: string
+ entityType: CatalogEntityType
+ /** The row this submission is about — always an existing row in this
+ * app's flow: either your own private entry (being promoted, possibly
+ * with edits) or a public entry (you're suggesting an edit to). */
+ entityId: string
+ /** Proposed field values, shaped like the underlying table's row (see
+ * data/catalogRowMapping.ts) — so applying an approval is just writing
+ * this object onto the row at `entityId`, not a separate translation
+ * step future Admin-approval logic would otherwise have to redo. */
+ proposedData: Record
+ submitterId: string
+ status: 'pending' | 'approved' | 'rejected'
+ reviewerId?: string
+ /** Set by an Admin on rejection — organized-ideas.md §3's "notified either
+ * way, with a reason attached on rejection rather than a silent disappearance." */
+ reviewReason?: string
+ createdAt: string
+ updatedAt: string
+}
+
+/** Storage abstraction for the catalog submission/review queue — mirrors
+ * DiagramRepository/CatalogRepository's role for this concern. Only covers
+ * the submitter's-eye view (create, revise, withdraw your own) for now; an
+ * Admin's view (list all pending, approve/reject) is the next sub-phase. */
+export interface SubmissionRepository {
+ /** Every submission the current user has made, most recent first. */
+ listMine(): Promise
+ /** Submits `entityId` (a row you own privately, or a public row you're
+ * proposing an edit to) for Admin review. */
+ submit(entityType: CatalogEntityType, entityId: string, proposedData: Record): Promise
+ /** Revises a still-pending or previously-rejected submission of your own
+ * and puts it (back) into the pending queue. */
+ resubmit(id: string, proposedData: Record): Promise
+ /** Updates only `proposed_data`, leaving status/reviewer/reason untouched.
+ * Used to keep a submission's snapshot in sync with the live private
+ * entry it's about (see catalogStore) — a plain edit to your library
+ * entry shouldn't silently pull a rejected submission back into the
+ * pending queue, so status changes stay a separate, explicit action
+ * (`resubmit`), while the data itself is kept fresh automatically. */
+ updateProposedData(id: string, proposedData: Record): Promise
+ /** Withdraws your own still-pending submission. */
+ withdraw(id: string): Promise
+}
diff --git a/src/data/SupabaseCatalogRepository.ts b/src/data/SupabaseCatalogRepository.ts
index bc4bc5b..4a620a3 100644
--- a/src/data/SupabaseCatalogRepository.ts
+++ b/src/data/SupabaseCatalogRepository.ts
@@ -1,5 +1,6 @@
import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
+import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository'
import { supabase } from './supabaseClient'
@@ -156,16 +157,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addPortType(portType: Omit): Promise {
const ownerId = await this.currentUserId()
const id = uuid()
- const { error } = await supabase.from('port_types').insert({
- id,
- name: portType.name,
- category: portType.category,
- family: portType.family,
- compatible_family_ids: portType.compatibleFamilyIds ?? [],
- max_connections: portType.maxConnections ?? null,
- is_public: false,
- owner_id: ownerId,
- })
+ const { error } = await supabase
+ .from('port_types')
+ .insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: true }
}
@@ -184,16 +178,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addCableType(cableType: Omit): Promise {
const ownerId = await this.currentUserId()
const id = uuid()
- const { error } = await supabase.from('cable_types').insert({
- id,
- name: cableType.name,
- family: cableType.family,
- family2: cableType.family2 ?? null,
- unit: cableType.unit,
- cost_per_unit: cableType.costPerUnit ?? null,
- is_public: false,
- owner_id: ownerId,
- })
+ const { error } = await supabase
+ .from('cable_types')
+ .insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: true }
}
@@ -212,16 +199,8 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addDeviceTemplate(template: Omit): Promise {
const ownerId = await this.currentUserId()
const id = uuid()
- const { error } = await supabase.from('device_templates').insert({
- id,
- name: template.name,
- category_id: template.category,
- manufacturer: template.manufacturer ?? null,
- model: template.model ?? null,
- cost: template.cost ?? null,
- is_public: false,
- owner_id: ownerId,
- })
+ const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
+ const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId })
if (error) {
console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: true }
diff --git a/src/data/SupabaseSubmissionRepository.ts b/src/data/SupabaseSubmissionRepository.ts
new file mode 100644
index 0000000..3b5d691
--- /dev/null
+++ b/src/data/SupabaseSubmissionRepository.ts
@@ -0,0 +1,93 @@
+import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from './SubmissionRepository'
+import { supabase } from './supabaseClient'
+
+interface SubmissionRow {
+ id: string
+ entity_type: CatalogEntityType
+ entity_id: string
+ proposed_data: Record
+ submitter_id: string
+ status: CatalogSubmission['status']
+ reviewer_id: string | null
+ review_reason: string | null
+ created_at: string
+ updated_at: string
+}
+
+function toSubmission(row: SubmissionRow): CatalogSubmission {
+ return {
+ id: row.id,
+ entityType: row.entity_type,
+ entityId: row.entity_id,
+ proposedData: row.proposed_data,
+ submitterId: row.submitter_id,
+ status: row.status,
+ reviewerId: row.reviewer_id ?? undefined,
+ reviewReason: row.review_reason ?? undefined,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ }
+}
+
+/** Backs the app with the `catalog_submissions` table. RLS already scopes
+ * every query/write here to what a regular (non-Admin) user should be able
+ * to see and do with their own submissions — see that table's policies in
+ * the init schema migration. */
+export class SupabaseSubmissionRepository implements SubmissionRepository {
+ async listMine(): Promise {
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+ if (!user) return []
+
+ const { data, error } = await supabase
+ .from('catalog_submissions')
+ .select('*')
+ .eq('submitter_id', user.id)
+ .order('created_at', { ascending: false })
+ if (error) {
+ console.error('Failed to load submissions from Supabase', error)
+ return []
+ }
+ return ((data ?? []) as SubmissionRow[]).map(toSubmission)
+ }
+
+ async submit(entityType: CatalogEntityType, entityId: string, proposedData: Record): Promise {
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+ if (!user) throw new Error('Not signed in.')
+
+ const { data, error } = await supabase
+ .from('catalog_submissions')
+ .insert({ entity_type: entityType, entity_id: entityId, proposed_data: proposedData, submitter_id: user.id, status: 'pending' })
+ .select('*')
+ .single()
+ if (error || !data) {
+ console.error('Failed to submit to Supabase', error)
+ throw error ?? new Error('Submission failed.')
+ }
+ return toSubmission(data as SubmissionRow)
+ }
+
+ async resubmit(id: string, proposedData: Record): Promise {
+ // Clears reviewer_id/review_reason too — a resubmission starts a fresh
+ // review cycle, so the old rejection reason shouldn't linger as if it
+ // still applied to the (now revised) proposed_data.
+ const { error } = await supabase
+ .from('catalog_submissions')
+ .update({ proposed_data: proposedData, status: 'pending', reviewer_id: null, review_reason: null })
+ .eq('id', id)
+ if (error) console.error('Failed to resubmit to Supabase', error)
+ }
+
+ async updateProposedData(id: string, proposedData: Record): Promise {
+ const { error } = await supabase.from('catalog_submissions').update({ proposed_data: proposedData }).eq('id', id)
+ if (error) console.error('Failed to sync submission data in Supabase', error)
+ }
+
+ async withdraw(id: string): Promise {
+ const { error } = await supabase.from('catalog_submissions').delete().eq('id', id)
+ if (error) console.error('Failed to withdraw submission from Supabase', error)
+ }
+}
diff --git a/src/data/catalogRowMapping.ts b/src/data/catalogRowMapping.ts
new file mode 100644
index 0000000..2324fec
--- /dev/null
+++ b/src/data/catalogRowMapping.ts
@@ -0,0 +1,102 @@
+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.
+ */
+
+export function portTypeToRow(portType: Omit): Record {
+ 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): Record {
+ 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 {
+ return { name }
+}
+
+export function deviceTemplateToRow(template: Omit): Record {
+ return {
+ name: template.name,
+ category_id: template.category,
+ manufacturer: template.manufacturer ?? 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,
+ })),
+ }
+}
+
+/**
+ * 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): Omit {
+ 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): Omit {
+ 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): Omit {
+ const ports = (row.ports as Array<{ name: string; direction: DeviceTemplate['ports'][number]['direction']; port_type_id: string }>) ?? []
+ return {
+ name: row.name as string,
+ category: row.category_id as string,
+ manufacturer: (row.manufacturer 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,
+ })),
+ }
+}
+
+export function rowToDeviceCategoryName(row: Record): string {
+ return row.name as string
+}
diff --git a/src/state/catalogStore.ts b/src/state/catalogStore.ts
index f60bbdd..c59b7ec 100644
--- a/src/state/catalogStore.ts
+++ b/src/state/catalogStore.ts
@@ -1,7 +1,9 @@
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 { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
+import { useSubmissionStore } from './submissionStore'
const repository: CatalogRepository = new SupabaseCatalogRepository()
@@ -80,6 +82,8 @@ export const useCatalogStore = create((set, get) => ({
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))
},
addCustomCableType: async (cableType) => {
@@ -95,6 +99,8 @@ export const useCatalogStore = create((set, get) => ({
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))
},
addCustomDeviceTemplate: async (template) => {
@@ -110,6 +116,8 @@ export const useCatalogStore = create((set, get) => ({
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) => {
diff --git a/src/state/submissionStore.ts b/src/state/submissionStore.ts
new file mode 100644
index 0000000..13b3089
--- /dev/null
+++ b/src/state/submissionStore.ts
@@ -0,0 +1,64 @@
+import { create } from 'zustand'
+import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from '../data/SubmissionRepository'
+import { SupabaseSubmissionRepository } from '../data/SupabaseSubmissionRepository'
+
+const repository: SubmissionRepository = new SupabaseSubmissionRepository()
+
+interface SubmissionStoreState {
+ mySubmissions: CatalogSubmission[]
+ isLoaded: boolean
+
+ loadMySubmissions: () => Promise
+ /** Submits `entityId` (your own private entry, or a public entry you're
+ * proposing an edit to) for Admin review. */
+ submitForReview: (entityType: CatalogEntityType, entityId: string, proposedData: Record) => Promise
+ /** Revises a pending/rejected submission of your own and puts it back in the queue. */
+ resubmit: (id: string, proposedData: Record) => Promise
+ /** Keeps a pending/rejected submission's proposed_data matching the live
+ * entity it's about, without touching its status — see catalogStore,
+ * which calls this after every edit to one of your private catalog
+ * entries. A no-op if you have no pending/rejected submission for that
+ * entity (the common case — most edits were never submitted at all). */
+ syncProposedData: (entityType: CatalogEntityType, entityId: string, proposedData: Record) => Promise
+ withdraw: (id: string) => Promise
+}
+
+export const useSubmissionStore = create((set, get) => ({
+ mySubmissions: [],
+ isLoaded: false,
+
+ loadMySubmissions: async () => {
+ const mySubmissions = await repository.listMine()
+ set({ mySubmissions, isLoaded: true })
+ },
+
+ submitForReview: async (entityType, entityId, proposedData) => {
+ const created = await repository.submit(entityType, entityId, proposedData)
+ set((state) => ({ mySubmissions: [created, ...state.mySubmissions] }))
+ },
+
+ resubmit: async (id, proposedData) => {
+ await repository.resubmit(id, proposedData)
+ set((state) => ({
+ mySubmissions: state.mySubmissions.map((s) =>
+ s.id === id ? { ...s, proposedData, status: 'pending', reviewerId: undefined, reviewReason: undefined } : s,
+ ),
+ }))
+ },
+
+ syncProposedData: async (entityType, entityId, proposedData) => {
+ const match = get().mySubmissions.find(
+ (s) => s.entityType === entityType && s.entityId === entityId && (s.status === 'pending' || s.status === 'rejected'),
+ )
+ if (!match) return
+ await repository.updateProposedData(match.id, proposedData)
+ set((state) => ({
+ mySubmissions: state.mySubmissions.map((s) => (s.id === match.id ? { ...s, proposedData } : s)),
+ }))
+ },
+
+ withdraw: async (id) => {
+ await repository.withdraw(id)
+ set((state) => ({ mySubmissions: state.mySubmissions.filter((s) => s.id !== id) }))
+ },
+}))