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:
@@ -430,6 +430,8 @@ mod tests {
|
|||||||
"mover_form",
|
"mover_form",
|
||||||
"interaccion_list",
|
"interaccion_list",
|
||||||
"interaccion_form",
|
"interaccion_form",
|
||||||
|
"cliente_detail",
|
||||||
|
"oportunidad_detail",
|
||||||
] {
|
] {
|
||||||
assert!(m.views.contains_key(view), "falta la vista «{view}»");
|
assert!(m.views.contains_key(view), "falta la vista «{view}»");
|
||||||
}
|
}
|
||||||
@@ -457,6 +459,30 @@ mod tests {
|
|||||||
),
|
),
|
||||||
"monto debe formatearse como moneda",
|
"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`
|
/// Carga el módulo crm por el mismo camino que usa `nakui-ui`
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ pub enum View {
|
|||||||
List(ListView),
|
List(ListView),
|
||||||
/// Formulario de creación / edición.
|
/// Formulario de creación / edición.
|
||||||
Form(FormView),
|
Form(FormView),
|
||||||
|
/// Ficha de un record: sus campos + listas de records relacionados.
|
||||||
|
Detail(DetailView),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -136,6 +138,39 @@ pub struct ListView {
|
|||||||
/// las filas por substring contra los valores de estas columnas.
|
/// las filas por substring contra los valores de estas columnas.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub search_in: Vec<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -366,6 +401,15 @@ pub enum SchemaError {
|
|||||||
view: String,
|
view: String,
|
||||||
field: 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 {
|
impl Module {
|
||||||
@@ -399,23 +443,37 @@ impl Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (view_key, view) in &self.views {
|
for (view_key, view) in &self.views {
|
||||||
if let View::Form(form) = view {
|
match view {
|
||||||
for f in &form.fields {
|
View::Form(form) => {
|
||||||
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
|
for f in &form.fields {
|
||||||
return Err(SchemaError::EntityRefMissingTarget {
|
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
|
||||||
id: self.id.clone(),
|
return Err(SchemaError::EntityRefMissingTarget {
|
||||||
view: view_key.clone(),
|
id: self.id.clone(),
|
||||||
field: f.name.clone(),
|
view: view_key.clone(),
|
||||||
});
|
field: f.name.clone(),
|
||||||
}
|
});
|
||||||
if f.kind == FieldKind::Select && f.options.is_empty() {
|
}
|
||||||
return Err(SchemaError::SelectMissingOptions {
|
if f.kind == FieldKind::Select && f.options.is_empty() {
|
||||||
id: self.id.clone(),
|
return Err(SchemaError::SelectMissingOptions {
|
||||||
view: view_key.clone(),
|
id: self.id.clone(),
|
||||||
field: f.name.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(())
|
Ok(())
|
||||||
@@ -538,6 +596,7 @@ mod tests {
|
|||||||
label: Some("Nuevo".into()),
|
label: Some("Nuevo".into()),
|
||||||
}],
|
}],
|
||||||
search_in: vec!["name".into(), "email".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 {
|
match v {
|
||||||
View::List(_) => has_list = true,
|
View::List(_) => has_list = true,
|
||||||
View::Form(_) => has_form = true,
|
View::Form(_) => has_form = true,
|
||||||
|
View::Detail(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ use nahual_meta_runtime::{
|
|||||||
value_to_input_text, MetaBackend, WriteOutcome,
|
value_to_input_text, MetaBackend, WriteOutcome,
|
||||||
};
|
};
|
||||||
use nahual_meta_schema::{
|
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_theme::Theme;
|
||||||
use nahual_widget_banner::{banner_themed, themed_colors, Banner};
|
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).
|
/// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
|
||||||
/// Navegación a otra view también cancela.
|
/// Navegación a otra view también cancela.
|
||||||
pending_delete: Option<(String, Uuid)>,
|
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.).
|
/// Mensaje toast al pie (success de submit, error de carga, etc.).
|
||||||
toast: Option<SharedString>,
|
toast: Option<SharedString>,
|
||||||
/// Si la carga de módulos falló al inicio.
|
/// Si la carga de módulos falló al inicio.
|
||||||
@@ -99,6 +106,8 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
form_inputs: BTreeMap::new(),
|
form_inputs: BTreeMap::new(),
|
||||||
editing: None,
|
editing: None,
|
||||||
pending_delete: None,
|
pending_delete: None,
|
||||||
|
detail_target: None,
|
||||||
|
detail_return: None,
|
||||||
toast: initial_toast.map(SharedString::from),
|
toast: initial_toast.map(SharedString::from),
|
||||||
load_error: initial_error.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:
|
// Navegar a otra view cancela cualquier delete pendiente:
|
||||||
// el record marcado puede no estar visible en la nueva view.
|
// el record marcado puede no estar visible en la nueva view.
|
||||||
self.pending_delete = None;
|
self.pending_delete = None;
|
||||||
|
self.detail_target = None;
|
||||||
self.form_inputs = BTreeMap::new();
|
self.form_inputs = BTreeMap::new();
|
||||||
if let Some(module) = self.modules.get(mod_idx) {
|
if let Some(module) = self.modules.get(mod_idx) {
|
||||||
if let Some(View::Form(form)) = module.views.get(&view_key) {
|
if let Some(View::Form(form)) = module.views.get(&view_key) {
|
||||||
@@ -153,6 +163,25 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
cx.notify();
|
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
|
/// Inicia un edit del record: setea `editing` y abre la primera
|
||||||
/// view de tipo Form del módulo (convención: la del schema).
|
/// 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>) {
|
||||||
@@ -748,6 +777,9 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
View::Form(fv) => {
|
View::Form(fv) => {
|
||||||
self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
|
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 rows = self.list_rows(&lv.entity);
|
||||||
let total = rows.len();
|
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 total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
|
||||||
let mut col_header = div()
|
let mut col_header = div()
|
||||||
@@ -833,7 +866,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
}
|
}
|
||||||
col_header = col_header
|
col_header = col_header
|
||||||
.child(div().w(px(80.)).text_color(text_dim).child("id"))
|
.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);
|
main = main.child(col_header);
|
||||||
|
|
||||||
let entity_name = lv.entity.clone();
|
let entity_name = lv.entity.clone();
|
||||||
@@ -866,13 +899,25 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(short_uuid(id)),
|
.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(
|
row = row.child(
|
||||||
div()
|
actions
|
||||||
.w(px(70.))
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.gap(px(4.))
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
|
.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
|
/// click en una opción setea el TextInput del field con el UUID
|
||||||
/// seleccionado. El item del UUID actualmente seleccionado (si
|
/// seleccionado. El item del UUID actualmente seleccionado (si
|
||||||
/// hay) se resalta con accent color.
|
/// 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
|
/// Render del valor de una celda de lista. Una columna con
|
||||||
/// `ref_entity` resuelve su UUID al label del record referido; el
|
/// `ref_entity` resuelve su UUID al label del record referido; el
|
||||||
/// resto aplica el `ValueFormat` declarado en la columna.
|
/// resto aplica el `ValueFormat` declarado en la columna.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fn customers_module() -> Module {
|
|||||||
}],
|
}],
|
||||||
actions: vec![],
|
actions: vec![],
|
||||||
search_in: vec![],
|
search_in: vec![],
|
||||||
|
row_detail: None,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
views.insert(
|
views.insert(
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
|
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)
|
### feat(meta-*): relaciones legibles + formato (Fase 2 del ERP nakui)
|
||||||
|
|
||||||
- **`Column.ref_entity`** — una columna de lista con esto resuelve su
|
- **`Column.ref_entity`** — una columna de lista con esto resuelve su
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
ERP categórico.
|
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
|
### feat(nakui): Fase 2 del ERP — relaciones legibles + formato
|
||||||
|
|
||||||
Segunda fase del plan maestro. El módulo CRM:
|
Segunda fase del plan maestro. El módulo CRM:
|
||||||
|
|||||||
@@ -60,11 +60,13 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
|
|||||||
separador de miles + símbolo (`12000` → `$12,000`).
|
separador de miles + símbolo (`12000` → `$12,000`).
|
||||||
- **Resultado**: las listas se leen como un ERP, no como un volcado.
|
- **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
|
- `View::Detail` — el botón 👁 de una fila abre la ficha del record:
|
||||||
campos + records relacionados (back-refs: las oportunidades e
|
sus campos + listas de records relacionados (back-refs: las
|
||||||
interacciones de un cliente) + acciones contextuales.
|
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.
|
- **Resultado**: navegación de ERP — lista → ficha → relacionados.
|
||||||
|
|
||||||
### Fase 4 · Listas profesionales
|
### Fase 4 · Listas profesionales
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
"actions": [
|
"actions": [
|
||||||
{ "kind": "open_view", "view": "cliente_form", "label": "✚ Nuevo cliente" }
|
{ "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": {
|
"cliente_form": {
|
||||||
"kind": "form",
|
"kind": "form",
|
||||||
@@ -92,7 +93,8 @@
|
|||||||
{ "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
|
{ "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
|
||||||
{ "kind": "open_view", "view": "mover_form", "label": "⏩ Mover etapa" }
|
{ "kind": "open_view", "view": "mover_form", "label": "⏩ Mover etapa" }
|
||||||
],
|
],
|
||||||
"search_in": ["titulo", "etapa"]
|
"search_in": ["titulo", "etapa"],
|
||||||
|
"row_detail": "oportunidad_detail"
|
||||||
},
|
},
|
||||||
"abrir_form": {
|
"abrir_form": {
|
||||||
"kind": "form",
|
"kind": "form",
|
||||||
@@ -181,6 +183,51 @@
|
|||||||
"params": ["interaccion_id", "canal", "nota", "timestamp"],
|
"params": ["interaccion_id", "canal", "nota", "timestamp"],
|
||||||
"next_view": "interaccion_list"
|
"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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user