diff --git a/web/src/App.tsx b/web/src/App.tsx index c0e67d1..ad74a93 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,11 +41,9 @@ import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' import { ShopFloorPage } from '@/pages/ShopFloorPage' import { AccountsPage } from '@/pages/AccountsPage' import { JournalEntriesPage } from '@/pages/JournalEntriesPage' - -// Stub pages (will be replaced in later tasks) -function MetadataAdminPage() { return
Metadata Admin - coming soon
} -function FormDesignerPage() { return
Form Designer - coming soon
} -function ListViewDesignerPage() { return
List View Designer - coming soon
} +import { FormDesignerPage } from '@/pages/FormDesignerPage' +import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage' +import { MetadataAdminPage } from '@/pages/MetadataAdminPage' export default function App() { return ( diff --git a/web/src/pages/FormDesignerPage.tsx b/web/src/pages/FormDesignerPage.tsx new file mode 100644 index 0000000..9794d51 --- /dev/null +++ b/web/src/pages/FormDesignerPage.tsx @@ -0,0 +1,727 @@ +// Form designer page. +// +// Two-panel layout with a property editor on the left and a live +// @rjsf preview on the right. Converts a flat DesignerField[] model +// into JSON Schema + UI Schema on every keystroke so the preview +// always reflects the current state. + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import Form from '@rjsf/core' +import type { RJSFSchema, UiSchema } from '@rjsf/utils' +import validator from '@rjsf/validator-ajv8' +import { metadata } from '@/api/client' +import type { FormDefinition, FormPurpose } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' +import { Loading } from '@/components/Loading' +import { vibeWidgets } from '@/components/form-widgets' +import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' + +// ── Types ────────────────────────────────────────────────────────── + +interface DesignerField { + id: string + key: string + label: string + type: 'string' | 'integer' | 'number' | 'boolean' + required: boolean + width: 1 | 2 | 3 + placeholder?: string + helpText?: string + widgetOverride?: string + visibleWhen?: { field: string; equals: string } + isSectionDivider?: boolean + sectionTitle?: string +} + +const FIELD_TYPES = ['string', 'integer', 'number', 'boolean'] as const +const WIDTH_OPTIONS = [1, 2, 3] as const +const PURPOSES: FormPurpose[] = ['create', 'edit', 'user-task', 'view'] +const WIDGET_OPTIONS = [ + '', + 'partner-picker', + 'item-picker', + 'uom-selector', + 'location-picker', + 'money-input', + 'quantity-input', + 'textarea', +] as const + +// ── Helpers ──────────────────────────────────────────────────────── + +function uid(): string { + return crypto.randomUUID() +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +function blankField(): DesignerField { + return { + id: uid(), + key: '', + label: '', + type: 'string', + required: false, + width: 3, + } +} + +function blankSection(): DesignerField { + return { + id: uid(), + key: '', + label: '', + type: 'string', + required: false, + width: 3, + isSectionDivider: true, + sectionTitle: '', + } +} + +// ── Schema builders ──────────────────────────────────────────────── + +function buildJsonSchema(fields: DesignerField[]): RJSFSchema { + const properties: Record = {} + const required: string[] = [] + + for (const f of fields) { + if (f.isSectionDivider) continue + if (!f.key) continue + properties[f.key] = { + type: f.type, + title: f.label || f.key, + ...(f.helpText ? { description: f.helpText } : {}), + } + if (f.required) required.push(f.key) + } + + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + } as RJSFSchema +} + +function buildUiSchema(fields: DesignerField[]): UiSchema { + const schema: Record = { + 'ui:order': fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key), + 'ui:submitButtonOptions': { norender: true }, + } + + for (const f of fields) { + if (f.isSectionDivider || !f.key) continue + const fieldUi: Record = {} + if (f.placeholder) fieldUi['ui:placeholder'] = f.placeholder + if (f.widgetOverride) fieldUi['ui:widget'] = f.widgetOverride + if (f.visibleWhen?.field && f.visibleWhen?.equals) { + fieldUi['ui:visible'] = f.visibleWhen + } + if (Object.keys(fieldUi).length > 0) schema[f.key] = fieldUi + } + + return schema as UiSchema +} + +/** Reverse a persisted FormDefinition back into DesignerField[]. */ +function hydrateFields(def: FormDefinition): DesignerField[] { + const jsonSchema = def.jsonSchema as { + properties?: Record + required?: string[] + } + const uiSchema = def.uiSchema as Record + const order = (uiSchema['ui:order'] as string[] | undefined) ?? Object.keys(jsonSchema.properties ?? {}) + const requiredSet = new Set(jsonSchema.required ?? []) + + return order.map((key) => { + const prop = jsonSchema.properties?.[key] + const fieldUi = (uiSchema[key] ?? {}) as Record + return { + id: uid(), + key, + label: prop?.title ?? key, + type: (prop?.type ?? 'string') as DesignerField['type'], + required: requiredSet.has(key), + width: 3 as const, + placeholder: (fieldUi['ui:placeholder'] as string) ?? undefined, + helpText: prop?.description ?? undefined, + widgetOverride: (fieldUi['ui:widget'] as string) ?? undefined, + visibleWhen: fieldUi['ui:visible'] + ? (fieldUi['ui:visible'] as { field: string; equals: string }) + : undefined, + } + }) +} + +// ── Component ────────────────────────────────────────────────────── + +export function FormDesignerPage() { + const { slug: routeSlug } = useParams<{ slug: string }>() + const navigate = useNavigate() + const isEdit = Boolean(routeSlug) + + // ── Top bar state ── + const [title, setTitle] = useState('') + const [entityName, setEntityName] = useState('') + const [purpose, setPurpose] = useState('create') + const [slug, setSlug] = useState('') + const [slugTouched, setSlugTouched] = useState(false) + const [existingVersion, setExistingVersion] = useState(0) + + // ── Field list state ── + const [fields, setFields] = useState([blankField()]) + const [expandedId, setExpandedId] = useState(null) + + // ── Loading / error state ── + const [loading, setLoading] = useState(isEdit) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // ── Preview form data ── + const [previewData, setPreviewData] = useState>({}) + + // ── Load existing form in edit mode ── + useEffect(() => { + if (!routeSlug) return + setLoading(true) + metadata + .getForm(routeSlug) + .then((def) => { + setTitle(def.title) + setEntityName(def.entityName) + setPurpose(def.purpose) + setSlug(def.slug) + setSlugTouched(true) + setExistingVersion(def.version) + const hydrated = hydrateFields(def) + setFields(hydrated.length > 0 ? hydrated : [blankField()]) + }) + .catch((err: unknown) => + setError(err instanceof Error ? err : new Error(String(err))), + ) + .finally(() => setLoading(false)) + }, [routeSlug]) + + // ── Auto-slug from title ── + useEffect(() => { + if (!slugTouched && title) { + setSlug(slugify(title)) + } + }, [title, slugTouched]) + + // ── Field mutations ── + const updateField = useCallback( + (id: string, patch: Partial) => { + setFields((prev) => + prev.map((f) => (f.id === id ? { ...f, ...patch } : f)), + ) + }, + [], + ) + + const removeField = useCallback((id: string) => { + setFields((prev) => { + const next = prev.filter((f) => f.id !== id) + return next.length > 0 ? next : [blankField()] + }) + }, []) + + const moveField = useCallback((id: string, direction: 'up' | 'down') => { + setFields((prev) => { + const idx = prev.findIndex((f) => f.id === id) + if (idx < 0) return prev + const target = direction === 'up' ? idx - 1 : idx + 1 + if (target < 0 || target >= prev.length) return prev + const next = [...prev] + ;[next[idx], next[target]] = [next[target], next[idx]] + return next + }) + }, []) + + const addField = useCallback(() => { + const f = blankField() + setFields((prev) => [...prev, f]) + setExpandedId(f.id) + }, []) + + const addSection = useCallback(() => { + const s = blankSection() + setFields((prev) => [...prev, s]) + setExpandedId(s.id) + }, []) + + // ── Computed schemas ── + const jsonSchema = useMemo(() => buildJsonSchema(fields), [fields]) + const uiSchema = useMemo(() => buildUiSchema(fields), [fields]) + + // ── Save ── + const handleSave = async () => { + if (!slug) { + setError(new Error('Slug is required.')) + return + } + if (!entityName) { + setError(new Error('Entity name is required.')) + return + } + setSaving(true) + setError(null) + try { + await metadata.saveForm(slug, { + slug, + entityName, + title: title || slug, + purpose, + jsonSchema: jsonSchema as Record, + uiSchema: uiSchema as Record, + version: existingVersion + 1, + }) + navigate('/admin/metadata') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSaving(false) + } + } + + // ── Collect non-divider field keys for the visibility dropdown ── + const fieldKeys = useMemo( + () => fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key), + [fields], + ) + + if (loading) return + + return ( +
+ + + {/* ── Top bar ── */} +
+
+
+ + setTitle(e.target.value)} + placeholder="My Form" + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + setEntityName(e.target.value)} + placeholder="SalesOrder" + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + +
+
+ + { + setSlugTouched(true) + setSlug(e.target.value) + }} + placeholder="auto-generated" + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+ + {error && ( +
+ +
+ )} + + {/* ── Two-panel layout ── */} +
+ {/* ── Left panel: field list (3/5 = 60%) ── */} +
+

