You need to sign in before continuing.

Commit 558467ec604dfc181af95be1d9462c963a9f1c25

Authored by zichun
1 parent 1875c068

feat(web): list view designer — column/filter/sort configuration

web/src/App.tsx
@@ -41,11 +41,9 @@ import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' @@ -41,11 +41,9 @@ import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage'
41 import { ShopFloorPage } from '@/pages/ShopFloorPage' 41 import { ShopFloorPage } from '@/pages/ShopFloorPage'
42 import { AccountsPage } from '@/pages/AccountsPage' 42 import { AccountsPage } from '@/pages/AccountsPage'
43 import { JournalEntriesPage } from '@/pages/JournalEntriesPage' 43 import { JournalEntriesPage } from '@/pages/JournalEntriesPage'
44 -  
45 -// Stub pages (will be replaced in later tasks)  
46 -function MetadataAdminPage() { return <div>Metadata Admin - coming soon</div> }  
47 -function FormDesignerPage() { return <div>Form Designer - coming soon</div> }  
48 -function ListViewDesignerPage() { return <div>List View Designer - coming soon</div> } 44 +import { FormDesignerPage } from '@/pages/FormDesignerPage'
  45 +import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage'
  46 +import { MetadataAdminPage } from '@/pages/MetadataAdminPage'
