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>
This commit is contained in:
sergio
2026-05-21 19:29:27 +00:00
parent ab1cf9998a
commit ab2b8f6638
10 changed files with 386 additions and 12 deletions
@@ -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;
@@ -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),
])
);
}
}
@@ -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(())
@@ -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!(
@@ -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.