diff --git a/docs/superpowers/plans/2026-04-10-metadata-forms-listviews.md b/docs/superpowers/plans/2026-04-10-metadata-forms-listviews.md new file mode 100644 index 0000000..f3e854c --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-metadata-forms-listviews.md @@ -0,0 +1,957 @@ +# Metadata-Driven Forms & List Views — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship P3.2 (form renderer), P3.3 (form designer), P3.6 (list view designer), and R3 (metadata admin UIs) so Tier 1 key users can manage custom fields, design forms, and configure list views through the browser. + +**Architecture:** Hybrid approach — core entity forms stay handcrafted; a new `MetadataFormRenderer` (powered by @rjsf/core) renders user-task forms and future custom entity forms. A structured property editor lets key users design form layouts. A list view designer lets them configure columns/filters/sort. A tabbed metadata admin page ties everything together. Backend adds CRUD endpoints for forms, list views, and custom fields with `source='user'` write protection. + +**Tech Stack:** Kotlin/Spring Boot (backend CRUD), @rjsf/core + @rjsf/validator-ajv8 (form rendering), React + TypeScript + Tailwind (SPA pages) + +**Spec:** `docs/superpowers/specs/2026-04-10-metadata-forms-listviews-design.md` + +--- + +## File Map + +### New files (Backend) +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt` +- `platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml` +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt` +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt` + +### New files (Frontend) +- `web/src/components/MetadataFormRenderer.tsx` +- `web/src/components/form-widgets/vibeErpTheme.tsx` +- `web/src/components/form-widgets/PartnerPicker.tsx` +- `web/src/components/form-widgets/ItemPicker.tsx` +- `web/src/components/form-widgets/UomSelector.tsx` +- `web/src/components/form-widgets/LocationPicker.tsx` +- `web/src/components/form-widgets/MoneyInput.tsx` +- `web/src/components/form-widgets/QuantityInput.tsx` +- `web/src/components/form-widgets/index.ts` +- `web/src/pages/FormDesignerPage.tsx` +- `web/src/pages/ListViewDesignerPage.tsx` +- `web/src/pages/MetadataAdminPage.tsx` + +### Modified files +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt` +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt` +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt` +- `reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml` +- `web/src/App.tsx` +- `web/src/api/client.ts` +- `web/src/types/api.ts` +- `web/src/i18n/messages.ts` +- `web/src/layout/AppLayout.tsx` +- `web/package.json` + +--- + +## Task 1: Extend MetadataYaml + MetadataLoader for forms and list views + +**Files:** +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` +- Modify: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt` +- Modify: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt` + +- [ ] **Step 1: Add FormYaml and ListViewYaml to MetadataYaml.kt** + +Add these data classes after the existing `CustomFieldTypeYaml`: + +```kotlin +@JsonIgnoreProperties(ignoreUnknown = true) +data class FormYaml( + val slug: String = "", + val entityName: String = "", + val title: String = "", + val purpose: String = "edit", + val jsonSchema: Map = emptyMap(), + val uiSchema: Map = emptyMap(), + val version: Int = 1, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewYaml( + val slug: String = "", + val entityName: String = "", + val title: String = "", + val columns: List = emptyList(), + val defaultSort: ListViewSortYaml? = null, + val filters: List = emptyList(), + val pageSize: Int = 25, + val version: Int = 1, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewColumnYaml( + val field: String = "", + val label: String = "", + val width: String? = null, + val sortable: Boolean = true, + val format: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewSortYaml( + val field: String = "", + val direction: String = "asc", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewFilterYaml( + val field: String = "", + val operator: String = "eq", + val label: String = "", +) +``` + +Add `forms` and `listViews` to `MetadataYamlFile`: + +```kotlin +data class MetadataYamlFile( + val entities: List = emptyList(), + val permissions: List = emptyList(), + val menus: List = emptyList(), + val customFields: List = emptyList(), + val forms: List = emptyList(), + val listViews: List = emptyList(), +) +``` + +- [ ] **Step 2: Write tests for YAML parsing of forms and list views** + +Add to `MetadataYamlParseTest.kt`: + +```kotlin +@Test +fun `forms section parses with slug, entityName, jsonSchema, uiSchema`() { + val yaml = """ + forms: + - slug: approval-form + entityName: Plate + title: Plate Approval + purpose: user-task + version: 1 + jsonSchema: + type: object + properties: + approved: + type: boolean + uiSchema: + "ui:order": + - approved + """.trimIndent() + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + assertThat(parsed.forms).hasSize(1) + assertThat(parsed.forms[0].slug).isEqualTo("approval-form") + assertThat(parsed.forms[0].entityName).isEqualTo("Plate") + assertThat(parsed.forms[0].purpose).isEqualTo("user-task") + assertThat(parsed.forms[0].jsonSchema).containsKey("type") + assertThat(parsed.forms[0].uiSchema).containsKey("ui:order") +} + +@Test +fun `listViews section parses with columns and filters`() { + val yaml = """ + listViews: + - slug: items-default + entityName: Item + title: Items + pageSize: 50 + columns: + - field: code + label: Code + sortable: true + - field: name + label: Name + format: link + defaultSort: + field: code + direction: asc + filters: + - field: itemType + operator: eq + label: Type + """.trimIndent() + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + assertThat(parsed.listViews).hasSize(1) + assertThat(parsed.listViews[0].slug).isEqualTo("items-default") + assertThat(parsed.listViews[0].columns).hasSize(2) + assertThat(parsed.listViews[0].defaultSort?.field).isEqualTo("code") + assertThat(parsed.listViews[0].filters).hasSize(1) + assertThat(parsed.listViews[0].pageSize).isEqualTo(50) +} +``` + +Update the existing "unknown top-level keys are ignored" test: change the test YAML key from `forms:` to `futureSection:` since `forms` is now a recognized key. + +- [ ] **Step 3: Run tests to verify parsing works** + +Run: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test` +Expected: All tests pass including new ones. + +- [ ] **Step 4: Extend MetadataLoader to process forms and list views** + +In `MetadataLoader.kt`: + +1. Add to `wipeBySource()`: +```kotlin +jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) +jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) +``` + +2. Add `insertForms()` and `insertListViews()` private methods following the same pattern as `insertEntities()`: +```kotlin +private fun insertForms(forms: List, source: String, now: Timestamp) { + forms.forEach { form -> + jdbc.update( + """INSERT INTO metadata__form (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)""", + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(form)) + .addValue("now", now), + ) + } +} +``` + +`insertListViews()` follows the identical pattern for `metadata__list_view`. + +3. Call them from `doLoad()` after the existing insert calls: +```kotlin +insertForms(merged.forms, source, now) +insertListViews(merged.listViews, source, now) +``` + +4. Add `formCount` and `listViewCount` to `LoadResult`. + +- [ ] **Step 5: Add loader tests for forms and list views** + +Add to `MetadataLoaderTest.kt`: + +```kotlin +@Test +fun `loadFromPluginJar with forms section inserts into metadata__form`() { + // Create temp JAR with metadata YAML containing a forms section + // Verify INSERT is called on metadata__form table + // Verify LoadResult.formCount == 1 +} +``` + +- [ ] **Step 6: Run full test suite and commit** + +Run: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test` + +```bash +git add platform/platform-metadata/ +git commit -m "feat(metadata): extend YAML schema + loader for forms and list views" +``` + +--- + +## Task 2: Add form/list-view read + write endpoints + +**Files:** +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt` +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt` +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt` + +- [ ] **Step 1: Add GET endpoints to MetadataController** + +Add to the `all()` response map: +```kotlin +"forms" to readPayloads("metadata__form"), +"listViews" to readPayloads("metadata__list_view"), +``` + +Add new GET endpoints: +```kotlin +@GetMapping("/forms") +fun forms(): List> = readPayloads("metadata__form") + +@GetMapping("/forms/{slug}") +fun formBySlug(@PathVariable slug: String): Map { + return readPayloadBySlug("metadata__form", slug) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found") +} + +@GetMapping("/list-views") +fun listViews(): List> = readPayloads("metadata__list_view") + +@GetMapping("/list-views/{slug}") +fun listViewBySlug(@PathVariable slug: String): Map { + return readPayloadBySlug("metadata__list_view", slug) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") +} +``` + +Add private helper: +```kotlin +private fun readPayloadBySlug(table: String, slug: String): Map? { + val rows = jdbc.query( + "SELECT source, payload FROM $table WHERE payload->>'slug' = :slug ORDER BY source LIMIT 1", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> + val source = rs.getString("source") + @Suppress("UNCHECKED_CAST") + val payload = objectMapper.readValue(rs.getString("payload") ?: "{}", Map::class.java) as Map + payload + mapOf("source" to source) + } + return rows.firstOrNull() +} +``` + +- [ ] **Step 2: Create FormDefinitionController with PUT/DELETE** + +Create `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt`: + +```kotlin +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/_meta/metadata/forms") +class FormDefinitionController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + @PutMapping("/{slug}") + @RequirePermission("admin.metadata.write") + fun upsert(@PathVariable slug: String, @RequestBody body: Map): Map { + val payload = body.toMutableMap().apply { put("slug", slug) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + val existing = jdbc.query( + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isNotEmpty()) { + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException(HttpStatus.FORBIDDEN, + "Cannot modify form '$slug' (source='$source')") + } + jdbc.update( + "UPDATE metadata__form SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now), + ) + } else { + jdbc.update( + "INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now), + ) + } + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{slug}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable slug: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found") + val (id, source) = existing.first() + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete form '$slug' (source='$source')") + jdbc.update("DELETE FROM metadata__form WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id)) + } +} +``` + +- [ ] **Step 3: Create ListViewDefinitionController (identical pattern)** + +Same structure as `FormDefinitionController`, operating on `metadata__list_view` table. Permission: `admin.metadata.write`. + +- [ ] **Step 4: Create CustomFieldWriteController** + +Create `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt`: + +```kotlin +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/_meta/metadata/custom-fields") +class CustomFieldWriteController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, + private val customFieldRegistry: CustomFieldRegistry, +) { + @PostMapping + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody body: Map): Map { + val key = body["key"]?.toString() + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "key is required") + + val existing = jdbc.query( + "SELECT id FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") } + if (existing.isNotEmpty()) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Custom field '$key' already exists") + } + + val payloadJson = objectMapper.writeValueAsString(body) + val now = Timestamp.from(Instant.now()) + jdbc.update( + "INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now), + ) + customFieldRegistry.refresh() + return body + mapOf("source" to "user") + } + + @PutMapping("/{key}") + @RequirePermission("admin.metadata.write") + fun update(@PathVariable key: String, @RequestBody body: Map): Map { + val existing = jdbc.query( + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found") + val (id, source) = existing.first() + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot modify field '$key' (source='$source')") + + val payload = body.toMutableMap().apply { put("key", key) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + jdbc.update( + "UPDATE metadata__custom_field SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now), + ) + customFieldRegistry.refresh() + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{key}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable key: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found") + val (id, source) = existing.first() + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete field '$key' (source='$source')") + + jdbc.update("DELETE FROM metadata__custom_field WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id)) + customFieldRegistry.refresh() + } +} +``` + +- [ ] **Step 5: Add admin.metadata permissions YAML** + +Create `platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml`: + +```yaml +permissions: + - key: admin.metadata.read + description: View metadata configuration + - key: admin.metadata.write + description: Create, edit, and delete user metadata + +menus: + - path: /admin/metadata + label: Metadata Admin + icon: database-cog + section: System + order: 900 +``` + +- [ ] **Step 6: Write backend tests** + +Create `FormDefinitionControllerTest.kt` and `CustomFieldWriteControllerTest.kt` with MockK-based unit tests covering: +- PUT creates new source='user' row +- PUT updates existing source='user' row +- PUT rejects source='core' row (403) +- DELETE removes source='user' row +- DELETE rejects source='core' row (403) +- DELETE returns 404 for unknown slug +- Custom field POST creates row + calls registry.refresh() +- Custom field POST rejects duplicate key (409) + +- [ ] **Step 7: Run full test suite and commit** + +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test +git add platform/platform-metadata/ +git commit -m "feat(metadata): form/list-view/custom-field CRUD endpoints with source enforcement" +``` + +--- + +## Task 3: Add reference form definition to printing-shop plugin + +**Files:** +- Modify: `reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml` + +- [ ] **Step 1: Add forms section to printing-shop metadata YAML** + +Append to `printing-shop.yml`: + +```yaml +forms: + - slug: plate-approval-task + entityName: Plate + title: Plate Approval + purpose: user-task + version: 1 + jsonSchema: + type: object + required: + - approved + properties: + plateCode: + type: string + title: Plate Code + readOnly: true + approved: + type: boolean + title: Approved + reviewNotes: + type: string + title: Review Notes + maxLength: 500 + uiSchema: + "ui:order": + - plateCode + - approved + - reviewNotes + reviewNotes: + "ui:widget": textarea +``` + +- [ ] **Step 2: Build and verify the plugin loads** + +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build +git add reference-customer/ +git commit -m "feat(ref-plugin): add plate-approval-task form definition" +``` + +--- + +## Task 4: Install @rjsf dependencies and add SPA types/routes/client + +**Files:** +- Modify: `web/package.json` +- Modify: `web/src/types/api.ts` +- Modify: `web/src/api/client.ts` +- Modify: `web/src/i18n/messages.ts` +- Modify: `web/src/App.tsx` +- Modify: `web/src/layout/AppLayout.tsx` + +- [ ] **Step 1: Install @rjsf packages** + +```bash +cd web && npm install @rjsf/core@^5 @rjsf/utils@^5 @rjsf/validator-ajv8@^5 && cd .. +``` + +- [ ] **Step 2: Add TypeScript types for metadata** + +Add to `web/src/types/api.ts`: + +```typescript +// ─── Metadata Definitions ─────────────────────────────────────────── + +export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view' + +export interface FormDefinition { + entityName: string + slug: string + title: string + purpose: FormPurpose + jsonSchema: Record + uiSchema: Record + version: number + source?: string +} + +export interface ListViewColumnDef { + field: string + label: string + width?: string + sortable: boolean + format?: 'date' | 'money' | 'status-badge' | 'link' +} + +export interface ListViewDefinition { + entityName: string + slug: string + title: string + columns: ListViewColumnDef[] + defaultSort?: { field: string; direction: 'asc' | 'desc' } + filters?: { field: string; operator: string; label: string }[] + pageSize: number + version: number + source?: string +} + +export interface CustomFieldType { + kind: string + maxLength?: number + precision?: number + scale?: number + targetEntity?: string + allowedValues?: string[] +} + +export interface CustomFieldDef { + key: string + targetEntity: string + type: CustomFieldType + required: boolean + pii: boolean + labelTranslations: Record + source?: string +} + +export interface MetadataEntity { + name: string + pbc: string + table: string + description?: string + source?: string +} + +export interface MetadataPermission { + key: string + description: string + source?: string +} +``` + +- [ ] **Step 3: Add API client functions** + +Add to `web/src/api/client.ts`: + +```typescript +export const metadata = { + entities: () => apiFetch('/api/v1/_meta/metadata/entities'), + permissions: () => apiFetch('/api/v1/_meta/metadata/permissions'), + menus: () => apiFetch('/api/v1/_meta/metadata/menus'), + customFields: () => apiFetch('/api/v1/_meta/metadata/custom-fields'), + customFieldsFor: (entity: string) => apiFetch(`/api/v1/_meta/metadata/custom-fields/${entity}`), + listForms: () => apiFetch('/api/v1/_meta/metadata/forms'), + getForm: (slug: string) => apiFetch(`/api/v1/_meta/metadata/forms/${slug}`), + saveForm: (slug: string, body: Omit) => + apiFetch(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteForm: (slug: string) => + apiFetch(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'DELETE' }, false), + listListViews: () => apiFetch('/api/v1/_meta/metadata/list-views'), + getListView: (slug: string) => apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`), + saveListView: (slug: string, body: Omit) => + apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteListView: (slug: string) => + apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'DELETE' }, false), + createCustomField: (body: Omit) => + apiFetch('/api/v1/_meta/metadata/custom-fields', { method: 'POST', body: JSON.stringify(body) }), + updateCustomField: (key: string, body: Omit) => + apiFetch(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteCustomField: (key: string) => + apiFetch(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false), +} +``` + +- [ ] **Step 4: Add i18n keys** + +Add to both `en` and `zhCN` objects in `messages.ts`: + +```typescript +// en additions +'nav.metadataAdmin': 'Metadata', +'page.metadataAdmin.title': 'Metadata Admin', +'tab.entities': 'Entities', +'tab.customFields': 'Custom Fields', +'tab.permissions': 'Permissions', +'tab.menus': 'Menus', +'tab.forms': 'Forms', +'tab.listViews': 'List Views', +'page.formDesigner.title': 'Form Designer', +'page.listViewDesigner.title': 'List View Designer', +'action.addField': 'Add Field', +'action.addSection': 'Add Section', +'action.discard': 'Discard', +'action.delete': 'Delete', +'label.slug': 'Slug', +'label.entity': 'Entity', +'label.purpose': 'Purpose', +'label.preview': 'Preview', +'label.source': 'Source', +'label.columns': 'Columns', +'label.filters': 'Filters', +'label.sorting': 'Sorting', +'label.pageSize': 'Page Size', +'label.fieldKey': 'Field Key', +'label.targetEntity': 'Target Entity', +'label.fieldType': 'Field Type', +'action.newCustomField': 'New Custom Field', +'confirm.delete': 'Are you sure?', +``` + +```typescript +// zhCN additions +'nav.metadataAdmin': '元数据', +'page.metadataAdmin.title': '元数据管理', +'tab.entities': '实体', +'tab.customFields': '自定义字段', +'tab.permissions': '权限', +'tab.menus': '菜单', +'tab.forms': '表单', +'tab.listViews': '列表视图', +'page.formDesigner.title': '表单设计器', +'page.listViewDesigner.title': '列表视图设计器', +'action.addField': '添加字段', +'action.addSection': '添加分区', +'action.discard': '放弃', +'action.delete': '删除', +'label.slug': '标识', +'label.entity': '实体', +'label.purpose': '用途', +'label.preview': '预览', +'label.source': '来源', +'label.columns': '列', +'label.filters': '筛选', +'label.sorting': '排序', +'label.pageSize': '每页行数', +'label.fieldKey': '字段键', +'label.targetEntity': '目标实体', +'label.fieldType': '字段类型', +'action.newCustomField': '新建自定义字段', +'confirm.delete': '确定删除?', +``` + +- [ ] **Step 5: Add routes and nav entry** + +Add to `App.tsx` imports and routes. Add "Metadata" nav entry to `AppLayout.tsx` in the System section. + +- [ ] **Step 6: Commit** + +```bash +git add web/ +git commit -m "feat(web): @rjsf deps + metadata API client + types + i18n + routes" +``` + +--- + +## Task 5: Build MetadataFormRenderer + VibeErp theme + custom widgets + +**Files:** +- Create: `web/src/components/form-widgets/vibeErpTheme.tsx` +- Create: `web/src/components/MetadataFormRenderer.tsx` +- Create: `web/src/components/form-widgets/PartnerPicker.tsx` +- Create: `web/src/components/form-widgets/ItemPicker.tsx` +- Create: `web/src/components/form-widgets/UomSelector.tsx` +- Create: `web/src/components/form-widgets/LocationPicker.tsx` +- Create: `web/src/components/form-widgets/MoneyInput.tsx` +- Create: `web/src/components/form-widgets/QuantityInput.tsx` +- Create: `web/src/components/form-widgets/index.ts` + +- [ ] **Step 1: Create the VibeErp theme** + +Create `vibeErpTheme.tsx` that exports custom @rjsf templates wrapping fields with existing Tailwind classes: +- Labels: `"block text-sm font-medium text-slate-700"` +- Inputs: `"mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"` +- Layout: `"grid grid-cols-1 gap-4 sm:grid-cols-2"` +- Submit: `"btn-primary"` +- Errors: red text under invalid fields + +- [ ] **Step 2: Create the widget registry and picker widgets** + +Each picker widget (PartnerPicker, ItemPicker, UomSelector, LocationPicker) follows the same pattern: +1. Fetches data from the API on mount using the existing `client.ts` functions +2. Renders a `