tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

⎇ b13486e240239baedd305d909fe9ed5d4f5f687e crates/modules/nahual/widgets/meta-form/src/lib.rs Rust · 1940 siq'ikuna · 74.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! `nahual-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`nahual_meta_schema::Module`] sobre
4//! cualquier impl de [`nahual_meta_runtime::MetaBackend`]: sidebar con
5//! menús, list/form views, modal de delete, EntityRef selector.
6//!
7//! El widget es **app-agnostic**: no asume Nakui, ni storage
8//! particular, ni lifecycle de bootstrapping. El binario que lo
9//! consume:
10//!
11//! 1. Carga sus módulos (típicamente via `brahman-cards`).
12//! 2. Construye su backend concreto (typicamente wireado a un
13//!    store/log/executor de su stack).
14//! 3. Crea `MetaApp::new(modules, backend, initial_toast,
15//!    initial_error, cx)` y lo monta como root view de GPUI.
16//!
17//! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario
18//! shell que provee `NakuiBackend`).
19
20#![forbid(unsafe_code)]
21
22use std::collections::BTreeMap;
23
24use gpui::{
25    div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render,
26    SharedString, Window,
27};
28
29use nahual_meta_runtime::{
30    cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value,
31    human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid,
32    to_csv, validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome,
33};
34use nahual_meta_schema::{
35    Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module,
36    RelatedList, SelectOption, View,
37};
38use nahual_theme::Theme;
39use nahual_widget_banner::{banner_themed, themed_colors, Banner};
40use nahual_widget_text_input::TextInput;
41use nahual_widget_theme_switcher::theme_switcher;
42use serde_json::Value;
43use uuid::Uuid;
44
45/// Filas por página en las vistas de lista.
46const PAGE_SIZE: usize = 25;
47
48/// Estado del runtime de UI. Toda la persistencia/ejecución está
49/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
50/// state y el schema de los módulos.
51///
52/// Genérico sobre el backend `B`. Un binario decide qué backend
53/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
54pub struct MetaApp<B: MetaBackend> {
55    /// Módulos cargados, ordenados por id.
56    modules: Vec<Module>,
57    /// Backend que ejecuta seed/update/delete/morphism.
58    backend: B,
59    /// (módulo idx, vista key) actualmente activos.
60    active: Option<(usize, String)>,
61    /// Inputs vivos para el form actual: nombre del campo → TextInput.
62    /// Se reemplaza al cambiar de vista (drop de los anteriores).
63    form_inputs: BTreeMap<String, Entity<TextInput>>,
64    /// Si está set, el próximo render del Form pre-llena los inputs
65    /// con los valores del record indicado, y `commit_seed` emite
66    /// un `update` (no un seed nuevo). Limpia al cambiar de view o
67    /// tras submit exitoso.
68    editing: Option<(String, Uuid)>,
69    /// Si está set, el banner modal de confirmación de delete está
70    /// activo: `(entity, id)` del record que el usuario marcó para
71    /// borrar. Permanece hasta que el usuario click [Confirmar]
72    /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
73    /// Navegación a otra view también cancela.
74    pending_delete: Option<(String, Uuid)>,
75    /// Si la vista activa es una `Detail`, el id del record que se
76    /// muestra. Lo setea [`Self::select_detail`].
77    detail_target: Option<Uuid>,
78    /// Key de la vista a la que vuelve el botón «← Volver» de una
79    /// ficha — la lista desde la que se abrió.
80    detail_return: Option<String>,
81    /// Estado de la vista de lista activa. Se reinician al navegar.
82    /// `list_search`: caja de búsqueda (sólo si la lista declara
83    /// `search_in`). `list_sort`: `(columna, ascendente)`.
84    /// `list_page`: página actual (0-based).
85    list_search: Option<Entity<TextInput>>,
86    list_sort: Option<(String, bool)>,
87    list_page: usize,
88    /// Mensaje toast al pie (success de submit, error de carga, etc.).
89    toast: Option<SharedString>,
90    /// Si la carga de módulos falló al inicio.
91    load_error: Option<SharedString>,
92}
93
94impl<B: MetaBackend> MetaApp<B> {
95    /// Constructor del widget. El caller pre-construye sus módulos +
96    /// backend + cualquier mensaje inicial que quiera mostrar al
97    /// usuario al abrir (típicamente el toast del replay y el error
98    /// banner de cosas que fallaron de bootstrap).
99    ///
100    /// La active view default es la primera entry del menú del
101    /// primer módulo (orden lexicográfico por id).
102    pub fn new(
103        modules: Vec<Module>,
104        backend: B,
105        initial_toast: Option<String>,
106        initial_error: Option<String>,
107        _cx: &mut Context<Self>,
108    ) -> Self {
109        let active = modules
110            .first()
111            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
112        Self {
113            modules,
114            backend,
115            active,
116            form_inputs: BTreeMap::new(),
117            editing: None,
118            pending_delete: None,
119            detail_target: None,
120            detail_return: None,
121            list_search: None,
122            list_sort: None,
123            list_page: 0,
124            toast: initial_toast.map(SharedString::from),
125            load_error: initial_error.map(SharedString::from),
126        }
127    }
128
129    /// Cambia la vista activa. Si la nueva vista es un Form, crea
130    /// `TextInput` entities para cada field. Pre-llena con valores
131    /// del record si hay `editing` para esa entity; si no, usa el
132    /// `default` del schema.
133    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
134    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
135        self.active = Some((mod_idx, view_key.clone()));
136        self.toast = None;
137        // Navegar a otra view cancela cualquier delete pendiente:
138        // el record marcado puede no estar visible en la nueva view.
139        self.pending_delete = None;
140        self.detail_target = None;
141        self.form_inputs = BTreeMap::new();
142        self.list_search = None;
143        self.list_sort = None;
144        self.list_page = 0;
145        if let Some(module) = self.modules.get(mod_idx) {
146            match module.views.get(&view_key) {
147                Some(View::Form(form)) => {
148                    // Snapshot del record si estamos editando esta entity.
149                    let editing_record: Option<Value> =
150                        self.editing.as_ref().and_then(|(e, id)| {
151                            if e == &form.entity {
152                                self.backend.load_record(e, *id)
153                            } else {
154                                None
155                            }
156                        });
157                    for f in &form.fields {
158                        let initial = if f.kind == FieldKind::AutoId {
159                            // Editando: conservar el id del record.
160                            // Alta: UUID nuevo, que el usuario no teclea.
161                            editing_record
162                                .as_ref()
163                                .and_then(|rec| rec.get(&f.name).map(value_to_input_text))
164                                .unwrap_or_else(|| Uuid::new_v4().to_string())
165                        } else if let Some(rec) = &editing_record {
166                            rec.get(&f.name)
167                                .map(value_to_input_text)
168                                .unwrap_or_else(|| f.default.clone().unwrap_or_default())
169                        } else {
170                            f.default.clone().unwrap_or_default()
171                        };
172                        let input = cx.new(|cx| TextInput::new(initial, cx));
173                        self.form_inputs.insert(f.name.clone(), input);
174                    }
175                }
176                Some(View::List(lv)) => {
177                    // Caja de búsqueda viva si la lista declara search_in.
178                    if !lv.search_in.is_empty() {
179                        let input = cx.new(|cx| TextInput::new("", cx).with_placeholder("buscar…"));
180                        // Re-render del widget cuando el input cambia →
181                        // filtrado en vivo mientras se teclea.
182                        cx.observe(&input, |_this, _, cx| cx.notify()).detach();
183                        self.list_search = Some(input);
184                    }
185                    self.editing = None;
186                }
187                _ => {
188                    // Cambiar a una view que no es Form invalida el
189                    // editing pendiente.
190                    self.editing = None;
191                }
192            }
193        }
194        cx.notify();
195    }
196
197    /// Abre la ficha de detalle `detail_view` para el record `id`.
198    /// Recuerda la vista actual para el botón «← Volver».
199    fn select_detail(
200        &mut self,
201        mod_idx: usize,
202        detail_view: String,
203        id: Uuid,
204        cx: &mut Context<Self>,
205    ) {
206        self.detail_return = self.active.as_ref().map(|(_, v)| v.clone());
207        self.active = Some((mod_idx, detail_view));
208        self.detail_target = Some(id);
209        self.editing = None;
210        self.pending_delete = None;
211        self.form_inputs = BTreeMap::new();
212        self.list_search = None;
213        self.list_sort = None;
214        self.list_page = 0;
215        self.toast = None;
216        cx.notify();
217    }
218
219    /// Cambia el orden de la lista al hacer clic en un header: misma
220    /// columna cicla ascendente → descendente → sin orden.
221    fn toggle_sort(&mut self, field: &str, cx: &mut Context<Self>) {
222        self.list_sort = next_sort(self.list_sort.take(), field);
223        self.list_page = 0;
224        cx.notify();
225    }
226
227    /// Inicia un edit del record: setea `editing` y abre la primera
228    /// view de tipo Form del módulo (convención: la del schema).
229    fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
230        self.editing = Some((entity.clone(), id));
231        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
232            m.views.iter().find_map(|(key, v)| match v {
233                View::Form(form) if form.entity == entity => Some(key.clone()),
234                _ => None,
235            })
236        });
237        match form_view_key {
238            Some(key) => self.select_view(mod_idx, key, cx),
239            None => {
240                self.toast = Some(SharedString::from(format!(
241                    "no hay form view para entity '{entity}' en este módulo"
242                )));
243                self.editing = None;
244                cx.notify();
245            }
246        }
247    }
248
249    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
250    /// aplica al store via `apply` (no via remove directo, mantiene
251    /// el modelo de "todo cambio post-seed pasa por ops").
252    /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
253    /// del backend (incluye eventual `post_status` del compact tick).
254    fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
255        self.backend.delete(entity, id)
256    }
257
258    /// Aplica una acción (click en menú, botón de form, action de
259    /// list). Mutaciones contra el backend ocurren acá.
260    pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
261        let mod_idx = match self.active.as_ref() {
262            Some((i, _)) => *i,
263            None => return,
264        };
265        match action {
266            Action::OpenView { view, .. } => {
267                // Salir a otra view cancela el edit pendiente.
268                self.editing = None;
269                self.select_view(mod_idx, view, cx);
270            }
271            Action::SeedEntity { entity, next_view } => {
272                let was_editing = self.editing.is_some();
273                match self.commit_seed(mod_idx, &entity, cx) {
274                    Ok(outcome) => {
275                        let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
276                        self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
277                        // Limpia editing tras un commit exitoso —
278                        // el record ya está sincronizado (incluso
279                        // un NoChange cierra el modo edit).
280                        self.editing = None;
281                        if let Some(v) = next_view {
282                            self.select_view(mod_idx, v, cx);
283                        } else {
284                            for input in self.form_inputs.values() {
285                                input.update(cx, |inp, cx| inp.set_text("", cx));
286                            }
287                        }
288                    }
289                    Err(e) => {
290                        self.toast = Some(SharedString::from(format!("error: {e}")));
291                    }
292                }
293                cx.notify();
294            }
295            Action::Morphism {
296                name,
297                inputs,
298                params,
299                next_view,
300            } => {
301                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
302                    Ok(outcome) => {
303                        let base =
304                            format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
305                        self.toast = Some(append_compact_msg(base, outcome.post_status));
306                        if let Some(v) = next_view {
307                            self.select_view(mod_idx, v, cx);
308                        }
309                    }
310                    Err(e) => {
311                        self.toast =
312                            Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
313                    }
314                }
315                cx.notify();
316            }
317        }
318    }
319
320    /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
321    /// + params (Value object) leyendo los TextInput del form.
322    fn commit_morphism(
323        &mut self,
324        mod_idx: usize,
325        morphism: &str,
326        inputs_map: &BTreeMap<String, String>,
327        params_fields: &[String],
328        cx: &mut Context<Self>,
329    ) -> Result<WriteOutcome, String> {
330        let module = self
331            .modules
332            .get(mod_idx)
333            .ok_or_else(|| "módulo inválido".to_string())?;
334        let module_id = module.id.clone();
335
336        // Resolver inputs: por cada (role, field_name), parsear el
337        // value del input como Uuid.
338        let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
339        for (role, field_name) in inputs_map {
340            let raw = self
341                .form_inputs
342                .get(field_name)
343                .map(|inp| inp.read(&*cx).text().to_string())
344                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
345            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
346                format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
347            })?;
348            inputs.insert(role.clone(), id);
349        }
350
351        // Resolver params: si la lista está vacía, todos los fields
352        // del form que no estén en `inputs_map` van a params.
353        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
354        let mut params_obj = serde_json::Map::new();
355        let field_iter: Vec<String> = if params_fields.is_empty() {
356            self.form_inputs
357                .keys()
358                .filter(|k| !input_field_set.contains(*k))
359                .cloned()
360                .collect()
361        } else {
362            params_fields.to_vec()
363        };
364
365        // FieldSpec del Form view activo para parseo estricto por kind.
366        let active_form_fields: Option<Vec<FieldSpec>> =
367            self.active.as_ref().and_then(|(_, vk)| {
368                module.views.get(vk).and_then(|v| match v {
369                    View::Form(f) => Some(f.fields.clone()),
370                    _ => None,
371                })
372            });
373
374        for field_name in field_iter {
375            let raw = self
376                .form_inputs
377                .get(&field_name)
378                .map(|inp| inp.read(&*cx).text().to_string())
379                .unwrap_or_default();
380            let spec = active_form_fields
381                .as_ref()
382                .and_then(|fs| fs.iter().find(|f| f.name == field_name));
383            let value = resolve_param_value(&field_name, &raw, spec)?;
384            params_obj.insert(field_name, value);
385        }
386
387        self.backend
388            .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
389    }
390
391    /// Construye un payload desde los TextInput vivos y delega al
392    /// backend (`seed` para alta nueva, `update` con set+clear para
393    /// edit). Devuelve el `WriteOutcome` del backend (incluye
394    /// `changed` y `post_status` para el toast).
395    fn commit_seed(
396        &mut self,
397        mod_idx: usize,
398        entity: &str,
399        cx: &mut Context<Self>,
400    ) -> Result<WriteOutcome, String> {
401        let module = &self.modules[mod_idx];
402        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
403            Some((_, view_key)) => match module.views.get(view_key) {
404                Some(View::Form(f)) => f.fields.clone(),
405                _ => return Err("la vista activa no es un Form".into()),
406            },
407            None => return Err("ninguna vista activa".into()),
408        };
409        let mut obj = serde_json::Map::new();
410        // Fields que el form deja vacíos y son optional: candidatos
411        // a Clear en el path de EDIT.
412        let mut to_clear: Vec<String> = Vec::new();
413        // EntityRef refs a validar tras parse loop (en una toma del
414        // backend en lugar de N).
415        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
416        for f in &spec_fields {
417            let raw = self
418                .form_inputs
419                .get(&f.name)
420                .map(|input| input.read(cx).text().to_string())
421                .unwrap_or_default();
422            if f.required && raw.trim().is_empty() {
423                return Err(format!("campo '{}' es obligatorio", f.label));
424            }
425            if raw.is_empty() && !f.required {
426                to_clear.push(f.name.clone());
427                continue;
428            }
429            let value =
430                parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
431            if f.kind == FieldKind::EntityRef {
432                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
433                    let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
434                    entity_refs.push((f.label.clone(), target.clone(), id));
435                }
436            }
437            obj.insert(f.name.clone(), value);
438        }
439        // Validar EntityRefs contra el backend actual. Cierre wrappea
440        // backend.load_record para mantener la firma de
441        // validate_entity_refs (que es store-agnóstica).
442        if !entity_refs.is_empty() {
443            let backend = &self.backend;
444            validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
445        }
446
447        // Ramificación: edit (hay editing para esta entity) vs alta.
448        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
449
450        if let Some((_, id)) = editing_match {
451            // EDIT: cargar current, computar delta, llamar
452            // backend.update con set+clear pre-computados.
453            let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
454            let set_delta = compute_field_delta(&current, &obj);
455            let clear_fields = compute_clear_fields(&current, &to_clear);
456            self.backend.update(entity, id, set_delta, clear_fields)
457        } else {
458            // SEED: alta nueva — el backend genera el Uuid.
459            self.backend.seed(entity, obj)
460        }
461    }
462
463    /// Snapshot ordenado de records de una entity (proxy al backend).
464    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
465        self.backend.list_records(entity)
466    }
467
468    /// Filas de una lista tras aplicar la búsqueda y el orden activos
469    /// (sin paginar). Compartido por el render de la lista y el export
470    /// a CSV.
471    fn list_filtered_sorted(&self, lv: &ListView, cx: &mut Context<Self>) -> Vec<(Uuid, Value)> {
472        let mut rows = self.list_rows(&lv.entity);
473        let query = self
474            .list_search
475            .as_ref()
476            .map(|i| i.read(cx).text().trim().to_lowercase())
477            .unwrap_or_default();
478        if !query.is_empty() {
479            rows.retain(|(_, v)| {
480                lv.search_in.iter().any(|field| {
481                    lookup_field(v, field)
482                        .map(|cell| render_value(Some(cell)).to_lowercase().contains(&query))
483                        .unwrap_or(false)
484                })
485            });
486        }
487        if let Some((field, asc)) = &self.list_sort {
488            rows.sort_by(|(_, a), (_, b)| {
489                let ord = cmp_values(lookup_field(a, field), lookup_field(b, field));
490                if *asc {
491                    ord
492                } else {
493                    ord.reverse()
494                }
495            });
496        }
497        rows
498    }
499
500    /// Exporta la lista (filas filtradas/ordenadas, todas las columnas)
501    /// a un archivo CSV en el directorio de trabajo; avisa por toast.
502    fn export_csv(&mut self, lv: &ListView, cx: &mut Context<Self>) {
503        let rows = self.list_filtered_sorted(lv, cx);
504        let headers: Vec<String> = lv.columns.iter().map(|c| c.label.clone()).collect();
505        let data: Vec<Vec<String>> = rows
506            .iter()
507            .map(|(_, v)| {
508                lv.columns
509                    .iter()
510                    .map(|c| self.render_cell(c, lookup_field(v, &c.field)))
511                    .collect()
512            })
513            .collect();
514        let csv = to_csv(&headers, &data);
515        let path = export_path(&lv.entity);
516        self.toast = Some(SharedString::from(match std::fs::write(&path, csv) {
517            Ok(()) => format!("exporté {} fila(s) a {}", rows.len(), path.display()),
518            Err(e) => format!("no pude exportar CSV: {e}"),
519        }));
520        cx.notify();
521    }
522}
523
524/// Formatea el toast para la rama Action::SeedEntity según el
525/// `WriteOutcome` del backend. `was_editing` distingue "creado"
526/// vs "actualizado" — el WriteOutcome solo no alcanza porque
527/// `seed` y `update` ambos devuelven `id = Some(...)`.
528fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
529    let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
530    match (was_editing, outcome.changed) {
531        (false, _) => format!("creado {entity} {id_short}"),
532        (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
533        (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
534    }
535}
536
537/// Concatena el msg opcional del compact tick al toast del op
538/// original con `"; "` separator.
539fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
540    match compact_msg {
541        Some(m) => SharedString::from(format!("{base}; {m}")),
542        None => SharedString::from(base),
543    }
544}
545
546/// Walker dentro de un `Value` por path con `.` como separador.
547/// Local porque sólo lo usa la lista renderer y no tiene tests
548/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
549fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
550    let mut cur = v;
551    for seg in path.split('.') {
552        cur = cur.get(seg)?;
553    }
554    Some(cur)
555}
556
557/// Ruta destino de un export CSV: `<entity>-<unix-secs>.csv` en el
558/// directorio de trabajo actual.
559fn export_path(entity: &str) -> std::path::PathBuf {
560    let secs = std::time::SystemTime::now()
561        .duration_since(std::time::UNIX_EPOCH)
562        .map(|d| d.as_secs())
563        .unwrap_or(0);
564    let name = format!("{entity}-{secs}.csv");
565    std::env::current_dir()
566        .map(|d| d.join(&name))
567        .unwrap_or_else(|_| std::path::PathBuf::from(name))
568}
569
570/// Próximo estado de orden al hacer clic en el header `field`: la misma
571/// columna cicla ascendente → descendente → sin orden; otra columna
572/// arranca ascendente.
573fn next_sort(current: Option<(String, bool)>, field: &str) -> Option<(String, bool)> {
574    match current {
575        Some((f, true)) if f == field => Some((f, false)),
576        Some((f, false)) if f == field => None,
577        _ => Some((field.to_string(), true)),
578    }
579}
580
581impl<B: MetaBackend> Render for MetaApp<B> {
582    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
583        // Paleta del chrome viene del Theme global. Derivamos los
584        // 7 slots que las funciones render_* usan; las firmas
585        // siguen tomando los colors individuales (Hsla / Background)
586        // para preservar el shape del API interno.
587        //
588        // Si el caller no instaló un Theme, `Theme::global` panicea.
589        // Convención: el binario shell instala el theme en main.
590        let theme = Theme::global(cx).clone();
591        let bg = theme.bg_app;
592        let panel = theme.bg_panel;
593        let border = theme.border;
594        let text = theme.fg_text;
595        let text_dim = theme.fg_muted;
596        let accent = theme.accent;
597        let accent_active = theme.accent_strong;
598
599        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
600        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
601        let confirm_banner = self.render_confirm_delete_banner(cx);
602        let toast_div = self
603            .toast
604            .as_ref()
605            .map(|t| banner_themed(cx, Banner::Success, t.clone()));
606        let error_banner = self
607            .load_error
608            .as_ref()
609            .map(|e| banner_themed(cx, Banner::Error, e.clone()));
610
611        div()
612            .flex()
613            .flex_col()
614            .size_full()
615            .bg(bg)
616            // Capture phase: el Esc llega al root ANTES que cualquier
617            // TextInput descendiente. Si hay un delete pendiente, lo
618            // cancelamos. Sin pending no hacemos nada (el evento sigue
619            // su flujo normal y el TextInput recibe el Esc bubble).
620            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
621                if event.keystroke.key != "escape" {
622                    return;
623                }
624                if let Some((entity, _id)) = this.pending_delete.take() {
625                    this.toast = Some(SharedString::from(format!(
626                        "delete cancelado ({entity}) [esc]"
627                    )));
628                    cx.notify();
629                }
630            }))
631            .when_some(error_banner, |d, b| d.child(b))
632            .when_some(confirm_banner, |d, b| d.child(b))
633            .child(
634                div()
635                    .flex()
636                    .flex_row()
637                    .flex_grow()
638                    .child(sidebar)
639                    .child(main_panel),
640            )
641            .when_some(toast_div, |d, t| d.child(t))
642    }
643}
644
645impl<B: MetaBackend> MetaApp<B> {
646    /// Renderea el banner modal de confirmación cuando hay un delete
647    /// pendiente. Devuelve `None` si no hay nada que confirmar.
648    ///
649    /// UX: banner amber prominente arriba de todo el contenido. No es
650    /// un overlay flotante (GPUI no expone z-index fácilmente sin
651    /// setup) — es un row del flex_col raíz, así que el contenido
652    /// debajo se desplaza unos pixels mientras está activo. Suficiente
653    /// para forzar al usuario a leer + click.
654    fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
655        let (entity, id) = self.pending_delete.as_ref()?;
656        let entity_owned = entity.clone();
657        let id_owned = *id;
658        let entity_for_cancel = entity_owned.clone();
659        let id_short = short_uuid(&id_owned);
660        let entity_for_confirm = entity_owned.clone();
661
662        // Banner base usa los colors themed del kind Warning (amber
663        // que sigue is_dark del theme actual). Los buttons confirm
664        // (rojo) y cancel (gris) usan los colors themed del Error
665        // y un derivative del border respectivamente.
666        let theme = Theme::global(cx);
667        let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
668        let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
669        let cancel_bg: gpui::Background = theme.bg_panel_alt;
670        let cancel_text = theme.fg_text;
671        // Hover colors capturados antes de las closures para que el
672        // move |d| d.bg(...) los cierre.
673        let cancel_hover = theme.bg_button_hover();
674        let confirm_hover = theme.bg_destructive_hover();
675        let hint_color = theme.fg_muted;
676
677        Some(
678            div()
679                .flex()
680                .flex_row()
681                .items_center()
682                .gap(px(12.))
683                .px(px(12.))
684                .py(px(8.))
685                .bg(banner_bg)
686                .text_color(banner_text)
687                .text_size(px(12.))
688                .child(
689                    div()
690                        .flex_grow()
691                        .flex()
692                        .flex_col()
693                        .child(format!("¿Borrar {entity_owned} {id_short}?"))
694                        .child(
695                            div()
696                                .text_size(px(10.))
697                                .text_color(hint_color)
698                                .child("Esc para cancelar · click [Confirmar] para borrar"),
699                        ),
700                )
701                .child(
702                    div()
703                        .id(SharedString::from(format!(
704                            "confirm-del-cancel-{}",
705                            id_owned
706                        )))
707                        .px(px(10.))
708                        .py(px(4.))
709                        .bg(cancel_bg)
710                        .text_color(cancel_text)
711                        .hover(move |d| d.bg(cancel_hover))
712                        .child("Cancelar")
713                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
714                            this.pending_delete = None;
715                            this.toast = Some(SharedString::from(format!(
716                                "delete cancelado ({entity_for_cancel})"
717                            )));
718                            cx.notify();
719                        })),
720                )
721                .child(
722                    div()
723                        .id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
724                        .px(px(10.))
725                        .py(px(4.))
726                        .bg(confirm_bg)
727                        .text_color(confirm_text)
728                        .hover(move |d| d.bg(confirm_hover))
729                        .child("Confirmar")
730                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
731                            // Limpiar primero para que un fallo del
732                            // commit no deje el banner colgado y el
733                            // toast tape el banner.
734                            this.pending_delete = None;
735                            match this.commit_delete(&entity_for_confirm, id_owned) {
736                                Ok(outcome) => {
737                                    let base = format!(
738                                        "borrado {entity_for_confirm} {}",
739                                        short_uuid(&id_owned)
740                                    );
741                                    this.toast =
742                                        Some(append_compact_msg(base, outcome.post_status));
743                                }
744                                Err(e) => {
745                                    this.toast =
746                                        Some(SharedString::from(format!("error borrando: {e}")));
747                                }
748                            }
749                            cx.notify();
750                        })),
751                ),
752        )
753    }
754
755    fn render_sidebar(
756        &self,
757        cx: &mut Context<Self>,
758        panel: gpui::Background,
759        border: gpui::Hsla,
760        text: gpui::Hsla,
761        text_dim: gpui::Hsla,
762        accent_active: gpui::Hsla,
763    ) -> gpui::Div {
764        // Slots ornament del theme para los menu items de abajo.
765        let theme = Theme::global(cx);
766        let menu_active_bg = theme.bg_row_active;
767        let menu_hover_bg = theme.bg_row_hover;
768        let mut sidebar = div()
769            .w(px(240.))
770            .h_full()
771            .bg(panel)
772            .border_r_1()
773            .border_color(border)
774            .flex()
775            .flex_col();
776
777        // Sidebar header: título + theme switcher en una row.
778        sidebar = sidebar.child(
779            div()
780                .flex()
781                .flex_row()
782                .items_center()
783                .px(px(12.))
784                .py(px(10.))
785                .text_color(text)
786                .text_size(px(13.))
787                .child(div().flex_grow().child("Nakui"))
788                .child(theme_switcher(cx)),
789        );
790
791        if self.modules.is_empty() {
792            return sidebar.child(
793                div()
794                    .px(px(12.))
795                    .py(px(8.))
796                    .text_color(text_dim)
797                    .text_size(px(11.))
798                    .child("(no hay módulos cargados)"),
799            );
800        }
801
802        // Snapshot del active para evitar borrow del self adentro de la closure.
803        let active_snapshot = self.active.clone();
804
805        for (mod_idx, m) in self.modules.iter().enumerate() {
806            sidebar = sidebar.child(
807                div()
808                    .px(px(12.))
809                    .py(px(8.))
810                    .border_t_1()
811                    .border_color(border)
812                    .text_color(text)
813                    .text_size(px(12.))
814                    .child(m.label.clone()),
815            );
816
817            for item in &m.menu {
818                let is_active = active_snapshot
819                    .as_ref()
820                    .map(|(i, v)| *i == mod_idx && v == &item.view)
821                    .unwrap_or(false);
822                let label = item
823                    .icon
824                    .as_deref()
825                    .map(|ic| format!("{ic}  {}", item.label))
826                    .unwrap_or_else(|| item.label.clone());
827
828                let view_key = item.view.clone();
829                sidebar = sidebar.child(
830                    div()
831                        .id(SharedString::from(format!(
832                            "menu-{}-{}",
833                            mod_idx, item.view
834                        )))
835                        .px(px(20.))
836                        .py(px(6.))
837                        .text_size(px(12.))
838                        .text_color(if is_active { accent_active } else { text_dim })
839                        .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
840                        .hover(move |d| d.bg(menu_hover_bg))
841                        .child(label)
842                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
843                            this.select_view(mod_idx, view_key.clone(), cx);
844                        })),
845                );
846            }
847        }
848        sidebar
849    }
850
851    fn render_main(
852        &mut self,
853        cx: &mut Context<Self>,
854        panel: gpui::Background,
855        border: gpui::Hsla,
856        text: gpui::Hsla,
857        text_dim: gpui::Hsla,
858        accent: gpui::Hsla,
859    ) -> gpui::Div {
860        let main = div()
861            .flex_grow()
862            .h_full()
863            .bg(panel)
864            .flex()
865            .flex_col()
866            .p(px(16.));
867
868        let (mod_idx, view_key) = match &self.active {
869            Some(a) => (a.0, a.1.clone()),
870            None => {
871                return main.child(
872                    div()
873                        .text_color(text_dim)
874                        .child("Seleccioná un menú a la izquierda."),
875                );
876            }
877        };
878
879        let view = match self
880            .modules
881            .get(mod_idx)
882            .and_then(|m| m.views.get(&view_key))
883        {
884            Some(v) => v.clone(),
885            None => {
886                return main.child(
887                    div()
888                        .text_color(text_dim)
889                        .child(format!("Vista no encontrada: {view_key}")),
890                );
891            }
892        };
893
894        match view {
895            View::List(lv) => {
896                self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
897            }
898            View::Form(fv) => {
899                self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
900            }
901            View::Detail(dv) => {
902                self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent)
903            }
904            View::Dashboard(dv) => {
905                self.render_dashboard(cx, main, &dv, border, text, text_dim, accent)
906            }
907        }
908    }
909
910    #[allow(clippy::too_many_arguments)]
911    fn render_list(
912        &self,
913        cx: &mut Context<Self>,
914        mut main: gpui::Div,
915        lv: &ListView,
916        mod_idx: usize,
917        border: gpui::Hsla,
918        text: gpui::Hsla,
919        text_dim: gpui::Hsla,
920        accent: gpui::Hsla,
921    ) -> gpui::Div {
922        // Ornament secundarios del theme para hovers, row separators,
923        // botones inline (edit ✎, delete ✕).
924        let theme = Theme::global(cx);
925        let row_separator = theme.bg_row_active;
926        let action_bg = theme.bg_button();
927        let action_hover = theme.bg_button_hover();
928        let destructive_fg = theme.accent_destructive();
929        let destructive_hover = theme.bg_destructive_hover();
930        let mut header = div()
931            .flex()
932            .flex_row()
933            .items_center()
934            .gap(px(12.))
935            .mb(px(12.))
936            .child(
937                div()
938                    .text_color(text)
939                    .text_size(px(18.))
940                    .child(lv.title.clone()),
941            );
942        for (idx, action) in lv.actions.iter().enumerate() {
943            let label = match action {
944                Action::OpenView { label, view } => {
945                    label.clone().unwrap_or_else(|| format!("→ {view}"))
946                }
947                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
948                Action::Morphism { name, .. } => format!("⚡ {name}"),
949            };
950            let action_clone = action.clone();
951            header = header.child(
952                div()
953                    .id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
954                    .px(px(10.))
955                    .py(px(4.))
956                    .bg(action_bg)
957                    .text_color(accent)
958                    .text_size(px(11.))
959                    .rounded(px(3.))
960                    .hover(move |d| d.bg(action_hover))
961                    .child(label)
962                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
963                        this.apply_action(action_clone.clone(), cx);
964                    })),
965            );
966        }
967        // Botón de export CSV — disponible en toda lista.
968        {
969            let lv_for_csv = lv.clone();
970            header = header.child(
971                div()
972                    .id(SharedString::from(format!("list-csv-{mod_idx}")))
973                    .px(px(10.))
974                    .py(px(4.))
975                    .bg(action_bg)
976                    .text_color(accent)
977                    .text_size(px(11.))
978                    .rounded(px(3.))
979                    .hover(move |d| d.bg(action_hover))
980                    .child("⬇ CSV")
981                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
982                        this.export_csv(&lv_for_csv, cx);
983                    })),
984            );
985        }
986        main = main.child(header);
987
988        // Caja de búsqueda (sólo si la lista declara `search_in`).
989        if let Some(search) = &self.list_search {
990            main = main.child(
991                div()
992                    .mb(px(8.))
993                    .flex()
994                    .flex_row()
995                    .items_center()
996                    .gap(px(6.))
997                    .child(div().text_color(text_dim).text_size(px(12.)).child("🔍"))
998                    .child(search.clone()),
999            );
1000        }
1001
1002        let row_detail = lv.row_detail.clone();
1003
1004        // Filas: buscar + ordenar (helper compartido con el export CSV),
1005        // luego paginar.
1006        let all_rows = self.list_filtered_sorted(lv, cx);
1007        let has_query = self
1008            .list_search
1009            .as_ref()
1010            .map(|i| !i.read(cx).text().trim().is_empty())
1011            .unwrap_or(false);
1012        let total = all_rows.len();
1013        let page_count = total.div_ceil(PAGE_SIZE).max(1);
1014        let page = self.list_page.min(page_count - 1);
1015        let start = page * PAGE_SIZE;
1016        let end = (start + PAGE_SIZE).min(total);
1017        let rows: Vec<(Uuid, Value)> = all_rows[start..end].to_vec();
1018
1019        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1020        let mut col_header = div()
1021            .flex()
1022            .flex_row()
1023            .py(px(6.))
1024            .border_b_1()
1025            .border_color(border)
1026            .text_color(text_dim)
1027            .text_size(px(11.));
1028        for c in &lv.columns {
1029            let frac = c.weight / total_weight;
1030            // Indicador del orden activo + clic en el header para ordenar.
1031            let arrow = match &self.list_sort {
1032                Some((f, asc)) if f == &c.field => {
1033                    if *asc {
1034                        " ▲"
1035                    } else {
1036                        " ▼"
1037                    }
1038                }
1039                _ => "",
1040            };
1041            let field = c.field.clone();
1042            col_header = col_header.child(
1043                div()
1044                    .id(SharedString::from(format!("col-{mod_idx}-{}", c.field)))
1045                    .flex_grow()
1046                    .flex_basis(px(100. * frac))
1047                    .hover(move |d| d.bg(action_hover))
1048                    .child(format!("{}{arrow}", c.label))
1049                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1050                        this.toggle_sort(&field, cx);
1051                    })),
1052            );
1053        }
1054        col_header = col_header
1055            .child(div().w(px(80.)).text_color(text_dim).child("id"))
1056            .child(div().w(px(100.)).text_color(text_dim).child("acciones"));
1057        main = main.child(col_header);
1058
1059        let entity_name = lv.entity.clone();
1060        for (id, value) in &rows {
1061            let id_copy = *id;
1062            let entity_for_edit = entity_name.clone();
1063            let entity_for_delete = entity_name.clone();
1064            let mut row = div()
1065                .flex()
1066                .flex_row()
1067                .py(px(6.))
1068                .border_b_1()
1069                .border_color(row_separator)
1070                .text_color(text)
1071                .text_size(px(12.));
1072            for c in &lv.columns {
1073                let frac = c.weight / total_weight;
1074                let v = lookup_field(value, &c.field);
1075                row = row.child(
1076                    div()
1077                        .flex_grow()
1078                        .flex_basis(px(100. * frac))
1079                        .child(self.render_cell(c, v)),
1080                );
1081            }
1082            row = row.child(
1083                div()
1084                    .w(px(80.))
1085                    .text_color(text_dim)
1086                    .text_size(px(11.))
1087                    .child(short_uuid(id)),
1088            );
1089            // Acciones por fila: 👁 ver (si hay row_detail) + ✎ + ✕.
1090            let mut actions = div().w(px(100.)).flex().flex_row().gap(px(4.));
1091            if let Some(detail_view) = &row_detail {
1092                let dv_key = detail_view.clone();
1093                actions = actions.child(
1094                    div()
1095                        .id(SharedString::from(format!("row-view-{mod_idx}-{id_copy}")))
1096                        .px(px(6.))
1097                        .text_color(text_dim)
1098                        .text_size(px(13.))
1099                        .hover(move |d| d.bg(action_hover))
1100                        .child("👁")
1101                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1102                            this.select_detail(mod_idx, dv_key.clone(), id_copy, cx);
1103                        })),
1104                );
1105            }
1106            row = row.child(
1107                actions
1108                    .child(
1109                        div()
1110                            .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
1111                            .px(px(6.))
1112                            .text_color(accent)
1113                            .text_size(px(13.))
1114                            .hover(move |d| d.bg(action_hover))
1115                            .child("✎")
1116                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1117                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1118                            })),
1119                    )
1120                    .child(
1121                        div()
1122                            .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
1123                            .px(px(6.))
1124                            .text_color(destructive_fg)
1125                            .text_size(px(13.))
1126                            .hover(move |d| d.bg(destructive_hover))
1127                            .child("✕")
1128                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1129                                // Marca para borrar en lugar de borrar
1130                                // directo: el modal de confirmación se
1131                                // renderea arriba en `render` y maneja
1132                                // confirm/cancel.
1133                                this.pending_delete = Some((entity_for_delete.clone(), id_copy));
1134                                this.toast = None;
1135                                cx.notify();
1136                            })),
1137                    ),
1138            );
1139            main = main.child(row);
1140        }
1141
1142        if total == 0 {
1143            let msg = if has_query {
1144                "(sin resultados para la búsqueda)".to_string()
1145            } else {
1146                format!("(sin {})", lv.entity)
1147            };
1148            main = main.child(
1149                div()
1150                    .py(px(12.))
1151                    .text_color(text_dim)
1152                    .text_size(px(12.))
1153                    .child(msg),
1154            );
1155        } else {
1156            let mut footer = div()
1157                .mt(px(8.))
1158                .flex()
1159                .flex_row()
1160                .items_center()
1161                .gap(px(8.))
1162                .text_color(text_dim)
1163                .text_size(px(11.));
1164            if page_count > 1 {
1165                let last_page = page_count - 1;
1166                footer = footer.child(
1167                    div()
1168                        .id("list-prev")
1169                        .px(px(8.))
1170                        .py(px(2.))
1171                        .bg(action_bg)
1172                        .rounded(px(3.))
1173                        .text_color(if page == 0 { text_dim } else { accent })
1174                        .hover(move |d| d.bg(action_hover))
1175                        .child("◀")
1176                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1177                            this.list_page = this.list_page.saturating_sub(1);
1178                            cx.notify();
1179                        })),
1180                );
1181                footer = footer.child(div().child(format!("página {}/{}", page + 1, page_count)));
1182                footer = footer.child(
1183                    div()
1184                        .id("list-next")
1185                        .px(px(8.))
1186                        .py(px(2.))
1187                        .bg(action_bg)
1188                        .rounded(px(3.))
1189                        .text_color(if page >= last_page { text_dim } else { accent })
1190                        .hover(move |d| d.bg(action_hover))
1191                        .child("▶")
1192                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1193                            this.list_page = (this.list_page + 1).min(last_page);
1194                            cx.notify();
1195                        })),
1196                );
1197            }
1198            footer = footer.child(div().child(format!("{total} fila(s)")));
1199            main = main.child(footer);
1200        }
1201
1202        main
1203    }
1204
1205    /// Renderea el selector clickable de records existentes para un
1206    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
1207    /// click en una opción setea el TextInput del field con el UUID
1208    /// seleccionado. El item del UUID actualmente seleccionado (si
1209    /// hay) se resalta con accent color.
1210    /// Renderea una ficha de detalle: campos del record + listas de
1211    /// records relacionados (back-references) + botones Volver/Editar.
1212    #[allow(clippy::too_many_arguments)]
1213    fn render_detail(
1214        &self,
1215        cx: &mut Context<Self>,
1216        mut main: gpui::Div,
1217        dv: &DetailView,
1218        mod_idx: usize,
1219        border: gpui::Hsla,
1220        text: gpui::Hsla,
1221        text_dim: gpui::Hsla,
1222        accent: gpui::Hsla,
1223    ) -> gpui::Div {
1224        let theme = Theme::global(cx);
1225        let action_bg = theme.bg_button();
1226        let action_hover = theme.bg_button_hover();
1227        let row_separator = theme.bg_row_active;
1228
1229        let back_view = self.detail_return.clone();
1230        let mut header = div()
1231            .flex()
1232            .flex_row()
1233            .items_center()
1234            .gap(px(10.))
1235            .mb(px(12.))
1236            .child(
1237                div()
1238                    .text_color(text)
1239                    .text_size(px(18.))
1240                    .flex_grow()
1241                    .child(dv.title.clone()),
1242            )
1243            .child(
1244                div()
1245                    .id("detail-back")
1246                    .px(px(10.))
1247                    .py(px(4.))
1248                    .bg(action_bg)
1249                    .text_color(accent)
1250                    .text_size(px(11.))
1251                    .rounded(px(3.))
1252                    .hover(move |d| d.bg(action_hover))
1253                    .child("← Volver")
1254                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1255                        match back_view.clone() {
1256                            Some(v) => this.select_view(mod_idx, v, cx),
1257                            None => {
1258                                if let Some(first) = this
1259                                    .modules
1260                                    .get(mod_idx)
1261                                    .and_then(|m| m.menu.first().map(|i| i.view.clone()))
1262                                {
1263                                    this.select_view(mod_idx, first, cx);
1264                                }
1265                            }
1266                        }
1267                    })),
1268            );
1269
1270        let Some(target_id) = self.detail_target else {
1271            return main.child(header).child(
1272                div()
1273                    .text_color(text_dim)
1274                    .child("Ningún record seleccionado."),
1275            );
1276        };
1277
1278        let entity_for_edit = dv.entity.clone();
1279        header = header.child(
1280            div()
1281                .id("detail-edit")
1282                .px(px(10.))
1283                .py(px(4.))
1284                .bg(action_bg)
1285                .text_color(accent)
1286                .text_size(px(11.))
1287                .rounded(px(3.))
1288                .hover(move |d| d.bg(action_hover))
1289                .child("✎ Editar")
1290                .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1291                    this.open_edit(mod_idx, entity_for_edit.clone(), target_id, cx);
1292                })),
1293        );
1294        main = main.child(header);
1295
1296        let Some(record) = self.backend.load_record(&dv.entity, target_id) else {
1297            return main.child(div().text_color(text_dim).child(format!(
1298                "El record {} ya no existe.",
1299                short_uuid(&target_id)
1300            )));
1301        };
1302
1303        // Campos del record.
1304        let mut fields_box = div().flex().flex_col();
1305        for c in &dv.fields {
1306            let v = lookup_field(&record, &c.field);
1307            fields_box = fields_box.child(
1308                div()
1309                    .flex()
1310                    .flex_row()
1311                    .gap(px(12.))
1312                    .py(px(4.))
1313                    .border_b_1()
1314                    .border_color(row_separator)
1315                    .child(
1316                        div()
1317                            .w(px(160.))
1318                            .flex_none()
1319                            .text_color(text_dim)
1320                            .text_size(px(12.))
1321                            .child(c.label.clone()),
1322                    )
1323                    .child(
1324                        div()
1325                            .flex_grow()
1326                            .text_color(text)
1327                            .text_size(px(12.))
1328                            .child(self.render_cell(c, v)),
1329                    ),
1330            );
1331        }
1332        main = main.child(fields_box);
1333
1334        // Listas de records relacionados.
1335        for rl in &dv.related {
1336            main = main.child(self.render_related(rl, target_id, border, text, text_dim));
1337        }
1338        main
1339    }
1340
1341    /// Renderea una lista de records relacionados (back-reference):
1342    /// los records de `rl.entity` cuyo `rl.via_field` apunta al record
1343    /// `target_id`.
1344    fn render_related(
1345        &self,
1346        rl: &RelatedList,
1347        target_id: Uuid,
1348        border: gpui::Hsla,
1349        text: gpui::Hsla,
1350        text_dim: gpui::Hsla,
1351    ) -> gpui::Div {
1352        let id_str = target_id.to_string();
1353        let rows: Vec<(Uuid, Value)> = self
1354            .backend
1355            .list_records(&rl.entity)
1356            .into_iter()
1357            .filter(|(_, v)| v.get(&rl.via_field).and_then(Value::as_str) == Some(id_str.as_str()))
1358            .collect();
1359
1360        let mut section = div().flex().flex_col().mt(px(18.)).child(
1361            div()
1362                .text_color(text)
1363                .text_size(px(13.))
1364                .mb(px(4.))
1365                .child(format!("{} ({})", rl.title, rows.len())),
1366        );
1367
1368        if rows.is_empty() {
1369            return section.child(
1370                div()
1371                    .py(px(4.))
1372                    .text_color(text_dim)
1373                    .text_size(px(11.))
1374                    .child("(ninguno)"),
1375            );
1376        }
1377
1378        let total_weight: f32 = rl.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1379        let mut head = div()
1380            .flex()
1381            .flex_row()
1382            .py(px(4.))
1383            .border_b_1()
1384            .border_color(border)
1385            .text_color(text_dim)
1386            .text_size(px(10.));
1387        for c in &rl.columns {
1388            head = head.child(
1389                div()
1390                    .flex_grow()
1391                    .flex_basis(px(100. * c.weight / total_weight))
1392                    .child(c.label.clone()),
1393            );
1394        }
1395        section = section.child(head);
1396
1397        for (_, v) in &rows {
1398            let mut row = div()
1399                .flex()
1400                .flex_row()
1401                .py(px(3.))
1402                .text_color(text)
1403                .text_size(px(11.));
1404            for c in &rl.columns {
1405                row = row.child(
1406                    div()
1407                        .flex_grow()
1408                        .flex_basis(px(100. * c.weight / total_weight))
1409                        .child(self.render_cell(c, lookup_field(v, &c.field))),
1410                );
1411            }
1412            section = section.child(row);
1413        }
1414        section
1415    }
1416
1417    /// Renderea un tablero: una grilla de tarjetas de KPI, cada una con
1418    /// su agregado computado sobre los records de su entity.
1419    #[allow(clippy::too_many_arguments)]
1420    fn render_dashboard(
1421        &self,
1422        cx: &mut Context<Self>,
1423        mut main: gpui::Div,
1424        dv: &DashboardView,
1425        border: gpui::Hsla,
1426        text: gpui::Hsla,
1427        text_dim: gpui::Hsla,
1428        accent: gpui::Hsla,
1429    ) -> gpui::Div {
1430        let card_bg = Theme::global(cx).bg_panel_alt;
1431        main = main.child(
1432            div()
1433                .text_color(text)
1434                .text_size(px(18.))
1435                .mb(px(12.))
1436                .child(dv.title.clone()),
1437        );
1438
1439        let mut grid = div().flex().flex_row().flex_wrap().gap(px(12.));
1440        for card in &dv.cards {
1441            let records = self.backend.list_records(&card.entity);
1442            let result = compute_metric(&card.metric, card.filter.as_ref(), &records);
1443            let mut card_box = div()
1444                .flex()
1445                .flex_col()
1446                .gap(px(6.))
1447                .p(px(14.))
1448                .min_w(px(190.))
1449                .bg(card_bg)
1450                .border_1()
1451                .border_color(border)
1452                .rounded(px(8.))
1453                .child(
1454                    div()
1455                        .text_color(text_dim)
1456                        .text_size(px(11.))
1457                        .child(card.label.clone()),
1458                );
1459            match result {
1460                MetricResult::Scalar(s) => {
1461                    // Entero si no tiene parte decimal — `Count` y sumas
1462                    // de enteros se ven sin `.00`.
1463                    let value = if s.fract() == 0.0 {
1464                        Value::from(s as i64)
1465                    } else {
1466                        Value::from(s)
1467                    };
1468                    card_box = card_box.child(
1469                        div()
1470                            .text_color(accent)
1471                            .text_size(px(26.))
1472                            .child(format_value(Some(&value), &card.format)),
1473                    );
1474                }
1475                MetricResult::Breakdown(rows) => {
1476                    if rows.is_empty() {
1477                        card_box = card_box.child(
1478                            div()
1479                                .text_color(text_dim)
1480                                .text_size(px(11.))
1481                                .child("(sin datos)"),
1482                        );
1483                    }
1484                    let max = rows.iter().map(|(_, n)| *n).max().unwrap_or(1).max(1);
1485                    for (key, n) in rows {
1486                        let bar = "█".repeat((n * 12 / max).max(1));
1487                        card_box = card_box.child(
1488                            div()
1489                                .flex()
1490                                .flex_row()
1491                                .items_center()
1492                                .gap(px(6.))
1493                                .text_size(px(11.))
1494                                .child(div().w(px(96.)).flex_none().text_color(text).child(key))
1495                                .child(div().flex_grow().text_color(accent).child(bar))
1496                                .child(div().text_color(text_dim).child(n.to_string())),
1497                        );
1498                    }
1499                }
1500            }
1501            grid = grid.child(card_box);
1502        }
1503        main.child(grid)
1504    }
1505
1506    /// Render del valor de una celda de lista. Una columna con
1507    /// `ref_entity` resuelve su UUID al label del record referido; el
1508    /// resto aplica el `ValueFormat` declarado en la columna.
1509    fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
1510        if let Some(ref_entity) = &c.ref_entity {
1511            return match v {
1512                Some(Value::String(s)) => match Uuid::parse_str(s) {
1513                    Ok(uuid) => self
1514                        .backend
1515                        .load_record(ref_entity, uuid)
1516                        .map(|rec| human_label_for_record(&rec, &uuid))
1517                        .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1518                    Err(_) => render_value(v),
1519                },
1520                _ => render_value(v),
1521            };
1522        }
1523        format_value(v, &c.format)
1524    }
1525
1526    /// Label legible del record referenciado por un campo EntityRef.
1527    /// `(sin seleccionar)` si el campo está vacío.
1528    fn ref_label(&self, target: &str, current: &str) -> String {
1529        let current = current.trim();
1530        if current.is_empty() {
1531            return "(sin seleccionar)".to_string();
1532        }
1533        match Uuid::parse_str(current) {
1534            Ok(uuid) => self
1535                .backend
1536                .load_record(target, uuid)
1537                .map(|rec| human_label_for_record(&rec, &uuid))
1538                .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1539            Err(_) => current.to_string(),
1540        }
1541    }
1542
1543    /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
1544    /// la opción elegida se resalta con accent. Click setea el
1545    /// `TextInput` del field (de donde lee el submit), igual que el
1546    /// selector de EntityRef.
1547    fn render_select_chips(
1548        &self,
1549        cx: &mut Context<Self>,
1550        field_name: String,
1551        options: &[SelectOption],
1552        text_dim: gpui::Hsla,
1553        accent: gpui::Hsla,
1554    ) -> gpui::Div {
1555        let theme = Theme::global(cx);
1556        let row_active = theme.bg_row_active;
1557        let row_hover = theme.bg_row_hover;
1558        let border = theme.border;
1559        let current = self
1560            .form_inputs
1561            .get(&field_name)
1562            .map(|inp| inp.read(&*cx).text().to_string())
1563            .unwrap_or_default();
1564
1565        let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
1566
1567        for opt in options {
1568            let value = opt.value.clone();
1569            let is_selected = current == value;
1570            let field_for_click = field_name.clone();
1571            let value_for_click = value.clone();
1572            row = row.child(
1573                div()
1574                    .id(SharedString::from(format!("select-{field_name}-{value}")))
1575                    .px(px(8.))
1576                    .py(px(3.))
1577                    .rounded(px(10.))
1578                    .border_1()
1579                    .border_color(if is_selected { accent } else { border })
1580                    .text_size(px(11.))
1581                    .text_color(if is_selected { accent } else { text_dim })
1582                    .when(is_selected, move |d| d.bg(row_active))
1583                    .hover(move |d| d.bg(row_hover))
1584                    .child(opt.display().to_string())
1585                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1586                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1587                            input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
1588                        }
1589                        cx.notify();
1590                    })),
1591            );
1592        }
1593        row
1594    }
1595
1596    #[allow(clippy::too_many_arguments)]
1597    fn render_entity_ref_selector(
1598        &self,
1599        cx: &mut Context<Self>,
1600        field_name: String,
1601        target_entity: String,
1602        text: gpui::Hsla,
1603        text_dim: gpui::Hsla,
1604        accent: gpui::Hsla,
1605    ) -> gpui::Div {
1606        let _ = text;
1607        // Slots ornament para hover/selected del selector + border.
1608        let theme = Theme::global(cx);
1609        let row_active = theme.bg_row_active;
1610        let row_hover = theme.bg_row_hover;
1611        let rows = self.list_rows(&target_entity);
1612        let current = self
1613            .form_inputs
1614            .get(&field_name)
1615            .map(|inp| inp.read(&*cx).text().to_string())
1616            .unwrap_or_default();
1617
1618        let mut container = div()
1619            .mt(px(4.))
1620            .pl(px(8.))
1621            .border_l_2()
1622            .border_color(theme.border)
1623            .flex()
1624            .flex_col()
1625            .gap(px(2.));
1626
1627        if rows.is_empty() {
1628            return container.child(
1629                div()
1630                    .px(px(6.))
1631                    .py(px(4.))
1632                    .text_color(text_dim)
1633                    .text_size(px(10.))
1634                    .child(format!(
1635                        "(sin {target_entity}: creá uno antes para referenciar)"
1636                    )),
1637            );
1638        }
1639
1640        container = container.child(
1641            div()
1642                .text_color(text_dim)
1643                .text_size(px(10.))
1644                .child(format!("Seleccioná un {target_entity}:")),
1645        );
1646
1647        for (id, value) in &rows {
1648            let label = human_label_for_record(value, id);
1649            let id_str = id.to_string();
1650            let is_selected = current == id_str;
1651            let field_for_click = field_name.clone();
1652            let id_for_click = id_str.clone();
1653            container = container.child(
1654                div()
1655                    .id(SharedString::from(format!(
1656                        "entity-ref-{field_name}-{id_str}"
1657                    )))
1658                    .px(px(6.))
1659                    .py(px(2.))
1660                    .text_size(px(11.))
1661                    .text_color(if is_selected { accent } else { text_dim })
1662                    .when(is_selected, move |d| d.bg(row_active))
1663                    .hover(move |d| d.bg(row_hover))
1664                    .child(label)
1665                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1666                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1667                            input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1668                        }
1669                        cx.notify();
1670                    })),
1671            );
1672        }
1673        container
1674    }
1675
1676    #[allow(clippy::too_many_arguments)]
1677    fn render_form(
1678        &self,
1679        cx: &mut Context<Self>,
1680        mut main: gpui::Div,
1681        fv: &FormView,
1682        mod_idx: usize,
1683        _border: gpui::Hsla,
1684        text: gpui::Hsla,
1685        text_dim: gpui::Hsla,
1686        accent: gpui::Hsla,
1687    ) -> gpui::Div {
1688        // Slots ornament para el botón submit + bg de fallback inputs.
1689        let theme = Theme::global(cx);
1690        let submit_bg = theme.bg_button();
1691        let submit_hover = theme.bg_button_hover();
1692        let input_bg = theme.bg_input();
1693        // En modo edit, el título refleja eso para que el user no
1694        // se confunda creyendo que hace alta nueva.
1695        let title = match self.editing.as_ref() {
1696            Some((e, id)) if e == &fv.entity => {
1697                format!("Editar {} {}", fv.entity, short_uuid(id))
1698            }
1699            _ => fv.title.clone(),
1700        };
1701        main = main.child(
1702            div()
1703                .text_color(text)
1704                .text_size(px(18.))
1705                .mb(px(12.))
1706                .child(title),
1707        );
1708        for f in &fv.fields {
1709            let label = if f.required {
1710                format!("{} *", f.label)
1711            } else {
1712                f.label.clone()
1713            };
1714
1715            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1716                div()
1717                    .text_color(text_dim)
1718                    .text_size(px(11.))
1719                    .mb(px(2.))
1720                    .child(label),
1721            );
1722
1723            // Render según el kind. AutoId y Select NO montan el
1724            // TextInput editable —aunque sí existe en `form_inputs`, de
1725            // donde lee el submit—; presentan su propio control.
1726            match f.kind {
1727                FieldKind::AutoId => {
1728                    // Read-only: el UUID autogenerado, sólo informativo.
1729                    let val = self
1730                        .form_inputs
1731                        .get(&f.name)
1732                        .map(|i| i.read(&*cx).text().to_string())
1733                        .unwrap_or_default();
1734                    field_box = field_box.child(
1735                        div()
1736                            .px(px(8.))
1737                            .py(px(6.))
1738                            .bg(input_bg)
1739                            .text_color(text_dim)
1740                            .text_size(px(11.))
1741                            .child(format!("{val}  ·  automático")),
1742                    );
1743                }
1744                FieldKind::Select => {
1745                    field_box = field_box.child(self.render_select_chips(
1746                        cx,
1747                        f.name.clone(),
1748                        &f.options,
1749                        text_dim,
1750                        accent,
1751                    ));
1752                }
1753                FieldKind::EntityRef => {
1754                    // Display read-only del record elegido (label, no
1755                    // el UUID crudo) + selector clickable debajo. El
1756                    // TextInput vive en `form_inputs` pero no se monta.
1757                    if let Some(target) = &f.ref_entity {
1758                        let current = self
1759                            .form_inputs
1760                            .get(&f.name)
1761                            .map(|i| i.read(&*cx).text().to_string())
1762                            .unwrap_or_default();
1763                        let is_empty = current.trim().is_empty();
1764                        field_box = field_box.child(
1765                            div()
1766                                .px(px(8.))
1767                                .py(px(6.))
1768                                .bg(input_bg)
1769                                .text_color(if is_empty { text_dim } else { text })
1770                                .text_size(px(11.))
1771                                .child(self.ref_label(target, &current)),
1772                        );
1773                        field_box = field_box.child(self.render_entity_ref_selector(
1774                            cx,
1775                            f.name.clone(),
1776                            target.clone(),
1777                            text,
1778                            text_dim,
1779                            accent,
1780                        ));
1781                    }
1782                    // Sin ref_entity es imposible: Module::validate lo
1783                    // rechaza al cargar el módulo.
1784                }
1785                _ => {
1786                    // Mount del TextInput vivo (creado en select_view).
1787                    if let Some(input) = self.form_inputs.get(&f.name) {
1788                        field_box = field_box.child(input.clone());
1789                    } else {
1790                        // No debería pasar — select_view crea inputs por
1791                        // cada field. Fallback estático por seguridad.
1792                        field_box = field_box.child(
1793                            div()
1794                                .px(px(8.))
1795                                .py(px(6.))
1796                                .bg(input_bg)
1797                                .text_color(text_dim)
1798                                .child("(input no inicializado)"),
1799                        );
1800                    }
1801                }
1802            }
1803
1804            if let Some(help) = &f.help {
1805                field_box = field_box.child(
1806                    div()
1807                        .mt(px(2.))
1808                        .text_color(text_dim)
1809                        .text_size(px(10.))
1810                        .child(help.clone()),
1811                );
1812            }
1813            main = main.child(field_box);
1814        }
1815
1816        let editing_this = matches!(
1817            self.editing.as_ref(),
1818            Some((e, _)) if e == &fv.entity
1819        );
1820        let submit_label = match &fv.on_submit {
1821            Action::SeedEntity { entity, .. } => {
1822                if editing_this {
1823                    format!("Guardar cambios en {entity}")
1824                } else {
1825                    format!("Crear {entity}")
1826                }
1827            }
1828            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1829            Action::OpenView { label, view } => {
1830                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1831            }
1832        };
1833        let submit_action = fv.on_submit.clone();
1834        main = main.child(
1835            div().mt(px(12.)).child(
1836                div()
1837                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1838                    .px(px(12.))
1839                    .py(px(6.))
1840                    .bg(submit_bg)
1841                    .text_color(accent)
1842                    .text_size(px(12.))
1843                    .rounded(px(3.))
1844                    .hover(move |d| d.bg(submit_hover))
1845                    .child(submit_label)
1846                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1847                        this.apply_action(submit_action.clone(), cx);
1848                    })),
1849            ),
1850        );
1851
1852        main = main.child(
1853            div()
1854                .mt(px(20.))
1855                .text_color(text_dim)
1856                .text_size(px(10.))
1857                .child(
1858                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1859                     usá el botón. Backspace borra el último carácter.",
1860                ),
1861        );
1862        main
1863    }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1869    //! tests de backend impl viven en el binario que provee el
1870    //! backend (ej: nakui-ui). Los helpers movidos a
1871    //! nahual-meta-runtime se testean allí.
1872    use super::*;
1873    use serde_json::json;
1874
1875    #[test]
1876    fn lookup_field_simple_and_nested() {
1877        let v = json!({
1878            "name": "Acme",
1879            "address": { "city": "Bogotá", "country": "CO" }
1880        });
1881        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1882        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1883        assert!(lookup_field(&v, "missing").is_none());
1884        assert!(lookup_field(&v, "address.zipcode").is_none());
1885    }
1886
1887    #[test]
1888    fn next_sort_cycles_asc_desc_off() {
1889        // Sin orden → ascendente.
1890        let s = next_sort(None, "monto");
1891        assert_eq!(s, Some(("monto".to_string(), true)));
1892        // Misma columna → descendente.
1893        let s = next_sort(s, "monto");
1894        assert_eq!(s, Some(("monto".to_string(), false)));
1895        // Misma columna otra vez → sin orden.
1896        let s = next_sort(s, "monto");
1897        assert_eq!(s, None);
1898        // Otra columna siempre arranca ascendente.
1899        let s = next_sort(Some(("monto".to_string(), false)), "etapa");
1900        assert_eq!(s, Some(("etapa".to_string(), true)));
1901    }
1902
1903    #[test]
1904    fn append_compact_msg_handles_both_branches() {
1905        // Sin compact: base solo, sin separador.
1906        let s = append_compact_msg("creado X".into(), None);
1907        assert_eq!(s.as_ref(), "creado X");
1908        // Con compact: concatena con "; ".
1909        let s = append_compact_msg(
1910            "creado X".into(),
1911            Some("auto-compact: snapshot @ seq 49".into()),
1912        );
1913        assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1914    }
1915
1916    #[test]
1917    fn format_seed_toast_distinguishes_create_update_no_change() {
1918        let id = Uuid::new_v4();
1919        let outcome_create = WriteOutcome {
1920            id: Some(id),
1921            changed: 1,
1922            post_status: None,
1923        };
1924        let toast = format_seed_toast("Customer", false, &outcome_create);
1925        assert!(toast.starts_with("creado Customer"));
1926
1927        let outcome_update = WriteOutcome {
1928            id: Some(id),
1929            changed: 3,
1930            post_status: None,
1931        };
1932        let toast = format_seed_toast("Customer", true, &outcome_update);
1933        assert!(toast.starts_with("actualizado Customer"));
1934        assert!(toast.contains("(3 campo(s))"));
1935
1936        let outcome_no_change = WriteOutcome::no_change(id);
1937        let toast = format_seed_toast("Customer", true, &outcome_no_change);
1938        assert!(toast.contains("sin cambios"));
1939    }
1940}