gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

feat(nakui): Fase 2 del ERP — relaciones legibles + formato

Column.ref_entity resuelve un UUID al label del record referido;
Column.format (ValueFormat Number/Currency) agrupa miles y prefija
símbolo. El campo entity_ref en formularios muestra el record elegido
por su label, no el UUID. human_label_for_record reconoce nombre/titulo
(español). El módulo CRM: las listas muestran el nombre del cliente y
monto como $12,000.

Helper format_value en meta-runtime. Tests en meta-schema, meta-runtime
y nakui-ui verdes; clippy limpio.

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

eba629a806c7f861dd00a37c20b0f9e9d1ef3a52 · por sergio · (hace 5 meses) · padre 86d06da02 · ver el árbol en este commit

Delta semántico · 18

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

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

10 archivos · +277 −36

diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index c1c3181..773936a 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -433,6 +433,30 @@ mod tests {
         ] {
             assert!(m.views.contains_key(view), "falta la vista «{view}»");
         }
+
+        // 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 {
+            panic!("oportunidad_list debe ser una lista");
+        };
+        let cliente_col = lv
+            .columns
+            .iter()
+            .find(|c| c.field == "cliente_id")
+            .expect("columna cliente_id");
+        assert_eq!(cliente_col.ref_entity.as_deref(), Some("Cliente"));
+        let monto_col = lv
+            .columns
+            .iter()
+            .find(|c| c.field == "monto")
+            .expect("columna monto");
+        assert!(
+            matches!(
+                monto_col.format,
+                nahual_meta_schema::ValueFormat::Currency { .. }
+            ),
+            "monto debe formatearse como moneda",
+        );
     }
 
     /// Carga el módulo crm por el mismo camino que usa `nakui-ui`
diff --git a/crates/modules/nahual/libs/meta-runtime/src/format.rs b/crates/modules/nahual/libs/meta-runtime/src/format.rs
index 4e0c47b..0dac8ed 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/format.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/format.rs
@@ -6,11 +6,15 @@
 use serde_json::Value;
 use uuid::Uuid;
 
+use nahual_meta_schema::ValueFormat;
+
 /// Etiqueta humana para representar un record en el selector de
-/// EntityRef. Heurística: prefiere campos comunes en este orden:
-/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
+/// EntityRef y en columnas de referencia. Heurística: prefiere campos
+/// de nombre comunes (ES + EN); fallback al UUID corto.
 pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
