Commit 558467ec604dfc181af95be1d9462c963a9f1c25
1 parent
1875c068
feat(web): list view designer — column/filter/sort configuration
Showing
4 changed files
with
1975 additions
and
5 deletions
web/src/App.tsx
| ... | ... | @@ -41,11 +41,9 @@ import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' |
| 41 | 41 | import { ShopFloorPage } from '@/pages/ShopFloorPage' |
| 42 | 42 | import { AccountsPage } from '@/pages/AccountsPage' |
| 43 | 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 | 48 | export default function App() { |
| 51 | 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 | + ▲ | |
| 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 | + ▼ | |
| 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 | + ▲ | |
| 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 | + ▼ | |
| 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 | + × | |
| 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 | + × | |
| 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 & 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 | +} | ... | ... |