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, + /// 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 MetaApp { 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 MetaApp { /// 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 MetaApp { 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, ¤t)), + ); + 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 MetaApp { .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": [