feat(nakui): Fase 2 del ERP — relaciones legibles + formato
Column.ref_entity resuelve un UUID al label del record referido; Column.format (ValueFormat Number/Currency) agrupa miles y prefija símbolo. El campo entity_ref en formularios muestra el record elegido por su label, no el UUID. human_label_for_record reconoce nombre/titulo (español). El módulo CRM: las listas muestran el nombre del cliente y monto como $12,000. Helper format_value en meta-runtime. Tests en meta-schema, meta-runtime y nakui-ui verdes; clippy limpio. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,30 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
assert!(m.views.contains_key(view), "falta la vista «{view}»");
|
assert!(m.views.contains_key(view), "falta la vista «{view}»");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
panic!("oportunidad_list debe ser una lista");
|
||||||
|
};
|
||||||
|
let cliente_col = lv
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.field == "cliente_id")
|
||||||
|
.expect("columna cliente_id");
|
||||||
|
assert_eq!(cliente_col.ref_entity.as_deref(), Some("Cliente"));
|
||||||
|
let monto_col = lv
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.field == "monto")
|
||||||
|
.expect("columna monto");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
monto_col.format,
|
||||||
|
nahual_meta_schema::ValueFormat::Currency { .. }
|
||||||
|
),
|
||||||
|
"monto debe formatearse como moneda",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Carga el módulo crm por el mismo camino que usa `nakui-ui`
|
/// Carga el módulo crm por el mismo camino que usa `nakui-ui`
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use nahual_meta_schema::ValueFormat;
|
||||||
|
|
||||||
/// Etiqueta humana para representar un record en el selector de
|
/// Etiqueta humana para representar un record en el selector de
|
||||||
/// EntityRef. Heurística: prefiere campos comunes en este orden:
|
/// EntityRef y en columnas de referencia. Heurística: prefiere campos
|
||||||
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
|
/// de nombre comunes (ES + EN); fallback al UUID corto.
|
||||||
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
|
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
|
||||||
for key in ["name", "label", "title", "sku", "sku_id"] {
|
for key in [
|
||||||
|
"name", "nombre", "label", "title", "titulo", "sku", "sku_id",
|
||||||
|
] {
|
||||||
if let Some(v) = value.get(key).and_then(Value::as_str) {
|
if let Some(v) = value.get(key).and_then(Value::as_str) {
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
return format!("{} ({})", v, short_uuid(id));
|
return format!("{} ({})", v, short_uuid(id));
|
||||||
@@ -33,6 +37,60 @@ pub fn render_value(v: Option<&Value>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
|
||||||
|
/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
|
||||||
|
/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
|
||||||
|
pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
|
||||||
|
match fmt {
|
||||||
|
ValueFormat::Plain => render_value(v),
|
||||||
|
ValueFormat::Number => match v {
|
||||||
|
Some(Value::Number(n)) => group_thousands(n),
|
||||||
|
_ => render_value(v),
|
||||||
|
},
|
||||||
|
ValueFormat::Currency { symbol } => match v {
|
||||||
|
Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
|
||||||
|
_ => render_value(v),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatea un `Number` con separador de miles. Enteros sin decimales;
|
||||||
|
/// flotantes con dos.
|
||||||
|
fn group_thousands(n: &serde_json::Number) -> String {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
group_int(i)
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
let neg = f.is_sign_negative();
|
||||||
|
let cents = (f.abs() * 100.0).round() as i64;
|
||||||
|
format!(
|
||||||
|
"{}{}.{:02}",
|
||||||
|
if neg { "-" } else { "" },
|
||||||
|
group_int(cents / 100),
|
||||||
|
cents % 100,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
n.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserta comas cada tres dígitos en un entero con signo.
|
||||||
|
fn group_int(i: i64) -> String {
|
||||||
|
let digits = i.unsigned_abs().to_string();
|
||||||
|
let bytes = digits.as_bytes();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (idx, &b) in bytes.iter().enumerate() {
|
||||||
|
if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push(b as char);
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
format!("-{out}")
|
||||||
|
} else {
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
|
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
|
||||||
/// que un input puede tomar y volver a parsearse igual al submit.
|
/// que un input puede tomar y volver a parsearse igual al submit.
|
||||||
/// Usado para pre-llenar inputs en modo edit.
|
/// Usado para pre-llenar inputs en modo edit.
|
||||||
@@ -116,6 +174,51 @@ mod tests {
|
|||||||
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
|
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_label_recognizes_spanish_name_fields() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
|
||||||
|
assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_number_groups_thousands() {
|
||||||
|
assert_eq!(
|
||||||
|
format_value(Some(&json!(12000)), &ValueFormat::Number),
|
||||||
|
"12,000"
|
||||||
|
);
|
||||||
|
assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
|
||||||
|
assert_eq!(
|
||||||
|
format_value(Some(&json!(-1234567)), &ValueFormat::Number),
|
||||||
|
"-1,234,567"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_currency_prefixes_symbol() {
|
||||||
|
let fmt = ValueFormat::Currency { symbol: "$".into() };
|
||||||
|
assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_float_gets_two_decimals() {
|
||||||
|
assert_eq!(
|
||||||
|
format_value(Some(&json!(1234.5)), &ValueFormat::Number),
|
||||||
|
"1,234.50"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_non_number_falls_back_to_render_value() {
|
||||||
|
assert_eq!(
|
||||||
|
format_value(Some(&json!("hola")), &ValueFormat::Plain),
|
||||||
|
"hola"
|
||||||
|
);
|
||||||
|
let fmt = ValueFormat::Currency { symbol: "$".into() };
|
||||||
|
assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
|
||||||
|
assert_eq!(format_value(None, &ValueFormat::Number), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_value_handles_basic_kinds() {
|
fn render_value_handles_basic_kinds() {
|
||||||
assert_eq!(render_value(None), "");
|
assert_eq!(render_value(None), "");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub mod testing;
|
|||||||
pub use backend::{MetaBackend, WriteOutcome};
|
pub use backend::{MetaBackend, WriteOutcome};
|
||||||
pub use delta::{compute_clear_fields, compute_field_delta};
|
pub use delta::{compute_clear_fields, compute_field_delta};
|
||||||
pub use format::{
|
pub use format::{
|
||||||
human_label_for_record, preview_value, render_value, short_hash, short_uuid,
|
format_value, human_label_for_record, preview_value, render_value, short_hash, short_uuid,
|
||||||
value_to_input_text,
|
value_to_input_text,
|
||||||
};
|
};
|
||||||
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
|
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
|
||||||
|
|||||||
@@ -148,12 +148,33 @@ pub struct Column {
|
|||||||
/// Ancho relativo (peso flex). Default 1.
|
/// Ancho relativo (peso flex). Default 1.
|
||||||
#[serde(default = "default_weight")]
|
#[serde(default = "default_weight")]
|
||||||
pub weight: f32,
|
pub weight: f32,
|
||||||
|
/// Si está set, la celda resuelve su valor (un UUID) al label
|
||||||
|
/// legible del record de esta entity, en vez de mostrar el UUID
|
||||||
|
/// crudo. Para columnas que son referencias a otra entity.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ref_entity: Option<String>,
|
||||||
|
/// Formato de presentación del valor de la celda.
|
||||||
|
#[serde(default)]
|
||||||
|
pub format: ValueFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_weight() -> f32 {
|
fn default_weight() -> f32 {
|
||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formato de presentación de un valor en una celda de lista.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ValueFormat {
|
||||||
|
/// Sin formato — el valor se muestra crudo. Default.
|
||||||
|
#[default]
|
||||||
|
Plain,
|
||||||
|
/// Entero/decimal con separador de miles (`12000` → `12,000`).
|
||||||
|
Number,
|
||||||
|
/// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
|
||||||
|
Currency { symbol: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FormView {
|
pub struct FormView {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -501,11 +522,15 @@ mod tests {
|
|||||||
field: "name".into(),
|
field: "name".into(),
|
||||||
label: "Nombre".into(),
|
label: "Nombre".into(),
|
||||||
weight: 2.0,
|
weight: 2.0,
|
||||||
|
ref_entity: None,
|
||||||
|
format: ValueFormat::Plain,
|
||||||
},
|
},
|
||||||
Column {
|
Column {
|
||||||
field: "email".into(),
|
field: "email".into(),
|
||||||
label: "Email".into(),
|
label: "Email".into(),
|
||||||
weight: 3.0,
|
weight: 3.0,
|
||||||
|
ref_entity: None,
|
||||||
|
format: ValueFormat::Plain,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
actions: vec![Action::OpenView {
|
actions: vec![Action::OpenView {
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use nahual_meta_runtime::{
|
use nahual_meta_runtime::{
|
||||||
compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
|
compute_clear_fields, compute_field_delta, format_value, human_label_for_record,
|
||||||
render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
|
parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
|
||||||
MetaBackend, WriteOutcome,
|
value_to_input_text, MetaBackend, WriteOutcome,
|
||||||
};
|
};
|
||||||
use nahual_meta_schema::{
|
use nahual_meta_schema::{
|
||||||
Action, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
|
Action, Column, FieldKind, FieldSpec, FormView, ListView, Module, 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};
|
||||||
@@ -856,7 +856,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
div()
|
div()
|
||||||
.flex_grow()
|
.flex_grow()
|
||||||
.flex_basis(px(100. * frac))
|
.flex_basis(px(100. * frac))
|
||||||
.child(render_value(v)),
|
.child(self.render_cell(c, v)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
row = row.child(
|
row = row.child(
|
||||||
@@ -933,6 +933,43 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
/// click en una opción setea el TextInput del field con el UUID
|
/// click en una opción setea el TextInput del field con el UUID
|
||||||
/// seleccionado. El item del UUID actualmente seleccionado (si
|
/// seleccionado. El item del UUID actualmente seleccionado (si
|
||||||
/// hay) se resalta con accent color.
|
/// hay) se resalta con accent color.
|
||||||
|
/// 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.
|
||||||
|
fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
|
||||||
|
if let Some(ref_entity) = &c.ref_entity {
|
||||||
|
return match v {
|
||||||
|
Some(Value::String(s)) => match Uuid::parse_str(s) {
|
||||||
|
Ok(uuid) => self
|
||||||
|
.backend
|
||||||
|
.load_record(ref_entity, uuid)
|
||||||
|
.map(|rec| human_label_for_record(&rec, &uuid))
|
||||||
|
.unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
|
||||||
|
Err(_) => render_value(v),
|
||||||
|
},
|
||||||
|
_ => render_value(v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
format_value(v, &c.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label legible del record referenciado por un campo EntityRef.
|
||||||
|
/// `(sin seleccionar)` si el campo está vacío.
|
||||||
|
fn ref_label(&self, target: &str, current: &str) -> String {
|
||||||
|
let current = current.trim();
|
||||||
|
if current.is_empty() {
|
||||||
|
return "(sin seleccionar)".to_string();
|
||||||
|
}
|
||||||
|
match Uuid::parse_str(current) {
|
||||||
|
Ok(uuid) => self
|
||||||
|
.backend
|
||||||
|
.load_record(target, uuid)
|
||||||
|
.map(|rec| human_label_for_record(&rec, &uuid))
|
||||||
|
.unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
|
||||||
|
Err(_) => current.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Chips clickables para un campo [`FieldKind::Select`]. El chip de
|
/// Chips clickables para un campo [`FieldKind::Select`]. El chip de
|
||||||
/// la opción elegida se resalta con accent. Click setea el
|
/// la opción elegida se resalta con accent. Click setea el
|
||||||
/// `TextInput` del field (de donde lee el submit), igual que el
|
/// `TextInput` del field (de donde lee el submit), igual que el
|
||||||
@@ -1143,6 +1180,38 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
accent,
|
accent,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
FieldKind::EntityRef => {
|
||||||
|
// Display read-only del record elegido (label, no
|
||||||
|
// el UUID crudo) + selector clickable debajo. El
|
||||||
|
// TextInput vive en `form_inputs` pero no se monta.
|
||||||
|
if let Some(target) = &f.ref_entity {
|
||||||
|
let current = self
|
||||||
|
.form_inputs
|
||||||
|
.get(&f.name)
|
||||||
|
.map(|i| i.read(&*cx).text().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let is_empty = current.trim().is_empty();
|
||||||
|
field_box = field_box.child(
|
||||||
|
div()
|
||||||
|
.px(px(8.))
|
||||||
|
.py(px(6.))
|
||||||
|
.bg(input_bg)
|
||||||
|
.text_color(if is_empty { text_dim } else { text })
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(self.ref_label(target, ¤t)),
|
||||||
|
);
|
||||||
|
field_box = field_box.child(self.render_entity_ref_selector(
|
||||||
|
cx,
|
||||||
|
f.name.clone(),
|
||||||
|
target.clone(),
|
||||||
|
text,
|
||||||
|
text_dim,
|
||||||
|
accent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Sin ref_entity es imposible: Module::validate lo
|
||||||
|
// rechaza al cargar el módulo.
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Mount del TextInput vivo (creado en select_view).
|
// Mount del TextInput vivo (creado en select_view).
|
||||||
if let Some(input) = self.form_inputs.get(&f.name) {
|
if let Some(input) = self.form_inputs.get(&f.name) {
|
||||||
@@ -1159,20 +1228,6 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.child("(input no inicializado)"),
|
.child("(input no inicializado)"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// EntityRef: selector clickable de records debajo
|
|
||||||
// del input. Click setea el TextInput con el UUID.
|
|
||||||
if f.kind == FieldKind::EntityRef {
|
|
||||||
if let Some(target_entity) = &f.ref_entity {
|
|
||||||
field_box = field_box.child(self.render_entity_ref_selector(
|
|
||||||
cx,
|
|
||||||
f.name.clone(),
|
|
||||||
target_entity.clone(),
|
|
||||||
text,
|
|
||||||
text_dim,
|
|
||||||
accent,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ use std::collections::BTreeMap;
|
|||||||
use gpui::TestAppContext;
|
use gpui::TestAppContext;
|
||||||
use nahual_meta_runtime::testing::MockBackend;
|
use nahual_meta_runtime::testing::MockBackend;
|
||||||
use nahual_meta_schema::{
|
use nahual_meta_schema::{
|
||||||
Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
|
Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module,
|
||||||
|
ValueFormat, View,
|
||||||
};
|
};
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
use nahual_widget_meta_form::MetaApp;
|
use nahual_widget_meta_form::MetaApp;
|
||||||
@@ -34,6 +35,8 @@ fn customers_module() -> Module {
|
|||||||
field: "name".into(),
|
field: "name".into(),
|
||||||
label: "Nombre".into(),
|
label: "Nombre".into(),
|
||||||
weight: 1.0,
|
weight: 1.0,
|
||||||
|
ref_entity: None,
|
||||||
|
format: ValueFormat::Plain,
|
||||||
}],
|
}],
|
||||||
actions: vec![],
|
actions: vec![],
|
||||||
search_in: vec![],
|
search_in: vec![],
|
||||||
|
|||||||
@@ -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-*): relaciones legibles + formato (Fase 2 del ERP nakui)
|
||||||
|
|
||||||
|
- **`Column.ref_entity`** — una columna de lista con esto resuelve su
|
||||||
|
valor (un UUID) al label legible del record referido, en vez de
|
||||||
|
mostrar el UUID crudo. `meta-form` carga el record vía el backend y
|
||||||
|
usa `human_label_for_record`.
|
||||||
|
- **`Column.format`** (`ValueFormat::{Plain, Number, Currency}`) —
|
||||||
|
formato de la celda: separador de miles, símbolo de moneda
|
||||||
|
(`12000` → `$12,000`). Helper `format_value` en `meta-runtime`.
|
||||||
|
- El campo `entity_ref` en formularios ahora muestra el **label del
|
||||||
|
record elegido** (read-only) + el selector, no el UUID crudo.
|
||||||
|
- `human_label_for_record` reconoce campos de nombre en español
|
||||||
|
(`nombre`, `titulo`), no sólo inglés.
|
||||||
|
|
||||||
|
Tests nuevos en `meta-runtime` (`format_value`, labels ES) y
|
||||||
|
`meta-schema`. Ver el changelog de `nakui` para el plan maestro.
|
||||||
|
|
||||||
### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui)
|
### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui)
|
||||||
|
|
||||||
La metainterfaz declarativa gana dos tipos de campo:
|
La metainterfaz declarativa gana dos tipos de campo:
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
ERP categórico.
|
ERP categórico.
|
||||||
|
|
||||||
|
### feat(nakui): Fase 2 del ERP — relaciones legibles + formato
|
||||||
|
|
||||||
|
Segunda fase del plan maestro. El módulo CRM:
|
||||||
|
|
||||||
|
- Las columnas `cliente_id` de las listas de Oportunidades e
|
||||||
|
Interacciones muestran el **nombre del cliente**, no su UUID
|
||||||
|
(`ref_entity` en la columna).
|
||||||
|
- La columna `monto` se formatea como moneda (`$12,000`).
|
||||||
|
- En los formularios, el campo de cliente/oportunidad muestra el
|
||||||
|
record elegido por su nombre.
|
||||||
|
|
||||||
|
Tipos nuevos en la metainterfaz: ver el changelog de `nahual`
|
||||||
|
(`Column.ref_entity` / `Column.format`).
|
||||||
|
|
||||||
### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)
|
### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)
|
||||||
|
|
||||||
Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases
|
Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Ordenadas por dependencia y por impacto visible. Cada fase toca
|
|||||||
`meta-schema` (constructo declarativo nuevo) → `meta-runtime` (helper
|
`meta-schema` (constructo declarativo nuevo) → `meta-runtime` (helper
|
||||||
puro) → `meta-form` (render) → módulos de ejemplo + tests.
|
puro) → `meta-form` (render) → módulos de ejemplo + tests.
|
||||||
|
|
||||||
### Fase 1 · Captura sin fricción — EN CURSO
|
### Fase 1 · Captura sin fricción — HECHA
|
||||||
|
|
||||||
- `FieldKind::Select` — campos enumerados como desplegable/chips, con
|
- `FieldKind::Select` — campos enumerados como desplegable/chips, con
|
||||||
`options` (valor + etiqueta) declaradas.
|
`options` (valor + etiqueta) declaradas.
|
||||||
@@ -51,12 +51,13 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
|
|||||||
- **Resultado**: ningún formulario pide un UUID a mano; etapa, canal y
|
- **Resultado**: ningún formulario pide un UUID a mano; etapa, canal y
|
||||||
similares son selects. El CRM se siente correcto al cargar datos.
|
similares son selects. El CRM se siente correcto al cargar datos.
|
||||||
|
|
||||||
### Fase 2 · Relaciones legibles + formato
|
### Fase 2 · Relaciones legibles + formato — HECHA
|
||||||
|
|
||||||
- Columnas/campos `entity_ref` muestran el **label** del record
|
- Columnas con `ref_entity` muestran el **label** del record referido
|
||||||
referido (vía `human_label_for_record`), no el UUID.
|
(vía `human_label_for_record`), no el UUID. El campo `entity_ref` en
|
||||||
- Formato de valores declarable: moneda (`12000` → `$12,000.00`),
|
formularios muestra el record elegido, no el UUID crudo.
|
||||||
fecha, número con separadores. `FieldSpec.format` / `Column.format`.
|
- Formato declarable por columna: `ValueFormat::{Number, Currency}` —
|
||||||
|
separador de miles + símbolo (`12000` → `$12,000`).
|
||||||
- **Resultado**: las listas se leen como un ERP, no como un volcado.
|
- **Resultado**: las listas se leen como un ERP, no como un volcado.
|
||||||
|
|
||||||
### Fase 3 · Ficha de detalle
|
### Fase 3 · Ficha de detalle
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"label": "Oportunidad",
|
"label": "Oportunidad",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "id", "label": "ID", "kind": "text" },
|
{ "name": "id", "label": "ID", "kind": "text" },
|
||||||
{ "name": "cliente_id", "label": "Cliente ref", "kind": "text" },
|
{ "name": "cliente_id", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente" },
|
||||||
{ "name": "titulo", "label": "Título", "kind": "text" },
|
{ "name": "titulo", "label": "Título", "kind": "text" },
|
||||||
{ "name": "monto", "label": "Monto", "kind": "number" },
|
{ "name": "monto", "label": "Monto", "kind": "number" },
|
||||||
{ "name": "currency", "label": "Moneda", "kind": "text" },
|
{ "name": "currency", "label": "Moneda", "kind": "text" },
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"label": "Interacción",
|
"label": "Interacción",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "id", "label": "ID", "kind": "text" },
|
{ "name": "id", "label": "ID", "kind": "text" },
|
||||||
{ "name": "cliente_id", "label": "Cliente ref", "kind": "text" },
|
{ "name": "cliente_id", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente" },
|
||||||
{ "name": "canal", "label": "Canal", "kind": "text" },
|
{ "name": "canal", "label": "Canal", "kind": "text" },
|
||||||
{ "name": "nota", "label": "Nota", "kind": "multiline" },
|
{ "name": "nota", "label": "Nota", "kind": "multiline" },
|
||||||
{ "name": "timestamp", "label": "Fecha", "kind": "text" }
|
{ "name": "timestamp", "label": "Fecha", "kind": "text" }
|
||||||
@@ -85,9 +85,8 @@
|
|||||||
"columns": [
|
"columns": [
|
||||||
{ "field": "titulo", "label": "Título", "weight": 2.5 },
|
{ "field": "titulo", "label": "Título", "weight": 2.5 },
|
||||||
{ "field": "etapa", "label": "Etapa", "weight": 1.2 },
|
{ "field": "etapa", "label": "Etapa", "weight": 1.2 },
|
||||||
{ "field": "monto", "label": "Monto", "weight": 1.0 },
|
{ "field": "monto", "label": "Monto", "weight": 1.2, "format": { "kind": "currency", "symbol": "$" } },
|
||||||
{ "field": "currency", "label": "Moneda", "weight": 0.6 },
|
{ "field": "cliente_id", "label": "Cliente", "weight": 2.0, "ref_entity": "Cliente" }
|
||||||
{ "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 }
|
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
{ "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
|
{ "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" },
|
||||||
@@ -149,7 +148,7 @@
|
|||||||
"columns": [
|
"columns": [
|
||||||
{ "field": "canal", "label": "Canal", "weight": 1.0 },
|
{ "field": "canal", "label": "Canal", "weight": 1.0 },
|
||||||
{ "field": "nota", "label": "Nota", "weight": 3.0 },
|
{ "field": "nota", "label": "Nota", "weight": 3.0 },
|
||||||
{ "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 },
|
{ "field": "cliente_id", "label": "Cliente", "weight": 2.0, "ref_entity": "Cliente" },
|
||||||
{ "field": "timestamp", "label": "Fecha", "weight": 1.2 }
|
{ "field": "timestamp", "label": "Fecha", "weight": 1.2 }
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
|
|||||||
Reference in New Issue
Block a user