feat(nakui-ui): CRM como ERP — UiModule con listas y formularios
examples/nakui-modules/crm/module.json: el módulo crm se ve ahora como un ERP en nakui-ui (sidebar + listas + formularios), no sólo como el timeline del event log. 7 vistas — lista+form de Clientes, Oportunidades e Interacciones — con los formularios de morfismo Abrir/Mover/Registrar que disparan los morfismos reales del kernel (nakui_module_dir engancha el módulo crm). 2 tests verifican parseo, validación y carga por el camino brahman_cards. Correr: NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,10 +30,10 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use brahman_cards::CardBody;
|
use brahman_cards::CardBody;
|
||||||
use nakui_core::executor::Executor;
|
|
||||||
use nahual_meta_schema::Module;
|
use nahual_meta_schema::Module;
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
use nahual_widget_meta_form::MetaApp;
|
use nahual_widget_meta_form::MetaApp;
|
||||||
|
use nakui_core::executor::Executor;
|
||||||
|
|
||||||
use crate::backend::NakuiBackend;
|
use crate::backend::NakuiBackend;
|
||||||
|
|
||||||
@@ -110,8 +110,7 @@ fn main() {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(50);
|
.unwrap_or(50);
|
||||||
let (backend, status) =
|
let (backend, status) = NakuiBackend::open(log_path, snapshot_threshold, executors);
|
||||||
NakuiBackend::open(log_path, snapshot_threshold, executors);
|
|
||||||
let initial_toast = status.init_toast;
|
let initial_toast = status.init_toast;
|
||||||
if let Some(msg) = status.load_error {
|
if let Some(msg) = status.load_error {
|
||||||
load_error = Some(match load_error {
|
load_error = Some(match load_error {
|
||||||
@@ -131,11 +130,7 @@ fn main() {
|
|||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|_w, cx| {
|
|_w, cx| cx.new(|cx| MetaApp::new(modules, backend, initial_toast, load_error, cx)),
|
||||||
cx.new(|cx| {
|
|
||||||
MetaApp::new(modules, backend, initial_toast, load_error, cx)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.expect("open window");
|
.expect("open window");
|
||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
@@ -153,11 +148,8 @@ fn main() {
|
|||||||
/// los direcciona por id; duplicados serían ambiguos).
|
/// los direcciona por id; duplicados serían ambiguos).
|
||||||
///
|
///
|
||||||
/// Devuelve `(modules, skipped_ids)` ordenados por id.
|
/// Devuelve `(modules, skipped_ids)` ordenados por id.
|
||||||
fn load_ui_modules(
|
fn load_ui_modules(dir: &std::path::Path) -> Result<(Vec<Module>, Vec<String>), String> {
|
||||||
dir: &std::path::Path,
|
let cards = brahman_cards::load_cards_from_dir(dir).map_err(|e| e.to_string())?;
|
||||||
) -> Result<(Vec<Module>, Vec<String>), String> {
|
|
||||||
let cards = brahman_cards::load_cards_from_dir(dir)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let mut modules: Vec<Module> = Vec::new();
|
let mut modules: Vec<Module> = Vec::new();
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for c in cards {
|
for c in cards {
|
||||||
@@ -237,10 +229,7 @@ mod tests {
|
|||||||
let mut store = MemoryStore::new();
|
let mut store = MemoryStore::new();
|
||||||
replay_into(&log, &mut store).unwrap();
|
replay_into(&log, &mut store).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(store.load("customer", id_a), Some(json!({"name": "Acme"})));
|
||||||
store.load("customer", id_a),
|
|
||||||
Some(json!({"name": "Acme"}))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.load("customer", id_b),
|
store.load("customer", id_b),
|
||||||
Some(json!({"name": "Globex"}))
|
Some(json!({"name": "Globex"}))
|
||||||
@@ -314,12 +303,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ops = execute_and_log_with_recovery(
|
let ops = execute_and_log_with_recovery(
|
||||||
&executor,
|
&executor, &mut store, &mut log, "vender", &inputs, params,
|
||||||
&mut store,
|
|
||||||
&mut log,
|
|
||||||
"vender",
|
|
||||||
&inputs,
|
|
||||||
params,
|
|
||||||
)
|
)
|
||||||
.expect("morphism vender debe ejecutar limpio");
|
.expect("morphism vender debe ejecutar limpio");
|
||||||
|
|
||||||
@@ -421,4 +405,52 @@ mod tests {
|
|||||||
assert!(err.contains("duplicado"));
|
assert!(err.contains("duplicado"));
|
||||||
assert!(err.contains("dup"));
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
ERP categórico.
|
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
|
### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones
|
||||||
|
|
||||||
Módulo CRM funcional, declarativo como inventory/sales/treasury
|
Módulo CRM funcional, declarativo como inventory/sales/treasury
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user