// CIL[BEGIN chunk_id="RecordDetailPage_module_header_1784761401" type="module_header" identity="module_header" parent="none"] // CIL[DOC-MACRO purpose="Render a generic detail/edit page for a Workbench record type"] // CIL[DOC-RESPONSIBILITY responsibility="Fetch a record by id, edit selected fields, save via admin-data"] // CIL[DOC-CONTEXT module="workbench.components.records.RecordDetailPage" layer="frontend-component" related_files="[\"custom/apps/workbench/components/records/RecordListPage.tsx\",\"custom/apps/workbench/pages/work/EpicDetailPage.tsx\",\"custom/apps/workbench/pages/work/SprintDetailPage.tsx\",\"custom/apps/workbench/pages/work/TestPlanDetailPage.tsx\",\"custom/apps/workbench/pages/work/RunbookDetailPage.tsx\"]"] // CIL[DOC-CONTRACT inputs="typeSlug:string, title:string, fields:FieldDef[], listRoute:string" outputs="JSX detail/edit page" errors="fetch/save errors rendered inline" side_effects="calls /api/admin-data, navigates on save/delete"] // CIL[DOC-MICRO logic_summary="Loads a record, renders fields in read-only or edit mode, and PATCHes/POSTs changes back to the items endpoint" notable_decisions="[]"] // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"] // CIL[DOC-STABILITY stability="experimental"] // CIL[DOC-AUDIENCE audience="developer"] import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { apiFetch } from '@core/lib/api' import { Button } from '@core/components/ui/button' import { Input } from '@core/components/ui/input' import { Textarea } from '@core/components/ui/textarea' import { Label } from '@core/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@core/components/ui/select' import { Card, CardContent, CardHeader, CardTitle } from '@core/components/ui/card' import { Badge } from '@core/components/ui/badge' import { ArrowLeft, Pencil, Trash2 } from 'lucide-react' // CIL[END chunk_id="RecordDetailPage_module_header_1784761401"] // CIL[BEGIN chunk_id="RecordDetailPage_types_1784761401" type="type" identity="FieldDef" parent="none"] // CIL[DOC-MACRO description="Implementation of FieldDef" macro="Renders and manages FieldDef UI state"] // CIL[DOC-CONTRACT input_spec="See module header or type signature" output_spec="JSX or value" side_effects="none"] // CIL[DOC-STABILITY stability="experimental"] export interface FieldDef { key: string label: string type: 'text' | 'textarea' | 'select' | 'number' options?: { label: string; value: string }[] source?: 'system' | 'data' } // CIL[END chunk_id="RecordDetailPage_types_1784761401"] const SYSTEM_KEYS = new Set(['title', 'description', 'status']) function isSystemField(field: FieldDef): boolean { if (field.source) return field.source === 'system' return SYSTEM_KEYS.has(field.key) } // CIL[BEGIN chunk_id="RecordDetailPage_recordDetailPage_1784761401" type="function" identity="RecordDetailPage" parent="none"] // CIL[DOC-MACRO description="Implementation of RecordDetailPage" macro="Renders and manages RecordDetailPage UI state"] // CIL[DOC-CONTRACT input_spec="See module header or type signature" output_spec="JSX or value" side_effects="none"] // CIL[DOC-STABILITY stability="experimental"] export interface RecordDetailPageProps { typeSlug: string title: string fields: FieldDef[] listRoute: string createTitle?: string beforeSave?: (payload: any, record: any) => Promise | any renderActions?: (record: any) => ReactNode renderSidebar?: (record: any) => ReactNode layout?: 'default' | 'workbench' } export function RecordDetailPage({ typeSlug, title, fields, listRoute, createTitle, beforeSave, renderActions, renderSidebar, layout = 'default' }: RecordDetailPageProps) { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const isCreating = id === 'new' const [record, setRecord] = useState(null) const [loading, setLoading] = useState(!isCreating) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [editing, setEditing] = useState(isCreating) const [formData, setFormData] = useState>({}) useEffect(() => { if (isCreating) { const initial: Record = {} fields.forEach(f => { initial[f.key] = f.type === 'number' ? 0 : f.options ? f.options[0]?.value : '' }) setFormData(initial) setLoading(false) return } setLoading(true) apiFetch(`/api/admin-data?entity=items&id=${id}`) .then(r => r.json()) .then(json => { const rec = json?.data ?? json if (!rec) throw new Error('Record not found') setRecord(rec) const initial: Record = {} fields.forEach(f => { initial[f.key] = isSystemField(f) ? rec[f.key] : rec.data?.[f.key] }) setFormData(initial) setError(null) }) .catch(err => setError(err instanceof Error ? err.message : 'Failed to load record')) .finally(() => setLoading(false)) }, [id, isCreating]) const changedFields = useMemo(() => { if (isCreating) return fields.map(f => f.key) return fields .filter(f => { const oldValue = isSystemField(f) ? record?.[f.key] : record?.data?.[f.key] return formData[f.key] !== oldValue }) .map(f => f.key) }, [formData, record, fields, isCreating]) const handleChange = (key: string, value: any) => { setFormData(prev => ({ ...prev, [key]: value })) } const buildPayload = () => { const system: Record = { type_slug: typeSlug } const data: Record = {} fields.forEach(f => { const value = formData[f.key] if (isSystemField(f)) { system[f.key] = value } else { data[f.key] = value } }) if (isCreating) { system.status = system.status || 'active' system.entity = 'items' } return { ...system, data: Object.keys(data).length ? data : undefined } } const handleSave = async () => { setSaving(true) setError(null) try { let payload = buildPayload() if (beforeSave) { payload = await beforeSave(payload, record) } let response if (isCreating) { response = await apiFetch('/api/admin-data?entity=items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) } else { response = await apiFetch(`/api/admin-data?entity=items&id=${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) } if (!response.ok) { const body = await response.json().catch(() => ({})) throw new Error(body.error || body.message || `Failed to save ${title.toLowerCase()}`) } if (isCreating) { const created = await response.json().then(j => j?.data ?? j) navigate(listRoute.replace(':id', created.id)) } else { setEditing(false) setRecord((prev: any) => { const next = { ...prev } fields.forEach(f => { const value = formData[f.key] if (isSystemField(f)) next[f.key] = value else next.data = { ...(next.data || {}), [f.key]: value } }) return next }) } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save') } finally { setSaving(false) } } const handleDelete = async () => { if (!id || isCreating) return if (!confirm(`Delete this ${title.toLowerCase()}?`)) return try { const r = await apiFetch(`/api/admin-data?entity=items&id=${id}`, { method: 'DELETE' }) if (!r.ok) throw new Error('Failed to delete') navigate(listRoute.replace(':id', '')) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete') } } const renderField = (field: FieldDef, value: any) => { if (!editing) { return (
{field.type === 'select' && field.options ? ( {field.options.find(o => o.value === value)?.label || value || '—'} ) : ( {value || '—'} )}
) } if (field.type === 'textarea') { return