+ Fields +

+ + {fields.map((field, idx) => ( + + setExpandedId(expandedId === field.id ? null : field.id) + } + onUpdate={(patch) => updateField(field.id, patch)} + onRemove={() => removeField(field.id)} + onMove={(dir) => moveField(field.id, dir)} + /> + ))} + +
+ + +
+
+ + {/* ── Right panel: live preview (2/5 = 40%) ── */} +
+

+ Live Preview +

+
+ {Object.keys(jsonSchema.properties ?? {}).length === 0 ? ( +

+ Add at least one field with a key to see the preview. +

+ ) : ( +
+ setPreviewData( + (e.formData as Record) ?? {}, + ) + } + formContext={{ formData: previewData }} + /> + )} +
+
+
+
+ ) +} + +// ── FieldRow sub-component ───────────────────────────────────────── + +interface FieldRowProps { + field: DesignerField + index: number + total: number + isExpanded: boolean + fieldKeys: string[] + onToggleExpand: () => void + onUpdate: (patch: Partial) => void + onRemove: () => void + onMove: (direction: 'up' | 'down') => void +} + +function FieldRow({ + field, + index, + total, + isExpanded, + fieldKeys, + onToggleExpand, + onUpdate, + onRemove, + onMove, +}: FieldRowProps) { + if (field.isSectionDivider) { + return ( +
+
+ + + Section + + onUpdate({ sectionTitle: e.target.value })} + placeholder="Section title" + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" + /> + +
+
+ ) + } + + return ( +
+ {/* ── Compact row ── */} +
+ + + onUpdate({ key: e.target.value.replace(/\s+/g, '_') })} + placeholder="field_key" + className="w-28 rounded-md border border-slate-300 px-2 py-1 text-sm font-mono" + /> + onUpdate({ label: e.target.value })} + placeholder="Label" + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" + /> + + + + + +
+ + {/* ── Expanded detail panel ── */} + {isExpanded && ( +
+
+ + onUpdate({ label: e.target.value })} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + + onUpdate({ placeholder: e.target.value || undefined }) + } + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + + onUpdate({ helpText: e.target.value || undefined }) + } + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + +
+
+ +
+ Show when + + equals + + onUpdate({ + visibleWhen: field.visibleWhen?.field + ? { field: field.visibleWhen.field, equals: e.target.value } + : undefined, + }) + } + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" + placeholder="value" + disabled={!field.visibleWhen?.field} + /> +
+
+
+ )} +
+ ) +} + +// ── Reorder buttons ──────────────────────────────────────────────── + +function ReorderButtons({ + index, + total, + onMove, +}: { + index: number + total: number + onMove: (direction: 'up' | 'down') => void +}) { + return ( +
+ + +
+ ) +} diff --git a/web/src/pages/ListViewDesignerPage.tsx b/web/src/pages/ListViewDesignerPage.tsx new file mode 100644 index 0000000..d0cdb15 --- /dev/null +++ b/web/src/pages/ListViewDesignerPage.tsx @@ -0,0 +1,635 @@ +// 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) => ( + + + + + + + + + + ))} + +
ShowFieldLabelFormatSortableOrder
+ + 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 ────────────────────────────────────────── */} +
+ + +
+ +
+ ) +} diff --git a/web/src/pages/MetadataAdminPage.tsx b/web/src/pages/MetadataAdminPage.tsx new file mode 100644 index 0000000..8d867d5 --- /dev/null +++ b/web/src/pages/MetadataAdminPage.tsx @@ -0,0 +1,610 @@ +// Metadata Admin page. +// +// Tabbed admin page for browsing and managing all metadata +// definitions: entities, custom fields, permissions, menus, +// forms, and list views. Read-only tabs display DataTables; +// the custom-fields tab supports inline create/edit/delete for +// source='user' rows; the forms and list-views tabs navigate +// to dedicated designer pages. + +import { useEffect, useState, type FormEvent, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { metadata } from '@/api/client' +import type { + CustomFieldDef, + FormDefinition, + ListViewDefinition, + MetadataEntity, + MetadataPermission, +} from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DataTable, type Column } from '@/components/DataTable' +import { useT } from '@/i18n/LocaleContext' + +// ─── Menu row shape (no formal type in api.ts) ───────────────────── + +interface MenuRow { + path: string + label: string + icon?: string + section?: string + order?: number + source?: string +} + +// ─── Source badge ─────────────────────────────────────────────────── + +function SourceBadge({ source }: { source: string }) { + const cls = + source === 'core' + ? 'bg-blue-100 text-blue-700' + : source === 'user' + ? 'bg-emerald-100 text-emerald-700' + : 'bg-amber-100 text-amber-700' // plugin:* + return ( + + {source} + + ) +} + +// ─── Tab definitions ──────────────────────────────────────────────── + +type TabId = + | 'entities' + | 'customFields' + | 'permissions' + | 'menus' + | 'forms' + | 'listViews' + +const TYPE_KINDS = [ + 'string', + 'integer', + 'decimal', + 'boolean', + 'date', + 'date_time', + 'enum', + 'uuid', + 'money', + 'quantity', +] as const + +// ─── Component ────────────────────────────────────────────────────── + +export function MetadataAdminPage() { + const t = useT() + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState('entities') + + // Shared loading / error state + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Tab data + const [entities, setEntities] = useState([]) + const [customFields, setCustomFields] = useState([]) + const [permissions, setPermissions] = useState([]) + const [menus, setMenus] = useState([]) + const [forms, setForms] = useState([]) + const [listViews, setListViews] = useState([]) + + // Custom-field inline form state + const [cfFormOpen, setCfFormOpen] = useState(false) + const [cfEditingKey, setCfEditingKey] = useState(null) // null = create + const [cfTargetEntity, setCfTargetEntity] = useState('') + const [cfFieldKey, setCfFieldKey] = useState('') + const [cfTypeKind, setCfTypeKind] = useState('string') + const [cfRequired, setCfRequired] = useState(false) + const [cfPii, setCfPii] = useState(false) + const [cfLabelEn, setCfLabelEn] = useState('') + const [cfLabelZh, setCfLabelZh] = useState('') + const [cfSaving, setCfSaving] = useState(false) + + // ── Loaders ─────────────────────────────────────────────────────── + + const loadTab = (tab: TabId) => { + setLoading(true) + setError(null) + const promise: Promise = (() => { + switch (tab) { + case 'entities': + return metadata.entities().then(setEntities).then(() => {}) + case 'customFields': + return metadata.customFields().then(setCustomFields).then(() => {}) + case 'permissions': + return metadata.permissions().then(setPermissions).then(() => {}) + case 'menus': + return metadata + .menus() + .then((rows: MenuRow[]) => setMenus(rows)) + .then(() => {}) + case 'forms': + return metadata.listForms().then(setForms).then(() => {}) + case 'listViews': + return metadata.listListViews().then(setListViews).then(() => {}) + } + })() + promise + .catch((e: unknown) => + setError(e instanceof Error ? e : new Error(String(e))), + ) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadTab(activeTab) + }, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps + + // ── Custom-field form helpers ───────────────────────────────────── + + const resetCfForm = () => { + setCfFormOpen(false) + setCfEditingKey(null) + setCfTargetEntity('') + setCfFieldKey('') + setCfTypeKind('string') + setCfRequired(false) + setCfPii(false) + setCfLabelEn('') + setCfLabelZh('') + } + + const openCfCreate = () => { + resetCfForm() + setCfFormOpen(true) + } + + const openCfEdit = (cf: CustomFieldDef) => { + setCfEditingKey(cf.key) + setCfTargetEntity(cf.targetEntity) + setCfFieldKey(cf.key) + setCfTypeKind(cf.type.kind) + setCfRequired(cf.required) + setCfPii(cf.pii) + setCfLabelEn(cf.labelTranslations['en'] ?? '') + setCfLabelZh(cf.labelTranslations['zh-CN'] ?? '') + setCfFormOpen(true) + } + + const onCfSubmit = async (e: FormEvent) => { + e.preventDefault() + setCfSaving(true) + setError(null) + const body: Omit = { + key: cfFieldKey, + targetEntity: cfTargetEntity, + type: { kind: cfTypeKind }, + required: cfRequired, + pii: cfPii, + labelTranslations: { + ...(cfLabelEn ? { en: cfLabelEn } : {}), + ...(cfLabelZh ? { 'zh-CN': cfLabelZh } : {}), + }, + } + try { + if (cfEditingKey) { + await metadata.updateCustomField(cfEditingKey, body) + } else { + await metadata.createCustomField(body) + } + resetCfForm() + loadTab('customFields') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setCfSaving(false) + } + } + + const onCfDelete = async (key: string) => { + if (!window.confirm(t('confirm.delete'))) return + setError(null) + try { + await metadata.deleteCustomField(key) + loadTab('customFields') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } + } + + // ── Form / ListView delete helpers ──────────────────────────────── + + const onFormDelete = async (slug: string) => { + if (!window.confirm(t('confirm.delete'))) return + setError(null) + try { + await metadata.deleteForm(slug) + loadTab('forms') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } + } + + const onListViewDelete = async (slug: string) => { + if (!window.confirm(t('confirm.delete'))) return + setError(null) + try { + await metadata.deleteListView(slug) + loadTab('listViews') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } + } + + // ── Column definitions ──────────────────────────────────────────── + + const entityCols: Column[] = [ + { header: t('label.name'), key: 'name' }, + { header: 'PBC', key: 'pbc' }, + { header: 'Table', key: 'table' }, + { header: t('label.description'), key: 'description', render: (r) => r.description ?? '—' }, + { header: t('label.source'), key: 'source', render: (r) => }, + ] + + const customFieldCols: Column[] = [ + { header: t('label.fieldKey'), key: 'key', render: (r) => {r.key} }, + { header: t('label.targetEntity'), key: 'targetEntity' }, + { header: t('label.fieldType'), key: 'type', render: (r) => r.type.kind }, + { header: 'Required', key: 'required', render: (r) => (r.required ? 'Yes' : 'No') }, + { header: 'PII', key: 'pii', render: (r) => (r.pii ? 'Yes' : 'No') }, + { header: t('label.source'), key: 'source', render: (r) => }, + { + header: '', + key: '_actions', + render: (r) => + r.source === 'user' ? ( + + + + + ) : null, + }, + ] + + const permissionCols: Column[] = [ + { header: 'Key', key: 'key', render: (r) => {r.key} }, + { header: t('label.description'), key: 'description' }, + { header: t('label.source'), key: 'source', render: (r) => }, + ] + + const menuCols: Column[] = [ + { header: 'Path', key: 'path', render: (r) => {r.path} }, + { header: 'Label', key: 'label' }, + { header: 'Icon', key: 'icon', render: (r) => r.icon ?? '—' }, + { header: 'Section', key: 'section', render: (r) => r.section ?? '—' }, + { header: 'Order', key: 'order', render: (r) => r.order ?? '—' }, + { header: t('label.source'), key: 'source', render: (r) => }, + ] + + const formCols: Column[] = [ + { + header: t('label.slug'), + key: 'slug', + render: (r) => ( + + ), + }, + { header: t('label.entity'), key: 'entityName' }, + { header: 'Title', key: 'title' }, + { header: t('label.purpose'), key: 'purpose' }, + { header: 'Version', key: 'version' }, + { header: t('label.source'), key: 'source', render: (r) => }, + { + header: '', + key: '_actions', + render: (r) => + r.source === 'user' ? ( + + ) : null, + }, + ] + + const listViewCols: Column[] = [ + { + header: t('label.slug'), + key: 'slug', + render: (r) => ( + + ), + }, + { header: t('label.entity'), key: 'entityName' }, + { header: 'Title', key: 'title' }, + { header: t('label.pageSize'), key: 'pageSize' }, + { header: 'Version', key: 'version' }, + { header: t('label.source'), key: 'source', render: (r) => }, + { + header: '', + key: '_actions', + render: (r) => + r.source === 'user' ? ( + + ) : null, + }, + ] + + // ── Tab bar ─────────────────────────────────────────────────────── + + const tabs: { id: TabId; label: string }[] = [ + { id: 'entities', label: t('tab.entities') }, + { id: 'customFields', label: t('tab.customFields') }, + { id: 'permissions', label: t('tab.permissions') }, + { id: 'menus', label: t('tab.menus') }, + { id: 'forms', label: t('tab.forms') }, + { id: 'listViews', label: t('tab.listViews') }, + ] + + // ── Tab content renderer ────────────────────────────────────────── + + function renderTabContent(): ReactNode { + if (loading) return + if (error) return + + switch (activeTab) { + case 'entities': + return ( + r.name} + /> + ) + + case 'customFields': + return ( +
+
+ +
+ {cfFormOpen && ( +
+
+
+ + setCfTargetEntity(e.target.value)} + placeholder="Partner" + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> +
+
+ + setCfFieldKey(e.target.value)} + disabled={cfEditingKey !== null} + placeholder="custom_my_field" + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm font-mono disabled:opacity-50" + /> +
+
+
+
+ + +
+
+ + +
+
+
+
+ + setCfLabelEn(e.target.value)} + placeholder="My Field" + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> +
+
+ + setCfLabelZh(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> +
+
+
+ + +
+
+ )} + r.key} + /> +
+ ) + + case 'permissions': + return ( + r.key} + /> + ) + + case 'menus': + return ( + r.path} + /> + ) + + case 'forms': + return ( +
+
+ +
+ r.slug} + /> +
+ ) + + case 'listViews': + return ( +
+
+ +
+ r.slug} + /> +
+ ) + } + } + + // ── Render ───────────────────────────────────────────────────────── + + return ( +
+ + + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {renderTabContent()} +
+ ) +}