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>
b13486e240239baedd305d909fe9ed5d4f5f687e · by sergio · 2026-05-21T19:32:48Z (5 months ago) · parent ab2b8f663 · browse the tree at this commit
Semantic delta · 13
crates/modules/nahual/libs/meta-runtime/src/csv.rs
crates/modules/nahual/libs/meta-runtime/src/lib.rs
crates/modules/nahual/widgets/meta-form/src/lib.rs
diff --git a/crates/modules/nahual/libs/meta-runtime/src/csv.rs b/crates/modules/nahual/libs/meta-runtime/src/csv.rs
new file mode 100644
index 0000000..114d616
--- /dev/null
+++ b/crates/modules/nahual/libs/meta-runtime/src/csv.rs
@@ -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");
+ }
+}
diff --git a/crates/modules/nahual/libs/meta-runtime/src/lib.rs b/crates/modules/nahual/libs/meta-runtime/src/lib.rs
index 43e7063..a0f19d2 100644
--- a/crates/modules/nahual/libs/meta-runtime/src/lib.rs
+++ b/crates/modules/nahual/libs/meta-runtime/src/lib.rs
@@ -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,
diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs
index c32f2ef..4fbae16 100644
--- a/crates/modules/nahual/widgets/meta-form/src/lib.rs
+++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs
@@ -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()
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index f3d5187..eb5c859 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -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:
diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md
index 92391d8..94d9d7f 100644
--- a/docs/changelog/nakui.md
+++ b/docs/changelog/nakui.md
@@ -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»
diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md
index 2956c0e..c7008d7 100644
--- a/docs/nakui-erp-masterplan.md
+++ b/docs/nakui-erp-masterplan.md
@@ -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