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.
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
|
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
|
|
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
|
|
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
|
import { useCatalogStore } from '../../state/catalogStore'
|
|
import { useSubmissionStore } from '../../state/submissionStore'
|
|
import CategorySelect from '../common/CategorySelect'
|
|
import Modal from '../common/Modal'
|
|
|
|
interface DraftPort {
|
|
key: string
|
|
name: string
|
|
direction: PortDirection
|
|
portTypeId: string
|
|
}
|
|
|
|
let draftKeySeq = 0
|
|
function nextDraftKey() {
|
|
draftKeySeq += 1
|
|
return `draft-${draftKeySeq}`
|
|
}
|
|
|
|
export default function DeviceTemplateEditor({
|
|
template,
|
|
submissionMode = false,
|
|
resubmitId,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
/** When set, edits/prefills from this existing template instead of creating a new one. */
|
|
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
|
|
/** Called with the template's category id right before closing, so the
|
|
* palette can expand that category and reveal what was just created/edited. */
|
|
onSaved?: (categoryId: string) => void
|
|
}) {
|
|
const catalog = useCatalogStore((s) => s.catalog)
|
|
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
|
|
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
|
|
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
|
const resubmit = useSubmissionStore((s) => s.resubmit)
|
|
|
|
const portTypes = useMemo(
|
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
|
[catalog],
|
|
)
|
|
|
|
const [name, setName] = useState(template?.name ?? '')
|
|
const [category, setCategory] = useState<string>(template?.category ?? 'other')
|
|
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
|
|
const [model, setModel] = useState(template?.model ?? '')
|
|
const [cost, setCost] = useState(template?.cost?.toString() ?? '')
|
|
const [ports, setPorts] = useState<DraftPort[]>(
|
|
() =>
|
|
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
|
|
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
|
|
],
|
|
)
|
|
const [newPortTypeOpen, setNewPortTypeOpen] = useState(false)
|
|
const [newPortTypeName, setNewPortTypeName] = useState('')
|
|
const [newPortTypeFamily, setNewPortTypeFamily] = useState('')
|
|
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
|
|
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
|
|
const [focusKey, setFocusKey] = useState<string | null>(null)
|
|
|
|
const toggleAlsoCompatible = (portTypeId: string) => {
|
|
setAlsoCompatibleWith((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(portTypeId)) next.delete(portTypeId)
|
|
else next.add(portTypeId)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const updatePort = (key: string, patch: Partial<DraftPort>) => {
|
|
setPorts((prev) => prev.map((p) => (p.key === key ? { ...p, ...patch } : p)))
|
|
}
|
|
|
|
const addPortRow = () => {
|
|
const key = nextDraftKey()
|
|
setPorts((prev) => [
|
|
...prev,
|
|
{ key, name: `Port ${prev.length + 1}`, direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
|
|
])
|
|
setFocusKey(key)
|
|
}
|
|
|
|
const clonePortRow = (sourceKey: string) => {
|
|
const key = nextDraftKey()
|
|
setPorts((prev) => {
|
|
const index = prev.findIndex((p) => p.key === sourceKey)
|
|
if (index === -1) return prev
|
|
const source = prev[index]
|
|
const clone: DraftPort = { ...source, key, name: incrementPortName(source.name) }
|
|
return [...prev.slice(0, index + 1), clone, ...prev.slice(index + 1)]
|
|
})
|
|
setFocusKey(key)
|
|
}
|
|
|
|
const removePortRow = (key: string) => {
|
|
setPorts((prev) => prev.filter((p) => p.key !== key))
|
|
}
|
|
|
|
const handleCreatePortType = () => {
|
|
if (!newPortTypeName.trim()) return
|
|
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
|
|
addCustomPortType({
|
|
name: newPortTypeName.trim(),
|
|
category: newPortTypeCategory,
|
|
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
|
|
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
|
|
})
|
|
setNewPortTypeName('')
|
|
setNewPortTypeFamily('')
|
|
setAlsoCompatibleWith(new Set())
|
|
setNewPortTypeOpen(false)
|
|
}
|
|
|
|
const canSave = name.trim().length > 0 && ports.length > 0 && ports.every((p) => p.portTypeId)
|
|
|
|
const handleSave = () => {
|
|
if (!canSave) return
|
|
const fields = {
|
|
name: name.trim(),
|
|
category,
|
|
manufacturer: manufacturer.trim() || undefined,
|
|
model: model.trim() || undefined,
|
|
cost: cost.trim() === '' ? undefined : Number(cost),
|
|
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
|
|
}
|
|
if (resubmitId) {
|
|
resubmit(resubmitId, deviceTemplateToRow(fields))
|
|
} else if (submissionMode && template) {
|
|
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
|
|
} else if (template) {
|
|
updateCustomDeviceTemplate(template.id, fields)
|
|
} else {
|
|
addCustomDeviceTemplate(fields)
|
|
}
|
|
onSaved?.(category)
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<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="grid grid-cols-2 gap-3">
|
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
|
Name
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
placeholder="e.g. Living Room Display"
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Category
|
|
<CategorySelect value={category} onChange={setCategory} />
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Manufacturer
|
|
<input
|
|
value={manufacturer}
|
|
onChange={(e) => setManufacturer(e.target.value)}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Model
|
|
<input
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Suggested cost
|
|
<div className="mt-1 flex items-center gap-1">
|
|
<span className="text-xs text-slate-500">$</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step={0.01}
|
|
value={cost}
|
|
onChange={(e) => setCost(e.target.value)}
|
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
placeholder="e.g. 249.00"
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
|
|
<div className="space-y-1.5">
|
|
{ports.map((port) => (
|
|
<div key={port.key} className="flex items-center gap-1.5">
|
|
<input
|
|
ref={(el) => {
|
|
if (el && focusKey === port.key) {
|
|
el.focus()
|
|
setFocusKey(null)
|
|
}
|
|
}}
|
|
value={port.name}
|
|
onChange={(e) => updatePort(port.key, { name: e.target.value })}
|
|
className="w-28 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
|
placeholder="Port name"
|
|
/>
|
|
<select
|
|
value={port.direction}
|
|
onChange={(e) => updatePort(port.key, { direction: e.target.value as PortDirection })}
|
|
className="rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
{PORT_DIRECTIONS.map((d) => (
|
|
<option key={d.value} value={d.value}>
|
|
{d.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={port.portTypeId}
|
|
onChange={(e) => updatePort(port.key, { portTypeId: e.target.value })}
|
|
className="w-32 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
{portTypes.map((pt) => (
|
|
<option key={pt.id} value={pt.id}>
|
|
{pt.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => clonePortRow(port.key)}
|
|
tabIndex={-1}
|
|
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
|
title="Duplicate this port"
|
|
>
|
|
⧉
|
|
</button>
|
|
<button
|
|
onClick={() => removePortRow(port.key)}
|
|
tabIndex={-1}
|
|
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500"
|
|
title="Remove port"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
|
|
</div>
|
|
<button onClick={addPortRow} className="mt-1.5 text-[11px] font-medium text-indigo-600 hover:underline">
|
|
+ Add port
|
|
</button>
|
|
|
|
<div className="mt-2 border-t border-slate-100 pt-2">
|
|
{!newPortTypeOpen ? (
|
|
<button
|
|
onClick={() => setNewPortTypeOpen(true)}
|
|
className="text-[11px] font-medium text-indigo-600 hover:underline"
|
|
>
|
|
+ Define a new connector type
|
|
</button>
|
|
) : (
|
|
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
value={newPortTypeName}
|
|
onChange={(e) => setNewPortTypeName(e.target.value)}
|
|
placeholder="Connector name (e.g. Zigbee)"
|
|
className="flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
|
/>
|
|
<select
|
|
value={newPortTypeCategory}
|
|
onChange={(e) => setNewPortTypeCategory(e.target.value as (typeof PORT_CATEGORIES)[number]['value'])}
|
|
className="rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
{PORT_CATEGORIES.map((c) => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<input
|
|
value={newPortTypeFamily}
|
|
onChange={(e) => setNewPortTypeFamily(e.target.value)}
|
|
placeholder="Compatibility family (optional, defaults to name)"
|
|
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-slate-500">
|
|
Two ports can be wired together only if their connector type shares the same family.
|
|
</p>
|
|
<div>
|
|
<p className="text-[10px] font-medium text-slate-600">
|
|
Also compatible with (for jacks that accept more than one kind — e.g. a patch bay that's
|
|
balanced or unbalanced):
|
|
</p>
|
|
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
|
|
{portTypes.map((pt) => (
|
|
<label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600">
|
|
<input
|
|
type="checkbox"
|
|
checked={alsoCompatibleWith.has(pt.id)}
|
|
onChange={() => toggleAlsoCompatible(pt.id)}
|
|
/>
|
|
{pt.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => setNewPortTypeOpen(false)} className="text-[11px] text-slate-500">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleCreatePortType}
|
|
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
|
>
|
|
Add connector type
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 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">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
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"
|
|
>
|
|
{resubmitId ? 'Resubmit' : submissionMode ? 'Submit for review' : template ? 'Save changes' : 'Save device'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|