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)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
pub mod csv;
|
||||||
pub mod delta;
|
pub mod delta;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod metric;
|
pub mod metric;
|
||||||
@@ -31,6 +32,7 @@ pub mod refs;
|
|||||||
pub mod testing;
|
pub mod testing;
|
||||||
|
|
||||||
pub use backend::{MetaBackend, WriteOutcome};
|
pub use backend::{MetaBackend, WriteOutcome};
|
||||||
|
pub use csv::to_csv;
|
||||||
pub use delta::{compute_clear_fields, compute_field_delta};
|
pub use delta::{compute_clear_fields, compute_field_delta};
|
||||||
pub use format::{
|
pub use format::{
|
||||||
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
|
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use gpui::{
|
|||||||
use nahual_meta_runtime::{
|
use nahual_meta_runtime::{
|
||||||
cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value,
|
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,
|
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::{
|
use nahual_meta_schema::{
|
||||||
Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module,
|
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)> {
|
fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
|
||||||
self.backend.list_records(entity)
|
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
|
/// 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)
|
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
|
/// Próximo estado de orden al hacer clic en el header `field`: la misma
|
||||||
/// columna cicla ascendente → descendente → sin orden; otra columna
|
/// columna cicla ascendente → descendente → sin orden; otra columna
|
||||||
/// arranca ascendente.
|
/// 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);
|
main = main.child(header);
|
||||||
|
|
||||||
// Caja de búsqueda (sólo si la lista declara `search_in`).
|
// 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();
|
let row_detail = lv.row_detail.clone();
|
||||||
|
|
||||||
// Filas: buscar → ordenar → paginar.
|
// Filas: buscar + ordenar (helper compartido con el export CSV),
|
||||||
let mut all_rows = self.list_rows(&lv.entity);
|
// luego paginar.
|
||||||
let query = self
|
let all_rows = self.list_filtered_sorted(lv, cx);
|
||||||
|
let has_query = self
|
||||||
.list_search
|
.list_search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|i| i.read(cx).text().trim().to_lowercase())
|
.map(|i| !i.read(cx).text().trim().is_empty())
|
||||||
.unwrap_or_default();
|
.unwrap_or(false);
|
||||||
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()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let total = all_rows.len();
|
let total = all_rows.len();
|
||||||
let page_count = total.div_ceil(PAGE_SIZE).max(1);
|
let page_count = total.div_ceil(PAGE_SIZE).max(1);
|
||||||
let page = self.list_page.min(page_count - 1);
|
let page = self.list_page.min(page_count - 1);
|
||||||
@@ -1071,10 +1140,10 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
let msg = if query.is_empty() {
|
let msg = if has_query {
|
||||||
format!("(sin {})", lv.entity)
|
|
||||||
} else {
|
|
||||||
"(sin resultados para la búsqueda)".to_string()
|
"(sin resultados para la búsqueda)".to_string()
|
||||||
|
} else {
|
||||||
|
format!("(sin {})", lv.entity)
|
||||||
};
|
};
|
||||||
main = main.child(
|
main = main.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-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)
|
### feat(meta-*): tablero de KPIs (Fase 5 del ERP nakui)
|
||||||
|
|
||||||
Cuarta clase de vista de la metainterfaz:
|
Cuarta clase de vista de la metainterfaz:
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
ERP categórico.
|
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
|
### feat(nakui): Fase 5 del ERP — tablero de KPIs
|
||||||
|
|
||||||
Quinta fase del plan maestro. El módulo CRM gana una vista «Panorama»
|
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
|
- Pendiente menor (a futuro): reemplazar las barras de texto por los
|
||||||
charts de `pineal`.
|
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
|
- Export CSV de cualquier lista: botón «⬇ CSV» que vuelca las filas
|
||||||
`nahual-theme` ya existen).
|
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
|
### Fase 7 · Pulido de producto
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user