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>
This commit is contained in:
sergio
2026-05-21 19:12:26 +00:00
parent eba629a806
commit 6588d0ed6c
9 changed files with 449 additions and 29 deletions
+26
View File
@@ -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`
@@ -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,7 +443,8 @@ impl Module {
}
}
for (view_key, view) in &self.views {
if let View::Form(form) = view {
match view {
View::Form(form) => {
for f in &form.fields {
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
return Err(SchemaError::EntityRefMissingTarget {
@@ -417,6 +462,19 @@ impl Module {
}
}
}
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,
}),
),
(
@@ -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!(
@@ -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.
row = row.child(
// 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()
.w(px(70.))
.flex()
.flex_row()
.gap(px(4.))
.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(
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.
@@ -40,6 +40,7 @@ fn customers_module() -> Module {
}],
actions: vec![],
search_in: vec![],
row_detail: None,
}),
);
views.insert(
+18
View File
@@ -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
+14
View File
@@ -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:
+6 -4
View File
@@ -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
+49 -2
View File
@@ -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" }
]
}
}
}