tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definitions

feat(nakui): Fase 3 del ERP — ficha de detalle

View::Detail: ficha de un record con sus campos + listas de records
relacionados (RelatedList, back-references por via_field) + botones
Volver/Editar. ListView.row_detail enlaza lista→ficha con un botón 👁
por fila; Module::validate exige que apunte a una vista detail. En
meta-form: render_detail/render_related + select_detail con retorno.

El CRM: 👁 en Clientes y Oportunidades abre su ficha; la del cliente
lista sus oportunidades e interacciones. Tests en meta-schema y
nakui-ui verdes; clippy limpio.

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

6588d0ed6c355a57194a34a488c1ff2b3d8eb6b3 · by sergio · (5 months ago) · parent eba629a80 · browse the tree at this commit

Semantic delta · 18

crates/apps/nakui-ui/src/main.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

9 files · +448 −28

diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 773936a..76c68b8 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -430,6 +430,8 @@ mod tests {
             "mover_form",
             "interaccion_list",
             "interaccion_form",
+            "cliente_detail",
+            "oportunidad_detail",
         ] {
             assert!(m.views.contains_key(view), "falta la vista «{view}»");
         }
@@ -457,6 +459,30 @@ mod tests {
             ),
             "monto debe formatearse como moneda",
         );
+        assert_eq!(
+            lv.row_detail.as_deref(),
+            Some("oportunidad_detail"),
+            "la fila de oportunidad debe abrir su ficha",
+        );
+
+        // Fase 3: la ficha del cliente lista sus oportunidades e
+        // interacciones (back-references).
+        let nahual_meta_schema::View::Detail(dv) = &m.views["cliente_detail"] else {
+            panic!("cliente_detail debe ser una ficha (detail)");
+        };
+        assert_eq!(dv.entity, "Cliente");
+        let related: Vec<&str> = dv.related.iter().map(|r| r.entity.as_str()).collect();
+        assert!(
+            related.contains(&"Oportunidad"),
+            "ficha cliente: falta Oportunidad"
+        );
+        assert!(
+            related.contains(&"Interaccion"),
+            "ficha cliente: falta Interaccion"
+        );
+        for r in &dv.related {
+            assert_eq!(r.via_field, "cliente_id", "back-ref por cliente_id");
+        }
     }
 
     /// Carga el módulo crm por el mismo camino que usa `nakui-ui`
diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs
index 9822499..6a68f01 100644
--- a/crates/modules/nahual/libs/meta-schema/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs
@@ -119,6 +119,8 @@ pub enum View {
     List(ListView),
     /// Formulario de creación / edición.
     Form(FormView),
+    /// Ficha de un record: sus campos + listas de records relacionados.
+    Detail(DetailView),
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -136,6 +138,39 @@ pub struct ListView {
     /// las filas por substring contra los valores de estas columnas.
     #[serde(default)]
     pub search_in: Vec<String>,
+    /// Si está set, cada fila gana un botón 👁 que abre esta vista
+    /// (debe ser una `View::Detail`) para el record de la fila.
+    #[serde(default)]
+    pub row_detail: Option<String>,
+}
+
+/// Ficha de un record: sus campos + listas de records relacionados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DetailView {
+    pub title: String,
+    /// Entity del record que se muestra.
+    pub entity: String,
+    /// Campos a mostrar, en orden. Reusa [`Column`] (label + field +
+    /// `ref_entity` + `format`; el `weight` se ignora en la ficha).
+    #[serde(default)]
+    pub fields: Vec<Column>,
+    /// Listas de records relacionados (back-references).
+    #[serde(default)]
+    pub related: Vec<RelatedList>,
+}
+
+/// Una lista de records relacionados dentro de una [`DetailView`]: los
+/// records de otra entity cuyo campo `via_field` apunta al record que
+/// se está viendo.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RelatedList {
+    pub title: String,
+    /// Entity de los records relacionados.
+    pub entity: String,
+    /// Campo de esa entity cuyo valor (UUID) referencia al record
+    /// actual. El runtime filtra `record[via_field] == id_actual`.
+    pub via_field: String,
+    pub columns: Vec<Column>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -366,6 +401,15 @@ pub enum SchemaError {
         view: String,
         field: String,
     },
