tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

feat(nakui): Fase 5 del ERP — tablero de KPIs

View::Dashboard: grilla de tarjetas de agregados. Metric Count/Sum/
GroupBy con filtro opcional (CardFilter), computado por compute_metric
en meta-runtime (MetricResult Scalar/Breakdown). meta-form render_dashboard
pinta cada tarjeta con el número grande formateado o un breakdown con
barras de texto. El CRM gana una vista «Panorama»: clientes,
oportunidades, pipeline, ganadas, y breakdowns por etapa y canal.

Tests de compute_metric; verificación del panorama en nakui-ui. Clippy
limpio en las libs.

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

ab2b8f6638bad7c8c1e7498d23ae1c81f652b392 · ruwaq sergio · (5 killa ñawpaq) · tayta ab1cf9998 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 19

crates/apps/nakui-ui/src/main.rs

crates/modules/nahual/libs/meta-runtime/src/lib.rs

crates/modules/nahual/libs/meta-runtime/src/metric.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

10 qillqakuna · +386 −12

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<String, usize> = 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<String> {
+    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<Column>,
 }
 
+/// Tablero de KPIs: una grilla de tarjetas de agregados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DashboardView {
+    pub title: String,
+    pub cards: Vec<DashboardCard>,
+}
+
+/// 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<CardFilter>,
+    /// 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<B: MetaBackend> MetaApp<B> {
             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<B: MetaBackend> MetaApp<B> {
         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<Self>,
+        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",