diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 543b866..c1c3181 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -30,10 +30,10 @@ use gpui::{ }; use brahman_cards::CardBody; -use nakui_core::executor::Executor; use nahual_meta_schema::Module; use nahual_theme::Theme; use nahual_widget_meta_form::MetaApp; +use nakui_core::executor::Executor; use crate::backend::NakuiBackend; @@ -110,8 +110,7 @@ fn main() { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(50); - let (backend, status) = - NakuiBackend::open(log_path, snapshot_threshold, executors); + let (backend, status) = NakuiBackend::open(log_path, snapshot_threshold, executors); let initial_toast = status.init_toast; if let Some(msg) = status.load_error { load_error = Some(match load_error { @@ -131,11 +130,7 @@ fn main() { }), ..Default::default() }, - |_w, cx| { - cx.new(|cx| { - MetaApp::new(modules, backend, initial_toast, load_error, cx) - }) - }, + |_w, cx| cx.new(|cx| MetaApp::new(modules, backend, initial_toast, load_error, cx)), ) .expect("open window"); cx.activate(true); @@ -153,11 +148,8 @@ fn main() { /// los direcciona por id; duplicados serían ambiguos). /// /// Devuelve `(modules, skipped_ids)` ordenados por id. -fn load_ui_modules( - dir: &std::path::Path, -) -> Result<(Vec, Vec), String> { - let cards = brahman_cards::load_cards_from_dir(dir) - .map_err(|e| e.to_string())?; +fn load_ui_modules(dir: &std::path::Path) -> Result<(Vec, Vec), String> { + let cards = brahman_cards::load_cards_from_dir(dir).map_err(|e| e.to_string())?; let mut modules: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for c in cards { @@ -237,10 +229,7 @@ mod tests { let mut store = MemoryStore::new(); replay_into(&log, &mut store).unwrap(); - assert_eq!( - store.load("customer", id_a), - Some(json!({"name": "Acme"})) - ); + assert_eq!(store.load("customer", id_a), Some(json!({"name": "Acme"}))); assert_eq!( store.load("customer", id_b), Some(json!({"name": "Globex"})) @@ -314,12 +303,7 @@ mod tests { }); let ops = execute_and_log_with_recovery( - &executor, - &mut store, - &mut log, - "vender", - &inputs, - params, + &executor, &mut store, &mut log, "vender", &inputs, params, ) .expect("morphism vender debe ejecutar limpio"); @@ -421,4 +405,52 @@ mod tests { assert!(err.contains("duplicado")); assert!(err.contains("dup")); } + + /// El UiModule del CRM (`examples/nakui-modules/crm`) debe parsear + /// como `Module` y pasar `validate()` — sino `nakui-ui` lo rechaza + /// al arrancar. Cubre que las 7 vistas del ERP existan y que + /// enganche el módulo-kernel. + #[test] + fn crm_example_module_parses_and_validates() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../examples/nakui-modules/crm/module.json"); + let m = Module::from_path(&path).expect("crm/module.json debe parsear"); + m.validate().expect("el módulo crm debe validar"); + + assert_eq!(m.id, "crm"); + assert!( + m.nakui_module_dir.is_some(), + "el CRM debe enganchar el módulo-kernel" + ); + for view in [ + "cliente_list", + "cliente_form", + "oportunidad_list", + "abrir_form", + "mover_form", + "interaccion_list", + "interaccion_form", + ] { + assert!(m.views.contains_key(view), "falta la vista «{view}»"); + } + } + + /// Carga el módulo crm por el mismo camino que usa `nakui-ui` + /// (`load_ui_modules` → `brahman_cards::load_cards_from_dir`). Se + /// aísla en un tempdir para no acoplar el test a los otros módulos + /// de ejemplo. + #[test] + fn crm_module_loads_via_card_pipeline() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../examples/nakui-modules/crm/module.json"); + let root = tempfile::tempdir().unwrap(); + let crm_dir = root.path().join("crm"); + std::fs::create_dir(&crm_dir).unwrap(); + std::fs::copy(&src, crm_dir.join("module.json")).unwrap(); + + let (modules, skipped) = load_ui_modules(root.path()).expect("el módulo crm debe cargar"); + assert!(skipped.is_empty(), "ninguna card debe saltarse"); + assert_eq!(modules.len(), 1); + assert_eq!(modules[0].id, "crm"); + } } diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md index 695fa1b..3d5838f 100644 --- a/docs/changelog/nakui.md +++ b/docs/changelog/nakui.md @@ -2,6 +2,23 @@ ERP categórico. +### feat(nakui-ui): CRM como ERP — listas y formularios + +UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo +`crm` se vea como un ERP de verdad en `nakui-ui` (sidebar + listas + +formularios), no como el timeline del event log de `nakui-explorer`. + +- 3 entities, 7 vistas: lista + formulario de Clientes, Oportunidades e + Interacciones, más los formularios de morfismo «Abrir», «Mover» y + «Registrar». +- `nakui_module_dir` engancha el módulo-kernel `crm`: el form de cliente + siembra (`seed_entity`); «Abrir/Mover/Registrar» disparan los morfismos + reales con la validación del kernel (etapas, transiciones, canal). +- 2 tests en `nakui-ui` verifican que el UiModule parsea, valida y carga + por el camino real (`brahman_cards::load_cards_from_dir`). + +Correr: `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`. + ### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones Módulo CRM funcional, declarativo como inventory/sales/treasury diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json new file mode 100644 index 0000000..7bc61f5 --- /dev/null +++ b/examples/nakui-modules/crm/module.json @@ -0,0 +1,171 @@ +{ + "id": "crm", + "label": "CRM", + "description": "CRM conectado al módulo nakui-core 'crm': clientes, oportunidades que recorren un pipeline de ventas e interacciones. Los formularios «Abrir», «Mover» y «Registrar» disparan morfismos reales con validación del kernel.", + "nakui_module_dir": "../../../crates/modules/nakui/modules/crm", + "entities": [ + { + "name": "Cliente", + "label": "Cliente", + "fields": [ + { "name": "id", "label": "ID", "kind": "text" }, + { "name": "nombre", "label": "Nombre", "kind": "text", "required": true }, + { "name": "email", "label": "Email", "kind": "text", "required": true }, + { "name": "empresa", "label": "Empresa", "kind": "text" } + ] + }, + { + "name": "Oportunidad", + "label": "Oportunidad", + "fields": [ + { "name": "id", "label": "ID", "kind": "text" }, + { "name": "cliente_id", "label": "Cliente ref", "kind": "text" }, + { "name": "titulo", "label": "Título", "kind": "text" }, + { "name": "monto", "label": "Monto", "kind": "number" }, + { "name": "currency", "label": "Moneda", "kind": "text" }, + { "name": "etapa", "label": "Etapa", "kind": "text" }, + { "name": "timestamp", "label": "Fecha", "kind": "text" } + ] + }, + { + "name": "Interaccion", + "label": "Interacción", + "fields": [ + { "name": "id", "label": "ID", "kind": "text" }, + { "name": "cliente_id", "label": "Cliente ref", "kind": "text" }, + { "name": "canal", "label": "Canal", "kind": "text" }, + { "name": "nota", "label": "Nota", "kind": "multiline" }, + { "name": "timestamp", "label": "Fecha", "kind": "text" } + ] + } + ], + "menu": [ + { "label": "Clientes", "view": "cliente_list", "icon": "👤" }, + { "label": "+ Cliente", "view": "cliente_form", "icon": "✚" }, + { "label": "Oportunidades", "view": "oportunidad_list", "icon": "🎯" }, + { "label": "Abrir oportunidad", "view": "abrir_form", "icon": "✚" }, + { "label": "Mover oportunidad", "view": "mover_form", "icon": "⏩" }, + { "label": "Interacciones", "view": "interaccion_list", "icon": "💬" }, + { "label": "Registrar interacción", "view": "interaccion_form", "icon": "✚" } + ], + "views": { + "cliente_list": { + "kind": "list", + "title": "Clientes", + "entity": "Cliente", + "columns": [ + { "field": "nombre", "label": "Nombre", "weight": 2.0 }, + { "field": "email", "label": "Email", "weight": 2.5 }, + { "field": "empresa", "label": "Empresa", "weight": 2.0 } + ], + "actions": [ + { "kind": "open_view", "view": "cliente_form", "label": "✚ Nuevo cliente" } + ], + "search_in": ["nombre", "email", "empresa"] + }, + "cliente_form": { + "kind": "form", + "title": "Nuevo cliente", + "entity": "Cliente", + "fields": [ + { "name": "id", "label": "ID interno", "kind": "text", "help": "Opcional; el runtime genera el UUID de la entity. Dejar vacío está bien." }, + { "name": "nombre", "label": "Nombre", "kind": "text", "required": true }, + { "name": "email", "label": "Email", "kind": "text", "required": true }, + { "name": "empresa", "label": "Empresa", "kind": "text" } + ], + "on_submit": { + "kind": "seed_entity", + "entity": "Cliente", + "next_view": "cliente_list" + } + }, + "oportunidad_list": { + "kind": "list", + "title": "Oportunidades", + "entity": "Oportunidad", + "columns": [ + { "field": "titulo", "label": "Título", "weight": 2.5 }, + { "field": "etapa", "label": "Etapa", "weight": 1.2 }, + { "field": "monto", "label": "Monto", "weight": 1.0 }, + { "field": "currency", "label": "Moneda", "weight": 0.6 }, + { "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 } + ], + "actions": [ + { "kind": "open_view", "view": "abrir_form", "label": "✚ Abrir oportunidad" }, + { "kind": "open_view", "view": "mover_form", "label": "⏩ Mover etapa" } + ], + "search_in": ["titulo", "etapa"] + }, + "abrir_form": { + "kind": "form", + "title": "Abrir oportunidad (morphism)", + "entity": "Oportunidad", + "fields": [ + { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista para seleccionarlo." }, + { "name": "oportunidad_id", "label": "Oportunidad UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." }, + { "name": "titulo", "label": "Título", "kind": "text", "required": true }, + { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" }, + { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" }, + { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } + ], + "on_submit": { + "kind": "morphism", + "name": "abrir_oportunidad", + "inputs": { "cliente": "cliente_ref" }, + "params": ["oportunidad_id", "titulo", "monto", "currency", "timestamp"], + "next_view": "oportunidad_list" + } + }, + "mover_form": { + "kind": "form", + "title": "Mover oportunidad (morphism)", + "entity": "Oportunidad", + "fields": [ + { "name": "oportunidad_ref", "label": "Oportunidad", "kind": "entity_ref", "ref_entity": "Oportunidad", "required": true, "help": "Click en una oportunidad de la lista." }, + { "name": "etapa", "label": "Etapa destino", "kind": "text", "required": true, "help": "prospecto | calificado | propuesta | negociacion | ganada | perdida" }, + { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } + ], + "on_submit": { + "kind": "morphism", + "name": "mover_oportunidad", + "inputs": { "oportunidad": "oportunidad_ref" }, + "params": ["etapa", "timestamp"], + "next_view": "oportunidad_list" + } + }, + "interaccion_list": { + "kind": "list", + "title": "Interacciones", + "entity": "Interaccion", + "columns": [ + { "field": "canal", "label": "Canal", "weight": 1.0 }, + { "field": "nota", "label": "Nota", "weight": 3.0 }, + { "field": "cliente_id", "label": "Cliente ref", "weight": 1.5 }, + { "field": "timestamp", "label": "Fecha", "weight": 1.2 } + ], + "actions": [ + { "kind": "open_view", "view": "interaccion_form", "label": "✚ Registrar interacción" } + ], + "search_in": ["canal", "nota"] + }, + "interaccion_form": { + "kind": "form", + "title": "Registrar interacción (morphism)", + "entity": "Interaccion", + "fields": [ + { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista." }, + { "name": "interaccion_id", "label": "Interacción UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." }, + { "name": "canal", "label": "Canal", "kind": "text", "required": true, "help": "llamada | email | reunion" }, + { "name": "nota", "label": "Nota", "kind": "multiline" }, + { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } + ], + "on_submit": { + "kind": "morphism", + "name": "registrar_interaccion", + "inputs": { "cliente": "cliente_ref" }, + "params": ["interaccion_id", "canal", "nota", "timestamp"], + "next_view": "interaccion_list" + } + } + } +}