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 · 2026-05-21T19:29:27Z (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
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",