From 6588d0ed6c355a57194a34a488c1ff2b3d8eb6b3 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 19:12:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(nakui):=20Fase=203=20del=20ERP=20=E2=80=94?= =?UTF-8?q?=20ficha=20de=20detalle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/nakui-ui/src/main.rs | 26 ++ .../nahual/libs/meta-schema/src/lib.rs | 89 +++++- .../libs/meta-schema/tests/example_modules.rs | 1 + .../nahual/widgets/meta-form/src/lib.rs | 268 +++++++++++++++++- .../tests/widget_with_mock_backend.rs | 1 + docs/changelog/nahual.md | 18 ++ docs/changelog/nakui.md | 14 + docs/nakui-erp-masterplan.md | 10 +- examples/nakui-modules/crm/module.json | 51 +++- 9 files changed, 449 insertions(+), 29 deletions(-) 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, + /// 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, +} + +/// 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, + /// Listas de records relacionados (back-references). + #[serde(default)] + pub related: Vec, +} + +/// 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, } #[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(), - }); - } - 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(), - }); + 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(), + }); + } } } + 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 { /// (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, + /// 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, /// Mensaje toast al pie (success de submit, error de carga, etc.). toast: Option, /// Si la carga de módulos falló al inicio. @@ -99,6 +106,8 @@ impl MetaApp { 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 MetaApp { // 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 MetaApp { 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.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) { @@ -748,6 +777,9 @@ impl MetaApp { 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 MetaApp { 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::().max(0.01); let mut col_header = div() @@ -833,7 +866,7 @@ impl MetaApp { } 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 MetaApp { .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 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. + /// 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, + 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::().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" } + ] } } }