diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 76c68b8..d2cacf8 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -432,10 +432,35 @@ mod tests { "interaccion_form", "cliente_detail", "oportunidad_detail", + "panorama", ] { assert!(m.views.contains_key(view), "falta la vista «{view}»"); } + // Fase 5: el tablero «panorama» con sus tarjetas de KPI. + let nahual_meta_schema::View::Dashboard(dash) = &m.views["panorama"] else { + panic!("panorama debe ser un tablero (dashboard)"); + }; + assert!( + dash.cards.len() >= 5, + "el panorama debe tener varias tarjetas" + ); + let ganadas = dash + .cards + .iter() + .find(|c| c.label.contains("ganadas")) + .expect("tarjeta de oportunidades ganadas"); + assert!( + ganadas.filter.is_some(), + "la tarjeta «ganadas» debe filtrar por etapa", + ); + assert!( + dash.cards + .iter() + .any(|c| matches!(c.metric, nahual_meta_schema::Metric::GroupBy { .. })), + "el panorama debe tener al menos un breakdown", + ); + // Fase 2: la lista de oportunidades resuelve `cliente_id` al // label del cliente y formatea `monto` como moneda. let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else { diff --git a/crates/modules/nahual/libs/meta-runtime/src/lib.rs b/crates/modules/nahual/libs/meta-runtime/src/lib.rs index f8492fb..43e7063 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/lib.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/lib.rs @@ -25,6 +25,7 @@ pub mod backend; pub mod delta; pub mod format; +pub mod metric; pub mod parse; pub mod refs; pub mod testing; @@ -35,5 +36,6 @@ pub use format::{ cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash, short_uuid, value_to_input_text, }; +pub use metric::{compute_metric, MetricResult}; pub use parse::{infer_param_value, parse_field_value, resolve_param_value}; pub use refs::validate_entity_refs; diff --git a/crates/modules/nahual/libs/meta-runtime/src/metric.rs b/crates/modules/nahual/libs/meta-runtime/src/metric.rs new file mode 100644 index 0000000..f1c76c2 --- /dev/null +++ b/crates/modules/nahual/libs/meta-runtime/src/metric.rs @@ -0,0 +1,137 @@ +//! Cómputo de los agregados de un tablero (`DashboardCard`). + +use std::collections::BTreeMap; + +use serde_json::Value; +use uuid::Uuid; + +use nahual_meta_schema::{CardFilter, Metric}; + +/// Resultado de computar una [`Metric`] sobre un conjunto de records. +#[derive(Debug, Clone, PartialEq)] +pub enum MetricResult { + /// Un único número — `Count` o `Sum`. + Scalar(f64), + /// Conteo por grupo, ordenado de mayor a menor — `GroupBy`. + Breakdown(Vec<(String, usize)>), +} + +/// Computa el agregado de una tarjeta sobre `records`, aplicando el +/// `filter` si lo hay. +pub fn compute_metric( + metric: &Metric, + filter: Option<&CardFilter>, + records: &[(Uuid, Value)], +) -> MetricResult { + let passes = |v: &Value| match filter { + None => true, + Some(f) => field_as_text(v, &f.field).as_deref() == Some(f.equals.as_str()), + }; + match metric { + Metric::Count => { + let n = records.iter().filter(|(_, v)| passes(v)).count(); + MetricResult::Scalar(n as f64) + } + Metric::Sum { field } => { + let total: f64 = records + .iter() + .filter(|(_, v)| passes(v)) + .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64)) + .sum(); + MetricResult::Scalar(total) + } + Metric::GroupBy { field } => { + let mut counts: BTreeMap = BTreeMap::new(); + for (_, v) in records.iter().filter(|(_, v)| passes(v)) { + let key = field_as_text(v, field).unwrap_or_else(|| "(vacío)".to_string()); + *counts.entry(key).or_default() += 1; + } + let mut ranked: Vec<(String, usize)> = counts.into_iter().collect(); + // Mayor conteo primero; empates ordenados por nombre. + ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + MetricResult::Breakdown(ranked) + } + } +} + +/// Valor de un campo de nivel superior como texto plano, para comparar +/// (filtros) o agrupar (`GroupBy`). +fn field_as_text(v: &Value, field: &str) -> Option { + match v.get(field)? { + Value::Null => None, + Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn recs(items: &[Value]) -> Vec<(Uuid, Value)> { + items.iter().map(|v| (Uuid::new_v4(), v.clone())).collect() + } + + #[test] + fn count_all_and_filtered() { + let rs = recs(&[ + json!({"etapa": "ganada"}), + json!({"etapa": "ganada"}), + json!({"etapa": "perdida"}), + ]); + assert_eq!( + compute_metric(&Metric::Count, None, &rs), + MetricResult::Scalar(3.0) + ); + let f = CardFilter { + field: "etapa".into(), + equals: "ganada".into(), + }; + assert_eq!( + compute_metric(&Metric::Count, Some(&f), &rs), + MetricResult::Scalar(2.0) + ); + } + + #[test] + fn sum_skips_missing_and_non_numeric() { + let rs = recs(&[ + json!({"monto": 1000}), + json!({"monto": 2500}), + json!({"otro": 1}), + ]); + assert_eq!( + compute_metric( + &Metric::Sum { + field: "monto".into() + }, + None, + &rs + ), + MetricResult::Scalar(3500.0) + ); + } + + #[test] + fn group_by_counts_and_ranks_by_frequency() { + let rs = recs(&[ + json!({"etapa": "prospecto"}), + json!({"etapa": "ganada"}), + json!({"etapa": "ganada"}), + ]); + assert_eq!( + compute_metric( + &Metric::GroupBy { + field: "etapa".into() + }, + None, + &rs + ), + MetricResult::Breakdown(vec![ + ("ganada".to_string(), 2), + ("prospecto".to_string(), 1), + ]) + ); + } +} diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs index 6a68f01..2c8054d 100644 --- a/crates/modules/nahual/libs/meta-schema/src/lib.rs +++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs @@ -121,6 +121,8 @@ pub enum View { Form(FormView), /// Ficha de un record: sus campos + listas de records relacionados. Detail(DetailView), + /// Tablero de KPIs: una grilla de tarjetas de agregados. + Dashboard(DashboardView), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,6 +175,51 @@ pub struct RelatedList { pub columns: Vec, } +/// Tablero de KPIs: una grilla de tarjetas de agregados. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardView { + pub title: String, + pub cards: Vec, +} + +/// Una tarjeta de KPI del tablero. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardCard { + /// Etiqueta de la tarjeta. + pub label: String, + /// Entity sobre cuyos records se computa el agregado. + pub entity: String, + /// Qué se computa. + pub metric: Metric, + /// Filtro opcional: sólo entran los records que lo cumplen. + #[serde(default)] + pub filter: Option, + /// Formato del número resultante (`Currency` para sumas de dinero). + /// Ignorado por `GroupBy`. + #[serde(default)] + pub format: ValueFormat, +} + +/// El agregado que computa una [`DashboardCard`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Metric { + /// Cantidad de records. + Count, + /// Suma de un campo numérico. + Sum { field: String }, + /// Conteo de records por cada valor distinto de un campo. + GroupBy { field: String }, +} + +/// Filtro de una [`DashboardCard`]: el record entra si el valor de +/// `field` (como texto) es igual a `equals`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardFilter { + pub field: String, + pub equals: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Column { /// Path del campo dentro del record. Para tipos planos: nombre. @@ -473,7 +520,7 @@ impl Module { } } } - View::Detail(_) => {} + View::Detail(_) | View::Dashboard(_) => {} } } Ok(()) 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 f85dbcd..29b3bc9 100644 --- a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs +++ b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs @@ -75,7 +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(_) => {} + View::Detail(_) | View::Dashboard(_) => {} } } assert!( diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs index 1aea455..c32f2ef 100644 --- a/crates/modules/nahual/widgets/meta-form/src/lib.rs +++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs @@ -27,14 +27,13 @@ use gpui::{ }; use nahual_meta_runtime::{ - cmp_values, compute_clear_fields, compute_field_delta, format_value, human_label_for_record, - parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs, - value_to_input_text, MetaBackend, WriteOutcome, + cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value, + human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid, + validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome, }; - use nahual_meta_schema::{ - Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList, - SelectOption, View, + Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, + RelatedList, SelectOption, View, }; use nahual_theme::Theme; use nahual_widget_banner::{banner_themed, themed_colors, Banner}; @@ -834,6 +833,9 @@ impl MetaApp { View::Detail(dv) => { self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent) } + View::Dashboard(dv) => { + self.render_dashboard(cx, main, &dv, border, text, text_dim, accent) + } } } @@ -1343,6 +1345,95 @@ impl MetaApp { section } + /// Renderea un tablero: una grilla de tarjetas de KPI, cada una con + /// su agregado computado sobre los records de su entity. + #[allow(clippy::too_many_arguments)] + fn render_dashboard( + &self, + cx: &mut Context, + mut main: gpui::Div, + dv: &DashboardView, + border: gpui::Hsla, + text: gpui::Hsla, + text_dim: gpui::Hsla, + accent: gpui::Hsla, + ) -> gpui::Div { + let card_bg = Theme::global(cx).bg_panel_alt; + main = main.child( + div() + .text_color(text) + .text_size(px(18.)) + .mb(px(12.)) + .child(dv.title.clone()), + ); + + let mut grid = div().flex().flex_row().flex_wrap().gap(px(12.)); + for card in &dv.cards { + let records = self.backend.list_records(&card.entity); + let result = compute_metric(&card.metric, card.filter.as_ref(), &records); + let mut card_box = div() + .flex() + .flex_col() + .gap(px(6.)) + .p(px(14.)) + .min_w(px(190.)) + .bg(card_bg) + .border_1() + .border_color(border) + .rounded(px(8.)) + .child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(card.label.clone()), + ); + match result { + MetricResult::Scalar(s) => { + // Entero si no tiene parte decimal — `Count` y sumas + // de enteros se ven sin `.00`. + let value = if s.fract() == 0.0 { + Value::from(s as i64) + } else { + Value::from(s) + }; + card_box = card_box.child( + div() + .text_color(accent) + .text_size(px(26.)) + .child(format_value(Some(&value), &card.format)), + ); + } + MetricResult::Breakdown(rows) => { + if rows.is_empty() { + card_box = card_box.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child("(sin datos)"), + ); + } + let max = rows.iter().map(|(_, n)| *n).max().unwrap_or(1).max(1); + for (key, n) in rows { + let bar = "█".repeat((n * 12 / max).max(1)); + card_box = card_box.child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .text_size(px(11.)) + .child(div().w(px(96.)).flex_none().text_color(text).child(key)) + .child(div().flex_grow().text_color(accent).child(bar)) + .child(div().text_color(text_dim).child(n.to_string())), + ); + } + } + } + grid = grid.child(card_box); + } + main.child(grid) + } + /// 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/docs/changelog/nahual.md b/docs/changelog/nahual.md index 76bf4bd..f3d5187 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -2,6 +2,23 @@ Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. +### feat(meta-*): tablero de KPIs (Fase 5 del ERP nakui) + +Cuarta clase de vista de la metainterfaz: + +- **`View::Dashboard(DashboardView)`** — grilla de tarjetas de KPI. + Cada `DashboardCard` declara su `entity`, su `metric` y un `filter` + opcional. +- **`Metric`** — `Count`, `Sum { field }` y `GroupBy { field }`. + `compute_metric` (nuevo módulo `meta-runtime/metric.rs`) los computa: + `MetricResult::Scalar` para conteos/sumas, `Breakdown` (ranking) para + agrupaciones. `CardFilter` restringe los records contados. +- `meta-form`: `render_dashboard` — tarjetas con el número grande + (formateado vía `format_value`) o un breakdown con barras de texto. + +Tests de `compute_metric` en `meta-runtime`; verificación del tablero +del CRM en `nakui-ui`. + ### feat(meta-form): listas profesionales — orden, búsqueda, paginación Fase 4 del ERP nakui. Las vistas de lista de `meta-form` ganan: diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md index 0a22934..92391d8 100644 --- a/docs/changelog/nakui.md +++ b/docs/changelog/nakui.md @@ -2,6 +2,21 @@ ERP categórico. +### feat(nakui): Fase 5 del ERP — tablero de KPIs + +Quinta fase del plan maestro. El módulo CRM gana una vista «Panorama» +(primera del menú): tarjetas de KPI — total de clientes y oportunidades, +monto en pipeline, oportunidades ganadas y monto ganado, más breakdowns +de oportunidades por etapa e interacciones por canal. + +Tipos nuevos en la metainterfaz: ver el changelog de `nahual` +(`View::Dashboard` / `Metric` / `compute_metric`). + +### feat(nakui): Fase 4 del ERP — listas profesionales (orden/búsqueda/página) + +Las vistas de lista de meta-form ganan orden por columna, búsqueda en +vivo y paginación. Ver el changelog de `nahual`. + ### feat(nakui): Fase 3 del ERP — ficha de detalle Tercera fase del plan maestro. El módulo CRM: diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md index 8f81350..2956c0e 100644 --- a/docs/nakui-erp-masterplan.md +++ b/docs/nakui-erp-masterplan.md @@ -79,12 +79,14 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests. - **Resultado**: listas usables con cientos/miles de registros. - Pendiente menor (a futuro): filtros por columna, columnas computadas. -### Fase 5 · Tablero y KPIs +### Fase 5 · Tablero y KPIs — HECHA -- `View::Dashboard` — tarjetas de agregados: conteos, sumas, breakdown - por grupo (oportunidades por etapa, monto en pipeline, ventas del - mes). Reusa los charts de `pineal`. +- `View::Dashboard` — grilla de tarjetas de agregados: `Count`, `Sum` + (con formato de moneda) y `GroupBy` (breakdown con barras de texto), + cada una con filtro opcional. `compute_metric` en `meta-runtime`. - **Resultado**: panorama ejecutivo al abrir el módulo. +- Pendiente menor (a futuro): reemplazar las barras de texto por los + charts de `pineal`. ### Fase 6 · Reportes y exportación diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json index 23fe020..692d0f7 100644 --- a/examples/nakui-modules/crm/module.json +++ b/examples/nakui-modules/crm/module.json @@ -40,6 +40,7 @@ } ], "menu": [ + { "label": "Panorama", "view": "panorama", "icon": "📊" }, { "label": "Clientes", "view": "cliente_list", "icon": "👤" }, { "label": "+ Cliente", "view": "cliente_form", "icon": "✚" }, { "label": "Oportunidades", "view": "oportunidad_list", "icon": "🎯" }, @@ -49,6 +50,43 @@ { "label": "Registrar interacción", "view": "interaccion_form", "icon": "✚" } ], "views": { + "panorama": { + "kind": "dashboard", + "title": "Panorama del CRM", + "cards": [ + { "label": "Clientes", "entity": "Cliente", "metric": { "kind": "count" } }, + { "label": "Oportunidades", "entity": "Oportunidad", "metric": { "kind": "count" } }, + { + "label": "Pipeline (monto total)", + "entity": "Oportunidad", + "metric": { "kind": "sum", "field": "monto" }, + "format": { "kind": "currency", "symbol": "$" } + }, + { + "label": "Oportunidades ganadas", + "entity": "Oportunidad", + "metric": { "kind": "count" }, + "filter": { "field": "etapa", "equals": "ganada" } + }, + { + "label": "Monto ganado", + "entity": "Oportunidad", + "metric": { "kind": "sum", "field": "monto" }, + "filter": { "field": "etapa", "equals": "ganada" }, + "format": { "kind": "currency", "symbol": "$" } + }, + { + "label": "Oportunidades por etapa", + "entity": "Oportunidad", + "metric": { "kind": "group_by", "field": "etapa" } + }, + { + "label": "Interacciones por canal", + "entity": "Interaccion", + "metric": { "kind": "group_by", "field": "canal" } + } + ] + }, "cliente_list": { "kind": "list", "title": "Clientes",