+    #[error(
+        "módulo {id} vista '{view}': row_detail='{target}' no apunta a \
+         una vista kind=detail"
+    )]
+    RowDetailInvalid {
+        id: String,
+        view: String,
+        target: String,
+    },
 }
 
 impl Module {
@@ -399,23 +443,37 @@ impl Module {
             }
         }
         for (view_key, view) in &self.views {
-            if let View::Form(form) = view {
-                for f in &form.fields {
-                    if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
-                        return Err(SchemaError::EntityRefMissingTarget {
-                            id: self.id.clone(),
-                            view: view_key.clone(),
-                            field: f.name.clone(),
-                        });
+            match view {
+                View::Form(form) => {
+                    for f in &form.fields {
+                        if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
+                            return Err(SchemaError::EntityRefMissingTarget {
+                                id: self.id.clone(),
+                                view: view_key.clone(),
+                                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(),
+                            });
+                        }
                     }
-                    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(),
-                        });
+                }
+                View::List(list) => {
+                    if let Some(target) = &list.row_detail {
+                        if !matches!(self.views.get(target), Some(View::Detail(_))) {
+                            return Err(SchemaError::RowDetailInvalid {
+                                id: self.id.clone(),
+                                view: view_key.clone(),
+                                target: target.clone(),
+                            });
+                        }
                     }
                 }
+                View::Detail(_) => {}
             }
         }
         Ok(())
@@ -538,6 +596,7 @@ mod tests {
                             label: Some("Nuevo".into()),
                         }],
                         search_in: vec!["name".into(), "email".into()],
