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:
@@ -6,11 +6,15 @@
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use nahual_meta_schema::ValueFormat;
|
||||
|
||||
/// Etiqueta humana para representar un record en el selector de
|
||||
/// EntityRef. Heurística: prefiere campos comunes en este orden:
|
||||
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
|
||||
/// EntityRef y en columnas de referencia. Heurística: prefiere campos
|
||||
/// de nombre comunes (ES + EN); fallback al UUID corto.
|
||||
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 !v.is_empty() {
|
||||
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
|
||||
/// que un input puede tomar y volver a parsearse igual al submit.
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn render_value_handles_basic_kinds() {
|
||||
assert_eq!(render_value(None), "");
|
||||
|
||||
@@ -32,7 +32,7 @@ pub mod testing;
|
||||
pub use backend::{MetaBackend, WriteOutcome};
|
||||
pub use delta::{compute_clear_fields, compute_field_delta};
|
||||
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,
|
||||
};
|
||||
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.
|
||||
#[serde(default = "default_weight")]
|
||||
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 {
|
||||
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)]
|
||||
pub struct FormView {
|
||||
pub title: String,
|
||||
@@ -501,11 +522,15 @@ mod tests {
|
||||
field: "name".into(),
|
||||
label: "Nombre".into(),
|
||||
weight: 2.0,
|
||||
ref_entity: None,
|
||||
format: ValueFormat::Plain,
|
||||
},
|
||||
Column {
|
||||
field: "email".into(),
|
||||
label: "Email".into(),
|
||||
weight: 3.0,
|
||||
ref_entity: None,
|
||||
format: ValueFormat::Plain,
|
||||
},
|
||||
],
|
||||
actions: vec![Action::OpenView {
|
||||
|
||||
Reference in New Issue
Block a user