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:
@@ -432,10 +432,35 @@ mod tests {
|
|||||||
"interaccion_form",
|
"interaccion_form",
|
||||||
"cliente_detail",
|
"cliente_detail",
|
||||||
"oportunidad_detail",
|
"oportunidad_detail",
|
||||||
|
"panorama",
|
||||||
] {
|
] {
|
||||||
assert!(m.views.contains_key(view), "falta la vista «{view}»");
|
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
|
// Fase 2: la lista de oportunidades resuelve `cliente_id` al
|
||||||
// label del cliente y formatea `monto` como moneda.
|
// label del cliente y formatea `monto` como moneda.
|
||||||
let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
|
let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod delta;
|
pub mod delta;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
|
pub mod metric;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
pub mod refs;
|
pub mod refs;
|
||||||
pub mod testing;
|
pub mod testing;
|
||||||
@@ -35,5 +36,6 @@ pub use format::{
|
|||||||
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
|
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
|
||||||
short_uuid, value_to_input_text,
|
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 parse::{infer_param_value, parse_field_value, resolve_param_value};
|
||||||
pub use refs::validate_entity_refs;
|
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),
|
Form(FormView),
|
||||||
/// Ficha de un record: sus campos + listas de records relacionados.
|
/// Ficha de un record: sus campos + listas de records relacionados.
|
||||||
Detail(DetailView),
|
Detail(DetailView),
|
||||||
|
/// Tablero de KPIs: una grilla de tarjetas de agregados.
|
||||||
|
Dashboard(DashboardView),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -173,6 +175,51 @@ pub struct RelatedList {
|
|||||||
pub columns: Vec<Column>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Column {
|
pub struct Column {
|
||||||
/// Path del campo dentro del record. Para tipos planos: nombre.
|
/// Path del campo dentro del record. Para tipos planos: nombre.
|
||||||
@@ -473,7 +520,7 @@ impl Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
View::Detail(_) => {}
|
View::Detail(_) | View::Dashboard(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ fn every_demo_module_has_list_and_form_views() {
|
|||||||
match v {
|
match v {
|
||||||
View::List(_) => has_list = true,
|
View::List(_) => has_list = true,
|
||||||
View::Form(_) => has_form = true,
|
View::Form(_) => has_form = true,
|
||||||
View::Detail(_) => {}
|
View::Detail(_) | View::Dashboard(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -27,14 +27,13 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use nahual_meta_runtime::{
|
use nahual_meta_runtime::{
|
||||||
cmp_values, compute_clear_fields, compute_field_delta, format_value, human_label_for_record,
|
cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value,
|
||||||
parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
|
human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid,
|
||||||
value_to_input_text, MetaBackend, WriteOutcome,
|
validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome,
|
||||||
};
|
};
|
||||||
|
|
||||||
use nahual_meta_schema::{
|
use nahual_meta_schema::{
|
||||||
Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList,
|
Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module,
|
||||||
SelectOption, View,
|
RelatedList, SelectOption, View,
|
||||||
};
|
};
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
use nahual_widget_banner::{banner_themed, themed_colors, Banner};
|
use nahual_widget_banner::{banner_themed, themed_colors, Banner};
|
||||||
@@ -834,6 +833,9 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
View::Detail(dv) => {
|
View::Detail(dv) => {
|
||||||
self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent)
|
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
|
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
|
/// Render del valor de una celda de lista. Una columna con
|
||||||
/// `ref_entity` resuelve su UUID al label del record referido; el
|
/// `ref_entity` resuelve su UUID al label del record referido; el
|
||||||
/// resto aplica el `ValueFormat` declarado en la columna.
|
/// resto aplica el `ValueFormat` declarado en la columna.
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
|
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
|
### feat(meta-form): listas profesionales — orden, búsqueda, paginación
|
||||||
|
|
||||||
Fase 4 del ERP nakui. Las vistas de lista de `meta-form` ganan:
|
Fase 4 del ERP nakui. Las vistas de lista de `meta-form` ganan:
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
ERP categórico.
|
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
|
### feat(nakui): Fase 3 del ERP — ficha de detalle
|
||||||
|
|
||||||
Tercera fase del plan maestro. El módulo CRM:
|
Tercera fase del plan maestro. El módulo CRM:
|
||||||
|
|||||||
@@ -79,12 +79,14 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
|
|||||||
- **Resultado**: listas usables con cientos/miles de registros.
|
- **Resultado**: listas usables con cientos/miles de registros.
|
||||||
- Pendiente menor (a futuro): filtros por columna, columnas computadas.
|
- 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
|
- `View::Dashboard` — grilla de tarjetas de agregados: `Count`, `Sum`
|
||||||
por grupo (oportunidades por etapa, monto en pipeline, ventas del
|
(con formato de moneda) y `GroupBy` (breakdown con barras de texto),
|
||||||
mes). Reusa los charts de `pineal`.
|
cada una con filtro opcional. `compute_metric` en `meta-runtime`.
|
||||||
- **Resultado**: panorama ejecutivo al abrir el módulo.
|
- **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
|
### Fase 6 · Reportes y exportación
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menu": [
|
"menu": [
|
||||||
|
{ "label": "Panorama", "view": "panorama", "icon": "📊" },
|
||||||
{ "label": "Clientes", "view": "cliente_list", "icon": "👤" },
|
{ "label": "Clientes", "view": "cliente_list", "icon": "👤" },
|
||||||
{ "label": "+ Cliente", "view": "cliente_form", "icon": "✚" },
|
{ "label": "+ Cliente", "view": "cliente_form", "icon": "✚" },
|
||||||
{ "label": "Oportunidades", "view": "oportunidad_list", "icon": "🎯" },
|
{ "label": "Oportunidades", "view": "oportunidad_list", "icon": "🎯" },
|
||||||
@@ -49,6 +50,43 @@
|
|||||||
{ "label": "Registrar interacción", "view": "interaccion_form", "icon": "✚" }
|
{ "label": "Registrar interacción", "view": "interaccion_form", "icon": "✚" }
|
||||||
],
|
],
|
||||||
"views": {
|
"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": {
|
"cliente_list": {
|
||||||
"kind": "list",
|
"kind": "list",
|
||||||
"title": "Clientes",
|
"title": "Clientes",
|
||||||
|
|||||||
Reference in New Issue
Block a user