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 { + 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 MetaApp { 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) -> 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) { + let rows = self.list_filtered_sorted(lv, cx); + let headers: Vec = lv.columns.iter().map(|c| c.label.clone()).collect(); + let data: Vec> = 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: `-.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 MetaApp { })), ); } + // 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 MetaApp { 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 MetaApp { } 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 + `-.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