-    for key in ["name", "label", "title", "sku", "sku_id"] {
+    for key in [
+        "name", "nombre", "label", "title", "titulo", "sku", "sku_id",
+    ] {
         if let Some(v) = value.get(key).and_then(Value::as_str) {
             if !v.is_empty() {
                 return format!("{} ({})", v, short_uuid(id));
@@ -33,6 +37,60 @@ pub fn render_value(v: Option<&Value>) -> String {
     }
 }
 
+/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
+/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
+/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
+pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
+    match fmt {
+        ValueFormat::Plain => render_value(v),
+        ValueFormat::Number => match v {
+            Some(Value::Number(n)) => group_thousands(n),
+            _ => render_value(v),
+        },
+        ValueFormat::Currency { symbol } => match v {
+            Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
+            _ => render_value(v),
+        },
+    }
+}
+
+/// Formatea un `Number` con separador de miles. Enteros sin decimales;
+/// flotantes con dos.
+fn group_thousands(n: &serde_json::Number) -> String {
+    if let Some(i) = n.as_i64() {
+        group_int(i)
+    } else if let Some(f) = n.as_f64() {
+        let neg = f.is_sign_negative();
+        let cents = (f.abs() * 100.0).round() as i64;
+        format!(
+            "{}{}.{:02}",
+            if neg { "-" } else { "" },
+            group_int(cents / 100),
+            cents % 100,
+        )
+    } else {
+        n.to_string()
+    }
+}
+
+/// Inserta comas cada tres dígitos en un entero con signo.
+fn group_int(i: i64) -> String {
+    let digits = i.unsigned_abs().to_string();
+    let bytes = digits.as_bytes();
+    let mut out = String::new();
+    for (idx, &b) in bytes.iter().enumerate() {
+        if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
+            out.push(',');
+        }
+        out.push(b as char);
+    }
+    if i < 0 {
+        format!("-{out}")
+    } else {
+        out
+    }
+}
+
 /// Conversión inversa a `parse_field_value`: del JSON al texto raw
 /// que un input puede tomar y volver a parsearse igual al submit.
 /// Usado para pre-llenar inputs en modo edit.
@@ -116,6 +174,51 @@ mod tests {
         assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
     }
 
+    #[test]
+    fn human_label_recognizes_spanish_name_fields() {
+        let id = Uuid::new_v4();
+        assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
+        assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
+    }
+
+    #[test]
+    fn format_value_number_groups_thousands() {
+        assert_eq!(
+            format_value(Some(&json!(12000)), &ValueFormat::Number),
+            "12,000"
+        );
+        assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
+        assert_eq!(
+            format_value(Some(&json!(-1234567)), &ValueFormat::Number),
+            "-1,234,567"
+        );
+    }
+
+    #[test]
+    fn format_value_currency_prefixes_symbol() {
+        let fmt = ValueFormat::Currency { symbol: "$".into() };
+        assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
+    }
+
+    #[test]
+    fn format_value_float_gets_two_decimals() {
+        assert_eq!(
+            format_value(Some(&json!(1234.5)), &ValueFormat::Number),
+            "1,234.50"
+        );
+    }
+
+    #[test]
+    fn format_value_non_number_falls_back_to_render_value() {
+        assert_eq!(
+            format_value(Some(&json!("hola")), &ValueFormat::Plain),
+            "hola"
+        );
+        let fmt = ValueFormat::Currency { symbol: "$".into() };
+        assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
+        assert_eq!(format_value(None, &ValueFormat::Number), "");
+    }
+
     #[test]
     fn render_value_handles_basic_kinds() {
         assert_eq!(render_value(None), "");
diff --git a/crates/modules/nahual/libs/meta-runtime/src/lib.rs b/crates/modules/nahual/libs/meta-runtime/src/lib.rs
index 09868bf..fa24255 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/lib.rs
@@ -32,7 +32,7 @@ pub mod testing;
 pub use backend::{MetaBackend, WriteOutcome};
 pub use delta::{compute_clear_fields, compute_field_delta};
 pub use format::{
-    human_label_for_record, preview_value, render_value, short_hash, short_uuid,
+    format_value, human_label_for_record, preview_value, render_value, short_hash, short_uuid,
     value_to_input_text,
 };
 pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs
index 380917e..9822499 100644
--- a/crates/modules/nahual/libs/meta-schema/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs
@@ -148,12 +148,33 @@ pub struct Column {
     /// Ancho relativo (peso flex). Default 1.
     #[serde(default = "default_weight")]
     pub weight: f32,
+    /// Si está set, la celda resuelve su valor (un UUID) al label
+    /// legible del record de esta entity, en vez de mostrar el UUID
+    /// crudo. Para columnas que son referencias a otra entity.
+    #[serde(default)]
+    pub ref_entity: Option<String>,
+    /// Formato de presentación del valor de la celda.
+    #[serde(default)]
+    pub format: ValueFormat,
 }
 
 fn default_weight() -> f32 {
     1.0
 }
 
+/// Formato de presentación de un valor en una celda de lista.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ValueFormat {
+    /// Sin formato — el valor se muestra crudo. Default.
+    #[default]
+    Plain,
+    /// Entero/decimal con separador de miles (`12000` → `12,000`).
+    Number,
+    /// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
+    Currency { symbol: String },
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct FormView {
     pub title: String,
@@ -501,11 +522,15 @@ mod tests {
                                 field: "name".into(),
                                 label: "Nombre".into(),
                                 weight: 2.0,
+                                ref_entity: None,
+                                format: ValueFormat::Plain,
                             },
                             Column {
                                 field: "email".into(),
                                 label: "Email".into(),
                                 weight: 3.0,
+                                ref_entity: None,
+                                format: ValueFormat::Plain,
                             },
                         ],
                         actions: vec![Action::OpenView {
diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs
index b590d2e..613d3d0 100644
--- a/crates/modules/nahual/widgets/meta-form/src/lib.rs
+++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs
@@ -27,12 +27,12 @@ use gpui::{
 };
 
 use nahual_meta_runtime::{
-    compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
-    render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
-    MetaBackend, WriteOutcome,
+    compute_clear_fields, compute_field_delta, format_value, human_label_for_record,
+    parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
+    value_to_input_text, MetaBackend, WriteOutcome,
 };
 use nahual_meta_schema::{
-    Action, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
+    Action, Column, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
 };
 use nahual_theme::Theme;
 use nahual_widget_banner::{banner_themed, themed_colors, Banner};
@@ -856,7 +856,7 @@ impl<B: MetaBackend> MetaApp<B> {
                     div()
                         .flex_grow()
                         .flex_basis(px(100. * frac))
-                        .child(render_value(v)),
+                        .child(self.render_cell(c, v)),
                 );
             }
             row = row.child(
@@ -933,6 +933,43 @@ impl<B: MetaBackend> MetaApp<B> {
     /// click en una opción setea el TextInput del field con el UUID
     /// seleccionado. El item del UUID actualmente seleccionado (si
     /// hay) se resalta con accent color.
+    /// Render del valor de una celda de lista. Una columna con
+    /// `ref_entity` resuelve su UUID al label del record referido; el
+    /// resto aplica el `ValueFormat` declarado en la columna.
+    fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
+        if let Some(ref_entity) = &c.ref_entity {
+            return match v {
+                Some(Value::String(s)) => match Uuid::parse_str(s) {
+                    Ok(uuid) => self
+                        .backend
+                        .load_record(ref_entity, uuid)
+                        .map(|rec| human_label_for_record(&rec, &uuid))
+                        .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
+                    Err(_) => render_value(v),
+                },
+                _ => render_value(v),
+            };
+        }
+        format_value(v, &c.format)
+    }
+
+    /// Label legible del record referenciado por un campo EntityRef.
+    /// `(sin seleccionar)` si el campo está vacío.
+    fn ref_label(&self, target: &str, current: &str) -> String {
+        let current = current.trim();
+        if current.is_empty() {
+            return "(sin seleccionar)".to_string();
+        }
+        match Uuid::parse_str(current) {
+            Ok(uuid) => self
+                .backend
+                .load_record(target, uuid)
+                .map(|rec| human_label_for_record(&rec, &uuid))
+                .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
+            Err(_) => current.to_string(),
+        }
+    }
+
     /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
     /// la opción elegida se resalta con accent. Click setea el
     /// `TextInput` del field (de donde lee el submit), igual que el
@@ -1143,6 +1180,38 @@ impl<B: MetaBackend> MetaApp<B> {
                         accent,
                     ));
                 }
+                FieldKind::EntityRef => {
+                    // Display read-only del record elegido (label, no
+                    // el UUID crudo) + selector clickable debajo. El
+                    // TextInput vive en `form_inputs` pero no se monta.
+                    if let Some(target) = &f.ref_entity {
+                        let current = self
+                            .form_inputs
+                            .get(&f.name)
+                            .map(|i| i.read(&*cx).text().to_string())
+                            .unwrap_or_default();
+                        let is_empty = current.trim().is_empty();
+                        field_box = field_box.child(
+                            div()
+                                .px(px(8.))
+                                .py(px(6.))
+                                .bg(input_bg)
+                                .text_color(if is_empty { text_dim } else { text })
+                                .text_size(px(11.))
+                                .child(self.ref_label(target, &current)),
+                        );
+                        field_box = field_box.child(self.render_entity_ref_selector(
+                            cx,
+                            f.name.clone(),
+                            target.clone(),
+                            text,
+                            text_dim,
+                            accent,
+                        ));
+                    }
+                    // Sin ref_entity es imposible: Module::validate lo
+                    // rechaza al cargar el módulo.
+                }
                 _ => {
                     // Mount del TextInput vivo (creado en select_view).
                     if let Some(input) = self.form_inputs.get(&f.name) {
@@ -1159,20 +1228,6 @@ impl<B: MetaBackend> MetaApp<B> {
                                 .child("(input no inicializado)"),
                         );
                     }
-                    // EntityRef: selector clickable de records debajo
-                    // del input. Click setea el TextInput con el UUID.
-                    if f.kind == FieldKind::EntityRef {
-                        if let Some(target_entity) = &f.ref_entity {
-                            field_box = field_box.child(self.render_entity_ref_selector(
-                                cx,
-                                f.name.clone(),
-                                target_entity.clone(),
-                                text,
-                                text_dim,
-                                accent,
-                            ));
-                        }
-                    }
                 }
             }
 
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 b28adf3..7523bcc 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
@@ -16,7 +16,8 @@ use std::collections::BTreeMap;
 use gpui::TestAppContext;
 use nahual_meta_runtime::testing::MockBackend;
 use nahual_meta_schema::{
-    Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
+    Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module,
+    ValueFormat, View,
 };
 use nahual_theme::Theme;
 use nahual_widget_meta_form::MetaApp;
@@ -34,6 +35,8 @@ fn customers_module() -> Module {
                 field: "name".into(),
                 label: "Nombre".into(),
                 weight: 1.0,
+                ref_entity: None,
+                format: ValueFormat::Plain,
             }],
             actions: vec![],
             search_in: vec![],
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index b31b6c1..0e0e4d6 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -2,6 +2,23 @@
 
 Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
 