49 47
50 export default function App() { 48 export default function App() {
51 return ( 49 return (
web/src/pages/FormDesignerPage.tsx 0 → 100644
  1 +// Form designer page.
  2 +//
  3 +// Two-panel layout with a property editor on the left and a live
  4 +// @rjsf preview on the right. Converts a flat DesignerField[] model
  5 +// into JSON Schema + UI Schema on every keystroke so the preview
  6 +// always reflects the current state.
  7 +
  8 +import { useCallback, useEffect, useMemo, useState } from 'react'
  9 +import { useNavigate, useParams } from 'react-router-dom'
  10 +import Form from '@rjsf/core'
  11 +import type { RJSFSchema, UiSchema } from '@rjsf/utils'
  12 +import validator from '@rjsf/validator-ajv8'
  13 +import { metadata } from '@/api/client'
  14 +import type { FormDefinition, FormPurpose } from '@/types/api'
  15 +import { PageHeader } from '@/components/PageHeader'
  16 +import { ErrorBox } from '@/components/ErrorBox'
  17 +import { Loading } from '@/components/Loading'
  18 +import { vibeWidgets } from '@/components/form-widgets'
  19 +import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme'
  20 +
  21 +// ── Types ──────────────────────────────────────────────────────────
  22 +
  23 +interface DesignerField {
  24 + id: string
  25 + key: string
  26 + label: string
  27 + type: 'string' | 'integer' | 'number' | 'boolean'
  28 + required: boolean
  29 + width: 1 | 2 | 3
  30 + placeholder?: string
  31 + helpText?: string
  32 + widgetOverride?: string
  33 + visibleWhen?: { field: string; equals: string }
  34 + isSectionDivider?: boolean
  35 + sectionTitle?: string
  36 +}
  37 +
  38 +const FIELD_TYPES = ['string', 'integer', 'number', 'boolean'] as const
  39 +const WIDTH_OPTIONS = [1, 2, 3] as const
  40 +const PURPOSES: FormPurpose[] = ['create', 'edit', 'user-task', 'view']
  41 +const WIDGET_OPTIONS = [
  42 + '',
  43 + 'partner-picker',
  44 + 'item-picker',
  45 + 'uom-selector',
  46 + 'location-picker',
  47 + 'money-input',
  48 + 'quantity-input',
  49 + 'textarea',
  50 +] as const
  51 +
  52 +// ── Helpers ────────────────────────────────────────────────────────
  53 +
  54 +function uid(): string {
  55 + return crypto.randomUUID()
  56 +}
  57 +
  58 +function slugify(text: string): string {
  59 + return text
  60 + .toLowerCase()
  61 + .replace(/[^a-z0-9]+/g, '-')
  62 + .replace(/^-|-$/g, '')
  63 +}
  64 +
  65 +function blankField(): DesignerField {
  66 + return {
  67 + id: uid(),
  68 + key: '',
  69 + label: '',
  70 + type: 'string',
  71 + required: false,
  72 + width: 3,
  73 + }
  74 +}
  75 +
  76 +function blankSection(): DesignerField {
  77 + return {
  78 + id: uid(),
  79 + key: '',
  80 + label: '',
  81 + type: 'string',
  82 + required: false,
  83 + width: 3,
  84 + isSectionDivider: true,
  85 + sectionTitle: '',
  86 + }
  87 +}
  88 +
  89 +// ── Schema builders ────────────────────────────────────────────────
  90 +
  91 +function buildJsonSchema(fields: DesignerField[]): RJSFSchema {
  92 + const properties: Record<string, unknown> = {}
  93 + const required: string[] = []
  94 +
  95 + for (const f of fields) {
  96 + if (f.isSectionDivider) continue
  97 + if (!f.key) continue
  98 + properties[f.key] = {
  99 + type: f.type,
  100 + title: f.label || f.key,
  101 + ...(f.helpText ? { description: f.helpText } : {}),
  102 + }
  103 + if (f.required) required.push(f.key)
  104 + }
  105 +
  106 + return {
  107 + type: 'object',
  108 + properties,
  109 + ...(required.length > 0 ? { required } : {}),
  110 + } as RJSFSchema
  111 +}
  112 +
  113 +function buildUiSchema(fields: DesignerField[]): UiSchema {
  114 + const schema: Record<string, unknown> = {
  115 + 'ui:order': fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key),
  116 + 'ui:submitButtonOptions': { norender: true },
  117 + }
  118 +
  119 + for (const f of fields) {
  120 + if (f.isSectionDivider || !f.key) continue
  121 + const fieldUi: Record<string, unknown> = {}
  122 + if (f.placeholder) fieldUi['ui:placeholder'] = f.placeholder
  123 + if (f.widgetOverride) fieldUi['ui:widget'] = f.widgetOverride
  124 + if (f.visibleWhen?.field && f.visibleWhen?.equals) {
  125 + fieldUi['ui:visible'] = f.visibleWhen
  126 + }
  127 + if (Object.keys(fieldUi).length > 0) schema[f.key] = fieldUi
  128 + }
  129 +
  130 + return schema as UiSchema
  131 +}
  132 +
  133 +/** Reverse a persisted FormDefinition back into DesignerField[]. */
  134 +function hydrateFields(def: FormDefinition): DesignerField[] {
  135 + const jsonSchema = def.jsonSchema as {
  136 + properties?: Record<string, { type?: string; title?: string; description?: string }>
  137 + required?: string[]
  138 + }
  139 + const uiSchema = def.uiSchema as Record<string, unknown>
  140 + const order = (uiSchema['ui:order'] as string[] | undefined) ?? Object.keys(jsonSchema.properties ?? {})
  141 + const requiredSet = new Set<string>(jsonSchema.required ?? [])
  142 +
  143 + return order.map((key) => {
  144 + const prop = jsonSchema.properties?.[key]
  145 + const fieldUi = (uiSchema[key] ?? {}) as Record<string, unknown>
  146 + return {
  147 + id: uid(),
  148 + key,
  149 + label: prop?.title ?? key,
  150 + type: (prop?.type ?? 'string') as DesignerField['type'],
  151 + required: requiredSet.has(key),
  152 + width: 3 as const,
  153 + placeholder: (fieldUi['ui:placeholder'] as string) ?? undefined,
  154 + helpText: prop?.description ?? undefined,
  155 + widgetOverride: (fieldUi['ui:widget'] as string) ?? undefined,
  156 + visibleWhen: fieldUi['ui:visible']
  157 + ? (fieldUi['ui:visible'] as { field: string; equals: string })
  158 + : undefined,
  159 + }
  160 + })
  161 +}
  162 +
  163 +// ── Component ──────────────────────────────────────────────────────
  164 +
  165 +export function FormDesignerPage() {
  166 + const { slug: routeSlug } = useParams<{ slug: string }>()
  167 + const navigate = useNavigate()
  168 + const isEdit = Boolean(routeSlug)
  169 +
  170 + // ── Top bar state ──
  171 + const [title, setTitle] = useState('')
  172 + const [entityName, setEntityName] = useState('')
  173 + const [purpose, setPurpose] = useState<FormPurpose>('create')
  174 + const [slug, setSlug] = useState('')
  175 + const [slugTouched, setSlugTouched] = useState(false)
  176 + const [existingVersion, setExistingVersion] = useState(0)
  177 +
  178 + // ── Field list state ──
  179 + const [fields, setFields] = useState<DesignerField[]>([blankField()])
  180 + const [expandedId, setExpandedId] = useState<string | null>(null)
  181 +
  182 + // ── Loading / error state ──
  183 + const [loading, setLoading] = useState(isEdit)
  184 + const [saving, setSaving] = useState(false)
  185 + const [error, setError] = useState<Error | null>(null)
  186 +
  187 + // ── Preview form data ──
  188 + const [previewData, setPreviewData] = useState<Record<string, unknown>>({})
  189 +
  190 + // ── Load existing form in edit mode ──
  191 + useEffect(() => {
  192 + if (!routeSlug) return
  193 + setLoading(true)
  194 + metadata
  195 + .getForm(routeSlug)
  196 + .then((def) => {
  197 + setTitle(def.title)
  198 + setEntityName(def.entityName)
  199 + setPurpose(def.purpose)
  200 + setSlug(def.slug)
  201 + setSlugTouched(true)
  202 + setExistingVersion(def.version)
  203 + const hydrated = hydrateFields(def)
  204 + setFields(hydrated.length > 0 ? hydrated : [blankField()])
  205 + })
  206 + .catch((err: unknown) =>
  207 + setError(err instanceof Error ? err : new Error(String(err))),
  208 + )
  209 + .finally(() => setLoading(false))
  210 + }, [routeSlug])
  211 +
  212 + // ── Auto-slug from title ──
  213 + useEffect(() => {
  214 + if (!slugTouched && title) {
  215 + setSlug(slugify(title))
  216 + }
  217 + }, [title, slugTouched])
  218 +
  219 + // ── Field mutations ──
  220 + const updateField = useCallback(
  221 + (id: string, patch: Partial<DesignerField>) => {
  222 + setFields((prev) =>
  223 + prev.map((f) => (f.id === id ? { ...f, ...patch } : f)),
  224 + )
  225 + },
  226 + [],
  227 + )
  228 +
  229 + const removeField = useCallback((id: string) => {
  230 + setFields((prev) => {
  231 + const next = prev.filter((f) => f.id !== id)
  232 + return next.length > 0 ? next : [blankField()]
  233 + })
  234 + }, [])
  235 +
  236 + const moveField = useCallback((id: string, direction: 'up' | 'down') => {
  237 + setFields((prev) => {
  238 + const idx = prev.findIndex((f) => f.id === id)
  239 + if (idx < 0) return prev
  240 + const target = direction === 'up' ? idx - 1 : idx + 1
  241 + if (target < 0 || target >= prev.length) return prev
  242 + const next = [...prev]
  243 + ;[next[idx], next[target]] = [next[target], next[idx]]
  244 + return next
  245 + })
  246 + }, [])
  247 +
  248 + const addField = useCallback(() => {
  249 + const f = blankField()
  250 + setFields((prev) => [...prev, f])
  251 + setExpandedId(f.id)
  252 + }, [])
  253 +
  254 + const addSection = useCallback(() => {
  255 + const s = blankSection()
  256 + setFields((prev) => [...prev, s])
  257 + setExpandedId(s.id)
  258 + }, [])
  259 +
  260 + // ── Computed schemas ──
  261 + const jsonSchema = useMemo(() => buildJsonSchema(fields), [fields])
  262 + const uiSchema = useMemo(() => buildUiSchema(fields), [fields])
  263 +
  264 + // ── Save ──
  265 + const handleSave = async () => {
  266 + if (!slug) {
  267 + setError(new Error('Slug is required.'))
  268 + return
  269 + }
  270 + if (!entityName) {
  271 + setError(new Error('Entity name is required.'))
  272 + return
  273 + }
  274 + setSaving(true)
  275 + setError(null)
  276 + try {
  277 + await metadata.saveForm(slug, {
  278 + slug,
  279 + entityName,
  280 + title: title || slug,
  281 + purpose,
  282 + jsonSchema: jsonSchema as Record<string, unknown>,
  283 + uiSchema: uiSchema as Record<string, unknown>,
  284 + version: existingVersion + 1,
  285 + })
  286 + navigate('/admin/metadata')
  287 + } catch (err: unknown) {
  288 + setError(err instanceof Error ? err : new Error(String(err)))
  289 + } finally {
  290 + setSaving(false)
  291 + }
  292 + }
  293 +
  294 + // ── Collect non-divider field keys for the visibility dropdown ──
  295 + const fieldKeys = useMemo(
  296 + () => fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key),
  297 + [fields],
  298 + )
  299 +
  300 + if (loading) return <Loading />
  301 +
  302 + return (
  303 + <div>
  304 + <PageHeader
  305 + title={isEdit ? 'Edit Form Definition' : 'New Form Definition'}
  306 + subtitle="Design a metadata-driven form with a live preview."
  307 + />
  308 +
  309 + {/* ── Top bar ── */}
  310 + <div className="card p-4 mb-4">
  311 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
  312 + <div>
  313 + <label className="block text-sm font-medium text-slate-700">Title</label>
  314 + <input
  315 + type="text"
  316 + value={title}
  317 + onChange={(e) => setTitle(e.target.value)}
  318 + placeholder="My Form"
  319 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  320 + />
  321 + </div>
  322 + <div>
  323 + <label className="block text-sm font-medium text-slate-700">Entity name</label>
  324 + <input
  325 + type="text"
  326 + value={entityName}
  327 + onChange={(e) => setEntityName(e.target.value)}
  328 + placeholder="SalesOrder"
  329 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  330 + />
  331 + </div>
  332 + <div>
  333 + <label className="block text-sm font-medium text-slate-700">Purpose</label>
  334 + <select
  335 + value={purpose}
  336 + onChange={(e) => setPurpose(e.target.value as FormPurpose)}
  337 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  338 + >
  339 + {PURPOSES.map((p) => (
  340 + <option key={p} value={p}>
  341 + {p}
  342 + </option>
  343 + ))}
  344 + </select>
  345 + </div>
  346 + <div>
  347 + <label className="block text-sm font-medium text-slate-700">Slug</label>
  348 + <input
  349 + type="text"
  350 + value={slug}
  351 + onChange={(e) => {
  352 + setSlugTouched(true)
  353 + setSlug(e.target.value)
  354 + }}
  355 + placeholder="auto-generated"
  356 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  357 + />
  358 + </div>
  359 + <div className="flex items-end gap-2">
  360 + <button
  361 + type="button"
  362 + className="btn-primary"
  363 + disabled={saving}
  364 + onClick={handleSave}
  365 + >
  366 + {saving ? 'Saving...' : 'Save'}
  367 + </button>
  368 + <button
  369 + type="button"
  370 + className="btn-secondary"
  371 + onClick={() => navigate('/admin/metadata')}
  372 + >
  373 + Discard
  374 + </button>
  375 + </div>
  376 + </div>
  377 + </div>
  378 +
  379 + {error && (
  380 + <div className="mb-4">
  381 + <ErrorBox error={error} />
  382 + </div>
  383 + )}
  384 +
  385 + {/* ── Two-panel layout ── */}
  386 + <div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
  387 + {/* ── Left panel: field list (3/5 = 60%) ── */}
  388 + <div className="lg:col-span-3 space-y-2">
  389 + <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
  390 + Fields
  391 + </h2>
  392 +
  393 + {fields.map((field, idx) => (
  394 + <FieldRow
  395 + key={field.id}
  396 + field={field}
  397 + index={idx}
  398 + total={fields.length}
  399 + isExpanded={expandedId === field.id}
  400 + fieldKeys={fieldKeys}
  401 + onToggleExpand={() =>
  402 + setExpandedId(expandedId === field.id ? null : field.id)
  403 + }
  404 + onUpdate={(patch) => updateField(field.id, patch)}
  405 + onRemove={() => removeField(field.id)}
  406 + onMove={(dir) => moveField(field.id, dir)}
  407 + />
  408 + ))}
  409 +
  410 + <div className="flex gap-2 pt-2">
  411 + <button type="button" className="btn-secondary" onClick={addField}>
  412 + + Add Field
  413 + </button>
  414 + <button type="button" className="btn-secondary" onClick={addSection}>
  415 + + Add Section Divider
  416 + </button>
  417 + </div>
  418 + </div>
  419 +
  420 + {/* ── Right panel: live preview (2/5 = 40%) ── */}
  421 + <div className="lg:col-span-2">
  422 + <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
  423 + Live Preview
  424 + </h2>
  425 + <div className="card p-6">
  426 + {Object.keys(jsonSchema.properties ?? {}).length === 0 ? (
  427 + <p className="text-sm text-slate-400 italic">
  428 + Add at least one field with a key to see the preview.
  429 + </p>
  430 + ) : (
  431 + <Form
  432 + schema={jsonSchema}
  433 + uiSchema={uiSchema}
  434 + formData={previewData}
  435 + validator={validator}
  436 + widgets={vibeWidgets}
  437 + templates={vibeTemplates}
  438 + onChange={(e) =>
  439 + setPreviewData(
  440 + (e.formData as Record<string, unknown>) ?? {},
  441 + )
  442 + }
  443 + formContext={{ formData: previewData }}
  444 + />
  445 + )}
  446 + </div>
  447 + </div>
  448 + </div>
  449 + </div>
  450 + )
  451 +}
  452 +
  453 +// ── FieldRow sub-component ─────────────────────────────────────────
  454 +
  455 +interface FieldRowProps {
  456 + field: DesignerField
  457 + index: number
  458 + total: number
  459 + isExpanded: boolean
  460 + fieldKeys: string[]
  461 + onToggleExpand: () => void
  462 + onUpdate: (patch: Partial<DesignerField>) => void
  463 + onRemove: () => void
  464 + onMove: (direction: 'up' | 'down') => void
  465 +}
  466 +
  467 +function FieldRow({
  468 + field,
  469 + index,
  470 + total,
  471 + isExpanded,
  472 + fieldKeys,
  473 + onToggleExpand,
  474 + onUpdate,
  475 + onRemove,
  476 + onMove,
  477 +}: FieldRowProps) {
  478 + if (field.isSectionDivider) {
  479 + return (
  480 + <div className="card p-3 border-l-4 border-indigo-300">
  481 + <div className="flex items-center gap-2">
  482 + <ReorderButtons
  483 + index={index}
  484 + total={total}
  485 + onMove={onMove}
  486 + />
  487 + <span className="text-xs font-semibold uppercase text-indigo-500 mr-2">
  488 + Section
  489 + </span>
  490 + <input
  491 + type="text"
  492 + value={field.sectionTitle ?? ''}
  493 + onChange={(e) => onUpdate({ sectionTitle: e.target.value })}
  494 + placeholder="Section title"
  495 + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm"
  496 + />
  497 + <button
  498 + type="button"
  499 + onClick={onRemove}
  500 + className="text-slate-400 hover:text-rose-500 text-sm px-1"
  501 + title="Remove section"
  502 + >
  503 + x
  504 + </button>
  505 + </div>
  506 + </div>
  507 + )
  508 + }
  509 +
  510 + return (
  511 + <div className="card p-3">
  512 + {/* ── Compact row ── */}
  513 + <div className="flex items-center gap-2">
  514 + <ReorderButtons index={index} total={total} onMove={onMove} />
  515 +
  516 + <input
  517 + type="text"
  518 + value={field.key}
  519 + onChange={(e) => onUpdate({ key: e.target.value.replace(/\s+/g, '_') })}
  520 + placeholder="field_key"
  521 + className="w-28 rounded-md border border-slate-300 px-2 py-1 text-sm font-mono"
  522 + />
  523 + <input
  524 + type="text"
  525 + value={field.label}
  526 + onChange={(e) => onUpdate({ label: e.target.value })}
  527 + placeholder="Label"
  528 + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm"
  529 + />
  530 + <select
  531 + value={field.type}
  532 + onChange={(e) =>
  533 + onUpdate({ type: e.target.value as DesignerField['type'] })
  534 + }
  535 + className="w-24 rounded-md border border-slate-300 px-2 py-1 text-sm"
  536 + >
  537 + {FIELD_TYPES.map((t) => (
  538 + <option key={t} value={t}>
  539 + {t}
  540 + </option>
  541 + ))}
  542 + </select>
  543 + <label className="flex items-center gap-1 text-sm text-slate-600 whitespace-nowrap">
  544 + <input
  545 + type="checkbox"
  546 + checked={field.required}
  547 + onChange={(e) => onUpdate({ required: e.target.checked })}
  548 + className="rounded border-slate-300"
  549 + />
  550 + Req
  551 + </label>
  552 + <select
  553 + value={field.width}
  554 + onChange={(e) =>
  555 + onUpdate({ width: Number(e.target.value) as 1 | 2 | 3 })
  556 + }
  557 + className="w-20 rounded-md border border-slate-300 px-2 py-1 text-sm"
  558 + title="Column span"
  559 + >
  560 + {WIDTH_OPTIONS.map((w) => (
  561 + <option key={w} value={w}>
  562 + {w} col{w > 1 ? 's' : ''}
  563 + </option>
  564 + ))}
  565 + </select>
  566 + <button
  567 + type="button"
  568 + onClick={onToggleExpand}
  569 + className="text-slate-400 hover:text-slate-600 text-sm px-1"
  570 + title="Toggle details"
  571 + >
  572 + {isExpanded ? '\u25B2' : '\u25BC'}
  573 + </button>
  574 + <button
  575 + type="button"
  576 + onClick={onRemove}
  577 + className="text-slate-400 hover:text-rose-500 text-sm px-1"
  578 + title="Remove field"
  579 + >
  580 + x
  581 + </button>
  582 + </div>
  583 +
  584 + {/* ── Expanded detail panel ── */}
  585 + {isExpanded && (
  586 + <div className="mt-3 ml-10 grid grid-cols-1 gap-3 sm:grid-cols-2 border-t border-slate-100 pt-3">
  587 + <div>
  588 + <label className="block text-sm font-medium text-slate-700">
  589 + Label (English)
  590 + </label>
  591 + <input
  592 + type="text"
  593 + value={field.label}
  594 + onChange={(e) => onUpdate({ label: e.target.value })}
  595 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  596 + />
  597 + </div>
  598 + <div>
  599 + <label className="block text-sm font-medium text-slate-700">
  600 + Placeholder
  601 + </label>
  602 + <input
  603 + type="text"
  604 + value={field.placeholder ?? ''}
  605 + onChange={(e) =>
  606 + onUpdate({ placeholder: e.target.value || undefined })
  607 + }
  608 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  609 + />
  610 + </div>
  611 + <div className="sm:col-span-2">
  612 + <label className="block text-sm font-medium text-slate-700">
  613 + Help text
  614 + </label>
  615 + <input
  616 + type="text"
  617 + value={field.helpText ?? ''}
  618 + onChange={(e) =>
  619 + onUpdate({ helpText: e.target.value || undefined })
  620 + }
  621 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  622 + />
  623 + </div>
  624 + <div>
  625 + <label className="block text-sm font-medium text-slate-700">
  626 + Widget override
  627 + </label>
  628 + <select
  629 + value={field.widgetOverride ?? ''}
  630 + onChange={(e) =>
  631 + onUpdate({ widgetOverride: e.target.value || undefined })
  632 + }
  633 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  634 + >
  635 + {WIDGET_OPTIONS.map((w) => (
  636 + <option key={w} value={w}>
  637 + {w || '(default)'}
  638 + </option>
  639 + ))}
  640 + </select>
  641 + </div>
  642 + <div>
  643 + <label className="block text-sm font-medium text-slate-700">
  644 + Visibility condition
  645 + </label>
  646 + <div className="mt-1 flex items-center gap-2">
  647 + <span className="text-sm text-slate-500">Show when</span>
  648 + <select
  649 + value={field.visibleWhen?.field ?? ''}
  650 + onChange={(e) =>
  651 + onUpdate({
  652 + visibleWhen: e.target.value
  653 + ? {
  654 + field: e.target.value,
  655 + equals: field.visibleWhen?.equals ?? '',
  656 + }
  657 + : undefined,
  658 + })
  659 + }
  660 + className="rounded-md border border-slate-300 px-2 py-1 text-sm"
  661 + >
  662 + <option value="">(always)</option>
  663 + {fieldKeys
  664 + .filter((k) => k !== field.key)
  665 + .map((k) => (
  666 + <option key={k} value={k}>
  667 + {k}
  668 + </option>
  669 + ))}
  670 + </select>
  671 + <span className="text-sm text-slate-500">equals</span>
  672 + <input
  673 + type="text"
  674 + value={field.visibleWhen?.equals ?? ''}
  675 + onChange={(e) =>
  676 + onUpdate({
  677 + visibleWhen: field.visibleWhen?.field
  678 + ? { field: field.visibleWhen.field, equals: e.target.value }
  679 + : undefined,
  680 + })
  681 + }
  682 + className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm"
  683 + placeholder="value"
  684 + disabled={!field.visibleWhen?.field}
  685 + />
  686 + </div>
  687 + </div>
  688 + </div>
  689 + )}
  690 + </div>
  691 + )
  692 +}
  693 +
  694 +// ── Reorder buttons ────────────────────────────────────────────────
  695 +
  696 +function ReorderButtons({
  697 + index,
  698 + total,
  699 + onMove,
  700 +}: {
  701 + index: number
  702 + total: number
  703 + onMove: (direction: 'up' | 'down') => void
  704 +}) {
  705 + return (
  706 + <div className="flex flex-col">
  707 + <button
  708 + type="button"
  709 + onClick={() => onMove('up')}
  710 + disabled={index === 0}
  711 + className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none"
  712 + title="Move up"
  713 + >
  714 + &#9650;
  715 + </button>
  716 + <button
  717 + type="button"
  718 + onClick={() => onMove('down')}
  719 + disabled={index === total - 1}
  720 + className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none"
  721 + title="Move down"
  722 + >
  723 + &#9660;
  724 + </button>
  725 + </div>
  726 + )
  727 +}
web/src/pages/ListViewDesignerPage.tsx 0 → 100644
  1 +// vibe_erp List View Designer page.
  2 +//
  3 +// Configuration editor for metadata list view definitions.
  4 +// Lets admins define columns (visibility, label, format, sortable,
  5 +// order), filters, default sort, and page size for any entity's
  6 +// list view. A live preview DataTable renders placeholder rows
  7 +// using the selected columns.
  8 +//
  9 +// Edit mode: when the URL carries a :slug param the page fetches
  10 +// the existing definition and populates all fields for editing.
  11 +// Create mode: all fields start blank / with sensible defaults.
  12 +
  13 +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'
  14 +import { useNavigate, useParams } from 'react-router-dom'
  15 +import { metadata } from '@/api/client'
  16 +import { PageHeader } from '@/components/PageHeader'
  17 +import { ErrorBox } from '@/components/ErrorBox'
  18 +import { DataTable, type Column } from '@/components/DataTable'
  19 +
  20 +// ─── Designer state types ──────────────────────────────────────────
  21 +
  22 +interface DesignerColumn {
  23 + field: string
  24 + label: string
  25 + width?: string
  26 + sortable: boolean
  27 + format?: string
  28 + visible: boolean
  29 +}
  30 +
  31 +interface DesignerFilter {
  32 + field: string
  33 + operator: string
  34 + label: string
  35 +}
  36 +
  37 +interface DesignerState {
  38 + slug: string
  39 + entityName: string
  40 + title: string
  41 + columns: DesignerColumn[]
  42 + defaultSort?: { field: string; direction: 'asc' | 'desc' }
  43 + filters: DesignerFilter[]
  44 + pageSize: number
  45 + version: number
  46 +}
  47 +
  48 +const FORMAT_OPTIONS = ['plain', 'date', 'money', 'status-badge', 'link'] as const
  49 +const OPERATOR_OPTIONS = ['eq', 'contains', 'gt', 'lt', 'in'] as const
  50 +
  51 +function emptyState(): DesignerState {
  52 + return {
  53 + slug: '',
  54 + entityName: '',
  55 + title: '',
  56 + columns: [],
  57 + defaultSort: undefined,
  58 + filters: [],
  59 + pageSize: 25,
  60 + version: 0,
  61 + }
  62 +}
  63 +
  64 +// ─── Component ─────────────────────────────────────────────────────
  65 +
  66 +export function ListViewDesignerPage() {
  67 + const navigate = useNavigate()
  68 + const { slug: routeSlug } = useParams<{ slug: string }>()
  69 + const isEdit = Boolean(routeSlug)
  70 +
  71 + const [state, setState] = useState<DesignerState>(emptyState)
  72 + const [loading, setLoading] = useState(isEdit)
  73 + const [saving, setSaving] = useState(false)
  74 + const [error, setError] = useState<unknown>(null)
  75 +
  76 + // ── Load existing definition in edit mode ──────────────────────
  77 + useEffect(() => {
  78 + if (!routeSlug) return
  79 + setLoading(true)
  80 + metadata
  81 + .getListView(routeSlug)
  82 + .then((def) => {
  83 + setState({
  84 + slug: def.slug,
  85 + entityName: def.entityName,
  86 + title: def.title,
  87 + columns: def.columns.map((c) => ({
  88 + field: c.field,
  89 + label: c.label,
  90 + width: c.width,
  91 + sortable: c.sortable,
  92 + format: c.format ?? 'plain',
  93 + visible: true,
  94 + })),
  95 + defaultSort: def.defaultSort,
  96 + filters: def.filters ?? [],
  97 + pageSize: def.pageSize,
  98 + version: def.version,
  99 + })
  100 + })
  101 + .catch(setError)
  102 + .finally(() => setLoading(false))
  103 + }, [routeSlug])
  104 +
  105 + // ── Field updaters ─────────────────────────────────────────────
  106 +
  107 + const set = useCallback(
  108 + <K extends keyof DesignerState>(key: K, val: DesignerState[K]) =>
  109 + setState((s) => ({ ...s, [key]: val })),
  110 + [],
  111 + )
  112 +
  113 + // ── Column helpers ─────────────────────────────────────────────
  114 +
  115 + const updateColumn = useCallback(
  116 + (idx: number, patch: Partial<DesignerColumn>) =>
  117 + setState((s) => {
  118 + const cols = [...s.columns]
  119 + cols[idx] = { ...cols[idx], ...patch }
  120 + return { ...s, columns: cols }
  121 + }),
  122 + [],
  123 + )
  124 +
  125 + const moveColumn = useCallback(
  126 + (idx: number, dir: -1 | 1) =>
  127 + setState((s) => {
  128 + const target = idx + dir
  129 + if (target < 0 || target >= s.columns.length) return s
  130 + const cols = [...s.columns]
  131 + ;[cols[idx], cols[target]] = [cols[target], cols[idx]]
  132 + return { ...s, columns: cols }
  133 + }),
  134 + [],
  135 + )
  136 +
  137 + const removeColumn = useCallback(
  138 + (idx: number) =>
  139 + setState((s) => ({
  140 + ...s,
  141 + columns: s.columns.filter((_, i) => i !== idx),
  142 + })),
  143 + [],
  144 + )
  145 +
  146 + const addColumn = useCallback(
  147 + () =>
  148 + setState((s) => ({
  149 + ...s,
  150 + columns: [
  151 + ...s.columns,
  152 + { field: '', label: '', sortable: false, format: 'plain', visible: true },
  153 + ],
  154 + })),
  155 + [],
  156 + )
  157 +
  158 + // ── Filter helpers ─────────────────────────────────────────────
  159 +
  160 + const updateFilter = useCallback(
  161 + (idx: number, patch: Partial<DesignerFilter>) =>
  162 + setState((s) => {
  163 + const filters = [...s.filters]
  164 + filters[idx] = { ...filters[idx], ...patch }
  165 + return { ...s, filters }
  166 + }),
  167 + [],
  168 + )
  169 +
  170 + const removeFilter = useCallback(
  171 + (idx: number) =>
  172 + setState((s) => ({
  173 + ...s,
  174 + filters: s.filters.filter((_, i) => i !== idx),
  175 + })),
  176 + [],
  177 + )
  178 +
  179 + const addFilter = useCallback(
  180 + () =>
  181 + setState((s) => ({
  182 + ...s,
  183 + filters: [...s.filters, { field: '', operator: 'eq', label: '' }],
  184 + })),
  185 + [],
  186 + )
  187 +
  188 + // ── Save ───────────────────────────────────────────────────────
  189 +
  190 + const onSave = async (e: FormEvent) => {
  191 + e.preventDefault()
  192 + setError(null)
  193 + setSaving(true)
  194 + try {
  195 + const slug = state.slug.trim()
  196 + if (!slug) throw new Error('Slug is required')
  197 + if (!state.entityName.trim()) throw new Error('Entity name is required')
  198 +
  199 + const visibleColumns = state.columns
  200 + .filter((c) => c.visible)
  201 + .map((c) => ({
  202 + field: c.field,
  203 + label: c.label,
  204 + width: c.width || undefined,
  205 + sortable: c.sortable,
  206 + format: c.format === 'plain' ? undefined : (c.format as 'date' | 'money' | 'status-badge' | 'link'),
  207 + }))
  208 +
  209 + await metadata.saveListView(slug, {
  210 + slug,
  211 + entityName: state.entityName,
  212 + title: state.title,
  213 + columns: visibleColumns,
  214 + defaultSort: state.defaultSort,
  215 + filters: state.filters.filter((f) => f.field.trim()),
  216 + pageSize: state.pageSize,
  217 + version: state.version + 1,
  218 + })
  219 + navigate('/admin/metadata')
  220 + } catch (err: unknown) {
  221 + setError(err)
  222 + } finally {
  223 + setSaving(false)
  224 + }
  225 + }
  226 +
  227 + // ── Preview columns for DataTable ──────────────────────────────
  228 +
  229 + const previewColumns: Column<Record<string, string>>[] = useMemo(
  230 + () =>
  231 + state.columns
  232 + .filter((c) => c.visible && c.field.trim())
  233 + .map((c) => ({
  234 + header: c.label || c.field,
  235 + key: c.field,
  236 + })),
  237 + [state.columns],
  238 + )
  239 +
  240 + const previewRows = useMemo(() => {
  241 + if (previewColumns.length === 0) return []
  242 + const rows: Record<string, string>[] = []
  243 + for (let i = 1; i <= 3; i++) {
  244 + const row: Record<string, string> = {}
  245 + for (const col of previewColumns) {
  246 + row[col.key] = `${col.key}-${i}`
  247 + }
  248 + rows.push(row)
  249 + }
  250 + return rows
  251 + }, [previewColumns])
  252 +
  253 + // ── Render ─────────────────────────────────────────────────────
  254 +
  255 + if (loading) {
  256 + return (
  257 + <div className="p-6 text-sm text-slate-500">Loading...</div>
  258 + )
  259 + }
  260 +
  261 + const inputCls =
  262 + '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'
  263 + const smallInputCls =
  264 + 'rounded-md border border-slate-300 px-2 py-1.5 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500'
  265 + const smallSelectCls =
  266 + 'rounded-md border border-slate-300 px-2 py-1.5 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500'
  267 +
  268 + return (
  269 + <div>
  270 + <PageHeader
  271 + title={isEdit ? 'Edit List View' : 'New List View'}
  272 + subtitle="Configure columns, filters, sorting, and pagination for an entity list view."
  273 + actions={
  274 + <button
  275 + className="btn-secondary"
  276 + onClick={() => navigate('/admin/metadata')}
  277 + >
  278 + Cancel
  279 + </button>
  280 + }
  281 + />
  282 +
  283 + <form onSubmit={onSave} className="space-y-6 max-w-5xl">
  284 + {error != null ? <ErrorBox error={error} /> : null}
  285 +
  286 + {/* ── Top bar: title, entity, slug ───────────────────── */}
  287 + <div className="card p-6 space-y-4">
  288 + <h2 className="text-lg font-semibold text-slate-800">General</h2>
  289 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
  290 + <div>
  291 + <label className="block text-sm font-medium text-slate-700">
  292 + Title
  293 + </label>
  294 + <input
  295 + type="text"
  296 + required
  297 + value={state.title}
  298 + onChange={(e) => set('title', e.target.value)}
  299 + placeholder="e.g. Sales Orders"
  300 + className={inputCls}
  301 + />
  302 + </div>
  303 + <div>
  304 + <label className="block text-sm font-medium text-slate-700">
  305 + Entity Name
  306 + </label>
  307 + <input
  308 + type="text"
  309 + required
  310 + value={state.entityName}
  311 + onChange={(e) => set('entityName', e.target.value)}
  312 + placeholder="e.g. SalesOrder"
  313 + className={inputCls}
  314 + />
  315 + </div>
  316 + <div>
  317 + <label className="block text-sm font-medium text-slate-700">
  318 + Slug
  319 + </label>
  320 + <input
  321 + type="text"
  322 + required
  323 + value={state.slug}
  324 + onChange={(e) => set('slug', e.target.value)}
  325 + placeholder="e.g. sales-orders-default"
  326 + disabled={isEdit}
  327 + className={`${inputCls}${isEdit ? ' bg-slate-50 text-slate-500' : ''}`}
  328 + />
  329 + </div>
  330 + </div>
  331 + </div>
  332 +
  333 + {/* ── Columns section ────────────────────────────────── */}
  334 + <div className="card p-6 space-y-4">
  335 + <div className="flex items-center justify-between">
  336 + <h2 className="text-lg font-semibold text-slate-800">Columns</h2>
  337 + <button
  338 + type="button"
  339 + className="btn-secondary text-xs"
  340 + onClick={addColumn}
  341 + >
  342 + + Add Column
  343 + </button>
  344 + </div>
  345 +
  346 + {state.columns.length === 0 ? (
  347 + <p className="text-sm text-slate-400">
  348 + No columns defined yet. Click "Add Column" to start.
  349 + </p>
  350 + ) : (
  351 + <div className="overflow-x-auto">
  352 + <table className="table-base text-sm">
  353 + <thead className="bg-slate-50">
  354 + <tr>
  355 + <th className="w-10">Show</th>
  356 + <th>Field</th>
  357 + <th>Label</th>
  358 + <th className="w-28">Format</th>
  359 + <th className="w-16">Sortable</th>
  360 + <th className="w-24">Order</th>
  361 + <th className="w-10"></th>
  362 + </tr>
  363 + </thead>
  364 + <tbody className="divide-y divide-slate-100">
  365 + {state.columns.map((col, idx) => (
  366 + <tr key={idx} className="hover:bg-slate-50">
  367 + <td className="text-center">
  368 + <input
  369 + type="checkbox"
  370 + checked={col.visible}
  371 + onChange={(e) =>
  372 + updateColumn(idx, { visible: e.target.checked })
  373 + }
  374 + className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
  375 + />
  376 + </td>
  377 + <td>
  378 + <input
  379 + type="text"
  380 + value={col.field}
  381 + onChange={(e) =>
  382 + updateColumn(idx, { field: e.target.value })
  383 + }
  384 + placeholder="field.name"
  385 + className={smallInputCls + ' w-full'}
  386 + />
  387 + </td>
  388 + <td>
  389 + <input
  390 + type="text"
  391 + value={col.label}
  392 + onChange={(e) =>
  393 + updateColumn(idx, { label: e.target.value })
  394 + }
  395 + placeholder="Column Label"
  396 + className={smallInputCls + ' w-full'}
  397 + />
  398 + </td>
  399 + <td>
  400 + <select
  401 + value={col.format ?? 'plain'}
  402 + onChange={(e) =>
  403 + updateColumn(idx, { format: e.target.value })
  404 + }
  405 + className={smallSelectCls + ' w-full'}
  406 + >
  407 + {FORMAT_OPTIONS.map((f) => (
  408 + <option key={f} value={f}>
  409 + {f}
  410 + </option>
  411 + ))}
  412 + </select>
  413 + </td>
  414 + <td className="text-center">
  415 + <input
  416 + type="checkbox"
  417 + checked={col.sortable}
  418 + onChange={(e) =>
  419 + updateColumn(idx, { sortable: e.target.checked })
  420 + }
  421 + className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
  422 + />
  423 + </td>
  424 + <td>
  425 + <div className="flex items-center justify-center gap-1">
  426 + <button
  427 + type="button"
  428 + className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30"
  429 + disabled={idx === 0}
  430 + onClick={() => moveColumn(idx, -1)}
  431 + title="Move up"
  432 + >
  433 + &#9650;
  434 + </button>
  435 + <button
  436 + type="button"
  437 + className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30"
  438 + disabled={idx === state.columns.length - 1}
  439 + onClick={() => moveColumn(idx, 1)}
  440 + title="Move down"
  441 + >
  442 + &#9660;
  443 + </button>
  444 + </div>
  445 + </td>
  446 + <td>
  447 + <button
  448 + type="button"
  449 + className="text-slate-400 hover:text-rose-500"
  450 + onClick={() => removeColumn(idx)}
  451 + title="Remove column"
  452 + >
  453 + &times;
  454 + </button>
  455 + </td>
  456 + </tr>
  457 + ))}
  458 + </tbody>
  459 + </table>
  460 + </div>
  461 + )}
  462 + </div>
  463 +
  464 + {/* ── Filters section ────────────────────────────────── */}
  465 + <div className="card p-6 space-y-4">
  466 + <div className="flex items-center justify-between">
  467 + <h2 className="text-lg font-semibold text-slate-800">Filters</h2>
  468 + <button
  469 + type="button"
  470 + className="btn-secondary text-xs"
  471 + onClick={addFilter}
  472 + >
  473 + + Add Filter
  474 + </button>
  475 + </div>
  476 +
  477 + {state.filters.length === 0 ? (
  478 + <p className="text-sm text-slate-400">
  479 + No filters defined. Click "Add Filter" to add filterable fields.
  480 + </p>
  481 + ) : (
  482 + <div className="space-y-2">
  483 + {state.filters.map((filter, idx) => (
  484 + <div key={idx} className="flex items-center gap-2">
  485 + <input
  486 + type="text"
  487 + value={filter.field}
  488 + onChange={(e) =>
  489 + updateFilter(idx, { field: e.target.value })
  490 + }
  491 + placeholder="Field name"
  492 + className={smallInputCls + ' flex-1'}
  493 + />
  494 + <select
  495 + value={filter.operator}
  496 + onChange={(e) =>
  497 + updateFilter(idx, { operator: e.target.value })
  498 + }
  499 + className={smallSelectCls + ' w-28'}
  500 + >
  501 + {OPERATOR_OPTIONS.map((op) => (
  502 + <option key={op} value={op}>
  503 + {op}
  504 + </option>
  505 + ))}
  506 + </select>
  507 + <input
  508 + type="text"
  509 + value={filter.label}
  510 + onChange={(e) =>
  511 + updateFilter(idx, { label: e.target.value })
  512 + }
  513 + placeholder="Display label"
  514 + className={smallInputCls + ' flex-1'}
  515 + />
  516 + <button
  517 + type="button"
  518 + className="text-slate-400 hover:text-rose-500"
  519 + onClick={() => removeFilter(idx)}
  520 + title="Remove filter"
  521 + >
  522 + &times;
  523 + </button>
  524 + </div>
  525 + ))}
  526 + </div>
  527 + )}
  528 + </div>
  529 +
  530 + {/* ── Sorting & page size section ────────────────────── */}
  531 + <div className="card p-6 space-y-4">
  532 + <h2 className="text-lg font-semibold text-slate-800">
  533 + Sorting &amp; Pagination
  534 + </h2>
  535 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
  536 + <div>
  537 + <label className="block text-sm font-medium text-slate-700">
  538 + Default Sort Field
  539 + </label>
  540 + <select
  541 + value={state.defaultSort?.field ?? ''}
  542 + onChange={(e) => {
  543 + const field = e.target.value
  544 + if (!field) {
  545 + set('defaultSort', undefined)
  546 + } else {
  547 + set('defaultSort', {
  548 + field,
  549 + direction: state.defaultSort?.direction ?? 'asc',
  550 + })
  551 + }
  552 + }}
  553 + className={inputCls}
  554 + >
  555 + <option value="">-- none --</option>
  556 + {state.columns
  557 + .filter((c) => c.field.trim())
  558 + .map((c) => (
  559 + <option key={c.field} value={c.field}>
  560 + {c.label || c.field}
  561 + </option>
  562 + ))}
  563 + </select>
  564 + </div>
  565 + <div>
  566 + <label className="block text-sm font-medium text-slate-700">
  567 + Direction
  568 + </label>
  569 + <select
  570 + value={state.defaultSort?.direction ?? 'asc'}
  571 + onChange={(e) => {
  572 + if (!state.defaultSort) return
  573 + set('defaultSort', {
  574 + ...state.defaultSort,
  575 + direction: e.target.value as 'asc' | 'desc',
  576 + })
  577 + }}
  578 + disabled={!state.defaultSort}
  579 + className={`${inputCls}${!state.defaultSort ? ' bg-slate-50 text-slate-500' : ''}`}
  580 + >
  581 + <option value="asc">Ascending</option>
  582 + <option value="desc">Descending</option>
  583 + </select>
  584 + </div>
  585 + <div>
  586 + <label className="block text-sm font-medium text-slate-700">
  587 + Page Size
  588 + </label>
  589 + <input
  590 + type="number"
  591 + min={1}
  592 + max={500}
  593 + value={state.pageSize}
  594 + onChange={(e) =>
  595 + set('pageSize', Math.max(1, parseInt(e.target.value, 10) || 25))
  596 + }
  597 + className={inputCls}
  598 + />
  599 + </div>
  600 + </div>
  601 + </div>
  602 +
  603 + {/* ── Preview section ────────────────────────────────── */}
  604 + <div className="card p-6 space-y-4">
  605 + <h2 className="text-lg font-semibold text-slate-800">Preview</h2>
  606 + {previewColumns.length === 0 ? (
  607 + <p className="text-sm text-slate-400">
  608 + Add visible columns with a field name to see a preview.
  609 + </p>
  610 + ) : (
  611 + <DataTable
  612 + rows={previewRows}
  613 + columns={previewColumns}
  614 + rowKey={(r) => String(r[previewColumns[0]?.key] ?? '')}
  615 + />
  616 + )}
  617 + </div>
  618 +
  619 + {/* ── Actions ────────────────────────────────────────── */}
  620 + <div className="flex items-center gap-3">
  621 + <button type="submit" className="btn-primary" disabled={saving}>
  622 + {saving ? 'Saving...' : 'Save List View'}
  623 + </button>
  624 + <button
  625 + type="button"
  626 + className="btn-secondary"
  627 + onClick={() => navigate('/admin/metadata')}
  628 + >
  629 + Cancel
  630 + </button>
  631 + </div>
  632 + </form>
  633 + </div>
  634 + )
  635 +}
web/src/pages/MetadataAdminPage.tsx 0 → 100644
  1 +// Metadata Admin page.
  2 +//
  3 +// Tabbed admin page for browsing and managing all metadata
  4 +// definitions: entities, custom fields, permissions, menus,
  5 +// forms, and list views. Read-only tabs display DataTables;
  6 +// the custom-fields tab supports inline create/edit/delete for
  7 +// source='user' rows; the forms and list-views tabs navigate
  8 +// to dedicated designer pages.
  9 +
  10 +import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
  11 +import { useNavigate } from 'react-router-dom'
  12 +import { metadata } from '@/api/client'
  13 +import type {
  14 + CustomFieldDef,
  15 + FormDefinition,
  16 + ListViewDefinition,
  17 + MetadataEntity,
  18 + MetadataPermission,
  19 +} from '@/types/api'
  20 +import { PageHeader } from '@/components/PageHeader'
  21 +import { Loading } from '@/components/Loading'
  22 +import { ErrorBox } from '@/components/ErrorBox'
  23 +import { DataTable, type Column } from '@/components/DataTable'
  24 +import { useT } from '@/i18n/LocaleContext'
  25 +
  26 +// ─── Menu row shape (no formal type in api.ts) ─────────────────────
  27 +
  28 +interface MenuRow {
  29 + path: string
  30 + label: string
  31 + icon?: string
  32 + section?: string
  33 + order?: number
  34 + source?: string
  35 +}
  36 +
  37 +// ─── Source badge ───────────────────────────────────────────────────
  38 +
  39 +function SourceBadge({ source }: { source: string }) {
  40 + const cls =
  41 + source === 'core'
  42 + ? 'bg-blue-100 text-blue-700'
  43 + : source === 'user'
  44 + ? 'bg-emerald-100 text-emerald-700'
  45 + : 'bg-amber-100 text-amber-700' // plugin:*
  46 + return (
  47 + <span
  48 + className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${cls}`}
  49 + >
  50 + {source}
  51 + </span>
  52 + )
  53 +}
  54 +
  55 +// ─── Tab definitions ────────────────────────────────────────────────
  56 +
  57 +type TabId =
  58 + | 'entities'
  59 + | 'customFields'
  60 + | 'permissions'
  61 + | 'menus'
  62 + | 'forms'
  63 + | 'listViews'
  64 +
  65 +const TYPE_KINDS = [
  66 + 'string',
  67 + 'integer',
  68 + 'decimal',
  69 + 'boolean',
  70 + 'date',
  71 + 'date_time',
  72 + 'enum',
  73 + 'uuid',
  74 + 'money',
  75 + 'quantity',
  76 +] as const
  77 +
  78 +// ─── Component ──────────────────────────────────────────────────────
  79 +
  80 +export function MetadataAdminPage() {
  81 + const t = useT()
  82 + const navigate = useNavigate()
  83 + const [activeTab, setActiveTab] = useState<TabId>('entities')
  84 +
  85 + // Shared loading / error state
  86 + const [loading, setLoading] = useState(false)
  87 + const [error, setError] = useState<Error | null>(null)
  88 +
  89 + // Tab data
  90 + const [entities, setEntities] = useState<MetadataEntity[]>([])
  91 + const [customFields, setCustomFields] = useState<CustomFieldDef[]>([])
  92 + const [permissions, setPermissions] = useState<MetadataPermission[]>([])
  93 + const [menus, setMenus] = useState<MenuRow[]>([])
  94 + const [forms, setForms] = useState<FormDefinition[]>([])
  95 + const [listViews, setListViews] = useState<ListViewDefinition[]>([])
  96 +
  97 + // Custom-field inline form state
  98 + const [cfFormOpen, setCfFormOpen] = useState(false)
  99 + const [cfEditingKey, setCfEditingKey] = useState<string | null>(null) // null = create
  100 + const [cfTargetEntity, setCfTargetEntity] = useState('')
  101 + const [cfFieldKey, setCfFieldKey] = useState('')
  102 + const [cfTypeKind, setCfTypeKind] = useState<string>('string')
  103 + const [cfRequired, setCfRequired] = useState(false)
  104 + const [cfPii, setCfPii] = useState(false)
  105 + const [cfLabelEn, setCfLabelEn] = useState('')
  106 + const [cfLabelZh, setCfLabelZh] = useState('')
  107 + const [cfSaving, setCfSaving] = useState(false)
  108 +
  109 + // ── Loaders ───────────────────────────────────────────────────────
  110 +
  111 + const loadTab = (tab: TabId) => {
  112 + setLoading(true)
  113 + setError(null)
  114 + const promise: Promise<void> = (() => {
  115 + switch (tab) {
  116 + case 'entities':
  117 + return metadata.entities().then(setEntities).then(() => {})
  118 + case 'customFields':
  119 + return metadata.customFields().then(setCustomFields).then(() => {})
  120 + case 'permissions':
  121 + return metadata.permissions().then(setPermissions).then(() => {})
  122 + case 'menus':
  123 + return metadata
  124 + .menus()
  125 + .then((rows: MenuRow[]) => setMenus(rows))
  126 + .then(() => {})
  127 + case 'forms':
  128 + return metadata.listForms().then(setForms).then(() => {})
  129 + case 'listViews':
  130 + return metadata.listListViews().then(setListViews).then(() => {})
  131 + }
  132 + })()
  133 + promise
  134 + .catch((e: unknown) =>
  135 + setError(e instanceof Error ? e : new Error(String(e))),
  136 + )
  137 + .finally(() => setLoading(false))
  138 + }
  139 +
  140 + useEffect(() => {
  141 + loadTab(activeTab)
  142 + }, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
  143 +
  144 + // ── Custom-field form helpers ─────────────────────────────────────
  145 +
  146 + const resetCfForm = () => {
  147 + setCfFormOpen(false)
  148 + setCfEditingKey(null)
  149 + setCfTargetEntity('')
  150 + setCfFieldKey('')
  151 + setCfTypeKind('string')
  152 + setCfRequired(false)
  153 + setCfPii(false)
  154 + setCfLabelEn('')
  155 + setCfLabelZh('')
  156 + }
  157 +
  158 + const openCfCreate = () => {
  159 + resetCfForm()
  160 + setCfFormOpen(true)
  161 + }
  162 +
  163 + const openCfEdit = (cf: CustomFieldDef) => {
  164 + setCfEditingKey(cf.key)
  165 + setCfTargetEntity(cf.targetEntity)
  166 + setCfFieldKey(cf.key)
  167 + setCfTypeKind(cf.type.kind)
  168 + setCfRequired(cf.required)
  169 + setCfPii(cf.pii)
  170 + setCfLabelEn(cf.labelTranslations['en'] ?? '')
  171 + setCfLabelZh(cf.labelTranslations['zh-CN'] ?? '')
  172 + setCfFormOpen(true)
  173 + }
  174 +
  175 + const onCfSubmit = async (e: FormEvent) => {
  176 + e.preventDefault()
  177 + setCfSaving(true)
  178 + setError(null)
  179 + const body: Omit<CustomFieldDef, 'source'> = {
  180 + key: cfFieldKey,
  181 + targetEntity: cfTargetEntity,
  182 + type: { kind: cfTypeKind },
  183 + required: cfRequired,
  184 + pii: cfPii,
  185 + labelTranslations: {
  186 + ...(cfLabelEn ? { en: cfLabelEn } : {}),
  187 + ...(cfLabelZh ? { 'zh-CN': cfLabelZh } : {}),
  188 + },
  189 + }
  190 + try {
  191 + if (cfEditingKey) {
  192 + await metadata.updateCustomField(cfEditingKey, body)
  193 + } else {
  194 + await metadata.createCustomField(body)
  195 + }
  196 + resetCfForm()
  197 + loadTab('customFields')
  198 + } catch (err: unknown) {
  199 + setError(err instanceof Error ? err : new Error(String(err)))
  200 + } finally {
  201 + setCfSaving(false)
  202 + }
  203 + }
  204 +
  205 + const onCfDelete = async (key: string) => {
  206 + if (!window.confirm(t('confirm.delete'))) return
  207 + setError(null)
  208 + try {
  209 + await metadata.deleteCustomField(key)
  210 + loadTab('customFields')
  211 + } catch (err: unknown) {
  212 + setError(err instanceof Error ? err : new Error(String(err)))
  213 + }
  214 + }
  215 +
  216 + // ── Form / ListView delete helpers ────────────────────────────────
  217 +
  218 + const onFormDelete = async (slug: string) => {
  219 + if (!window.confirm(t('confirm.delete'))) return
  220 + setError(null)
  221 + try {
  222 + await metadata.deleteForm(slug)
  223 + loadTab('forms')
  224 + } catch (err: unknown) {
  225 + setError(err instanceof Error ? err : new Error(String(err)))
  226 + }
  227 + }
  228 +
  229 + const onListViewDelete = async (slug: string) => {
  230 + if (!window.confirm(t('confirm.delete'))) return
  231 + setError(null)
  232 + try {
  233 + await metadata.deleteListView(slug)
  234 + loadTab('listViews')
  235 + } catch (err: unknown) {
  236 + setError(err instanceof Error ? err : new Error(String(err)))
  237 + }
  238 + }
  239 +
  240 + // ── Column definitions ────────────────────────────────────────────
  241 +
  242 + const entityCols: Column<MetadataEntity>[] = [
  243 + { header: t('label.name'), key: 'name' },
  244 + { header: 'PBC', key: 'pbc' },
  245 + { header: 'Table', key: 'table' },
  246 + { header: t('label.description'), key: 'description', render: (r) => r.description ?? '—' },
  247 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  248 + ]
  249 +
  250 + const customFieldCols: Column<CustomFieldDef>[] = [
  251 + { header: t('label.fieldKey'), key: 'key', render: (r) => <span className="font-mono">{r.key}</span> },
  252 + { header: t('label.targetEntity'), key: 'targetEntity' },
  253 + { header: t('label.fieldType'), key: 'type', render: (r) => r.type.kind },
  254 + { header: 'Required', key: 'required', render: (r) => (r.required ? 'Yes' : 'No') },
  255 + { header: 'PII', key: 'pii', render: (r) => (r.pii ? 'Yes' : 'No') },
  256 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  257 + {
  258 + header: '',
  259 + key: '_actions',
  260 + render: (r) =>
  261 + r.source === 'user' ? (
  262 + <span className="flex gap-2">
  263 + <button
  264 + className="text-xs text-blue-600 hover:underline"
  265 + onClick={() => openCfEdit(r)}
  266 + >
  267 + Edit
  268 + </button>
  269 + <button
  270 + className="text-xs text-rose-600 hover:underline"
  271 + onClick={() => onCfDelete(r.key)}
  272 + >
  273 + {t('action.delete')}
  274 + </button>
  275 + </span>
  276 + ) : null,
  277 + },
  278 + ]
  279 +
  280 + const permissionCols: Column<MetadataPermission>[] = [
  281 + { header: 'Key', key: 'key', render: (r) => <span className="font-mono">{r.key}</span> },
  282 + { header: t('label.description'), key: 'description' },
  283 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  284 + ]
  285 +
  286 + const menuCols: Column<MenuRow>[] = [
  287 + { header: 'Path', key: 'path', render: (r) => <span className="font-mono">{r.path}</span> },
  288 + { header: 'Label', key: 'label' },
  289 + { header: 'Icon', key: 'icon', render: (r) => r.icon ?? '—' },
  290 + { header: 'Section', key: 'section', render: (r) => r.section ?? '—' },
  291 + { header: 'Order', key: 'order', render: (r) => r.order ?? '—' },
  292 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  293 + ]
  294 +
  295 + const formCols: Column<FormDefinition>[] = [
  296 + {
  297 + header: t('label.slug'),
  298 + key: 'slug',
  299 + render: (r) => (
  300 + <button
  301 + className="font-mono text-brand-600 hover:underline"
  302 + onClick={() => navigate(`/admin/metadata/forms/${r.slug}/edit`)}
  303 + >
  304 + {r.slug}
  305 + </button>
  306 + ),
  307 + },
  308 + { header: t('label.entity'), key: 'entityName' },
  309 + { header: 'Title', key: 'title' },
  310 + { header: t('label.purpose'), key: 'purpose' },
  311 + { header: 'Version', key: 'version' },
  312 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  313 + {
  314 + header: '',
  315 + key: '_actions',
  316 + render: (r) =>
  317 + r.source === 'user' ? (
  318 + <button
  319 + className="text-xs text-rose-600 hover:underline"
  320 + onClick={() => onFormDelete(r.slug)}
  321 + >
  322 + {t('action.delete')}
  323 + </button>
  324 + ) : null,
  325 + },
  326 + ]
  327 +
  328 + const listViewCols: Column<ListViewDefinition>[] = [
  329 + {
  330 + header: t('label.slug'),
  331 + key: 'slug',
  332 + render: (r) => (
  333 + <button
  334 + className="font-mono text-brand-600 hover:underline"
  335 + onClick={() =>
  336 + navigate(`/admin/metadata/list-views/${r.slug}/edit`)
  337 + }
  338 + >
  339 + {r.slug}
  340 + </button>
  341 + ),
  342 + },
  343 + { header: t('label.entity'), key: 'entityName' },
  344 + { header: 'Title', key: 'title' },
  345 + { header: t('label.pageSize'), key: 'pageSize' },
  346 + { header: 'Version', key: 'version' },
  347 + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> },
  348 + {
  349 + header: '',
  350 + key: '_actions',
  351 + render: (r) =>
  352 + r.source === 'user' ? (
  353 + <button
  354 + className="text-xs text-rose-600 hover:underline"
  355 + onClick={() => onListViewDelete(r.slug)}
  356 + >
  357 + {t('action.delete')}
  358 + </button>
  359 + ) : null,
  360 + },
  361 + ]
  362 +
  363 + // ── Tab bar ───────────────────────────────────────────────────────
  364 +
  365 + const tabs: { id: TabId; label: string }[] = [
  366 + { id: 'entities', label: t('tab.entities') },
  367 + { id: 'customFields', label: t('tab.customFields') },
  368 + { id: 'permissions', label: t('tab.permissions') },
  369 + { id: 'menus', label: t('tab.menus') },
  370 + { id: 'forms', label: t('tab.forms') },
  371 + { id: 'listViews', label: t('tab.listViews') },
  372 + ]
  373 +
  374 + // ── Tab content renderer ──────────────────────────────────────────
  375 +
  376 + function renderTabContent(): ReactNode {
  377 + if (loading) return <Loading />
  378 + if (error) return <ErrorBox error={error} />
  379 +
  380 + switch (activeTab) {
  381 + case 'entities':
  382 + return (
  383 + <DataTable
  384 + rows={entities}
  385 + columns={entityCols}
  386 + rowKey={(r) => r.name}
  387 + />
  388 + )
  389 +
  390 + case 'customFields':
  391 + return (
  392 + <div>
  393 + <div className="mb-3">
  394 + <button className="btn-primary" onClick={openCfCreate}>
  395 + {t('action.newCustomField')}
  396 + </button>
  397 + </div>
  398 + {cfFormOpen && (
  399 + <form
  400 + onSubmit={onCfSubmit}
  401 + className="card p-4 space-y-3 mt-3 mb-4 max-w-2xl"
  402 + >
  403 + <div className="grid grid-cols-2 gap-3">
  404 + <div>
  405 + <label className="block text-xs font-medium text-slate-700">
  406 + {t('label.targetEntity')}
  407 + </label>
  408 + <input
  409 + type="text"
  410 + required
  411 + value={cfTargetEntity}
  412 + onChange={(e) => setCfTargetEntity(e.target.value)}
  413 + placeholder="Partner"
  414 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
  415 + />
  416 + </div>
  417 + <div>
  418 + <label className="block text-xs font-medium text-slate-700">
  419 + {t('label.fieldKey')}
  420 + </label>
  421 + <input
  422 + type="text"
  423 + required
  424 + value={cfFieldKey}
  425 + onChange={(e) => setCfFieldKey(e.target.value)}
  426 + disabled={cfEditingKey !== null}
  427 + placeholder="custom_my_field"
  428 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm font-mono disabled:opacity-50"
  429 + />
  430 + </div>
  431 + </div>
  432 + <div className="grid grid-cols-2 gap-3">
  433 + <div>
  434 + <label className="block text-xs font-medium text-slate-700">
  435 + {t('label.fieldType')}
  436 + </label>
  437 + <select
  438 + value={cfTypeKind}
  439 + onChange={(e) => setCfTypeKind(e.target.value)}
  440 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
  441 + >
  442 + {TYPE_KINDS.map((k) => (
  443 + <option key={k} value={k}>
  444 + {k}
  445 + </option>
  446 + ))}
  447 + </select>
  448 + </div>
  449 + <div className="flex items-end gap-4 pb-1">
  450 + <label className="flex items-center gap-1.5 text-sm text-slate-700">
  451 + <input
  452 + type="checkbox"
  453 + checked={cfRequired}
  454 + onChange={(e) => setCfRequired(e.target.checked)}
  455 + />
  456 + Required
  457 + </label>
  458 + <label className="flex items-center gap-1.5 text-sm text-slate-700">
  459 + <input
  460 + type="checkbox"
  461 + checked={cfPii}
  462 + onChange={(e) => setCfPii(e.target.checked)}
  463 + />
  464 + PII
  465 + </label>
  466 + </div>
  467 + </div>
  468 + <div className="grid grid-cols-2 gap-3">
  469 + <div>
  470 + <label className="block text-xs font-medium text-slate-700">
  471 + Label EN
  472 + </label>
  473 + <input
  474 + type="text"
  475 + value={cfLabelEn}
  476 + onChange={(e) => setCfLabelEn(e.target.value)}
  477 + placeholder="My Field"
  478 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
  479 + />
  480 + </div>
  481 + <div>
  482 + <label className="block text-xs font-medium text-slate-700">
  483 + Label zh-CN
  484 + </label>
  485 + <input
  486 + type="text"
  487 + value={cfLabelZh}
  488 + onChange={(e) => setCfLabelZh(e.target.value)}
  489 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
  490 + />
  491 + </div>
  492 + </div>
  493 + <div className="flex gap-2">
  494 + <button
  495 + type="submit"
  496 + className="btn-primary"
  497 + disabled={cfSaving}
  498 + >
  499 + {cfSaving ? '...' : t('action.save')}
  500 + </button>
  501 + <button
  502 + type="button"
  503 + className="btn-secondary"
  504 + onClick={resetCfForm}
  505 + >
  506 + {t('action.cancel')}
  507 + </button>
  508 + </div>
  509 + </form>
  510 + )}
  511 + <DataTable
  512 + rows={customFields}
  513 + columns={customFieldCols}
  514 + rowKey={(r) => r.key}
  515 + />
  516 + </div>
  517 + )
  518 +
  519 + case 'permissions':
  520 + return (
  521 + <DataTable
  522 + rows={permissions}
  523 + columns={permissionCols}
  524 + rowKey={(r) => r.key}
  525 + />
  526 + )
  527 +
  528 + case 'menus':
  529 + return (
  530 + <DataTable
  531 + rows={menus}
  532 + columns={menuCols}
  533 + rowKey={(r) => r.path}
  534 + />
  535 + )
  536 +
  537 + case 'forms':
  538 + return (
  539 + <div>
  540 + <div className="mb-3">
  541 + <button
  542 + className="btn-primary"
  543 + onClick={() => navigate('/admin/metadata/forms/new')}
  544 + >
  545 + + New Form
  546 + </button>
  547 + </div>
  548 + <DataTable
  549 + rows={forms}
  550 + columns={formCols}
  551 + rowKey={(r) => r.slug}
  552 + />
  553 + </div>
  554 + )
  555 +
  556 + case 'listViews':
  557 + return (
  558 + <div>
  559 + <div className="mb-3">
  560 + <button
  561 + className="btn-primary"
  562 + onClick={() => navigate('/admin/metadata/list-views/new')}
  563 + >
  564 + + New List View
  565 + </button>
  566 + </div>
  567 + <DataTable
  568 + rows={listViews}
  569 + columns={listViewCols}
  570 + rowKey={(r) => r.slug}
  571 + />
  572 + </div>
  573 + )
  574 + }
  575 + }
  576 +
  577 + // ── Render ─────────────────────────────────────────────────────────
  578 +
  579 + return (
  580 + <div>
  581 + <PageHeader
  582 + title={t('page.metadataAdmin.title')}
  583 + subtitle="Browse and manage metadata definitions"
  584 + />
  585 +
  586 + {/* Tab bar */}
  587 + <div className="flex border-b border-slate-200 mb-4">
  588 + {tabs.map((tab) => (
  589 + <button
  590 + key={tab.id}
  591 + className={`px-4 py-2 text-sm font-medium -mb-px ${
  592 + activeTab === tab.id
  593 + ? 'border-b-2 border-blue-500 text-blue-600'
  594 + : 'text-slate-500 hover:text-slate-700'
  595 + }`}
  596 + onClick={() => {
  597 + resetCfForm()
  598 + setActiveTab(tab.id)
  599 + }}
  600 + >
  601 + {tab.label}
  602 + </button>
  603 + ))}
  604 + </div>
  605 +
  606 + {/* Tab content */}
  607 + {renderTabContent()}
  608 + </div>
  609 + )
  610 +}