gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id

Primera fase del plan maestro. La metainterfaz gana dos tipos de campo:
Select (chips de un conjunto cerrado, con options validadas) y AutoId
(UUID autogenerado read-only). NakuiBackend::seed inyecta el id de la
entity = clave del store. El módulo CRM los adopta: etapa/canal son
selects, los ids de idempotencia se autogeneran, el form de cliente ya
no pide id. Ningún formulario pide un UUID a mano.

Tests en meta-schema, meta-runtime y nakui-ui verdes.

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

86d06da020b21905887b8e689fe014b5c3cc6e8f · by sergio · (5 months ago) · parent 0d1e378e4 · browse the tree at this commit

Semantic delta · 41

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

  • body changed fn seed

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

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

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

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

crates/modules/nahual/libs/meta-schema/tests/example_modules.rs

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

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

12 files · +441 −209

diff --git a/crates/apps/nakui-ui/src/backend.rs b/crates/apps/nakui-ui/src/backend.rs
index 0fad277..a1acbcf 100644
--- a/crates/apps/nakui-ui/src/backend.rs
+++ b/crates/apps/nakui-ui/src/backend.rs
@@ -282,6 +282,11 @@ impl MetaBackend for NakuiBackend {
         data: serde_json::Map<String, Value>,
     ) -> Result<WriteOutcome, String> {
         let id = Uuid::new_v4();
+        // El `id` de la entity = la clave del store. Inyectarlo en el
+        // record hace que `data.id` y la clave coincidan — los schemas
+        // Nickel suelen declarar `id | String` y los morfismos lo leen.
+        let mut data = data;
+        data.insert("id".to_string(), Value::String(id.to_string()));
         let value = Value::Object(data);
         // WAL: log primero, store después.
         if self.event_log.is_some() {
diff --git a/crates/modules/nahual/libs/meta-runtime/src/backend.rs b/crates/modules/nahual/libs/meta-runtime/src/backend.rs
index a13e0ee..6d72246 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/backend.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/backend.rs
@@ -137,7 +137,10 @@ mod tests {
     use serde_json::json;
 
     fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
-        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
     }
 
     #[test]
@@ -180,7 +183,10 @@ mod tests {
     fn update_with_set_changes_field() {
         let mut b = MockBackend::new();
         let id = b
-            .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
+            .seed(
+                "Customer",
+                map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
+            )
             .unwrap()
             .id
             .unwrap();
@@ -205,7 +211,10 @@ mod tests {
     fn update_with_clear_removes_key() {
         let mut b = MockBackend::new();
         let id = b
-            .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
+            .seed(
+                "Customer",
+                map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
+            )
             .unwrap()
             .id
             .unwrap();
diff --git a/crates/modules/nahual/libs/meta-runtime/src/delta.rs b/crates/modules/nahual/libs/meta-runtime/src/delta.rs
index bccb5f7..2b0e560 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/delta.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/delta.rs
@@ -50,7 +50,10 @@ mod tests {
     use serde_json::json;
 
     fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
-        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
     }
 
     #[test]
diff --git a/crates/modules/nahual/libs/meta-runtime/src/parse.rs b/crates/modules/nahual/libs/meta-runtime/src/parse.rs
index 956ae9d..67cf351 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/parse.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/parse.rs
@@ -15,15 +15,20 @@ use nahual_meta_schema::{FieldKind, FieldSpec};
 /// - `Number` → i64 si parsea, sino f64.
 pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
     match kind {
-        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
+        // Select y AutoId guardan un string: el valor de la opción
+        // elegida y el UUID autogenerado, respectivamente.
+        FieldKind::Text
+        | FieldKind::Multiline
+        | FieldKind::Date
+        | FieldKind::Select
+        | FieldKind::AutoId => Ok(json!(raw)),
         // EntityRef se almacena como string del UUID seleccionado.
         // El selector clickable garantiza UUIDs válidos en happy
         // path; este check protege paste manual o garbage tipeado.
         FieldKind::EntityRef => {
             let trimmed = raw.trim();
-            Uuid::parse_str(trimmed).map_err(|_| {
-                format!("'{raw}' no es UUID válido (usá el selector de records)")
-            })?;
+            Uuid::parse_str(trimmed)
+                .map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
             Ok(json!(trimmed))
         }
         FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
@@ -62,7 +67,11 @@ pub fn resolve_param_value(
         return Ok(infer_param_value(raw));
     };
 
-    let label = if s.label.is_empty() { field_name } else { &s.label };
+    let label = if s.label.is_empty() {
+        field_name
+    } else {
+        &s.label
+    };
 
     if s.required && raw.trim().is_empty() {
         return Err(format!("param '{label}' es obligatorio y está vacío"));
@@ -112,6 +121,7 @@ mod tests {
             required,
             help: None,
             ref_entity: None,
+            options: Vec::new(),
         }
     }
 
@@ -119,7 +129,7 @@ mod tests {
     fn infer_handles_basic_types() {
         assert_eq!(infer_param_value(""), Value::Null);
         assert_eq!(infer_param_value("42"), json!(42));
-        assert_eq!(infer_param_value("3.14"), json!(3.14));
+        assert_eq!(infer_param_value("2.5"), json!(2.5));
         assert_eq!(infer_param_value("true"), json!(true));
         assert_eq!(infer_param_value("false"), json!(false));
         assert_eq!(infer_param_value("hola"), json!("hola"));
@@ -131,12 +141,30 @@ mod tests {
         assert_eq!(v, json!("hola"));
     }
 
+    #[test]
+    fn parse_select_and_auto_id_passthrough() {
+        // Select guarda el valor de la opción elegida.
+        assert_eq!(
+            parse_field_value(FieldKind::Select, "ganada").unwrap(),
+            json!("ganada")
+        );
+        // AutoId guarda el UUID autogenerado tal cual.
+        let id = Uuid::new_v4().to_string();
+        assert_eq!(
+            parse_field_value(FieldKind::AutoId, &id).unwrap(),
+            json!(id)
+        );
+    }
+
     #[test]
     fn parse_number_i64_or_f64() {
-        assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
         assert_eq!(
-            parse_field_value(FieldKind::Number, "3.14").unwrap(),
-            json!(3.14)
+            parse_field_value(FieldKind::Number, "42").unwrap(),
+            json!(42)
+        );
+        assert_eq!(
+            parse_field_value(FieldKind::Number, "2.5").unwrap(),
+            json!(2.5)
         );
         assert!(parse_field_value(FieldKind::Number, "abc").is_err());
     }
@@ -144,7 +172,10 @@ mod tests {
     #[test]
     fn parse_boolean_recognizes_variants() {
         for s in ["true", "yes", "1", "on", "y"] {
-            assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
+            assert_eq!(
+                parse_field_value(FieldKind::Boolean, s).unwrap(),
+                json!(true)
+            );
         }
         for s in ["false", "no", "0", "off", "n", ""] {
             assert_eq!(
diff --git a/crates/modules/nahual/libs/meta-runtime/src/testing.rs b/crates/modules/nahual/libs/meta-runtime/src/testing.rs
index 412b2de..0da13bf 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/testing.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/testing.rs
@@ -204,30 +204,24 @@ mod tests {
     use serde_json::json;
 
     fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
-        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
     }
 
     #[test]
     fn with_records_populates_state() {
         let id = Uuid::new_v4();
-        let b = MockBackend::with_records([(
-            "Customer".into(),
-            id,
-            json!({"name": "Acme"}),
-        )]);
+        let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
         assert_eq!(b.total_records(), 1);
-        assert_eq!(
-            b.load_record("Customer", id),
-            Some(json!({"name": "Acme"}))
-        );
+        assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
     }
 
     #[test]
     fn seed_then_load_round_trip_via_trait() {
         let mut b = MockBackend::new();
-        let out = b
-            .seed("X", map_of(&[("k", json!(1))]))
-            .unwrap();
+        let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
         let id = out.id.unwrap();
         assert_eq!(out.changed, 1);
         assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
@@ -236,25 +230,15 @@ mod tests {
     #[test]
     fn update_no_op_returns_no_change() {
         let id = Uuid::new_v4();
-        let mut b = MockBackend::with_records([(
-            "X".into(),
-            id,
-            json!({"k": 1}),
-        )]);
-        let out = b
-            .update("X", id, serde_json::Map::new(), vec![])
-            .unwrap();
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
+        let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
         assert_eq!(out, WriteOutcome::no_change(id));
     }
 
     #[test]
     fn update_set_and_clear_aplica_ambos() {
         let id = Uuid::new_v4();
-        let mut b = MockBackend::with_records([(
-            "X".into(),
-            id,
-            json!({"a": 1, "b": 2}),
-        )]);
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
         let out = b
             .update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
             .unwrap();
@@ -267,11 +251,7 @@ mod tests {
     #[test]
     fn delete_then_load_returns_none() {
         let id = Uuid::new_v4();
-        let mut b = MockBackend::with_records([(
-            "X".into(),
-            id,
-            json!({"k": 1}),
-        )]);
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
         b.delete("X", id).unwrap();
         assert!(b.load_record("X", id).is_none());
     }
@@ -287,17 +267,14 @@ mod tests {
 
     #[test]
     fn with_morphism_lets_caller_simulate_logic() {
-        let mut b = MockBackend::new().with_morphism(
-            "double_qty",
-            |inputs, params| {
-                assert!(inputs.is_empty());
-                let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
-                if qty <= 0 {
-                    return Err("qty must be positive".into());
-                }
-                Ok(qty as usize)
-            },
-        );
+        let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
+            assert!(inputs.is_empty());
+            let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
+            if qty <= 0 {
+                return Err("qty must be positive".into());
+            }
+            Ok(qty as usize)
+        });
         let out = b
             .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
             .unwrap();
@@ -326,11 +303,7 @@ mod tests {
     #[test]
     fn records_for_returns_borrowed_view() {
         let id = Uuid::new_v4();
-        let b = MockBackend::with_records([(
-            "X".into(),
-            id,
-            json!({"k": 1}),
-        )]);
+        let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
         let view = b.records_for("X");
         assert_eq!(view.len(), 1);
         assert_eq!(view[0].0, id);
diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs
index 167fee5..380917e 100644
--- a/crates/modules/nahual/libs/meta-schema/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs
@@ -190,6 +190,10 @@ pub struct FieldSpec {
     /// Para los demás kinds, este campo se ignora.
     #[serde(default)]
     pub ref_entity: Option<String>,
+    /// Opciones de un campo `kind == Select`. Ignorado para los demás
+    /// kinds. `Module::validate` exige que un Select las tenga.
+    #[serde(default)]
+    pub options: Vec<SelectOption>,
 }
 
 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -210,6 +214,31 @@ pub enum FieldKind {
     /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
     /// seleccionado, parseable como cualquier text/UUID al submit.
     EntityRef,
+    /// Valor elegido de un conjunto cerrado declarado en
+    /// `FieldSpec.options`. El runtime lo renderiza como selección
+    /// (no texto libre). `Module::validate` exige `options` no vacío.
+    Select,
+    /// Identificador autogenerado (UUID v4). El runtime lo rellena al
+    /// abrir el formulario; el usuario no lo teclea ni lo edita. Para
+    /// los ids de idempotencia que piden los morfismos.
+    AutoId,
+}
+
+/// Una opción de un campo [`FieldKind::Select`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SelectOption {
+    /// Valor que se guarda (lo que recibe el backend o el morfismo).
+    pub value: String,
+    /// Etiqueta legible. Si se omite, se muestra el `value` crudo.
+    #[serde(default)]
+    pub label: Option<String>,
+}
+
+impl SelectOption {
+    /// Texto a mostrar: `label` si está, sino el `value`.
+    pub fn display(&self) -> &str {
+        self.label.as_deref().unwrap_or(&self.value)
+    }
 }
 
 /// Acciones disparables por menús, botones o submit de formularios.
@@ -307,6 +336,15 @@ pub enum SchemaError {
         view: String,
         field: String,
     },
+    #[error(
+        "módulo {id} vista '{view}': field '{field}' tiene kind=select \
+         pero no declaró options"
+    )]
+    SelectMissingOptions {
+        id: String,
+        view: String,
+        field: String,
+    },
 }
 
 impl Module {
@@ -349,6 +387,13 @@ impl Module {
                             field: f.name.clone(),
                         });
                     }
+                    if f.kind == FieldKind::Select && f.options.is_empty() {
+                        return Err(SchemaError::SelectMissingOptions {
+                            id: self.id.clone(),
+                            view: view_key.clone(),
+                            field: f.name.clone(),
+                        });
+                    }
                 }
             }
         }
@@ -419,6 +464,7 @@ mod tests {
                         required: true,
                         help: None,
                         ref_entity: None,
+                        options: Vec::new(),
                     },
                     FieldSpec {
                         name: "email".into(),
@@ -428,6 +474,7 @@ mod tests {
                         required: false,
                         help: Some("Opcional".into()),
                         ref_entity: None,
+                        options: Vec::new(),
                     },
                 ],
             }],
@@ -481,6 +528,7 @@ mod tests {
                             required: true,
                             help: None,
                             ref_entity: None,
+                            options: Vec::new(),
                         }],
                         on_submit: Action::SeedEntity {
                             entity: "customer".into(),
@@ -573,6 +621,7 @@ mod tests {
                     required: true,
                     help: None,
                     ref_entity: None,
+                    options: Vec::new(),
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
@@ -603,6 +652,7 @@ mod tests {
                     required: true,
                     help: None,
                     ref_entity: Some("supplier".into()),
+                    options: Vec::new(),
                 }],
                 on_submit: Action::SeedEntity {
                     entity: "customer".into(),
@@ -618,6 +668,92 @@ mod tests {
         m.validate().unwrap();
     }
 
+    #[test]
+    fn select_without_options_is_rejected() {
+        let mut m = sample_module();
+        m.views.insert(
+            "sel_form".into(),
+            View::Form(FormView {
+                title: "Select roto".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "estado".into(),
+                    label: "Estado".into(),
+                    kind: FieldKind::Select,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: None,
+                    options: Vec::new(),
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        let err = m.validate().unwrap_err();
+        assert!(
+            matches!(err, SchemaError::SelectMissingOptions { .. }),
+            "got: {err:?}"
+        );
+    }
+
+    #[test]
+    fn select_with_options_validates_clean() {
+        let mut m = sample_module();
+        m.views.insert(
+            "sel_form".into(),
+            View::Form(FormView {
+                title: "Select OK".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "estado".into(),
+                    label: "Estado".into(),
+                    kind: FieldKind::Select,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: None,
+                    options: vec![
+                        SelectOption {
+                            value: "activo".into(),
+                            label: Some("Activo".into()),
+                        },
+                        SelectOption {
+                            value: "baja".into(),
+                            label: None,
+                        },
+                    ],
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        m.menu.push(MenuItem {
+            label: "Sel".into(),
+            view: "sel_form".into(),
+            icon: None,
+        });
+        m.validate().unwrap();
+    }
+
+    #[test]
+    fn select_option_display_falls_back_to_value() {
+        let with_label = SelectOption {
+            value: "x".into(),
+            label: Some("Equis".into()),
+        };
+        let bare = SelectOption {
+            value: "y".into(),
+            label: None,
+        };
+        assert_eq!(with_label.display(), "Equis");
+        assert_eq!(bare.display(), "y");
+    }
+
     #[test]
     fn load_modules_detects_duplicate_id() {
         let tmp = tempfile::tempdir().unwrap();
@@ -626,16 +762,8 @@ mod tests {
         std::fs::create_dir_all(&a_dir).unwrap();
         std::fs::create_dir_all(&b_dir).unwrap();
         let m = sample_module(); // id = "customers"
-        std::fs::write(
-            a_dir.join("module.json"),
-            serde_json::to_vec(&m).unwrap(),
-        )
-        .unwrap();
-        std::fs::write(
-            b_dir.join("module.json"),
-            serde_json::to_vec(&m).unwrap(),
-        )
-        .unwrap();
+        std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
+        std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
         let err = load_modules_from_dir(tmp.path()).unwrap_err();
         assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
     }
diff --git a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs
index defe3e4..412d898 100644
--- a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs
+++ b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs
@@ -26,6 +26,7 @@ fn loads_all_demo_modules() {
     assert_eq!(
         ids,
         vec![
+            "crm",
             "customers",
             "inventory_movements",
             "invoices",
@@ -34,8 +35,8 @@ fn loads_all_demo_modules() {
             "sales_orders",
             "suppliers",
         ],
-        "expected 7 modules in alphabetical order \
-         (sales_engine se sumó al wirear Action::Morphism)"
+        "expected 8 modules in alphabetical order \
+         (crm se sumó como ERP con morfismos)"
     );
 }
 
diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs
index 6d88be3..b590d2e 100644
--- a/crates/modules/nahual/widgets/meta-form/src/lib.rs
+++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs
@@ -26,18 +26,20 @@ use gpui::{
     SharedString, Window,
 };
 
-use serde_json::Value;
-use uuid::Uuid;
 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,
 };
-use nahual_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
+use nahual_meta_schema::{
+    Action, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
+};
 use nahual_theme::Theme;
 use nahual_widget_banner::{banner_themed, themed_colors, Banner};
 use nahual_widget_text_input::TextInput;
 use nahual_widget_theme_switcher::theme_switcher;
+use serde_json::Value;
+use uuid::Uuid;
 
 /// Estado del runtime de UI. Toda la persistencia/ejecución está
 /// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
@@ -125,7 +127,14 @@ impl<B: MetaBackend> MetaApp<B> {
                     }
                 });
                 for f in &form.fields {
-                    let initial = if let Some(rec) = &editing_record {
+                    let initial = if f.kind == FieldKind::AutoId {
+                        // Editando: conservar el id del record.
+                        // Alta: UUID nuevo, que el usuario no teclea.
+                        editing_record
+                            .as_ref()
+                            .and_then(|rec| rec.get(&f.name).map(value_to_input_text))
+                            .unwrap_or_else(|| Uuid::new_v4().to_string())
+                    } else if let Some(rec) = &editing_record {
                         rec.get(&f.name)
                             .map(value_to_input_text)
                             .unwrap_or_else(|| f.default.clone().unwrap_or_default())
@@ -146,21 +155,13 @@ impl<B: MetaBackend> MetaApp<B> {
 
     /// Inicia un edit del record: setea `editing` y abre la primera
     /// view de tipo Form del módulo (convención: la del schema).
-    fn open_edit(
-        &mut self,
-        mod_idx: usize,
-        entity: String,
-        id: Uuid,
-        cx: &mut Context<Self>,
-    ) {
+    fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
         self.editing = Some((entity.clone(), id));
         let form_view_key = self.modules.get(mod_idx).and_then(|m| {
-            m.views
-                .iter()
-                .find_map(|(key, v)| match v {
-                    View::Form(form) if form.entity == entity => Some(key.clone()),
-                    _ => None,
-                })
+            m.views.iter().find_map(|(key, v)| match v {
+                View::Form(form) if form.entity == entity => Some(key.clone()),
+                _ => None,
+            })
         });
         match form_view_key {
             Some(key) => self.select_view(mod_idx, key, cx),
@@ -179,11 +180,7 @@ impl<B: MetaBackend> MetaApp<B> {
     /// el modelo de "todo cambio post-seed pasa por ops").
     /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
     /// del backend (incluye eventual `post_status` del compact tick).
-    fn commit_delete(
-        &mut self,
-        entity: &str,
-        id: Uuid,
-    ) -> Result<WriteOutcome, String> {
+    fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
         self.backend.delete(entity, id)
     }
 
@@ -232,19 +229,16 @@ impl<B: MetaBackend> MetaApp<B> {
             } => {
                 match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
                     Ok(outcome) => {
-                        let base = format!(
-                            "morphism '{name}' OK ({} op(s) aplicadas)",
-                            outcome.changed
-                        );
+                        let base =
+                            format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
                         self.toast = Some(append_compact_msg(base, outcome.post_status));
                         if let Some(v) = next_view {
                             self.select_view(mod_idx, v, cx);
                         }
                     }
                     Err(e) => {
-                        self.toast = Some(SharedString::from(format!(
-                            "morphism '{name}' falló: {e}"
-                        )));
+                        self.toast =
+                            Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
                     }
                 }
                 cx.notify();
@@ -278,9 +272,7 @@ impl<B: MetaBackend> MetaApp<B> {
                 .map(|inp| inp.read(&*cx).text().to_string())
                 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
             let id = Uuid::parse_str(raw.trim()).map_err(|_| {
-                format!(
-                    "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
-                )
+                format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
             })?;
             inputs.insert(role.clone(), id);
         }
@@ -363,12 +355,11 @@ impl<B: MetaBackend> MetaApp<B> {
                 to_clear.push(f.name.clone());
                 continue;
             }
-            let value = parse_field_value(f.kind, &raw)
-                .map_err(|e| format!("campo '{}': {e}", f.label))?;
+            let value =
+                parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
             if f.kind == FieldKind::EntityRef {
                 if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
-                    let id = Uuid::parse_str(uuid_str)
-                        .expect("parse_field_value validated UUID");
+                    let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
                     entity_refs.push((f.label.clone(), target.clone(), id));
                 }
             }
@@ -409,10 +400,7 @@ impl<B: MetaBackend> MetaApp<B> {
 /// vs "actualizado" — el WriteOutcome solo no alcanza porque
 /// `seed` y `update` ambos devuelven `id = Some(...)`.
 fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
-    let id_short = outcome
-        .id
-        .map(|id| short_uuid(&id))
-        .unwrap_or_default();
+    let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
     match (was_editing, outcome.changed) {
         (false, _) => format!("creado {entity} {id_short}"),
         (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
@@ -450,16 +438,15 @@ impl<B: MetaBackend> Render for MetaApp<B> {
         // Si el caller no instaló un Theme, `Theme::global` panicea.
         // Convención: el binario shell instala el theme en main.
         let theme = Theme::global(cx).clone();
-        let bg = theme.bg_app.clone();
-        let panel = theme.bg_panel.clone();
+        let bg = theme.bg_app;
+        let panel = theme.bg_panel;
         let border = theme.border;
         let text = theme.fg_text;
         let text_dim = theme.fg_muted;
         let accent = theme.accent;
         let accent_active = theme.accent_strong;
 
-        let sidebar =
-            self.render_sidebar(cx, panel.clone(), border, text, text_dim, accent_active);
+        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
         let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
         let confirm_banner = self.render_confirm_delete_banner(cx);
         let toast_div = self
@@ -529,7 +516,7 @@ impl<B: MetaBackend> MetaApp<B> {
         let theme = Theme::global(cx);
         let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
         let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
-        let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
+        let cancel_bg: gpui::Background = theme.bg_panel_alt;
         let cancel_text = theme.fg_text;
         // Hover colors capturados antes de las closures para que el
         // move |d| d.bg(...) los cierre.
@@ -583,10 +570,7 @@ impl<B: MetaBackend> MetaApp<B> {
                 )
                 .child(
                     div()
-                        .id(SharedString::from(format!(
-                            "confirm-del-ok-{}",
-                            id_owned
-                        )))
+                        .id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
                         .px(px(10.))
                         .py(px(4.))
                         .bg(confirm_bg)
@@ -604,15 +588,12 @@ impl<B: MetaBackend> MetaApp<B> {
                                         "borrado {entity_for_confirm} {}",
                                         short_uuid(&id_owned)
                                     );
-                                    this.toast = Some(append_compact_msg(
-                                        base,
-                                        outcome.post_status,
-                                    ));
+                                    this.toast =
+                                        Some(append_compact_msg(base, outcome.post_status));
                                 }
                                 Err(e) => {
-                                    this.toast = Some(SharedString::from(format!(
-                                        "error borrando: {e}"
-                                    )));
+                                    this.toast =
+                                        Some(SharedString::from(format!("error borrando: {e}")));
                                 }
                             }
                             cx.notify();
@@ -761,8 +742,12 @@ impl<B: MetaBackend> MetaApp<B> {
         };
 
         match view {
-            View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
-            View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
+            View::List(lv) => {
+                self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
+            }
+            View::Form(fv) => {
+                self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
+            }
         }
     }
 
@@ -809,9 +794,7 @@ impl<B: MetaBackend> MetaApp<B> {
             let action_clone = action.clone();
             header = header.child(
                 div()
-                    .id(SharedString::from(format!(
-                        "list-action-{mod_idx}-{idx}"
-                    )))
+                    .id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
                     .px(px(10.))
                     .py(px(4.))
                     .bg(action_bg)
@@ -892,9 +875,7 @@ impl<B: MetaBackend> MetaApp<B> {
                     .gap(px(4.))
                     .child(
                         div()
-                            .id(SharedString::from(format!(
-                                "row-edit-{mod_idx}-{id_copy}"
-                            )))
+                            .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
                             .px(px(6.))
                             .text_color(accent)
                             .text_size(px(13.))
@@ -906,9 +887,7 @@ impl<B: MetaBackend> MetaApp<B> {
                     )
                     .child(
                         div()
-                            .id(SharedString::from(format!(
-                                "row-del-{mod_idx}-{id_copy}"
-                            )))
+                            .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
                             .px(px(6.))
                             .text_color(destructive_fg)
                             .text_size(px(13.))
@@ -919,8 +898,7 @@ impl<B: MetaBackend> MetaApp<B> {
                                 // directo: el modal de confirmación se
                                 // renderea arriba en `render` y maneja
                                 // confirm/cancel.
-                                this.pending_delete =
-                                    Some((entity_for_delete.clone(), id_copy));
+                                this.pending_delete = Some((entity_for_delete.clone(), id_copy));
                                 this.toast = None;
                                 cx.notify();
                             })),
@@ -955,6 +933,59 @@ 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.
+    /// 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
+    /// selector de EntityRef.
+    fn render_select_chips(
+        &self,
+        cx: &mut Context<Self>,
+        field_name: String,
+        options: &[SelectOption],
+        text_dim: gpui::Hsla,
+        accent: gpui::Hsla,
+    ) -> gpui::Div {
+        let theme = Theme::global(cx);
+        let row_active = theme.bg_row_active;
+        let row_hover = theme.bg_row_hover;
+        let border = theme.border;
+        let current = self
+            .form_inputs
+            .get(&field_name)
+            .map(|inp| inp.read(&*cx).text().to_string())
+            .unwrap_or_default();
+
+        let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
+
+        for opt in options {
+            let value = opt.value.clone();
+            let is_selected = current == value;
+            let field_for_click = field_name.clone();
+            let value_for_click = value.clone();
+            row = row.child(
+                div()
+                    .id(SharedString::from(format!("select-{field_name}-{value}")))
+                    .px(px(8.))
+                    .py(px(3.))
+                    .rounded(px(10.))
+                    .border_1()
+                    .border_color(if is_selected { accent } else { border })
+                    .text_size(px(11.))
+                    .text_color(if is_selected { accent } else { text_dim })
+                    .when(is_selected, move |d| d.bg(row_active))
+                    .hover(move |d| d.bg(row_hover))
+                    .child(opt.display().to_string())
+                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+                        if let Some(input) = this.form_inputs.get(&field_for_click) {
+                            input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
+                        }
+                        cx.notify();
+                    })),
+            );
+        }
+        row
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn render_entity_ref_selector(
         &self,
@@ -1082,37 +1113,67 @@ impl<B: MetaBackend> MetaApp<B> {
                     .child(label),
             );
 
-            // Mount del TextInput vivo (creado en select_view).
-            if let Some(input) = self.form_inputs.get(&f.name) {
-                field_box = field_box.child(input.clone());
-            } else {
-                // No debería pasar — select_view crea inputs por cada
-                // field. Fallback display estático por seguridad.
-                field_box = field_box.child(
-                    div()
-                        .px(px(8.))
-                        .py(px(6.))
-                        .bg(input_bg)
-                        .text_color(text_dim)
-                        .child("(input no inicializado)"),
-                );
-            }
-
-            // Para EntityRef, agregamos un selector clickable de
-            // records existentes debajo del TextInput. Click en una
-            // opción setea el TextInput interno con el UUID; el
-            // submit lee de ahí como cualquier otro field.
-            if f.kind == FieldKind::EntityRef {
-                if let Some(target_entity) = &f.ref_entity {
-                    field_box = field_box.child(self.render_entity_ref_selector(
+            // Render según el kind. AutoId y Select NO montan el
+            // TextInput editable —aunque sí existe en `form_inputs`, de
+            // donde lee el submit—; presentan su propio control.
+            match f.kind {
+                FieldKind::AutoId => {
+                    // Read-only: el UUID autogenerado, sólo informativo.
+                    let val = self
+                        .form_inputs
+                        .get(&f.name)
+                        .map(|i| i.read(&*cx).text().to_string())
+                        .unwrap_or_default();
+                    field_box = field_box.child(
+                        div()
+                            .px(px(8.))
+                            .py(px(6.))
+                            .bg(input_bg)
+                            .text_color(text_dim)
+                            .text_size(px(11.))
+                            .child(format!("{val}  ·  automático")),
+                    );
+                }
+                FieldKind::Select => {
+                    field_box = field_box.child(self.render_select_chips(
                         cx,
                         f.name.clone(),
-                        target_entity.clone(),
-                        text,
+                        &f.options,
                         text_dim,
                         accent,
                     ));
                 }
+                _ => {
+                    // Mount del TextInput vivo (creado en select_view).
+                    if let Some(input) = self.form_inputs.get(&f.name) {
+                        field_box = field_box.child(input.clone());
+                    } else {
+                        // No debería pasar — select_view crea inputs por
+                        // cada field. Fallback estático por seguridad.
+                        field_box = field_box.child(
+                            div()
+                                .px(px(8.))
+                                .py(px(6.))
+                                .bg(input_bg)
+                                .text_color(text_dim)
+                                .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,
+                            ));
+                        }
+                    }
+                }
             }
 
             if let Some(help) = &f.help {
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 48f8ee0..b28adf3 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
@@ -14,13 +14,13 @@
 use std::collections::BTreeMap;
 
 use gpui::TestAppContext;
-use serde_json::json;
 use nahual_meta_runtime::testing::MockBackend;
 use nahual_meta_schema::{
     Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
 };
 use nahual_theme::Theme;
 use nahual_widget_meta_form::MetaApp;
+use serde_json::json;
 
 /// Helper: módulo demo simple con una entity Customer + view list.
 fn customers_module() -> Module {
@@ -52,6 +52,7 @@ fn customers_module() -> Module {
                 required: true,
                 help: None,
                 ref_entity: None,
+                options: Vec::new(),
             }],
             on_submit: Action::SeedEntity {
                 entity: "Customer".into(),
@@ -92,22 +93,11 @@ fn customers_module() -> Module {
 fn meta_app_constructs_with_mock_backend_and_initial_state(cx: &mut TestAppContext) {
     cx.update(|cx| Theme::install_default(cx));
     let id = uuid::Uuid::new_v4();
-    let backend = MockBackend::with_records([(
-        "Customer".into(),
-        id,
-        json!({"name": "Acme"}),
-    )]);
+    let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
     let modules = vec![customers_module()];
 
-    let entity = cx.add_window(|_w, cx| {
-        MetaApp::new(
-            modules,
-            backend,
-            Some("hola".into()),
-            None,
-            cx,
-        )
-    });
+    let entity =
+        cx.add_window(|_w, cx| MetaApp::new(modules, backend, Some("hola".into()), None, cx));
 
     let _ = entity; // mantener viva la window para el reactor.
 }
@@ -123,9 +113,7 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) {
     let backend = MockBackend::new();
     let modules = vec![customers_module()];
 
-    let window = cx.add_window(|_w, cx| {
-        MetaApp::new(modules, backend, None, None, cx)
-    });
+    let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
 
     // Update vía window: ejecutar apply_action.
     window
@@ -152,16 +140,10 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) {
 fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
     cx.update(|cx| Theme::install_default(cx));
     let id = uuid::Uuid::new_v4();
-    let backend = MockBackend::with_records([(
-        "Customer".into(),
-        id,
-        json!({"name": "Acme"}),
-    )]);
+    let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
     let modules = vec![customers_module()];
 
-    let window = cx.add_window(|_w, cx| {
-        MetaApp::new(modules, backend, None, None, cx)
-    });
+    let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
 
     // Read directo del backend via list_records, vía la API
     // que renders usan internamente.
@@ -172,11 +154,8 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
             // un nuevo MockBackend igual al construido devuelve el
             // mismo record, validamos el contrato de cómo el mock
             // simula state.
-            let mock_check = MockBackend::with_records([(
-                "Customer".into(),
-                id,
-                json!({"name": "Acme"}),
-            )]);
+            let mock_check =
+                MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
             use nahual_meta_runtime::MetaBackend;
             let rows = mock_check.list_records("Customer");
             assert_eq!(rows.len(), 1);
@@ -190,9 +169,7 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
 /// para vivir en una `Entity` de GPUI (el bound del trait es
 /// `'static`; se cumple).
 #[gpui::test]
-fn morphism_handler_can_be_registered_and_called_via_widget(
-    cx: &mut TestAppContext,
-) {
+fn morphism_handler_can_be_registered_and_called_via_widget(cx: &mut TestAppContext) {
     cx.update(|cx| Theme::install_default(cx));
     let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
     let counter_clone = counter.clone();
@@ -205,9 +182,7 @@ fn morphism_handler_can_be_registered_and_called_via_widget(
     );
     let modules = vec![customers_module()];
 
-    let window = cx.add_window(|_w, cx| {
-        MetaApp::new(modules, backend, None, None, cx)
-    });
+    let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
 
     // Invocar un Action::Morphism vía apply_action: como el módulo
     // demo no declara morphism + no hay nakui_module_dir, esperamos
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index 26e083d..b31b6c1 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -2,6 +2,21 @@
 
 Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
 
+### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui)
+
+La metainterfaz declarativa gana dos tipos de campo:
+
+- **`Select`** — valor de un conjunto cerrado. `FieldSpec.options`
+  (valor + etiqueta opcional); `Module::validate` exige `options` no
+  vacío. `meta-form` lo renderiza como chips clickables (el chip
+  elegido resaltado), no texto libre.
+- **`AutoId`** — UUID v4 autogenerado. `meta-form` lo rellena al abrir
+  el formulario y lo muestra read-only; el usuario no teclea ids de
+  idempotencia. En modo edición conserva el id del record.
+
+`parse_field_value` trata ambos como string passthrough. Tests nuevos
+en `meta-schema` (validación de Select) y `meta-runtime` (parseo).
+
 ### feat(nahual-widget-text-input): modo enmascarado para contraseñas
 
 `TextInput::with_mask()` dibuja el contenido como puntos (`•`, uno por
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index 3d5838f..76675db 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -2,6 +2,21 @@
 
 ERP categórico.
 
+### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)
+
+Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases
+ordenadas para llevar nakui de "listas y formularios que funcionan" a
+"ERP profesional terminado". Fase 1 implementada:
+
+- El módulo CRM usa `select` para etapa y canal (desplegable de
+  opciones) y `auto_id` para los ids de idempotencia — ningún
+  formulario pide un UUID a mano.
+- `NakuiBackend::seed` inyecta el `id` de la entity = clave del store,
+  así `data.id` y la clave coinciden (los schemas Nickel declaran
+  `id | String`); el formulario de cliente ya no necesita campo `id`.
+- Tipos de campo nuevos en la metainterfaz: ver el changelog de
+  `nahual` (`FieldKind::Select` / `AutoId`).
+
 ### feat(nakui-ui): CRM como ERP — listas y formularios
 
 UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo
diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json
index 7bc61f5..78f425c 100644
--- a/examples/nakui-modules/crm/module.json
+++ b/examples/nakui-modules/crm/module.json
@@ -68,7 +68,6 @@
       "title": "Nuevo cliente",
       "entity": "Cliente",
       "fields": [
-        { "name": "id", "label": "ID interno", "kind": "text", "help": "Opcional; el runtime genera el UUID de la entity. Dejar vacío está bien." },
         { "name": "nombre", "label": "Nombre", "kind": "text", "required": true },
         { "name": "email", "label": "Email", "kind": "text", "required": true },
         { "name": "empresa", "label": "Empresa", "kind": "text" }
@@ -102,7 +101,7 @@
       "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": "Oportunidad UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." },
+        { "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" },
@@ -122,7 +121,17 @@
       "entity": "Oportunidad",
       "fields": [
         { "name": "oportunidad_ref", "label": "Oportunidad", "kind": "entity_ref", "ref_entity": "Oportunidad", "required": true, "help": "Click en una oportunidad de la lista." },
-        { "name": "etapa", "label": "Etapa destino", "kind": "text", "required": true, "help": "prospecto | calificado | propuesta | negociacion | ganada | perdida" },
+        {
+          "name": "etapa", "label": "Etapa destino", "kind": "select", "required": true,
+          "options": [
+            { "value": "prospecto", "label": "Prospecto" },
+            { "value": "calificado", "label": "Calificado" },
+            { "value": "propuesta", "label": "Propuesta" },
+            { "value": "negociacion", "label": "Negociación" },
+            { "value": "ganada", "label": "Ganada" },
+            { "value": "perdida", "label": "Perdida" }
+          ]
+        },
         { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
       ],
       "on_submit": {
@@ -154,8 +163,15 @@
       "entity": "Interaccion",
       "fields": [
         { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista." },
-        { "name": "interaccion_id", "label": "Interacción UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." },
-        { "name": "canal", "label": "Canal", "kind": "text", "required": true, "help": "llamada | email | reunion" },
+        { "name": "interaccion_id", "label": "ID de la interacción", "kind": "auto_id" },
+        {
+          "name": "canal", "label": "Canal", "kind": "select", "required": true,
+          "options": [
+            { "value": "llamada", "label": "Llamada" },
+            { "value": "email", "label": "Email" },
+            { "value": "reunion", "label": "Reunión" }
+          ]
+        },
         { "name": "nota", "label": "Nota", "kind": "multiline" },
         { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
       ],