+                        row_detail: None,
                     }),
                 ),
                 (
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 412d898..f85dbcd 100644
--- a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs
+++ b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs
@@ -75,6 +75,7 @@ fn every_demo_module_has_list_and_form_views() {
             match v {
                 View::List(_) => has_list = true,
                 View::Form(_) => has_form = true,
+                View::Detail(_) => {}
             }
         }
         assert!(
diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs
index 613d3d0..a9b8b90 100644
--- a/crates/modules/nahual/widgets/meta-form/src/lib.rs
+++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs
@@ -32,7 +32,8 @@ use nahual_meta_runtime::{
     value_to_input_text, MetaBackend, WriteOutcome,
 };
 use nahual_meta_schema::{
-    Action, Column, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
+    Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList,
+    SelectOption, View,
 };
 use nahual_theme::Theme;
 use nahual_widget_banner::{banner_themed, themed_colors, Banner};
@@ -68,6 +69,12 @@ pub struct MetaApp<B: MetaBackend> {
     /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
     /// Navegación a otra view también cancela.
     pending_delete: Option<(String, Uuid)>,
+    /// Si la vista activa es una `Detail`, el id del record que se
+    /// muestra. Lo setea [`Self::select_detail`].
+    detail_target: Option<Uuid>,
+    /// Key de la vista a la que vuelve el botón «← Volver» de una
+    /// ficha — la lista desde la que se abrió.
+    detail_return: Option<String>,
     /// Mensaje toast al pie (success de submit, error de carga, etc.).
     toast: Option<SharedString>,
     /// Si la carga de módulos falló al inicio.
@@ -99,6 +106,8 @@ impl<B: MetaBackend> MetaApp<B> {
             form_inputs: BTreeMap::new(),
             editing: None,
             pending_delete: None,
+            detail_target: None,
+            detail_return: None,
             toast: initial_toast.map(SharedString::from),
             load_error: initial_error.map(SharedString::from),
         }
@@ -115,6 +124,7 @@ impl<B: MetaBackend> MetaApp<B> {
         // Navegar a otra view cancela cualquier delete pendiente:
         // el record marcado puede no estar visible en la nueva view.
         self.pending_delete = None;
+        self.detail_target = None;
         self.form_inputs = BTreeMap::new();
         if let Some(module) = self.modules.get(mod_idx) {
             if let Some(View::Form(form)) = module.views.get(&view_key) {
@@ -153,6 +163,25 @@ impl<B: MetaBackend> MetaApp<B> {
         cx.notify();
     }
 
+    /// Abre la ficha de detalle `detail_view` para el record `id`.
+    /// Recuerda la vista actual para el botón «← Volver».
+    fn select_detail(
+        &mut self,
+        mod_idx: usize,
+        detail_view: String,
+        id: Uuid,
+        cx: &mut Context<Self>,
+    ) {
+        self.detail_return = self.active.as_ref().map(|(_, v)| v.clone());
+        self.active = Some((mod_idx, detail_view));
+        self.detail_target = Some(id);
+        self.editing = None;
+        self.pending_delete = None;
+        self.form_inputs = BTreeMap::new();
+        self.toast = None;
+        cx.notify();
+    }
+
     /// 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>) {
@@ -748,6 +777,9 @@ impl<B: MetaBackend> MetaApp<B> {
             View::Form(fv) => {
                 self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
             }
+            View::Detail(dv) => {
+                self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent)
+            }
         }
     }
 
@@ -812,6 +844,7 @@ impl<B: MetaBackend> MetaApp<B> {
 
         let rows = self.list_rows(&lv.entity);
         let total = rows.len();
+        let row_detail = lv.row_detail.clone();
 
         let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
         let mut col_header = div()
@@ -833,7 +866,7 @@ impl<B: MetaBackend> MetaApp<B> {
         }
         col_header = col_header
             .child(div().w(px(80.)).text_color(text_dim).child("id"))
-            .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
+            .child(div().w(px(100.)).text_color(text_dim).child("acciones"));
         main = main.child(col_header);
 
         let entity_name = lv.entity.clone();
@@ -866,13 +899,25 @@ impl<B: MetaBackend> MetaApp<B> {
                     .text_size(px(11.))
                     .child(short_uuid(id)),
             );
-            // Acciones: ✎ edit + ✕ delete por fila.
+            // Acciones por fila: 👁 ver (si hay row_detail) + ✎ + ✕.
+            let mut actions = div().w(px(100.)).flex().flex_row().gap(px(4.));
+            if let Some(detail_view) = &row_detail {
+                let dv_key = detail_view.clone();
+                actions = actions.child(
+                    div()
+                        .id(SharedString::from(format!("row-view-{mod_idx}-{id_copy}")))
+                        .px(px(6.))
+                        .text_color(text_dim)
+                        .text_size(px(13.))
+                        .hover(move |d| d.bg(action_hover))
+                        .child("👁")
+                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+                            this.select_detail(mod_idx, dv_key.clone(), id_copy, cx);
+                        })),
+                );
+            }
             row = row.child(
-                div()
-                    .w(px(70.))
-                    .flex()
-                    .flex_row()
-                    .gap(px(4.))
+                actions
                     .child(
                         div()
                             .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
@@ -933,6 +978,213 @@ 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.
+    /// Renderea una ficha de detalle: campos del record + listas de
+    /// records relacionados (back-references) + botones Volver/Editar.
+    #[allow(clippy::too_many_arguments)]
+    fn render_detail(
+        &self,
+        cx: &mut Context<Self>,
+        mut main: gpui::Div,
+        dv: &DetailView,
+        mod_idx: usize,
+        border: gpui::Hsla,
+        text: gpui::Hsla,
+        text_dim: gpui::Hsla,
+        accent: gpui::Hsla,
+    ) -> gpui::Div {
+        let theme = Theme::global(cx);
+        let action_bg = theme.bg_button();
+        let action_hover = theme.bg_button_hover();
+        let row_separator = theme.bg_row_active;
+
+        let back_view = self.detail_return.clone();
+        let mut header = div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(10.))
+            .mb(px(12.))
+            .child(
+                div()
+                    .text_color(text)
+                    .text_size(px(18.))
+                    .flex_grow()
+                    .child(dv.title.clone()),
+            )
+            .child(
+                div()
+                    .id("detail-back")
+                    .px(px(10.))
+                    .py(px(4.))
+                    .bg(action_bg)
+                    .text_color(accent)
+                    .text_size(px(11.))
+                    .rounded(px(3.))
+                    .hover(move |d| d.bg(action_hover))
+                    .child("← Volver")
+                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+                        match back_view.clone() {
+                            Some(v) => this.select_view(mod_idx, v, cx),
+                            None => {
+                                if let Some(first) = this
+                                    .modules
+                                    .get(mod_idx)
+                                    .and_then(|m| m.menu.first().map(|i| i.view.clone()))
+                                {
+                                    this.select_view(mod_idx, first, cx);
+                                }
+                            }
+                        }
+                    })),
+            );
+
+        let Some(target_id) = self.detail_target else {
+            return main.child(header).child(
+                div()
+                    .text_color(text_dim)
+                    .child("Ningún record seleccionado."),
+            );
+        };
+
+        let entity_for_edit = dv.entity.clone();
+        header = header.child(
+            div()
+                .id("detail-edit")
+                .px(px(10.))
+                .py(px(4.))
+                .bg(action_bg)
+                .text_color(accent)
+                .text_size(px(11.))
+                .rounded(px(3.))
+                .hover(move |d| d.bg(action_hover))
+                .child("✎ Editar")
+                .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+                    this.open_edit(mod_idx, entity_for_edit.clone(), target_id, cx);
+                })),
+        );
+        main = main.child(header);
+
+        let Some(record) = self.backend.load_record(&dv.entity, target_id) else {
+            return main.child(div().text_color(text_dim).child(format!(
+                "El record {} ya no existe.",
+                short_uuid(&target_id)
+            )));
+        };
+
+        // Campos del record.
+        let mut fields_box = div().flex().flex_col();
+        for c in &dv.fields {
+            let v = lookup_field(&record, &c.field);
+            fields_box = fields_box.child(
+                div()
+                    .flex()
+                    .flex_row()
+                    .gap(px(12.))
+                    .py(px(4.))
+                    .border_b_1()
+                    .border_color(row_separator)
+                    .child(
+                        div()
+                            .w(px(160.))
+                            .flex_none()
+                            .text_color(text_dim)
+                            .text_size(px(12.))
+                            .child(c.label.clone()),
+                    )
+                    .child(
+                        div()
+                            .flex_grow()
+                            .text_color(text)
+                            .text_size(px(12.))
+                            .child(self.render_cell(c, v)),
+                    ),
+            );
+        }
+        main = main.child(fields_box);
+
+        // Listas de records relacionados.
+        for rl in &dv.related {
+            main = main.child(self.render_related(rl, target_id, border, text, text_dim));
+        }
+        main
+    }
+
+    /// Renderea una lista de records relacionados (back-reference):
+    /// los records de `rl.entity` cuyo `rl.via_field` apunta al record
+    /// `target_id`.
+    fn render_related(
+        &self,
+        rl: &RelatedList,
+        target_id: Uuid,
+        border: gpui::Hsla,
+        text: gpui::Hsla,
+        text_dim: gpui::Hsla,
+    ) -> gpui::Div {
+        let id_str = target_id.to_string();
+        let rows: Vec<(Uuid, Value)> = self
+            .backend
+            .list_records(&rl.entity)
+            .into_iter()
+            .filter(|(_, v)| v.get(&rl.via_field).and_then(Value::as_str) == Some(id_str.as_str()))
+            .collect();
+
+        let mut section = div().flex().flex_col().mt(px(18.)).child(
+            div()
+                .text_color(text)
+                .text_size(px(13.))
+                .mb(px(4.))
+                .child(format!("{} ({})", rl.title, rows.len())),
+        );
+
+        if rows.is_empty() {
+            return section.child(
+                div()
+                    .py(px(4.))
+                    .text_color(text_dim)
+                    .text_size(px(11.))
+                    .child("(ninguno)"),
+            );
+        }
+
+        let total_weight: f32 = rl.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
+        let mut head = div()
+            .flex()
+            .flex_row()
+            .py(px(4.))
+            .border_b_1()
+            .border_color(border)
+            .text_color(text_dim)
+            .text_size(px(10.));
+        for c in &rl.columns {
+            head = head.child(
+                div()
+                    .flex_grow()
+                    .flex_basis(px(100. * c.weight / total_weight))
+                    .child(c.label.clone()),
+            );
+        }
+        section = section.child(head);
+
+        for (_, v) in &rows {
+            let mut row = div()
+                .flex()
+                .flex_row()
+                .py(px(3.))
+                .text_color(text)
+                .text_size(px(11.));
+            for c in &rl.columns {
+                row = row.child(
+                    div()
+                        .flex_grow()
+                        .flex_basis(px(100. * c.weight / total_weight))
+                        .child(self.render_cell(c, lookup_field(v, &c.field))),
+                );
+            }
+            section = section.child(row);
+        }
+        section
+    }
+
     /// 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.
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 7523bcc..19feb44 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
@@ -40,6 +40,7 @@ fn customers_module() -> Module {
             }],
             actions: vec![],
             search_in: vec![],
