You need to sign in before continuing.
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,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 | + ▲ | ||
| 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 | +} |