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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user