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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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»
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user