feat(nakui): Fase 6 del ERP — export CSV de listas

Toda vista de lista gana un botón «⬇ CSV» que exporta las filas
filtradas/ordenadas (con refs resueltas y montos formateados) a un
archivo <entity>-<timestamp>.csv. Serializador to_csv (RFC 4180, con
escape) en el módulo nuevo meta-runtime/csv.rs. Refactor:
list_filtered_sorted extraído como helper compartido entre el render
de la lista y el export.

Tests de to_csv; meta-runtime 70 + meta-form 8 verdes, clippy limpio.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 19:32:48 +00:00
parent ab2b8f6638
commit b13486e240
6 changed files with 192 additions and 31 deletions
@@ -0,0 +1,67 @@
//! Serialización de filas a CSV (RFC 4180) para exportar listas.
/// Arma un documento CSV: una línea de headers + una por fila. Cada
/// celda se escapa si contiene coma, comilla o salto de línea.
pub fn to_csv(headers: &[String], rows: &[Vec<String>]) -> String {
let mut out = String::new();
push_csv_line(&mut out, headers);
for row in rows {
push_csv_line(&mut out, row);
}
out
}
/// Agrega una línea CSV (celdas separadas por coma + `\n` final).
fn push_csv_line(out: &mut String, cells: &[String]) {
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&csv_escape(cell));
}
out.push('\n');
}
/// Escapa una celda: la envuelve en comillas y duplica las comillas
/// internas si contiene coma, comilla, CR o LF. Si no, va tal cual.
fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_cells_unquoted() {
let csv = to_csv(
&["Nombre".into(), "Edad".into()],
&[vec!["Ana".into(), "30".into()]],
);
assert_eq!(csv, "Nombre,Edad\nAna,30\n");
}
#[test]
fn cells_with_comma_or_quote_are_escaped() {
let csv = to_csv(
&["a".into(), "b".into()],
&[vec!["x,y".into(), "dijo \"hola\"".into()]],
);
assert_eq!(csv, "a,b\n\"x,y\",\"dijo \"\"hola\"\"\"\n");
}
#[test]
fn newline_in_cell_is_quoted() {
let csv = to_csv(&["n".into()], &[vec!["línea1\nlínea2".into()]]);
assert_eq!(csv, "n\n\"línea1\nlínea2\"\n");
}
#[test]
fn empty_rows_yields_just_header() {
assert_eq!(to_csv(&["x".into()], &[]), "x\n");
}
}
@@ -23,6 +23,7 @@
#![forbid(unsafe_code)]
pub mod backend;
pub mod csv;
pub mod delta;
pub mod format;
pub mod metric;
@@ -31,6 +32,7 @@ pub mod refs;
pub mod testing;
pub use backend::{MetaBackend, WriteOutcome};
pub use csv::to_csv;
pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
@@ -29,7 +29,7 @@ use gpui::{
use nahual_meta_runtime::{
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,
to_csv, validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome,
};
use nahual_meta_schema::{
Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module,
@@ -464,6 +464,61 @@ impl<B: MetaBackend> MetaApp<B> {
fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
self.backend.list_records(entity)
}
/// Filas de una lista tras aplicar la búsqueda y el orden activos
/// (sin paginar). Compartido por el render de la lista y el export
/// a CSV.
fn list_filtered_sorted(&self, lv: &ListView, cx: &mut Context<Self>) -> Vec<(Uuid, Value)> {
let mut rows = self.list_rows(&lv.entity);
let query = self
.list_search
.as_ref()
.map(|i| i.read(cx).text().trim().to_lowercase())
.unwrap_or_default();
if !query.is_empty() {
rows.retain(|(_, v)| {
lv.search_in.iter().any(|field| {
lookup_field(v, field)
.map(|cell| render_value(Some(cell)).to_lowercase().contains(&query))
.unwrap_or(false)
})
});
}
if let Some((field, asc)) = &self.list_sort {
rows.sort_by(|(_, a), (_, b)| {
let ord = cmp_values(lookup_field(a, field), lookup_field(b, field));
if *asc {
ord
} else {
ord.reverse()
}
});
}
rows
}
/// Exporta la lista (filas filtradas/ordenadas, todas las columnas)
/// a un archivo CSV en el directorio de trabajo; avisa por toast.
fn export_csv(&mut self, lv: &ListView, cx: &mut Context<Self>) {
let rows = self.list_filtered_sorted(lv, cx);
let headers: Vec<String> = lv.columns.iter().map(|c| c.label.clone()).collect();
let data: Vec<Vec<String>> = rows
.iter()
.map(|(_, v)| {
lv.columns
.iter()
.map(|c| self.render_cell(c, lookup_field(v, &c.field)))
.collect()
})
.collect();
let csv = to_csv(&headers, &data);
let path = export_path(&lv.entity);
self.toast = Some(SharedString::from(match std::fs::write(&path, csv) {
Ok(()) => format!("exporté {} fila(s) a {}", rows.len(), path.display()),
Err(e) => format!("no pude exportar CSV: {e}"),
}));
cx.notify();
}
}
/// Formatea el toast para la rama Action::SeedEntity según el
@@ -499,6 +554,19 @@ fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
Some(cur)
}
/// Ruta destino de un export CSV: `<entity>-<unix-secs>.csv` en el
/// directorio de trabajo actual.
fn export_path(entity: &str) -> std::path::PathBuf {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let name = format!("{entity}-{secs}.csv");
std::env::current_dir()
.map(|d| d.join(&name))
.unwrap_or_else(|_| std::path::PathBuf::from(name))
}
/// Próximo estado de orden al hacer clic en el header `field`: la misma
/// columna cicla ascendente → descendente → sin orden; otra columna
/// arranca ascendente.
@@ -896,6 +964,25 @@ impl<B: MetaBackend> MetaApp<B> {
})),
);
}
// Botón de export CSV — disponible en toda lista.
{
let lv_for_csv = lv.clone();
header = header.child(
div()
.id(SharedString::from(format!("list-csv-{mod_idx}")))
.px(px(10.))
.py(px(4.))
.bg(action_bg)
.text_color(accent)
.text_size(px(11.))
.rounded(px(3.))
.hover(move |d| d.bg(action_hover))
.child("⬇ CSV")
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
this.export_csv(&lv_for_csv, cx);
})),
);
}
main = main.child(header);
// Caja de búsqueda (sólo si la lista declara `search_in`).
@@ -914,32 +1001,14 @@ impl<B: MetaBackend> MetaApp<B> {
let row_detail = lv.row_detail.clone();
// Filas: buscar ordenar → paginar.
let mut all_rows = self.list_rows(&lv.entity);
let query = self
// Filas: buscar + ordenar (helper compartido con el export CSV),
// luego paginar.
let all_rows = self.list_filtered_sorted(lv, cx);
let has_query = self
.list_search
.as_ref()
.map(|i| i.read(cx).text().trim().to_lowercase())
.unwrap_or_default();
if !query.is_empty() {
all_rows.retain(|(_, v)| {
lv.search_in.iter().any(|field| {
lookup_field(v, field)
.map(|cell| render_value(Some(cell)).to_lowercase().contains(&query))
.unwrap_or(false)
})
});
}
if let Some((field, asc)) = &self.list_sort {
all_rows.sort_by(|(_, a), (_, b)| {
let ord = cmp_values(lookup_field(a, field), lookup_field(b, field));
if *asc {
ord
} else {
ord.reverse()
}
});
}
.map(|i| !i.read(cx).text().trim().is_empty())
.unwrap_or(false);
let total = all_rows.len();
let page_count = total.div_ceil(PAGE_SIZE).max(1);
let page = self.list_page.min(page_count - 1);
@@ -1071,10 +1140,10 @@ impl<B: MetaBackend> MetaApp<B> {
}
if total == 0 {
let msg = if query.is_empty() {
format!("(sin {})", lv.entity)
} else {
let msg = if has_query {
"(sin resultados para la búsqueda)".to_string()
} else {
format!("(sin {})", lv.entity)
};
main = main.child(
div()
+13
View File
@@ -2,6 +2,19 @@
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
### feat(meta-form): export CSV de listas (Fase 6 del ERP nakui)
- Toda vista de lista gana un botón «⬇ CSV» que exporta las filas
**filtradas y ordenadas** (todas, no sólo la página) a un archivo
`<entity>-<timestamp>.csv` en el directorio de trabajo; avisa por
toast con la ruta.
- Las celdas exportadas usan el mismo render que la lista —
referencias resueltas a su nombre, montos formateados.
- Serializador `to_csv` (RFC 4180, con escape de comas/comillas/saltos)
en el módulo nuevo `meta-runtime/csv.rs`, con tests.
- Refactor: `list_filtered_sorted` extraído como helper compartido
entre el render de la lista y el export.
### feat(meta-*): tablero de KPIs (Fase 5 del ERP nakui)
Cuarta clase de vista de la metainterfaz:
+7
View File
@@ -2,6 +2,13 @@
ERP categórico.
### feat(nakui): Fase 6 del ERP — export CSV de listas
Sexta fase del plan maestro. Toda lista del ERP (clientes,
oportunidades, interacciones y los demás módulos) gana un botón
«⬇ CSV» que exporta sus filas a un archivo. Ver el changelog de
`nahual` (`to_csv` / `meta-form`).
### feat(nakui): Fase 5 del ERP — tablero de KPIs
Quinta fase del plan maestro. El módulo CRM gana una vista «Panorama»
+6 -3
View File
@@ -88,10 +88,13 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
- 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 — HECHA
- Export CSV de cualquier lista; impresión (los temas `Print` de
`nahual-theme` ya existen).
- Export CSV de cualquier lista: botón «⬇ CSV» que vuelca las filas
filtradas/ordenadas (con refs resueltas y montos formateados) a un
archivo. Serializador `to_csv` (RFC 4180) en `meta-runtime`.
- Pendiente menor (a futuro): impresión / export PDF (los temas
`Print` de `nahual-theme` ya existen).
### Fase 7 · Pulido de producto