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>
This commit is contained in:
sergio
2026-05-21 19:43:44 +00:00
parent b13486e240
commit c56ef25546
9 changed files with 168 additions and 12 deletions
+19
View File
@@ -461,6 +461,25 @@ mod tests {
"el panorama debe tener al menos un breakdown", "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 // Fase 2: la lista de oportunidades resuelve `cliente_id` al
// label del cliente y formatea `monto` como moneda. // label del cliente y formatea `monto` como moneda.
let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else { let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
@@ -122,6 +122,7 @@ mod tests {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
} }
} }
@@ -297,6 +297,10 @@ pub struct FieldSpec {
/// kinds. `Module::validate` exige que un Select las tenga. /// kinds. `Module::validate` exige que un Select las tenga.
#[serde(default)] #[serde(default)]
pub options: Vec<SelectOption>, 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)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -591,6 +595,7 @@ mod tests {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}, },
FieldSpec { FieldSpec {
name: "email".into(), name: "email".into(),
@@ -601,6 +606,7 @@ mod tests {
help: Some("Opcional".into()), help: Some("Opcional".into()),
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}, },
], ],
}], }],
@@ -660,6 +666,7 @@ mod tests {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "customer".into(), entity: "customer".into(),
@@ -753,6 +760,7 @@ mod tests {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "customer".into(), entity: "customer".into(),
@@ -784,6 +792,7 @@ mod tests {
help: None, help: None,
ref_entity: Some("supplier".into()), ref_entity: Some("supplier".into()),
options: Vec::new(), options: Vec::new(),
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "customer".into(), entity: "customer".into(),
@@ -816,6 +825,7 @@ mod tests {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "customer".into(), entity: "customer".into(),
@@ -856,6 +866,7 @@ mod tests {
label: None, label: None,
}, },
], ],
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "customer".into(), entity: "customer".into(),
@@ -85,6 +85,10 @@ pub struct MetaApp<B: MetaBackend> {
list_search: Option<Entity<TextInput>>, list_search: Option<Entity<TextInput>>,
list_sort: Option<(String, bool)>, list_sort: Option<(String, bool)>,
list_page: usize, 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.). /// Mensaje toast al pie (success de submit, error de carga, etc.).
toast: Option<SharedString>, toast: Option<SharedString>,
/// Si la carga de módulos falló al inicio. /// Si la carga de módulos falló al inicio.
@@ -121,6 +125,7 @@ impl<B: MetaBackend> MetaApp<B> {
list_search: None, list_search: None,
list_sort: None, list_sort: None,
list_page: 0, list_page: 0,
form_errors: BTreeMap::new(),
toast: initial_toast.map(SharedString::from), toast: initial_toast.map(SharedString::from),
load_error: initial_error.map(SharedString::from), load_error: initial_error.map(SharedString::from),
} }
@@ -139,6 +144,7 @@ impl<B: MetaBackend> MetaApp<B> {
self.pending_delete = None; self.pending_delete = None;
self.detail_target = None; self.detail_target = None;
self.form_inputs = BTreeMap::new(); self.form_inputs = BTreeMap::new();
self.form_errors.clear();
self.list_search = None; self.list_search = None;
self.list_sort = None; self.list_sort = None;
self.list_page = 0; self.list_page = 0;
@@ -209,6 +215,7 @@ impl<B: MetaBackend> MetaApp<B> {
self.editing = None; self.editing = None;
self.pending_delete = None; self.pending_delete = None;
self.form_inputs = BTreeMap::new(); self.form_inputs = BTreeMap::new();
self.form_errors.clear();
self.list_search = None; self.list_search = None;
self.list_sort = None; self.list_sort = None;
self.list_page = 0; self.list_page = 0;
@@ -216,6 +223,34 @@ impl<B: MetaBackend> MetaApp<B> {
cx.notify(); 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 /// Cambia el orden de la lista al hacer clic en un header: misma
/// columna cicla ascendente → descendente → sin orden. /// columna cicla ascendente → descendente → sin orden.
fn toggle_sort(&mut self, field: &str, cx: &mut Context<Self>) { 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); self.select_view(mod_idx, view, cx);
} }
Action::SeedEntity { entity, next_view } => { 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(); let was_editing = self.editing.is_some();
match self.commit_seed(mod_idx, &entity, cx) { match self.commit_seed(mod_idx, &entity, cx) {
Ok(outcome) => { Ok(outcome) => {
@@ -298,6 +344,17 @@ impl<B: MetaBackend> MetaApp<B> {
params, params,
next_view, 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) { match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
Ok(outcome) => { Ok(outcome) => {
let base = let base =
@@ -1680,7 +1737,7 @@ impl<B: MetaBackend> MetaApp<B> {
mut main: gpui::Div, mut main: gpui::Div,
fv: &FormView, fv: &FormView,
mod_idx: usize, mod_idx: usize,
_border: gpui::Hsla, border: gpui::Hsla,
text: gpui::Hsla, text: gpui::Hsla,
text_dim: gpui::Hsla, text_dim: gpui::Hsla,
accent: gpui::Hsla, accent: gpui::Hsla,
@@ -1690,6 +1747,7 @@ impl<B: MetaBackend> MetaApp<B> {
let submit_bg = theme.bg_button(); let submit_bg = theme.bg_button();
let submit_hover = theme.bg_button_hover(); let submit_hover = theme.bg_button_hover();
let input_bg = theme.bg_input(); let input_bg = theme.bg_input();
let destructive = theme.accent_destructive();
// En modo edit, el título refleja eso para que el user no // En modo edit, el título refleja eso para que el user no
// se confunda creyendo que hace alta nueva. // se confunda creyendo que hace alta nueva.
let title = match self.editing.as_ref() { let title = match self.editing.as_ref() {
@@ -1705,16 +1763,40 @@ impl<B: MetaBackend> MetaApp<B> {
.mb(px(12.)) .mb(px(12.))
.child(title), .child(title),
); );
let mut current_section: Option<&str> = None;
for f in &fv.fields { 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 { let label = if f.required {
format!("{} *", f.label) format!("{} *", f.label)
} else { } else {
f.label.clone() 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( let mut field_box = div().flex().flex_col().mb(px(10.)).child(
div() div()
.text_color(text_dim) .text_color(label_color)
.text_size(px(11.)) .text_size(px(11.))
.mb(px(2.)) .mb(px(2.))
.child(label), .child(label),
@@ -1810,6 +1892,16 @@ impl<B: MetaBackend> MetaApp<B> {
.child(help.clone()), .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); main = main.child(field_box);
} }
@@ -57,6 +57,7 @@ fn customers_module() -> Module {
help: None, help: None,
ref_entity: None, ref_entity: None,
options: Vec::new(), options: Vec::new(),
section: None,
}], }],
on_submit: Action::SeedEntity { on_submit: Action::SeedEntity {
entity: "Customer".into(), entity: "Customer".into(),
+12
View File
@@ -2,6 +2,18 @@
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. 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) ### 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 - Toda vista de lista gana un botón «⬇ CSV» que exporta las filas
+12
View File
@@ -2,6 +2,18 @@
ERP categórico. 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 ### feat(nakui): Fase 6 del ERP — export CSV de listas
Sexta fase del plan maestro. Toda lista del ERP (clientes, Sexta fase del plan maestro. Toda lista del ERP (clientes,
+12 -4
View File
@@ -1,6 +1,8 @@
# Plan maestro — Nakui como ERP profesional # 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 ## 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 - Pendiente menor (a futuro): impresión / export PDF (los temas
`Print` de `nahual-theme` ya existen). `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), - Validación inline: al fallar un submit, los campos `required` vacíos
secciones de formulario, campos condicionales, pulido visual. 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) ### Fase 8 · Operación (futuro)
+6 -6
View File
@@ -139,12 +139,12 @@
"title": "Abrir oportunidad (morphism)", "title": "Abrir oportunidad (morphism)",
"entity": "Oportunidad", "entity": "Oportunidad",
"fields": [ "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": "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" }, { "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id", "section": "Oportunidad" },
{ "name": "titulo", "label": "Título", "kind": "text", "required": true }, { "name": "titulo", "label": "Título", "kind": "text", "required": true, "section": "Oportunidad" },
{ "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" }, { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0", "section": "Importe y fecha" },
{ "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" }, { "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" } { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z", "section": "Importe y fecha" }
], ],
"on_submit": { "on_submit": {
"kind": "morphism", "kind": "morphism",