Add the user-submission flow for the catalog
Lets users submit a private catalog entry (port type, cable type, device template) for promotion to the public catalog, or suggest an edit to an existing public entry — both go into the catalog_submissions review queue per organized-ideas.md §3. No Admin review UI yet (next sub-phase); this covers the submitter's side only. - data/SubmissionRepository + SupabaseSubmissionRepository: submit, resubmit, withdraw, list-mine, backed by the existing catalog_submissions RLS policies (no schema changes needed). - data/catalogRowMapping.ts: shared domain<->row mappers, in both directions, so a submission's proposed_data is always shaped like the underlying table row (what an eventual admin-approval would write directly) and can be turned back into form-editable fields for revision. - state/submissionStore.ts: mySubmissions + submit/resubmit/withdraw, plus syncProposedData — called from catalogStore's updateCustom* actions so a submission about your own still-private entry never goes stale relative to it (edits from the library and from My Submissions are the same action and always agree). - UI: "Submit"/"Suggest edit" wired into PortTypeManager, CableTypeManager, DevicePalette/DeviceTemplateEditor; new MySubmissionsModal (opened from TopBar, with a pending-count badge) shows status, rejection reasons, and lets you edit/resubmit or withdraw. - Deliberately deferred: device_category submissions (no listing UI to hang a button on yet) and the normalized manufacturer catalog. Verified: tsc -b and oxlint clean; supabase db reset + 23/23 pgTAP RLS tests still pass (no schema changes this round); manually tested submit, suggest-edit, edit-from-either-side sync, reject/resubmit, and withdraw.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import FlowCanvas from '../canvas/FlowCanvas'
|
import FlowCanvas from '../canvas/FlowCanvas'
|
||||||
import DevicePalette from '../palette/DevicePalette'
|
import DevicePalette from '../palette/DevicePalette'
|
||||||
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
||||||
@@ -12,11 +13,13 @@ export default function AppShell() {
|
|||||||
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
|
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
|
||||||
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
||||||
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
||||||
|
const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialDiagram()
|
loadInitialDiagram()
|
||||||
loadCatalog()
|
loadCatalog()
|
||||||
}, [loadInitialDiagram, loadCatalog])
|
loadMySubmissions()
|
||||||
|
}, [loadInitialDiagram, loadCatalog, loadMySubmissions])
|
||||||
|
|
||||||
if (!isLoaded || !isCatalogLoaded) {
|
if (!isLoaded || !isCatalogLoaded) {
|
||||||
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
|
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
||||||
import { supabase } from '../../data/supabaseClient'
|
import { supabase } from '../../data/supabaseClient'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import MySubmissionsModal from '../submissions/MySubmissionsModal'
|
||||||
import DiagramManagerModal from './DiagramManagerModal'
|
import DiagramManagerModal from './DiagramManagerModal'
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const renameProject = useProjectStore((s) => s.renameProject)
|
const renameProject = useProjectStore((s) => s.renameProject)
|
||||||
const importProject = useProjectStore((s) => s.importProject)
|
const importProject = useProjectStore((s) => s.importProject)
|
||||||
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
||||||
|
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
||||||
const [username, setUsername] = useState<string | null>(null)
|
const [username, setUsername] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const pendingSubmissionCount = useMemo(
|
||||||
|
() => mySubmissions.filter((s) => s.status === 'pending').length,
|
||||||
|
[mySubmissions],
|
||||||
|
)
|
||||||
|
|
||||||
// One-time fetch — nothing else in the app changes your own username after
|
// One-time fetch — nothing else in the app changes your own username after
|
||||||
// CompleteProfileScreen sets it, so there's no need to keep this live.
|
// CompleteProfileScreen sets it, so there's no need to keep this live.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +69,17 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
Diagrams
|
Diagrams
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmissionsOpen(true)}
|
||||||
|
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
My Submissions
|
||||||
|
{pendingSubmissionCount > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
|
||||||
|
{pendingSubmissionCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -79,6 +99,7 @@ export default function TopBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
||||||
|
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { allCableTypes, allPortTypes } from '../../domain/project'
|
import { allCableTypes, allPortTypes } from '../../domain/project'
|
||||||
import type { CableType, PortType } from '../../domain/types'
|
import type { CableType, PortType } from '../../domain/types'
|
||||||
|
import { cableTypeToRow } from '../../data/catalogRowMapping'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFormFields'
|
||||||
|
|
||||||
const UNITS: CableType['unit'][] = ['ft', 'm']
|
const UNITS: CableType['unit'][] = ['ft', 'm']
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ function familyLabel(family: string, portTypes: PortType[]): string {
|
|||||||
return portTypes.find((pt) => pt.family === family)?.name ?? family
|
return portTypes.find((pt) => pt.family === family)?.name ?? family
|
||||||
}
|
}
|
||||||
|
|
||||||
function CableTypeForm({
|
export function CableTypeForm({
|
||||||
initial,
|
initial,
|
||||||
portTypes,
|
portTypes,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -21,13 +24,7 @@ function CableTypeForm({
|
|||||||
initial?: CableType
|
initial?: CableType
|
||||||
portTypes: PortType[]
|
portTypes: PortType[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSave: (fields: {
|
onSave: (fields: CableTypeFormFields) => void
|
||||||
name: string
|
|
||||||
endAPortTypeId: string
|
|
||||||
endBPortTypeId: string
|
|
||||||
unit: CableType['unit']
|
|
||||||
costPerUnit?: number
|
|
||||||
}) => void
|
|
||||||
}) {
|
}) {
|
||||||
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
|
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
|
||||||
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
|
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
|
||||||
@@ -132,8 +129,11 @@ export default function CableTypesPanel() {
|
|||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
|
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
|
||||||
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
|
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
|
||||||
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [suggestingId, setSuggestingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const portTypes = useMemo(
|
const portTypes = useMemo(
|
||||||
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
@@ -146,55 +146,45 @@ export default function CableTypesPanel() {
|
|||||||
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
|
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
|
||||||
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
|
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
|
||||||
|
|
||||||
|
const pendingEntityIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
mySubmissions.filter((s) => s.entityType === 'cable_type' && s.status === 'pending').map((s) => s.entityId),
|
||||||
|
),
|
||||||
|
[mySubmissions],
|
||||||
|
)
|
||||||
|
|
||||||
const describeEnds = (ct: CableType) => {
|
const describeEnds = (ct: CableType) => {
|
||||||
const a = familyLabel(ct.family, portTypes)
|
const a = familyLabel(ct.family, portTypes)
|
||||||
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
|
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
|
||||||
return a === b ? a : `${a} ↔ ${b}`
|
return a === b ? a : `${a} ↔ ${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = (fields: {
|
const handleCreate = (fields: CableTypeFormFields) => {
|
||||||
name: string
|
const resolved = resolveCableTypeFields(fields, portTypes)
|
||||||
endAPortTypeId: string
|
if (!resolved) return
|
||||||
endBPortTypeId: string
|
addCustomCableType(resolved)
|
||||||
unit: CableType['unit']
|
|
||||||
costPerUnit?: number
|
|
||||||
}) => {
|
|
||||||
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
|
||||||
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
|
||||||
if (!endA || !endB) return
|
|
||||||
addCustomCableType({
|
|
||||||
name: fields.name,
|
|
||||||
family: endA.family,
|
|
||||||
family2: endA.family === endB.family ? undefined : endB.family,
|
|
||||||
unit: fields.unit,
|
|
||||||
costPerUnit: fields.costPerUnit,
|
|
||||||
})
|
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = (
|
const handleUpdate = (id: string, fields: CableTypeFormFields) => {
|
||||||
id: string,
|
const resolved = resolveCableTypeFields(fields, portTypes)
|
||||||
fields: {
|
if (!resolved) return
|
||||||
name: string
|
updateCustomCableType(id, resolved)
|
||||||
endAPortTypeId: string
|
|
||||||
endBPortTypeId: string
|
|
||||||
unit: CableType['unit']
|
|
||||||
costPerUnit?: number
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
|
||||||
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
|
||||||
if (!endA || !endB) return
|
|
||||||
updateCustomCableType(id, {
|
|
||||||
name: fields.name,
|
|
||||||
family: endA.family,
|
|
||||||
family2: endA.family === endB.family ? undefined : endB.family,
|
|
||||||
unit: fields.unit,
|
|
||||||
costPerUnit: fields.costPerUnit,
|
|
||||||
})
|
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitForPublic = (ct: CableType) => {
|
||||||
|
submitForReview('cable_type', ct.id, cableTypeToRow(ct))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuggestEdit = (id: string, fields: CableTypeFormFields) => {
|
||||||
|
const resolved = resolveCableTypeFields(fields, portTypes)
|
||||||
|
if (!resolved) return
|
||||||
|
submitForReview('cable_type', id, cableTypeToRow(resolved))
|
||||||
|
setSuggestingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -240,13 +230,28 @@ export default function CableTypesPanel() {
|
|||||||
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
|
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{pendingEntityIds.has(ct.id) ? (
|
||||||
|
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
|
||||||
|
Pending review
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmitForPublic(ct)}
|
||||||
|
title="Submit this to the public catalog for Admin review"
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingId(ct.id)}
|
onClick={() => setEditingId(ct.id)}
|
||||||
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,14 +262,41 @@ export default function CableTypesPanel() {
|
|||||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Built-in ({builtInTypes.length})
|
Built-in ({builtInTypes.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
|
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
|
||||||
{builtInTypes.map((ct) => (
|
{builtInTypes.map((ct) =>
|
||||||
<div key={ct.id} className="px-1 py-0.5 text-[11px] text-slate-500">
|
suggestingId === ct.id ? (
|
||||||
|
<div key={ct.id} className="mb-1.5">
|
||||||
|
<CableTypeForm
|
||||||
|
initial={ct}
|
||||||
|
portTypes={portTypes}
|
||||||
|
onCancel={() => setSuggestingId(null)}
|
||||||
|
onSave={(fields) => handleSuggestEdit(ct.id, fields)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={ct.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
{ct.name} <span className="text-slate-400">· {describeEnds(ct)}</span>
|
{ct.name} <span className="text-slate-400">· {describeEnds(ct)}</span>
|
||||||
|
</span>
|
||||||
|
{pendingEntityIds.has(ct.id) ? (
|
||||||
|
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
|
||||||
|
Edit pending review
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setSuggestingId(ct.id)}
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Suggest edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-slate-400">Built-in cable types can't be edited or removed.</p>
|
<p className="mt-1 text-[10px] text-slate-400">
|
||||||
|
Built-in cable types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
||||||
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project'
|
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project'
|
||||||
import type { DeviceTemplate } from '../../domain/types'
|
import type { DeviceTemplate } from '../../domain/types'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
||||||
import Chevron from '../common/Chevron'
|
import Chevron from '../common/Chevron'
|
||||||
import ConnectorLibraryModal from './ConnectorLibraryModal'
|
import ConnectorLibraryModal from './ConnectorLibraryModal'
|
||||||
import DeviceTemplateEditor from './DeviceTemplateEditor'
|
import DeviceTemplateEditor from './DeviceTemplateEditor'
|
||||||
|
|
||||||
|
type EditorTarget = { mode: 'new' } | { mode: 'edit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate }
|
||||||
|
|
||||||
export default function DevicePalette() {
|
export default function DevicePalette() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
||||||
@@ -16,7 +20,9 @@ export default function DevicePalette() {
|
|||||||
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
|
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
|
||||||
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
|
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
|
||||||
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
|
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
|
||||||
const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null)
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||||
|
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
|
||||||
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
||||||
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
||||||
// Collapsed by default — expanding on demand is the whole point (keeps a
|
// Collapsed by default — expanding on demand is the whole point (keeps a
|
||||||
@@ -81,6 +87,18 @@ export default function DevicePalette() {
|
|||||||
|
|
||||||
const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds])
|
const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds])
|
||||||
|
|
||||||
|
const pendingEntityIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
mySubmissions.filter((s) => s.entityType === 'device_template' && s.status === 'pending').map((s) => s.entityId),
|
||||||
|
),
|
||||||
|
[mySubmissions],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmitForPublic = (template: DeviceTemplate) => {
|
||||||
|
submitForReview('device_template', template.id, deviceTemplateToRow(template))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
|
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
|
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
|
||||||
@@ -94,7 +112,7 @@ export default function DevicePalette() {
|
|||||||
Connectors
|
Connectors
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorTarget('new')}
|
onClick={() => setEditorTarget({ mode: 'new' })}
|
||||||
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
||||||
>
|
>
|
||||||
+ Custom
|
+ Custom
|
||||||
@@ -136,9 +154,33 @@ export default function DevicePalette() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||||
|
{pendingEntityIds.has(template.id) ? (
|
||||||
|
<span
|
||||||
|
title="A submission for this device is pending Admin review"
|
||||||
|
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
) : template.custom ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmitForPublic(template)}
|
||||||
|
title="Submit this to the public catalog for Admin review"
|
||||||
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
⇪
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditorTarget({ mode: 'suggestEdit', template })}
|
||||||
|
title="Suggest an edit to this device for Admin review"
|
||||||
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{template.custom && (
|
{template.custom && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorTarget(template)}
|
onClick={() => setEditorTarget({ mode: 'edit', template })}
|
||||||
title="Edit this device"
|
title="Edit this device"
|
||||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
>
|
>
|
||||||
@@ -199,7 +241,8 @@ export default function DevicePalette() {
|
|||||||
)}
|
)}
|
||||||
{editorTarget && (
|
{editorTarget && (
|
||||||
<DeviceTemplateEditor
|
<DeviceTemplateEditor
|
||||||
template={editorTarget === 'new' ? undefined : editorTarget}
|
template={editorTarget.mode === 'new' ? undefined : editorTarget.template}
|
||||||
|
submissionMode={editorTarget.mode === 'suggestEdit'}
|
||||||
onClose={() => setEditorTarget(null)}
|
onClose={() => setEditorTarget(null)}
|
||||||
onSaved={(categoryId) => expandCategory(categoryId)}
|
onSaved={(categoryId) => expandCategory(categoryId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
||||||
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
|
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
|
||||||
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
|
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
|
||||||
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import CategorySelect from '../common/CategorySelect'
|
import CategorySelect from '../common/CategorySelect'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
@@ -21,11 +23,22 @@ function nextDraftKey() {
|
|||||||
|
|
||||||
export default function DeviceTemplateEditor({
|
export default function DeviceTemplateEditor({
|
||||||
template,
|
template,
|
||||||
|
submissionMode = false,
|
||||||
|
resubmitId,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
/** When set, edits this existing custom template instead of creating a new one. */
|
/** When set, edits/prefills from this existing template instead of creating a new one. */
|
||||||
template?: DeviceTemplate
|
template?: DeviceTemplate
|
||||||
|
/** When true, `template` is a public entry you don't own — saving submits
|
||||||
|
* a suggested edit for Admin review instead of mutating it directly
|
||||||
|
* (which RLS wouldn't allow anyway). */
|
||||||
|
submissionMode?: boolean
|
||||||
|
/** When set, saving revises this existing submission (of your own,
|
||||||
|
* pending or rejected) instead of creating a new one — see
|
||||||
|
* MySubmissionsModal's "Edit & resubmit". Takes precedence over
|
||||||
|
* `submissionMode` if both are somehow set. */
|
||||||
|
resubmitId?: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** Called with the template's category id right before closing, so the
|
/** Called with the template's category id right before closing, so the
|
||||||
* palette can expand that category and reveal what was just created/edited. */
|
* palette can expand that category and reveal what was just created/edited. */
|
||||||
@@ -35,6 +48,8 @@ export default function DeviceTemplateEditor({
|
|||||||
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
|
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
|
||||||
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
|
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
|
||||||
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
||||||
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||||
|
const resubmit = useSubmissionStore((s) => s.resubmit)
|
||||||
|
|
||||||
const portTypes = useMemo(
|
const portTypes = useMemo(
|
||||||
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
@@ -124,7 +139,11 @@ export default function DeviceTemplateEditor({
|
|||||||
cost: cost.trim() === '' ? undefined : Number(cost),
|
cost: cost.trim() === '' ? undefined : Number(cost),
|
||||||
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
|
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
|
||||||
}
|
}
|
||||||
if (template) {
|
if (resubmitId) {
|
||||||
|
resubmit(resubmitId, deviceTemplateToRow(fields))
|
||||||
|
} else if (submissionMode && template) {
|
||||||
|
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
|
||||||
|
} else if (template) {
|
||||||
updateCustomDeviceTemplate(template.id, fields)
|
updateCustomDeviceTemplate(template.id, fields)
|
||||||
} else {
|
} else {
|
||||||
addCustomDeviceTemplate(fields)
|
addCustomDeviceTemplate(fields)
|
||||||
@@ -134,7 +153,11 @@ export default function DeviceTemplateEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={template ? 'Edit Custom Device' : 'New Custom Device'} onClose={onClose} width="max-w-xl">
|
<Modal
|
||||||
|
title={resubmitId ? 'Revise Submission' : submissionMode ? 'Suggest an Edit' : template ? 'Edit Custom Device' : 'New Custom Device'}
|
||||||
|
onClose={onClose}
|
||||||
|
width="max-w-xl"
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="col-span-2 text-xs font-medium text-slate-600">
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
||||||
@@ -327,7 +350,7 @@ export default function DeviceTemplateEditor({
|
|||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{template ? 'Save changes' : 'Save device'}
|
{resubmitId ? 'Resubmit' : submissionMode ? 'Submit for review' : template ? 'Save changes' : 'Save device'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { useMemo, useState } from 'react'
|
|||||||
import { PORT_CATEGORIES } from '../../domain/constants'
|
import { PORT_CATEGORIES } from '../../domain/constants'
|
||||||
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/project'
|
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/project'
|
||||||
import type { PortType } from '../../domain/types'
|
import type { PortType } from '../../domain/types'
|
||||||
|
import { portTypeToRow } from '../../data/catalogRowMapping'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
|
||||||
function EditCustomPortTypeForm({
|
export function PortTypeForm({
|
||||||
portType,
|
portType,
|
||||||
allTypes,
|
allTypes,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
portType: PortType
|
portType: PortType
|
||||||
allTypes: PortType[]
|
allTypes: PortType[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
|
onSave: (fields: Omit<PortType, 'id' | 'custom'>) => void
|
||||||
}) {
|
}) {
|
||||||
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
|
|
||||||
const [name, setName] = useState(portType.name)
|
const [name, setName] = useState(portType.name)
|
||||||
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
|
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
|
||||||
const [family, setFamily] = useState(portType.family)
|
const [family, setFamily] = useState(portType.family)
|
||||||
@@ -36,13 +39,13 @@ function EditCustomPortTypeForm({
|
|||||||
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
|
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
|
||||||
(f) => f !== family.trim(),
|
(f) => f !== family.trim(),
|
||||||
)
|
)
|
||||||
updateCustomPortType(portType.id, {
|
onSave({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
category,
|
category,
|
||||||
family: family.trim(),
|
family: family.trim(),
|
||||||
compatibleFamilyIds,
|
compatibleFamilyIds,
|
||||||
|
maxConnections: portType.maxConnections,
|
||||||
})
|
})
|
||||||
onCancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +104,11 @@ function EditCustomPortTypeForm({
|
|||||||
|
|
||||||
export default function PortTypesPanel() {
|
export default function PortTypesPanel() {
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
|
||||||
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [suggestingId, setSuggestingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const allTypes = useMemo(
|
const allTypes = useMemo(
|
||||||
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
@@ -110,6 +117,17 @@ export default function PortTypesPanel() {
|
|||||||
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
|
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
|
||||||
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
|
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
|
||||||
|
|
||||||
|
// Entities with a submission of yours already pending — used to hide the
|
||||||
|
// submit/suggest actions so the same entity can't be piled onto the
|
||||||
|
// review queue twice while an earlier submission is still outstanding.
|
||||||
|
const pendingEntityIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
mySubmissions.filter((s) => s.entityType === 'port_type' && s.status === 'pending').map((s) => s.entityId),
|
||||||
|
),
|
||||||
|
[mySubmissions],
|
||||||
|
)
|
||||||
|
|
||||||
const describeCompatibility = (pt: PortType) => {
|
const describeCompatibility = (pt: PortType) => {
|
||||||
const families = new Set(pt.compatibleFamilyIds ?? [])
|
const families = new Set(pt.compatibleFamilyIds ?? [])
|
||||||
if (families.size === 0) return null
|
if (families.size === 0) return null
|
||||||
@@ -117,6 +135,15 @@ export default function PortTypesPanel() {
|
|||||||
return names.length > 0 ? names.join(', ') : null
|
return names.length > 0 ? names.join(', ') : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitForPublic = (pt: PortType) => {
|
||||||
|
submitForReview('port_type', pt.id, portTypeToRow(pt))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuggestEdit = (id: string, fields: Omit<PortType, 'id' | 'custom'>) => {
|
||||||
|
submitForReview('port_type', id, portTypeToRow(fields))
|
||||||
|
setSuggestingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -131,11 +158,15 @@ export default function PortTypesPanel() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{customTypes.map((pt) =>
|
{customTypes.map((pt) =>
|
||||||
editingId === pt.id ? (
|
editingId === pt.id ? (
|
||||||
<EditCustomPortTypeForm
|
<PortTypeForm
|
||||||
key={pt.id}
|
key={pt.id}
|
||||||
portType={pt}
|
portType={pt}
|
||||||
allTypes={allTypes}
|
allTypes={allTypes}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
|
onSave={(fields) => {
|
||||||
|
updateCustomPortType(pt.id, fields)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -149,13 +180,28 @@ export default function PortTypesPanel() {
|
|||||||
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''}
|
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{pendingEntityIds.has(pt.id) ? (
|
||||||
|
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
|
||||||
|
Pending review
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmitForPublic(pt)}
|
||||||
|
title="Submit this to the public catalog for Admin review"
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingId(pt.id)}
|
onClick={() => setEditingId(pt.id)}
|
||||||
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,17 +212,43 @@ export default function PortTypesPanel() {
|
|||||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Built-in ({builtInTypes.length})
|
Built-in ({builtInTypes.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
|
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
|
||||||
{builtInTypes.map((pt) => (
|
{builtInTypes.map((pt) =>
|
||||||
<div key={pt.id} className="px-1 py-0.5 text-[11px] text-slate-500">
|
suggestingId === pt.id ? (
|
||||||
|
<PortTypeForm
|
||||||
|
key={pt.id}
|
||||||
|
portType={pt}
|
||||||
|
allTypes={allTypes}
|
||||||
|
onCancel={() => setSuggestingId(null)}
|
||||||
|
onSave={(fields) => handleSuggestEdit(pt.id, fields)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={pt.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
{pt.name}
|
{pt.name}
|
||||||
{describeCompatibility(pt) && (
|
{describeCompatibility(pt) && (
|
||||||
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span>
|
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
{pendingEntityIds.has(pt.id) ? (
|
||||||
|
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
|
||||||
|
Edit pending review
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setSuggestingId(pt.id)}
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Suggest edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-slate-400">Built-in connector types can't be edited or removed.</p>
|
<p className="mt-1 text-[10px] text-slate-400">
|
||||||
|
Built-in connector types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<CableType, 'id' | 'custom'> | 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CatalogSubmission['entityType'], string> = {
|
||||||
|
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<string, unknown> | 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 <span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">Pending</span>
|
||||||
|
case 'approved':
|
||||||
|
return <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">Approved</span>
|
||||||
|
case 'rejected':
|
||||||
|
return <span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">Rejected</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Modal title="My Submissions" onClose={onClose} width="max-w-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mySubmissions.length === 0 && (
|
||||||
|
<p className="py-2 text-center text-xs italic text-slate-400">
|
||||||
|
No submissions yet — use "Submit" or "Suggest edit" on a catalog entry to propose it for the public
|
||||||
|
catalog.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<PortTypeForm
|
||||||
|
key={submission.id}
|
||||||
|
portType={entity}
|
||||||
|
allTypes={allPortTypesList}
|
||||||
|
onCancel={() => 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 (
|
||||||
|
<CableTypeForm
|
||||||
|
key={submission.id}
|
||||||
|
initial={entity}
|
||||||
|
portTypes={allPortTypesList}
|
||||||
|
onCancel={() => 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 <DeviceTemplateEditor key={submission.id} template={entity} onClose={() => 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 (
|
||||||
|
<PortTypeForm
|
||||||
|
key={submission.id}
|
||||||
|
portType={{ id: submission.entityId, custom: false, ...rowToPortTypeFields(submission.proposedData) }}
|
||||||
|
allTypes={allPortTypesList}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
onSave={(fields) => {
|
||||||
|
resubmit(submission.id, portTypeToRow(fields))
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isEditing && submission.entityType === 'cable_type') {
|
||||||
|
return (
|
||||||
|
<CableTypeForm
|
||||||
|
key={submission.id}
|
||||||
|
initial={{ id: submission.entityId, custom: false, ...rowToCableTypeFields(submission.proposedData) }}
|
||||||
|
portTypes={allPortTypesList}
|
||||||
|
onCancel={() => 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 (
|
||||||
|
<DeviceTemplateEditor
|
||||||
|
key={submission.id}
|
||||||
|
template={{ id: submission.entityId, custom: false, ...rowToDeviceTemplateFields(submission.proposedData) }}
|
||||||
|
resubmitId={submission.id}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onSaved={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isEditing && submission.entityType === 'device_category') {
|
||||||
|
return (
|
||||||
|
<div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={categoryDraft}
|
||||||
|
onChange={(e) => setCategoryDraft(e.target.value)}
|
||||||
|
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button onClick={() => setEditingId(null)} className="shrink-0 text-[11px] text-slate-500">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!categoryDraft.trim()) return
|
||||||
|
resubmit(submission.id, { name: categoryDraft.trim() })
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
Resubmit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={submission.id} className="rounded border border-slate-200 bg-white p-2 text-xs">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium text-slate-700">{entityDisplayName(catalog, submission)}</span>
|
||||||
|
{statusBadge(submission)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400">
|
||||||
|
{ENTITY_TYPE_LABELS[submission.entityType]} · submitted {formatDate(submission.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(submission)}
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{ownEntry && submission.status === 'rejected' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const row = liveEntityRow(catalog, submission)
|
||||||
|
if (row) resubmit(submission.id, row)
|
||||||
|
}}
|
||||||
|
title="Resubmit your current library entry for review"
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Resubmit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canRevise && (
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(submission)}
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
{submission.status === 'rejected' ? 'Revise & resubmit' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{submission.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleWithdraw(submission)}
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
|
||||||
|
<p className="mt-1.5 text-[10px] text-slate-400">
|
||||||
|
Tracks your library entry — editing it here (or from the palette) keeps this submission up to date automatically.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{submission.status === 'rejected' && submission.reviewReason && (
|
||||||
|
<p className="mt-1.5 rounded bg-red-50 px-2 py-1 text-[11px] text-red-700">
|
||||||
|
<span className="font-medium">Reason: </span>
|
||||||
|
{submission.reviewReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<CatalogSubmission[]>
|
||||||
|
/** 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<string, unknown>): Promise<CatalogSubmission>
|
||||||
|
/** Revises a still-pending or previously-rejected submission of your own
|
||||||
|
* and puts it (back) into the pending queue. */
|
||||||
|
resubmit(id: string, proposedData: Record<string, unknown>): Promise<void>
|
||||||
|
/** 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<string, unknown>): Promise<void>
|
||||||
|
/** Withdraws your own still-pending submission. */
|
||||||
|
withdraw(id: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
|
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
|
||||||
|
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
|
||||||
import type { CatalogRepository } from './CatalogRepository'
|
import type { CatalogRepository } from './CatalogRepository'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
@@ -156,16 +157,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
|||||||
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
|
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
|
||||||
const ownerId = await this.currentUserId()
|
const ownerId = await this.currentUserId()
|
||||||
const id = uuid()
|
const id = uuid()
|
||||||
const { error } = await supabase.from('port_types').insert({
|
const { error } = await supabase
|
||||||
id,
|
.from('port_types')
|
||||||
name: portType.name,
|
.insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId })
|
||||||
category: portType.category,
|
|
||||||
family: portType.family,
|
|
||||||
compatible_family_ids: portType.compatibleFamilyIds ?? [],
|
|
||||||
max_connections: portType.maxConnections ?? null,
|
|
||||||
is_public: false,
|
|
||||||
owner_id: ownerId,
|
|
||||||
})
|
|
||||||
if (error) console.error('Failed to add port type to Supabase', error)
|
if (error) console.error('Failed to add port type to Supabase', error)
|
||||||
return { ...portType, id, custom: true }
|
return { ...portType, id, custom: true }
|
||||||
}
|
}
|
||||||
@@ -184,16 +178,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
|||||||
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
|
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
|
||||||
const ownerId = await this.currentUserId()
|
const ownerId = await this.currentUserId()
|
||||||
const id = uuid()
|
const id = uuid()
|
||||||
const { error } = await supabase.from('cable_types').insert({
|
const { error } = await supabase
|
||||||
id,
|
.from('cable_types')
|
||||||
name: cableType.name,
|
.insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId })
|
||||||
family: cableType.family,
|
|
||||||
family2: cableType.family2 ?? null,
|
|
||||||
unit: cableType.unit,
|
|
||||||
cost_per_unit: cableType.costPerUnit ?? null,
|
|
||||||
is_public: false,
|
|
||||||
owner_id: ownerId,
|
|
||||||
})
|
|
||||||
if (error) console.error('Failed to add cable type to Supabase', error)
|
if (error) console.error('Failed to add cable type to Supabase', error)
|
||||||
return { ...cableType, id, custom: true }
|
return { ...cableType, id, custom: true }
|
||||||
}
|
}
|
||||||
@@ -212,16 +199,8 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
|||||||
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
|
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
|
||||||
const ownerId = await this.currentUserId()
|
const ownerId = await this.currentUserId()
|
||||||
const id = uuid()
|
const id = uuid()
|
||||||
const { error } = await supabase.from('device_templates').insert({
|
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
|
||||||
id,
|
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId })
|
||||||
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,
|
|
||||||
})
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to add device template to Supabase', error)
|
console.error('Failed to add device template to Supabase', error)
|
||||||
return { ...template, id, custom: true }
|
return { ...template, id, custom: true }
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
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<CatalogSubmission[]> {
|
||||||
|
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<string, unknown>): Promise<CatalogSubmission> {
|
||||||
|
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<string, unknown>): Promise<void> {
|
||||||
|
// 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<string, unknown>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const { error } = await supabase.from('catalog_submissions').delete().eq('id', id)
|
||||||
|
if (error) console.error('Failed to withdraw submission from Supabase', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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 deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
|
||||||
|
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<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 }>) ?? []
|
||||||
|
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, unknown>): string {
|
||||||
|
return row.name as string
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
|
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
|
||||||
import type { CatalogRepository } from '../data/CatalogRepository'
|
import type { CatalogRepository } from '../data/CatalogRepository'
|
||||||
|
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from '../data/catalogRowMapping'
|
||||||
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
|
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
|
||||||
|
import { useSubmissionStore } from './submissionStore'
|
||||||
|
|
||||||
const repository: CatalogRepository = new SupabaseCatalogRepository()
|
const repository: CatalogRepository = new SupabaseCatalogRepository()
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
|||||||
portTypes: state.catalog.portTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
|
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) => {
|
addCustomCableType: async (cableType) => {
|
||||||
@@ -95,6 +99,8 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
|||||||
cableTypes: state.catalog.cableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
|
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) => {
|
addCustomDeviceTemplate: async (template) => {
|
||||||
@@ -110,6 +116,8 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
|||||||
deviceTemplates: state.catalog.deviceTemplates.map((t) => (t.id === id ? { ...t, ...patch } : t)),
|
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) => {
|
removeCustomDeviceTemplate: async (id) => {
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
/** 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<string, unknown>) => Promise<void>
|
||||||
|
/** Revises a pending/rejected submission of your own and puts it back in the queue. */
|
||||||
|
resubmit: (id: string, proposedData: Record<string, unknown>) => Promise<void>
|
||||||
|
/** 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<string, unknown>) => Promise<void>
|
||||||
|
withdraw: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubmissionStore = create<SubmissionStoreState>((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) }))
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user