+            row_detail: None,
         }),
     );
     views.insert(
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index 0e0e4d6..b57f60d 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -2,6 +2,24 @@
 
 Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
 
+### feat(meta-*): ficha de detalle (Fase 3 del ERP nakui)
+
+La metainterfaz gana una tercera clase de vista:
+
+- **`View::Detail(DetailView)`** — ficha de un record: sus `fields`
+  (reusan `Column`, con resolución de refs y formato) + `related`
+  (listas de back-references) + botones «← Volver» / «✎ Editar».
+- **`RelatedList`** — declara una lista de records relacionados por
+  `via_field`: el runtime filtra los records de otra entity cuyo campo
+  apunta al record que se ve (las oportunidades de un cliente, etc.).
+- **`ListView.row_detail`** — enlaza lista → ficha: cada fila gana un
+  botón 👁 que abre la ficha del record. `Module::validate` exige que
+  apunte a una vista `Detail`.
+- `meta-form`: `render_detail` + `render_related`, navegación
+  `select_detail` con retorno a la lista de origen.
+
+Tests en `meta-schema` y `nakui-ui`.
+
 ### feat(meta-*): relaciones legibles + formato (Fase 2 del ERP nakui)
 
 - **`Column.ref_entity`** — una columna de lista con esto resuelve su
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index e11bf9c..0a22934 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -2,6 +2,20 @@
 
 ERP categórico.
 
+### feat(nakui): Fase 3 del ERP — ficha de detalle
+
+Tercera fase del plan maestro. El módulo CRM:
+
+- Las listas de Clientes y Oportunidades ganan un botón 👁 por fila que
+  abre la **ficha** del record (`row_detail`).
+- `cliente_detail` muestra los campos del cliente + sus oportunidades e
+  interacciones (back-references). `oportunidad_detail` muestra los
+  campos de la oportunidad, con el cliente resuelto a su nombre.
+- Navegación lista → ficha → volver.
+
+Tipos nuevos en la metainterfaz: ver el changelog de `nahual`
+(`View::Detail` / `RelatedList` / `ListView.row_detail`).
+
 ### feat(nakui): Fase 2 del ERP — relaciones legibles + formato
 
 Segunda fase del plan maestro. El módulo CRM:
diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md
index aa64f7b..70c65f9 100644
--- a/docs/nakui-erp-masterplan.md
+++ b/docs/nakui-erp-masterplan.md
@@ -60,11 +60,13 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
   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
+### Fase 3 · Ficha de detalle — HECHA
 
-- `View::Detail` — clic en una fila abre la ficha del record: todos sus
-  campos + records relacionados (back-refs: las oportunidades e
-  interacciones de un cliente) + acciones contextuales.
+- `View::Detail` — el botón 👁 de una fila abre la ficha del record:
+  sus campos + listas de records relacionados (back-refs: las
+  oportunidades e interacciones de un cliente) + botones Volver/Editar.
+- `ListView.row_detail` enlaza lista → ficha; `RelatedList` declara los
+  back-references por `via_field`.
 - **Resultado**: navegación de ERP — lista → ficha → relacionados.
 
 ### Fase 4 · Listas profesionales
diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json
index c593035..23fe020 100644
--- a/examples/nakui-modules/crm/module.json
+++ b/examples/nakui-modules/crm/module.json
@@ -61,7 +61,8 @@
       "actions": [
         { "kind": "open_view", "view": "cliente_form", "label": "✚ Nuevo cliente" }
       ],
-      "search_in": ["nombre", "email", "empresa"]
+      "search_in": ["nombre", "email", "empresa"],
+      "row_detail": "cliente_detail"
     },
     "cliente_form": {
       "kind": "form",
@@ -92,7 +93,8 @@
         { "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
         { "kind": "open_view", "view": "mover_form", "label": "⏩ Mover etapa" }
       ],
-      "search_in": ["titulo", "etapa"]
+      "search_in": ["titulo", "etapa"],
+      "row_detail": "oportunidad_detail"
     },
     "abrir_form": {
       "kind": "form",
@@ -181,6 +183,51 @@
         "params": ["interaccion_id", "canal", "nota", "timestamp"],
         "next_view": "interaccion_list"
       }
