// 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, RuleDefinition, RuleCondition, RuleAction, } 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' | 'rules' const TRIGGER_EVENTS = [ 'SalesOrderConfirmedEvent', 'SalesOrderShippedEvent', 'SalesOrderCancelledEvent', 'PurchaseOrderConfirmedEvent', 'PurchaseOrderReceivedEvent', 'PurchaseOrderCancelledEvent', 'WorkOrderCreatedEvent', 'WorkOrderStartedEvent', 'WorkOrderCompletedEvent', 'WorkOrderCancelledEvent', 'InspectionRecordedEvent', ] as const const CONDITION_OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in'] as const function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } 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([]) const [rules, setRules] = useState([]) // Rule inline form state const [ruleFormOpen, setRuleFormOpen] = useState(false) const [ruleEditingSlug, setRuleEditingSlug] = useState(null) const [ruleName, setRuleName] = useState('') const [ruleDescription, setRuleDescription] = useState('') const [ruleTriggerEvent, setRuleTriggerEvent] = useState(TRIGGER_EVENTS[0]) const [ruleConditionLogic, setRuleConditionLogic] = useState<'AND' | 'OR'>('AND') const [ruleConditions, setRuleConditions] = useState([]) const [ruleActions, setRuleActions] = useState([]) const [ruleEnabled, setRuleEnabled] = useState(true) const [ruleVersion, setRuleVersion] = useState(1) const [ruleSaving, setRuleSaving] = useState(false) // 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 [cfAllowedValues, setCfAllowedValues] = useState('') const [cfMaxLength, setCfMaxLength] = 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(() => {}) case 'rules': return metadata.listRules().then(setRules).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') setCfAllowedValues('') setCfMaxLength('') 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, ...(cfTypeKind === 'enum' && cfAllowedValues ? { allowedValues: cfAllowedValues.split(',').map((v) => v.trim()).filter(Boolean) } : {}), ...(cfTypeKind === 'string' && cfMaxLength ? { maxLength: Number(cfMaxLength) } : {}), }, 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))) } } // ── Rule form helpers ────────────────────────────────────────────── const resetRuleForm = () => { setRuleFormOpen(false) setRuleEditingSlug(null) setRuleName('') setRuleDescription('') setRuleTriggerEvent(TRIGGER_EVENTS[0]) setRuleConditionLogic('AND') setRuleConditions([]) setRuleActions([]) setRuleEnabled(true) setRuleVersion(1) } const openRuleCreate = () => { resetRuleForm() setRuleFormOpen(true) } const openRuleEdit = (rule: RuleDefinition) => { setRuleEditingSlug(rule.slug) setRuleName(rule.name) setRuleDescription(rule.description ?? '') setRuleTriggerEvent(rule.triggerEvent) setRuleConditionLogic(rule.conditionLogic) setRuleConditions([...rule.conditions]) setRuleActions(rule.actions.map((a) => ({ type: a.type, config: { ...a.config } }))) setRuleEnabled(rule.enabled) setRuleVersion(rule.version) setRuleFormOpen(true) } const onRuleSubmit = async (e: FormEvent) => { e.preventDefault() setRuleSaving(true) setError(null) const slug = ruleEditingSlug ?? slugify(ruleName) const body: Omit = { slug, name: ruleName, description: ruleDescription || undefined, enabled: ruleEnabled, triggerEvent: ruleTriggerEvent, conditions: ruleConditions, conditionLogic: ruleConditionLogic, actions: ruleActions, version: ruleVersion, } try { await metadata.saveRule(slug, body) resetRuleForm() loadTab('rules') } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))) } finally { setRuleSaving(false) } } const onRuleDelete = async (slug: string) => { if (!window.confirm(t('confirm.delete'))) return setError(null) try { await metadata.deleteRule(slug) loadTab('rules') } 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: t('label.pbc'), key: 'pbc' }, { header: t('label.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: t('label.required'), key: 'required', render: (r) => (r.required ? t('label.yes') : t('label.no')) }, { header: t('label.pii'), key: 'pii', render: (r) => (r.pii ? t('label.yes') : t('label.no')) }, { header: t('label.source'), key: 'source', render: (r) => }, { header: '', key: '_actions', render: (r) => r.source === 'user' ? ( ) : null, }, ] const permissionCols: Column[] = [ { header: t('label.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: t('label.path'), key: 'path', render: (r) => {r.path} }, { header: t('label.label'), key: 'label' }, { header: t('label.icon'), key: 'icon', render: (r) => r.icon ?? '—' }, { header: t('label.sectionMeta'), key: 'section', render: (r) => r.section ?? '—' }, { header: t('label.orderMeta'), 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: t('label.title'), key: 'title' }, { header: t('label.purpose'), key: 'purpose' }, { header: t('label.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: t('label.title'), key: 'title' }, { header: t('label.pageSize'), key: 'pageSize' }, { header: t('label.version'), key: 'version' }, { header: t('label.source'), key: 'source', render: (r) => }, { header: '', key: '_actions', render: (r) => r.source === 'user' ? ( ) : null, }, ] const ruleCols: Column[] = [ { header: t('label.name'), key: 'name' }, { header: t('label.triggerEvent'), key: 'triggerEvent' }, { header: t('label.enabled'), key: 'enabled', render: (r) => ( {r.enabled ? 'Yes' : 'No'} ), }, { header: t('label.conditions'), key: 'conditions', render: (r) => {r.conditions.length}, }, { header: t('label.actions'), key: 'actions', render: (r) => {r.actions.length}, }, { 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') }, { id: 'rules', label: t('tab.rules') }, ] // ── 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" />
{cfTypeKind === 'enum' && (
setCfAllowedValues(e.target.value)} placeholder="LOW, NORMAL, HIGH, URGENT" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
)} {cfTypeKind === 'string' && (
setCfMaxLength(e.target.value)} placeholder="255" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
)}
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} />
) case 'rules': return (
{ruleFormOpen && (
setRuleName(e.target.value)} placeholder="My Rule" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
setRuleDescription(e.target.value)} className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
{/* Condition logic */}
{/* Conditions */}
{ruleConditions.map((cond, i) => (
{ const next = [...ruleConditions] next[i] = { ...next[i], field: e.target.value } setRuleConditions(next) }} className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> { const next = [...ruleConditions] next[i] = { ...next[i], value: e.target.value } setRuleConditions(next) }} className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
))}
{/* Actions */}
{ruleActions.map((act, i) => (
{ const next = [...ruleActions] const oldVal = Object.values(act.config)[0] ?? '' next[i] = { ...next[i], config: { [e.target.value]: oldVal } } setRuleActions(next) }} className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm font-mono" /> { const next = [...ruleActions] const oldKey = Object.keys(act.config)[0] ?? 'message' next[i] = { ...next[i], config: { [oldKey]: e.target.value } } setRuleActions(next) }} className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
))}
{/* Enabled */}
)} r.slug} />
) } } // ── Render ───────────────────────────────────────────────────────── return (
{/* Tab bar */}
{tabs.map((tab) => ( ))}
{/* Tab content */} {renderTabContent()}
) }