+### feat(meta-*): relaciones legibles + formato (Fase 2 del ERP nakui)
+
+- **`Column.ref_entity`** — una columna de lista con esto resuelve su
+  valor (un UUID) al label legible del record referido, en vez de
+  mostrar el UUID crudo. `meta-form` carga el record vía el backend y
+  usa `human_label_for_record`.
+- **`Column.format`** (`ValueFormat::{Plain, Number, Currency}`) —
+  formato de la celda: separador de miles, símbolo de moneda
+  (`12000` → `$12,000`). Helper `format_value` en `meta-runtime`.
+- El campo `entity_ref` en formularios ahora muestra el **label del
+  record elegido** (read-only) + el selector, no el UUID crudo.
+- `human_label_for_record` reconoce campos de nombre en español
+  (`nombre`, `titulo`), no sólo inglés.
+
+Tests nuevos en `meta-runtime` (`format_value`, labels ES) y
+`meta-schema`. Ver el changelog de `nakui` para el plan maestro.
+
 ### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui)
 
 La metainterfaz declarativa gana dos tipos de campo:
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index 76675db..e11bf9c 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -2,6 +2,20 @@
 
 ERP categórico.
 
+### feat(nakui): Fase 2 del ERP — relaciones legibles + formato
+
+Segunda fase del plan maestro. El módulo CRM:
+
+- Las columnas `cliente_id` de las listas de Oportunidades e
+  Interacciones muestran el **nombre del cliente**, no su UUID
+  (`ref_entity` en la columna).
+- La columna `monto` se formatea como moneda (`$12,000`).
+- En los formularios, el campo de cliente/oportunidad muestra el
+  record elegido por su nombre.
+
+Tipos nuevos en la metainterfaz: ver el changelog de `nahual`
+(`Column.ref_entity` / `Column.format`).
+
 ### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)
 
 Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases
diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md
index fb16aa2..aa64f7b 100644
--- a/docs/nakui-erp-masterplan.md
+++ b/docs/nakui-erp-masterplan.md
@@ -40,7 +40,7 @@ Ordenadas por dependencia y por impacto visible. Cada fase toca
 `meta-schema` (constructo declarativo nuevo) → `meta-runtime` (helper
 puro) → `meta-form` (render) → módulos de ejemplo + tests.
 
-### Fase 1 · Captura sin fricción — EN CURSO
+### Fase 1 · Captura sin fricción — HECHA
 
 - `FieldKind::Select` — campos enumerados como desplegable/chips, con
   `options` (valor + etiqueta) declaradas.
@@ -51,12 +51,13 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
 - **Resultado**: ningún formulario pide un UUID a mano; etapa, canal y
   similares son selects. El CRM se siente correcto al cargar datos.
 
-### Fase 2 · Relaciones legibles + formato
+### Fase 2 · Relaciones legibles + formato — HECHA
 
-- Columnas/campos `entity_ref` muestran el **label** del record
-  referido (vía `human_label_for_record`), no el UUID.
-- Formato de valores declarable: moneda (`12000` → `$12,000.00`),
-  fecha, número con separadores. `FieldSpec.format` / `Column.format`.
+- Columnas con `ref_entity` muestran el **label** del record referido
+  (vía `human_label_for_record`), no el UUID. El campo `entity_ref` en
+  formularios muestra el record elegido, no el UUID crudo.
+- Formato declarable por columna: `ValueFormat::{Number, Currency}` —
+  separador de miles + símbolo (`12000` → `$12,000`).
 - **Resultado**: las listas se leen como un ERP, no como un volcado.
 
 ### Fase 3 · Ficha de detalle
diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json
index 78f425c..c593035 100644
--- a/examples/nakui-modules/crm/module.json
+++ b/examples/nakui-modules/crm/module.json
@@ -19,7 +19,7 @@
       "label": "Oportunidad",
       "fields": [
         { "name": "id", "label": "ID", "kind": "text" },
-        { "name": "cliente_id", "label": "Cliente ref", "kind": "text" },
+        { "name": "cliente_id", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente" },
         { "name": "titulo", "label": "Título", "kind": "text" },
         { "name": "monto", "label": "Monto", "kind": "number" },
         { "name": "currency", "label": "Moneda", "kind": "text" },
@@ -32,7 +32,7 @@
       "label": "Interacción",
       "fields": [
         { "name": "id", "label": "ID", "kind": "text" },
-        { "name": "cliente_id", "label": "Cliente ref", "kind": "text" },
+        { "name": "cliente_id", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente" },
         { "name": "canal", "label": "Canal", "kind": "text" },
         { "name": "nota", "label": "Nota", "kind": "multiline" },
         { "name": "timestamp", "label": "Fecha", "kind": "text" }
@@ -85,9 +85,8 @@
       "columns": [
         { "field": "titulo", "label": "Título", "weight": 2.5 },
         { "field": "etapa", "label": "Etapa", "weight": 1.2 },
-        { "field": "monto", "label": "Monto", "weight": 1.0 },
-        { "field": "currency", "label": "Moneda", "weight": 0.6 },
-        { "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 }
+        { "field": "monto", "label": "Monto", "weight": 1.2, "format": { "kind": "currency", "symbol": "$" } },
+        { "field": "cliente_id", "label": "Cliente", "weight": 2.0, "ref_entity": "Cliente" }
       ],
       "actions": [
         { "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
@@ -149,7 +148,7 @@
       "columns": [
         { "field": "canal", "label": "Canal", "weight": 1.0 },
         { "field": "nota", "label": "Nota", "weight": 3.0 },
-        { "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 },
+        { "field": "cliente_id", "label": "Cliente", "weight": 2.0, "ref_entity": "Cliente" },
         { "field": "timestamp", "label": "Fecha", "weight": 1.2 }
       ],
       "actions": [