tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Definiciones

feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)

Validación inline: al fallar un submit por campos required vacíos, el
form los marca (label destructivo + mensaje debajo), no sólo un toast.
MetaApp.form_errors + validate_required_fields. Secciones de formulario:
FieldSpec.section agrupa campos bajo encabezados; abrir_form del CRM las
usa. Campos condicionales y pulido puramente visual: scope-out conciente.

El plan docs/nakui-erp-masterplan.md queda completo (7/7 fases). Tests
verdes (meta-schema 16, meta-runtime 70, meta-form 8, nakui-ui 14);
clippy limpio en las libs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

c56ef2554615e03b5fcdfb80c0e5e5c826a94f00 · por sergio · (hace 5 meses) · padre b13486e24 · ver el árbol en este commit

Delta semántico · 16

crates/apps/nakui-ui/src/main.rs

crates/modules/nahual/libs/meta-runtime/src/parse.rs

  • cambió por dentro fn spec

crates/modules/nahual/libs/meta-schema/src/lib.rs

crates/modules/nahual/widgets/meta-form/src/lib.rs

crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs

9 archivos · +168 −12

diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index d2cacf8..ee400c4 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -461,6 +461,25 @@ mod tests {
             "el panorama debe tener al menos un breakdown",
         );
 
+        // Fase 7: el formulario «abrir_form» agrupa sus campos en
+        // secciones.
+        let nahual_meta_schema::View::Form(abrir) = &m.views["abrir_form"] else {
+            panic!("abrir_form debe ser un formulario");
+        };
+        assert!(
+            abrir.fields.iter().all(|f| f.section.is_some()),
+            "todos los campos de abrir_form deben tener sección",
+        );
+        let secciones: std::collections::BTreeSet<&str> = abrir
+            .fields
+            .iter()
+            .filter_map(|f| f.section.as_deref())
+            .collect();
+        assert!(
+            secciones.len() >= 2,
+            "abrir_form debe tener varias secciones"
+        );
+
         // Fase 2: la lista de oportunidades resuelve `cliente_id` al
         // label del cliente y formatea `monto` como moneda.
         let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
diff --git a/crates/modules/nahual/libs/meta-runtime/src/parse.rs b/crates/modules/nahual/libs/meta-runtime/src/parse.rs
index 67cf351..5a94aba 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/parse.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/parse.rs
@@ -122,6 +122,7 @@ mod tests {
             help: None,
             ref_entity: None,
             options: Vec::new(),
+            section: None,
         }
     }
 
diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs
index 2c8054d..12007c8 100644
--- a/crates/modules/nahual/libs/meta-schema/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs
@@ -297,6 +297,10 @@ pub struct FieldSpec {
     /// kinds. `Module::validate` exige que un Select las tenga.
     #[serde(default)]
     pub options: Vec<SelectOption>,
+    /// Sección del formulario a la que pertenece el campo. Campos
+    /// consecutivos con la misma sección se agrupan bajo un encabezado.
+    #[serde(default)]
+    pub section: Option<String>,
 }
 
 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -591,6 +595,7 @@ mod tests {
                         help: None,
                         ref_entity: None,
                         options: Vec::new(),
+                        section: None,
                     },
                     FieldSpec {
                         name: "email".into(),
@@ -601,6 +606,7 @@ mod tests {
                         help: Some("Opcional".into()),
                         ref_entity: None,
                         options: Vec::new(),
+                        section: None,
                     },
                 ],
             }],
