// vibe_erp List View Designer page. // // Configuration editor for metadata list view definitions. // Lets admins define columns (visibility, label, format, sortable, // order), filters, default sort, and page size for any entity's // list view. A live preview DataTable renders placeholder rows // using the selected columns. // // Edit mode: when the URL carries a :slug param the page fetches // the existing definition and populates all fields for editing. // Create mode: all fields start blank / with sensible defaults. import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { metadata } from '@/api/client' import { PageHeader } from '@/components/PageHeader' import { ErrorBox } from '@/components/ErrorBox' import { DataTable, type Column } from '@/components/DataTable' // ─── Designer state types ────────────────────────────────────────── interface DesignerColumn { field: string label: string width?: string sortable: boolean format?: string visible: boolean } interface DesignerFilter { field: string operator: string label: string } interface DesignerState { slug: string entityName: string title: string columns: DesignerColumn[] defaultSort?: { field: string; direction: 'asc' | 'desc' } filters: DesignerFilter[] pageSize: number version: number } const FORMAT_OPTIONS = ['plain', 'date', 'money', 'status-badge', 'link'] as const const OPERATOR_OPTIONS = ['eq', 'contains', 'gt', 'lt', 'in'] as const function emptyState(): DesignerState { return { slug: '', entityName: '', title: '', columns: [], defaultSort: undefined, filters: [], pageSize: 25, version: 0, } } // ─── Component ───────────────────────────────────────────────────── export function ListViewDesignerPage() { const navigate = useNavigate() const { slug: routeSlug } = useParams<{ slug: string }>() const isEdit = Boolean(routeSlug) const [state, setState] = useState(emptyState) const [loading, setLoading] = useState(isEdit) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // ── Load existing definition in edit mode ────────────────────── useEffect(() => { if (!routeSlug) return setLoading(true) metadata .getListView(routeSlug) .then((def) => { setState({ slug: def.slug, entityName: def.entityName, title: def.title, columns: def.columns.map((c) => ({ field: c.field, label: c.label, width: c.width, sortable: c.sortable, format: c.format ?? 'plain', visible: true, })), defaultSort: def.defaultSort, filters: def.filters ?? [], pageSize: def.pageSize, version: def.version, }) }) .catch(setError) .finally(() => setLoading(false)) }, [routeSlug]) // ── Field updaters ───────────────────────────────────────────── const set = useCallback( (key: K, val: DesignerState[K]) => setState((s) => ({ ...s, [key]: val })), [], ) // ── Column helpers ───────────────────────────────────────────── const updateColumn = useCallback( (idx: number, patch: Partial) => setState((s) => { const cols = [...s.columns] cols[idx] = { ...cols[idx], ...patch } return { ...s, columns: cols } }), [], ) const moveColumn = useCallback( (idx: number, dir: -1 | 1) => setState((s) => { const target = idx + dir if (target < 0 || target >= s.columns.length) return s const cols = [...s.columns] ;[cols[idx], cols[target]] = [cols[target], cols[idx]] return { ...s, columns: cols } }), [], ) const removeColumn = useCallback( (idx: number) => setState((s) => ({ ...s, columns: s.columns.filter((_, i) => i !== idx), })), [], ) const addColumn = useCallback( () => setState((s) => ({ ...s, columns: [ ...s.columns, { field: '', label: '', sortable: false, format: 'plain', visible: true }, ], })), [], ) // ── Filter helpers ───────────────────────────────────────────── const updateFilter = useCallback( (idx: number, patch: Partial) => setState((s) => { const filters = [...s.filters] filters[idx] = { ...filters[idx], ...patch } return { ...s, filters } }), [], ) const removeFilter = useCallback( (idx: number) => setState((s) => ({ ...s, filters: s.filters.filter((_, i) => i !== idx), })), [], ) const addFilter = useCallback( () => setState((s) => ({ ...s, filters: [...s.filters, { field: '', operator: 'eq', label: '' }], })), [], ) // ── Save ─────────────────────────────────────────────────────── const onSave = async (e: FormEvent) => { e.preventDefault() setError(null) setSaving(true) try { const slug = state.slug.trim() if (!slug) throw new Error('Slug is required') if (!state.entityName.trim()) throw new Error('Entity name is required') const visibleColumns = state.columns .filter((c) => c.visible) .map((c) => ({ field: c.field, label: c.label, width: c.width || undefined, sortable: c.sortable, format: c.format === 'plain' ? undefined : (c.format as 'date' | 'money' | 'status-badge' | 'link'), })) await metadata.saveListView(slug, { slug, entityName: state.entityName, title: state.title, columns: visibleColumns, defaultSort: state.defaultSort, filters: state.filters.filter((f) => f.field.trim()), pageSize: state.pageSize, version: state.version + 1, }) navigate('/admin/metadata') } catch (err: unknown) { setError(err) } finally { setSaving(false) } } // ── Preview columns for DataTable ────────────────────────────── const previewColumns: Column>[] = useMemo( () => state.columns .filter((c) => c.visible && c.field.trim()) .map((c) => ({ header: c.label || c.field, key: c.field, })), [state.columns], ) const previewRows = useMemo(() => { if (previewColumns.length === 0) return [] const rows: Record[] = [] for (let i = 1; i <= 3; i++) { const row: Record = {} for (const col of previewColumns) { row[col.key] = `${col.key}-${i}` } rows.push(row) } return rows }, [previewColumns]) // ── Render ───────────────────────────────────────────────────── if (loading) { return (
Loading...
) } const inputCls = 'mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500' const smallInputCls = 'rounded-md border border-slate-300 px-2 py-1.5 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500' const smallSelectCls = 'rounded-md border border-slate-300 px-2 py-1.5 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500' return (
navigate('/admin/metadata')} > Cancel } />
{error != null ? : null} {/* ── Top bar: title, entity, slug ───────────────────── */}