+    },
+    "cliente_detail": {
+      "kind": "detail",
+      "title": "Ficha del cliente",
+      "entity": "Cliente",
+      "fields": [
+        { "field": "nombre", "label": "Nombre" },
+        { "field": "email", "label": "Email" },
+        { "field": "empresa", "label": "Empresa" }
+      ],
+      "related": [
+        {
+          "title": "Oportunidades",
+          "entity": "Oportunidad",
+          "via_field": "cliente_id",
+          "columns": [
+            { "field": "titulo", "label": "Título", "weight": 2.5 },
+            { "field": "etapa", "label": "Etapa", "weight": 1.2 },
+            { "field": "monto", "label": "Monto", "weight": 1.2, "format": { "kind": "currency", "symbol": "$" } }
+          ]
+        },
+        {
+          "title": "Interacciones",
+          "entity": "Interaccion",
+          "via_field": "cliente_id",
+          "columns": [
+            { "field": "canal", "label": "Canal", "weight": 1.0 },
+            { "field": "nota", "label": "Nota", "weight": 3.0 },
+            { "field": "timestamp", "label": "Fecha", "weight": 1.2 }
+          ]
+        }
+      ]
+    },
+    "oportunidad_detail": {
+      "kind": "detail",
+      "title": "Ficha de la oportunidad",
+      "entity": "Oportunidad",
+      "fields": [
+        { "field": "titulo", "label": "Título" },
+        { "field": "etapa", "label": "Etapa" },
+        { "field": "monto", "label": "Monto", "format": { "kind": "currency", "symbol": "$" } },
+        { "field": "currency", "label": "Moneda" },
+        { "field": "cliente_id", "label": "Cliente", "ref_entity": "Cliente" },
+        { "field": "timestamp", "label": "Fecha" }
+      ]
     }
   }
 }