@@ -660,6 +666,7 @@ mod tests {
                             help: None,
                             ref_entity: None,
                             options: Vec::new(),
+                            section: None,
                         }],
                         on_submit: Action::SeedEntity {
                             entity: "customer".into(),
@@ -753,6 +760,7 @@ mod tests {
                     help: None,
                     ref_entity: None,
                     options: Vec::new(),
+                    section: None,
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
@@ -784,6 +792,7 @@ mod tests {
                     help: None,
                     ref_entity: Some("supplier".into()),
                     options: Vec::new(),
+                    section: None,
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
@@ -816,6 +825,7 @@ mod tests {
                     help: None,
                     ref_entity: None,
                     options: Vec::new(),
+                    section: None,
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
@@ -856,6 +866,7 @@ mod tests {
                             label: None,
                         },
                     ],
+                    section: None,
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs
index 4fbae16..34d1766 100644
--- a/crates/modules/nahual/widgets/meta-form/src/lib.rs
+++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs
@@ -85,6 +85,10 @@ pub struct MetaApp<B: MetaBackend> {
     list_search: Option<Entity<TextInput>>,
     list_sort: Option<(String, bool)>,
     list_page: usize,
+    /// Errores de validación por campo del formulario activo
+    /// (nombre → mensaje). Se llenan al fallar un submit y el form los
+    /// muestra inline, debajo del campo.
+    form_errors: BTreeMap<String, String>,
     /// Mensaje toast al pie (success de submit, error de carga, etc.).
     toast: Option<SharedString>,
     /// Si la carga de módulos falló al inicio.
@@ -121,6 +125,7 @@ impl<B: MetaBackend> MetaApp<B> {
             list_search: None,
             list_sort: None,
             list_page: 0,
+            form_errors: BTreeMap::new(),
             toast: initial_toast.map(SharedString::from),
             load_error: initial_error.map(SharedString::from),
         }
@@ -139,6 +144,7 @@ impl<B: MetaBackend> MetaApp<B> {
         self.pending_delete = None;
         self.detail_target = None;
         self.form_inputs = BTreeMap::new();
+        self.form_errors.clear();
         self.list_search = None;
         self.list_sort = None;
         self.list_page = 0;
@@ -209,6 +215,7 @@ impl<B: MetaBackend> MetaApp<B> {
         self.editing = None;
         self.pending_delete = None;
         self.form_inputs = BTreeMap::new();
+        self.form_errors.clear();
         self.list_search = None;
         self.list_sort = None;
         self.list_page = 0;
@@ -216,6 +223,34 @@ impl<B: MetaBackend> MetaApp<B> {
         cx.notify();
     }
 
+    /// Revisa los campos `required` del formulario activo: devuelve un
+    /// mapa nombre→error con los que están vacíos. Mapa vacío = todo OK.
+    /// `AutoId` se omite — se autogenera, nunca está vacío.
+    fn validate_required_fields(&self, cx: &mut Context<Self>) -> BTreeMap<String, String> {
+        let mut errors = BTreeMap::new();
+        let Some(View::Form(fv)) = self
+            .active
+            .as_ref()
+            .and_then(|(i, vk)| self.modules.get(*i).and_then(|m| m.views.get(vk)))
+        else {
+            return errors;
+        };
+        for f in &fv.fields {
+            if !f.required || f.kind == FieldKind::AutoId {
+                continue;
+            }
+            let empty = self
+                .form_inputs
+                .get(&f.name)
+                .map(|i| i.read(cx).text().trim().is_empty())
+                .unwrap_or(true);
+            if empty {
+                errors.insert(f.name.clone(), "este campo es obligatorio".to_string());
+            }
+        }
+        errors
+    }
+
     /// Cambia el orden de la lista al hacer clic en un header: misma
     /// columna cicla ascendente → descendente → sin orden.
     fn toggle_sort(&mut self, field: &str, cx: &mut Context<Self>) {
@@ -269,6 +304,17 @@ impl<B: MetaBackend> MetaApp<B> {
                 self.select_view(mod_idx, view, cx);
             }
             Action::SeedEntity { entity, next_view } => {
+                let errors = self.validate_required_fields(cx);
+                if !errors.is_empty() {
+                    let n = errors.len();
+                    self.form_errors = errors;
+                    self.toast = Some(SharedString::from(format!(
+                        "faltan {n} campo(s) obligatorio(s)"
+                    )));
+                    cx.notify();
+                    return;
+                }
+                self.form_errors.clear();
                 let was_editing = self.editing.is_some();
                 match self.commit_seed(mod_idx, &entity, cx) {
                     Ok(outcome) => {
@@ -298,6 +344,17 @@ impl<B: MetaBackend> MetaApp<B> {
                 params,
                 next_view,
             } => {
+                let errors = self.validate_required_fields(cx);
+                if !errors.is_empty() {
+                    let n = errors.len();
+                    self.form_errors = errors;
+                    self.toast = Some(SharedString::from(format!(
+                        "faltan {n} campo(s) obligatorio(s)"
+                    )));
+                    cx.notify();
+                    return;
+                }
+                self.form_errors.clear();
                 match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
                     Ok(outcome) => {
                         let base =
@@ -1680,7 +1737,7 @@ impl<B: MetaBackend> MetaApp<B> {
         mut main: gpui::Div,
         fv: &FormView,
         mod_idx: usize,
-        _border: gpui::Hsla,
+        border: gpui::Hsla,
         text: gpui::Hsla,
         text_dim: gpui::Hsla,
         accent: gpui::Hsla,
@@ -1690,6 +1747,7 @@ impl<B: MetaBackend> MetaApp<B> {
         let submit_bg = theme.bg_button();
         let submit_hover = theme.bg_button_hover();
         let input_bg = theme.bg_input();
+        let destructive = theme.accent_destructive();
         // En modo edit, el título refleja eso para que el user no
         // se confunda creyendo que hace alta nueva.
         let title = match self.editing.as_ref() {
@@ -1705,16 +1763,40 @@ impl<B: MetaBackend> MetaApp<B> {
                 .mb(px(12.))
                 .child(title),
         );
+        let mut current_section: Option<&str> = None;
         for f in &fv.fields {
+            // Encabezado al cambiar de sección (campos consecutivos con
+            // la misma `section` se agrupan bajo un título).
+            if f.section.as_deref() != current_section {
+                if let Some(sec) = &f.section {
+                    main = main.child(
+                        div()
+                            .mt(px(8.))
+                            .mb(px(4.))
+                            .pb(px(2.))
+                            .border_b_1()
+                            .border_color(border)
+                            .text_color(accent)
+                            .text_size(px(12.))
+                            .child(sec.clone()),
+                    );
+                }
+                current_section = f.section.as_deref();
+            }
+
             let label = if f.required {
                 format!("{} *", f.label)
             } else {
                 f.label.clone()
             };
+            // Si el campo tiene un error de validación, el label se
+            // resalta en color destructivo.
+            let has_error = self.form_errors.contains_key(&f.name);
+            let label_color = if has_error { destructive } else { text_dim };
 
             let mut field_box = div().flex().flex_col().mb(px(10.)).child(
                 div()
-                    .text_color(text_dim)
+                    .text_color(label_color)
                     .text_size(px(11.))
                     .mb(px(2.))
                     .child(label),
@@ -1810,6 +1892,16 @@ impl<B: MetaBackend> MetaApp<B> {
                         .child(help.clone()),
                 );
             }
+            // Error de validación inline, debajo del campo.
+            if let Some(err) = self.form_errors.get(&f.name) {
+                field_box = field_box.child(
+                    div()
+                        .mt(px(2.))
+                        .text_color(destructive)
+                        .text_size(px(10.))
+                        .child(err.clone()),
+                );
+            }
             main = main.child(field_box);
         }
 
diff --git a/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs b/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs
index 19feb44..f345ae8 100644
--- a/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs
+++ b/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs
@@ -57,6 +57,7 @@ fn customers_module() -> Module {
                 help: None,
                 ref_entity: None,
                 options: Vec::new(),
+                section: None,
             }],
             on_submit: Action::SeedEntity {
                 entity: "Customer".into(),
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index eb5c859..51ecf94 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -2,6 +2,18 @@
 
 Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
 
+### feat(meta-form): pulido de formularios (Fase 7 del ERP nakui)
+
+Cierre del plan ERP. Pulido de la metainterfaz:
+
+- **Validación inline** — al fallar un submit por campos `required`
+  vacíos, el form ya no sólo muestra un toast genérico: marca cada
+  campo faltante (label en color destructivo + mensaje debajo).
+  `MetaApp.form_errors` + `validate_required_fields`. `AutoId` se
+  exime (se autogenera).
+- **Secciones de formulario** — `FieldSpec.section` agrupa campos
+  consecutivos bajo un encabezado en el render del formulario.
+
 ### feat(meta-form): export CSV de listas (Fase 6 del ERP nakui)
 
 - Toda vista de lista gana un botón «⬇ CSV» que exporta las filas
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index 94d9d7f..6ce75a3 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -2,6 +2,18 @@
 
 ERP categórico.
 
+### feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)
+
+Última fase: el plan `docs/nakui-erp-masterplan.md` queda **completo
+(7/7)**. Pulido de formularios — validación inline (campos obligatorios
+marcados al fallar el submit) y secciones de formulario; el `abrir_form`
+del CRM agrupa sus campos en «Oportunidad» e «Importe y fecha». Ver el
+changelog de `nahual` (`FieldSpec.section`, `form_errors`).
+
+Nakui es ahora un ERP dirigido por datos completo: tablero → listas
+(orden/filtro/paginación/export CSV) → fichas con relacionados →
+formularios con captura sin fricción y validación inline.
+
 ### feat(nakui): Fase 6 del ERP — export CSV de listas
 
 Sexta fase del plan maestro. Toda lista del ERP (clientes,
diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md
index c7008d7..85c1c09 100644
--- a/docs/nakui-erp-masterplan.md
+++ b/docs/nakui-erp-masterplan.md
@@ -1,6 +1,8 @@
 # Plan maestro — Nakui como ERP profesional
 
-Estado: 2026-05-21 · Subproyecto: `nakui` + la metainterfaz `nahual` (meta-schema / meta-runtime / meta-form) + `nakui-ui`.
+Estado: 2026-05-21 · **PLAN COMPLETADO — 7/7 fases.** Subproyecto:
+`nakui` + la metainterfaz `nahual` (meta-schema / meta-runtime /
+meta-form) + `nakui-ui`.
 
 ## 1 · Visión
 
@@ -96,10 +98,16 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
 - Pendiente menor (a futuro): impresión / export PDF (los temas
   `Print` de `nahual-theme` ya existen).
 
-### Fase 7 · Pulido de producto
+### Fase 7 · Pulido de producto — HECHA
 
-- Validación inline (error por campo, resaltado de requeridos),
-  secciones de formulario, campos condicionales, pulido visual.
+- Validación inline: al fallar un submit, los campos `required` vacíos
+  se marcan en color destructivo con un mensaje debajo del campo — no
+  sólo un toast genérico.
+- Secciones de formulario: `FieldSpec.section` agrupa campos
+  consecutivos bajo un encabezado.
+- Scope-out consciente: «campos condicionales» y el pulido puramente
+  visual quedan fuera — ningún módulo actual los necesita y el schema
+  los soporta agregar sin fricción si emergen.
 
 ### Fase 8 · Operación (futuro)
 
diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json
index 692d0f7..7a252d0 100644
--- a/examples/nakui-modules/crm/module.json
+++ b/examples/nakui-modules/crm/module.json
@@ -139,12 +139,12 @@
       "title": "Abrir oportunidad (morphism)",
       "entity": "Oportunidad",
       "fields": [
-        { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista para seleccionarlo." },
-        { "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id" },
-        { "name": "titulo", "label": "Título", "kind": "text", "required": true },
-        { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" },
-        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
-        { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
+        { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "section": "Oportunidad", "help": "Click en un cliente de la lista para seleccionarlo." },
+        { "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id", "section": "Oportunidad" },
+        { "name": "titulo", "label": "Título", "kind": "text", "required": true, "section": "Oportunidad" },
+        { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0", "section": "Importe y fecha" },
+        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD", "section": "Importe y fecha" },
+        { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z", "section": "Importe y fecha" }
       ],
       "on_submit": {
         "kind": "morphism",