General

set('title', e.target.value)} placeholder="e.g. Sales Orders" className={inputCls} />
set('entityName', e.target.value)} placeholder="e.g. SalesOrder" className={inputCls} />
set('slug', e.target.value)} placeholder="e.g. sales-orders-default" disabled={isEdit} className={`${inputCls}${isEdit ? ' bg-slate-50 text-slate-500' : ''}`} />
{/* ── Columns section ────────────────────────────────── */}

Columns

{state.columns.length === 0 ? (

No columns defined yet. Click "Add Column" to start.

) : (
{state.columns.map((col, idx) => ( ))}
Show Field Label Format Sortable Order
updateColumn(idx, { visible: e.target.checked }) } className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500" /> updateColumn(idx, { field: e.target.value }) } placeholder="field.name" className={smallInputCls + ' w-full'} /> updateColumn(idx, { label: e.target.value }) } placeholder="Column Label" className={smallInputCls + ' w-full'} /> updateColumn(idx, { sortable: e.target.checked }) } className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500" />
)}
{/* ── Filters section ────────────────────────────────── */}

Filters

{state.filters.length === 0 ? (

No filters defined. Click "Add Filter" to add filterable fields.

) : (
{state.filters.map((filter, idx) => (
updateFilter(idx, { field: e.target.value }) } placeholder="Field name" className={smallInputCls + ' flex-1'} /> updateFilter(idx, { label: e.target.value }) } placeholder="Display label" className={smallInputCls + ' flex-1'} />
))}
)}
{/* ── Sorting & page size section ────────────────────── */}

Sorting & Pagination

set('pageSize', Math.max(1, parseInt(e.target.value, 10) || 25)) } className={inputCls} />
{/* ── Preview section ────────────────────────────────── */}

Preview

{previewColumns.length === 0 ? (

Add visible columns with a field name to see a preview.

) : ( String(r[previewColumns[0]?.key] ?? '')} /> )}
{/* ── Actions ────────────────────────────────────────── */}
) }