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.
322 lines
15 KiB
TypeScript
322 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|