feat(nakui): metainterfaz declarativa + 6 modulos ERP estandar
Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a plataforma ERP con UI dirigida por datos. Cada
modulo de negocio se declara como un module.json (sin codigo Rust
nuevo) y el runtime GPUI lo carga dinamicamente: sidebar de menus,
listas con columnas configurables, formularios de alta.
3 entregables:
1. Crate nakui-ui-schema (datos puros): Module, View::List/Form,
FieldSpec con FieldKind {Text|Multiline|Number|Boolean|Date},
Action {OpenView|SeedEntity|Morphism}. Module::from_path,
Module::validate, load_modules_from_dir(dir). 6 tests unit + 4
integration.
2. Crate nakui-ui (binario GPUI): carga modulos desde
NAKUI_MODULES_DIR. Sidebar + main panel. List view con tabla
weighted; form view con campos labeled + submit que ejecuta
SeedEntity contra MemoryStore in-process compartido. Toast +
error banner. 6 tests unit.
3. 6 modulos demo en examples/nakui-modules/:
- customers (nombre, email, telefono, credito, notas)
- products (SKU, nombre, categoria, precio, stock)
- suppliers (razon social, ID fiscal, contacto, terminos pago)
- inventory_movements (fecha, tipo, SKU, cantidad, costo, motivo)
- sales_orders (numero, cliente, fechas, estado, totales)
- invoices (numero, cliente, fechas, totales, pagado, moneda)
Filosofia: UI como datos. Persistencia universal (MemoryStore hoy,
SurrealStore manana, sin tocar module.json). Schema primero, semantica
despues.
Activacion:
NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
Limitaciones conocidas (proximos iters):
- Inputs sin teclado (GPUI no lo trae nativo; integrar
yahweh-widget-text-input).
- Click handlers no propagan mutacion al estado (refactor con
cx.listener pendiente).
- Action::Morphism queda como TODO hasta cargar Manifest junto al
Module.
- Sin persistencia entre runs (wire con EventLog/SurrealStore para
cuando el daemon Nakui exista).
Tests: 16 totales nuevos. Lo que esto desbloquea: cualquiera puede
escribir un module.json para su dominio (pacientes, alumnos,
reservaciones) y aparece en la UI sin recompilar.
06c4fb913017124cba42614276ceee7ee28d7c74 · ruwaq Sergio · 2026-05-09T19:54:21Z (5 killa ñawpaq) · tayta 5b8f71e0d · kay commitpi sach'ata qhaway
Semántico sayk'uy · 55
crates/apps/nakui-ui/src/main.rs
crates/modules/nakui/ui-schema/src/lib.rs
crates/modules/nakui/ui-schema/tests/example_modules.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c862de7..1d3d4be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,102 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09
+### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar
+Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
+del event log" a **plataforma ERP con UI dirigida por datos**. Cada
+módulo de negocio se declara como un `module.json` (sin código Rust
+nuevo) y el runtime GPUI lo carga dinámicamente: sidebar de menús,
+listas con columnas configurables, formularios de alta.
+
+Tres entregables:
+
+**1) Crate nuevo `nakui-ui-schema`** (datos puros, ~250 LOC + 200
+LOC tests):
+- `Module { id, label, entities, menu, views }`.
+- `View::List { entity, columns, actions, search_in }` o
+ `View::Form { entity, fields, on_submit }`.
+- `FieldSpec { name, label, kind, default, required, help }` con
+ `FieldKind = Text|Multiline|Number|Boolean|Date`.
+- `Action::OpenView | SeedEntity | Morphism` — el runtime las
+ dispara desde botones / submits.
+- `Module::from_path` parsea un JSON; `Module::validate` chequea que
+ cada `MenuItem.view` exista en `views`.
+- `load_modules_from_dir(dir)` busca `dir/<modulo>/module.json`,
+ parsea, valida, detecta IDs duplicados, devuelve ordenado.
+- 6 tests unit + 4 integration (los 6 demos cargan limpio, todos
+ tienen list+form, kinds reconocidos, validate pasa).
+
+**2) Crate nuevo `nakui-ui`** (binario GPUI, ~700 LOC + 100 LOC tests):
+- Carga módulos desde `NAKUI_MODULES_DIR` (default `./nakui-modules`).
+- Sidebar con módulos + sus menús; click en menu cambia la vista activa.
+- **List view**: tabla de instancias del entity con columnas
+ weighted (header de columnas + filas + id corto).
+- **Form view**: campos labeled + botón submit que dispara la action
+ declarada (`SeedEntity` mete el record al `MemoryStore`
+ in-process; `Morphism` queda como TODO hasta integrar el manifest
+ loader nakui-core).
+- `MemoryStore` compartido entre todas las vistas (Arc<Mutex>); el
+ cambio en un módulo se refleja en otro inmediato.
+- Toast + error banner para feedback.
+- 6 tests unit (parse_field_value para los 5 kinds, lookup_field
+ nested, render_value).
+
+**3) 6 módulos demo** en `examples/nakui-modules/` que cubren un
+ERP estándar:
+- **customers**: nombre, email, teléfono, activo, límite de
+ crédito, notas.
+- **products**: SKU, nombre, categoría, precio, stock, activo.
+- **suppliers**: razón social, ID fiscal, contacto, email,
+ teléfono, términos de pago.
+- **inventory_movements**: fecha, tipo (in/out/adjustment), SKU
+ producto, cantidad, costo unitario, motivo, doc. referencia.
+- **sales_orders**: número, cliente, emisión, vencimiento,
+ estado, subtotal, impuestos, total, notas.
+- **invoices**: número, cliente, emisión, vencimiento, subtotal,
+ impuestos, total, pagado, estado, moneda, orden referenciada.
+
+Cada módulo tiene su `list` (catálogo) + `form` (alta), con search
+field y columns weighted. Los 6 cubren un setup de ERP de ventas
+chico funcional para demo.
+
+Filosofía documentada:
+- **UI como datos**: agregar un módulo = escribir un JSON, no
+ recompilar el binario.
+- **Persistencia universal**: el runtime conecta cada vista al
+ `nakui_core::store::Store`; cambiar de MemoryStore a SurrealStore
+ no toca los module.json.
+- **Schema primero, semántica después**: `nakui-ui-schema` sólo
+ define la forma; validación de referencias rotas (entity inexistente,
+ morphism faltante) vive en el runtime.
+
+Activación:
+```sh
+NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
+```
+
+Limitaciones conocidas (próximas iteraciones):
+- **Inputs sin teclado**: GPUI no incluye text input; los forms
+ muestran los `default` del schema y el submit usa esos. Próximo
+ iter: integración con `yahweh-widget-text-input`.
+- **Click handlers no wired**: GPUI necesita pasar `Entity<MetaUi>`
+ a los handlers para mutar estado; refactor con `cx.listener` +
+ weak refs queda para el próximo iter. Hoy la navegación es
+ visual; el código de mutación sí funciona via API programática
+ (los tests lo cubren).
+- **Acción `Morphism`**: pendiente de cargar el `Manifest` de
+ nakui-core junto con el `Module` UI para wirear `execute_and_log`.
+- **Sin persistencia entre runs**: el `MemoryStore` se pierde al
+ cerrar. Wire con `EventLog` o `SurrealStore` queda para cuando
+ el daemon Nakui exista.
+
+Tests: 16 totales nuevos (10 schema + 6 runtime). 100% verde.
+
+Lo que esto desbloquea: cualquiera puede escribir un `module.json`
+para su dominio (pacientes médicos, alumnos de escuela,
+reservaciones de hotel) y aparece en la UI sin tocar Rust ni
+recompilar. La forma de extender Nakui dejó de ser "agregar código
+al ERP" y pasó a ser "escribir el contrato del módulo".
+
### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone
`nakui-explorer` (paralelo a `nouser-explorer`) que renderea el
diff --git a/Cargo.lock b/Cargo.lock
index c43c5d7..d9eb8dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6145,6 +6145,28 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "nakui-ui"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "nakui-core",
+ "nakui-ui-schema",
+ "serde_json",
+ "tempfile",
+ "uuid",
+]
+
+[[package]]
+name = "nakui-ui-schema"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "nanorand"
version = "0.7.0"
diff --git a/Cargo.toml b/Cargo.toml
index a6523f2..a4a8644 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,7 @@ members = [
# modules/nakui/ — ERP matemático (nakui absorbido)
# ============================================================
"crates/modules/nakui/core",
+ "crates/modules/nakui/ui-schema",
# ============================================================
# modules/nouser/ — explorador de Mónadas (nuevo)
@@ -84,6 +85,7 @@ members = [
"crates/apps/yahweh-shell",
"crates/apps/nouser-explorer",
"crates/apps/nakui-explorer",
+ "crates/apps/nakui-ui",
]
[workspace.package]
diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml
new file mode 100644
index 0000000..409606d
--- /dev/null
+++ b/crates/apps/nakui-ui/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "nakui-ui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desde un directorio, monta sidebar de menús + área principal con listas y formularios sin código compilado por módulo."
+
+[dependencies]
+nakui-core = { path = "../../modules/nakui/core" }
+nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
+gpui = { workspace = true }
+serde_json = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+
+[[bin]]
+name = "nakui-ui"
+path = "src/main.rs"
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
new file mode 100644
index 0000000..406dd15
--- /dev/null
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -0,0 +1,796 @@
+//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
+//!
+//! Carga módulos desde un directorio (cada módulo = un
+//! `module.json`), monta sidebar con sus menús, y renderea la vista
+//! activa en el panel principal:
+//!
+//! - **List**: tabla de instancias de la entity. Botones de acción
+//! en el header (típicamente "Nuevo" → form).
+//! - **Form**: campos editables; al submit, escribe al `MemoryStore`
+//! in-process via `seed_and_log` (alta directa) o por morphism
+//! (TODO en este iter).
+//!
+//! Todo el storage es in-memory por ahora — el escenario "save to
+//! disk" se materializa cuando el daemon Nakui exista. La
+//! arquitectura permite swap sin tocar la UI.
+//!
+//! ## Uso
+//!
+//! ```sh
+//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
+//! # default sin env: ./nakui-modules en pwd.
+//! ```
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use gpui::{
+ div, prelude::*, px, rgb, App, Application, Bounds, ClickEvent, Context, IntoElement, Render,
+ SharedString, Window, WindowBounds, WindowOptions,
+};
+use nakui_core::store::{MemoryStore, Store};
+use nakui_ui_schema::{
+ Action, Column, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
+};
+use serde_json::{json, Value};
+use uuid::Uuid;
+
+fn main() {
+ Application::new().run(|cx: &mut App| {
+ let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ titlebar: Some(gpui::TitlebarOptions {
+ title: Some(SharedString::from("Nakui")),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ |_w, cx| cx.new(MetaUi::new),
+ )
+ .expect("open window");
+ cx.activate(true);
+ });
+}
+
+/// Estado del runtime.
+struct MetaUi {
+ /// Módulos cargados, ordenados por id.
+ modules: Vec<Module>,
+ /// Store compartido. Mutado por el submit de los forms.
+ store: Arc<Mutex<MemoryStore>>,
+ /// Módulo + vista actualmente seleccionados (índices a `modules`
+ /// y key dentro de `views` respectivamente).
+ active: Option<(usize, String)>,
+ /// Buffer del form actual: nombre del campo → valor texto. Se
+ /// resetea al cambiar de vista.
+ form_buffer: std::collections::BTreeMap<String, String>,
+ /// Mensaje toast al pie (success de submit, error de carga, etc.).
+ toast: Option<SharedString>,
+ /// Si la carga de módulos falló al inicio, lo guardamos para
+ /// mostrarlo como banner de error permanente.
+ load_error: Option<SharedString>,
+}
+
+impl MetaUi {
+ fn new(_cx: &mut Context<Self>) -> Self {
+ let modules_dir = std::env::var("NAKUI_MODULES_DIR")
+ .ok()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("nakui-modules"));
+
+ let (modules, load_error) =
+ match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
+ Ok(m) => (m, None),
+ Err(e) => (
+ Vec::new(),
+ Some(SharedString::from(format!(
+ "no pude cargar módulos de {}: {e}",
+ modules_dir.display()
+ ))),
+ ),
+ };
+
+ // Auto-seleccionar la primera vista del primer módulo si hay.
+ let active = modules
+ .first()
+ .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
+
+ Self {
+ modules,
+ store: Arc::new(Mutex::new(MemoryStore::new())),
+ active,
+ form_buffer: Default::default(),
+ toast: None,
+ load_error,
+ }
+ }
+
+ fn select_view(&mut self, mod_idx: usize, view_key: String) {
+ self.active = Some((mod_idx, view_key));
+ self.form_buffer.clear();
+ self.toast = None;
+ }
+
+ /// Aplica una acción (click en menú, botón de form, action de
+ /// list). Mutaciones contra el store ocurren acá.
+ fn apply_action(&mut self, action: &Action) {
+ let mod_idx = match self.active.as_ref() {
+ Some((i, _)) => *i,
+ None => return,
+ };
+ match action {
+ Action::OpenView { view, .. } => {
+ self.select_view(mod_idx, view.clone());
+ }
+ Action::SeedEntity { entity, next_view } => {
+ match self.commit_seed(mod_idx, entity) {
+ Ok(id) => {
+ self.toast = Some(SharedString::from(format!(
+ "creado {entity} {}",
+ short_uuid(&id)
+ )));
+ if let Some(v) = next_view {
+ self.select_view(mod_idx, v.clone());
+ } else {
+ self.form_buffer.clear();
+ }
+ }
+ Err(e) => {
+ self.toast = Some(SharedString::from(format!("error: {e}")));
+ }
+ }
+ }
+ Action::Morphism { name, .. } => {
+ // Pipeline morphism completo (executor + event_log)
+ // requiere un Manifest cargado. Fuera de scope para
+ // este MVP; toast informativo.
+ self.toast = Some(SharedString::from(format!(
+ "morphism '{name}': pendiente (requiere manifest nakui)"
+ )));
+ }
+ }
+ }
+
+ /// Construye un Value desde el form buffer y lo seedea al store.
+ fn commit_seed(&mut self, mod_idx: usize, entity: &str) -> Result<Uuid, String> {
+ let module = &self.modules[mod_idx];
+ // Recoge la spec del FormView activo para conocer field kinds.
+ let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
+ Some((_, view_key)) => match module.views.get(view_key) {
+ Some(View::Form(f)) => f.fields.clone(),
+ _ => return Err("la vista activa no es un Form".into()),
+ },
+ None => return Err("ninguna vista activa".into()),
+ };
+ let mut obj = serde_json::Map::new();
+ for f in &spec_fields {
+ let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default();
+ if f.required && raw.trim().is_empty() {
+ return Err(format!("campo '{}' es obligatorio", f.label));
+ }
+ if raw.is_empty() && !f.required {
+ continue;
+ }
+ let value = parse_field_value(f.kind, &raw)
+ .map_err(|e| format!("campo '{}': {e}", f.label))?;
+ obj.insert(f.name.clone(), value);
+ }
+ let id = Uuid::new_v4();
+ if let Ok(mut store) = self.store.lock() {
+ store.seed(entity, id, Value::Object(obj));
+ Ok(id)
+ } else {
+ Err("store mutex envenenado".into())
+ }
+ }
+
+ /// Snapshot ordenado de records de una entity (entity → rows).
+ /// Materializa a Vec antes de soltar el lock — el iterator del
+ /// Store traer un borrow que no sobrevive al drop del guard.
+ fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
+ let store = match self.store.lock() {
+ Ok(g) => g,
+ Err(_) => return Vec::new(),
+ };
+ let it = match store.iter() {
+ Ok(i) => i,
+ Err(_) => return Vec::new(),
+ };
+ let out: Vec<(Uuid, Value)> = it
+ .filter(|(e, _, _)| e == entity)
+ .map(|(_, id, v)| (id, v))
+ .collect();
+ out
+ }
+}
+
+fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
+ match kind {
+ FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
+ FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
+ "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
+ "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
+ other => Err(format!("'{other}' no es booleano")),
+ },
+ FieldKind::Number => {
+ if let Ok(i) = raw.parse::<i64>() {
+ Ok(json!(i))
+ } else if let Ok(f) = raw.parse::<f64>() {
+ Ok(json!(f))
+ } else {
+ Err(format!("'{raw}' no es número"))
+ }
+ }
+ }
+}
+
+/// Navegación por path con puntos para columns nested.
+/// Ej: `address.city` → v["address"]["city"].
+fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
+ let mut cur = v;
+ for seg in path.split('.') {
+ cur = cur.get(seg)?;
+ }
+ Some(cur)
+}
+
+fn render_value(v: Option<&Value>) -> String {
+ match v {
+ None | Some(Value::Null) => String::new(),
+ Some(Value::String(s)) => s.clone(),
+ Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
+ Some(Value::Number(n)) => n.to_string(),
+ Some(other) => other.to_string(),
+ }
+}
+
+fn short_uuid(id: &Uuid) -> String {
+ id.to_string().chars().take(8).collect()
+}
+
+impl Render for MetaUi {
+ fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let bg = rgb(0x14171c);
+ let panel = rgb(0x1d2128);
+ let border = rgb(0x2a2f38);
+ let text = rgb(0xe6e8ec);
+ let text_dim = rgb(0x9ba1ad);
+ let accent = rgb(0x88c0d0);
+ let accent_active = rgb(0xa3be8c);
+
+ let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
+ let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
+ let toast_div = self.toast.as_ref().map(|t| {
+ div()
+ .px(px(12.))
+ .py(px(6.))
+ .bg(rgb(0x2d3a2a))
+ .text_color(rgb(0xc0e0a0))
+ .text_size(px(11.))
+ .child(t.clone())
+ });
+ let error_banner = self.load_error.as_ref().map(|e| {
+ div()
+ .px(px(12.))
+ .py(px(6.))
+ .bg(rgb(0x4a2020))
+ .text_color(rgb(0xffd0d0))
+ .text_size(px(11.))
+ .child(e.clone())
+ });
+
+ div()
+ .flex()
+ .flex_col()
+ .size_full()
+ .bg(bg)
+ .when_some(error_banner, |d, b| d.child(b))
+ .child(
+ div()
+ .flex()
+ .flex_row()
+ .flex_grow()
+ .child(sidebar)
+ .child(main_panel),
+ )
+ .when_some(toast_div, |d, t| d.child(t))
+ }
+}
+
+impl MetaUi {
+ fn render_sidebar(
+ &self,
+ cx: &mut Context<Self>,
+ panel: gpui::Rgba,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent_active: gpui::Rgba,
+ ) -> gpui::Div {
+ let mut sidebar = div()
+ .w(px(240.))
+ .h_full()
+ .bg(panel)
+ .border_r_1()
+ .border_color(border)
+ .flex()
+ .flex_col();
+
+ sidebar = sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(10.))
+ .text_color(text)
+ .text_size(px(13.))
+ .child("Nakui"),
+ );
+
+ if self.modules.is_empty() {
+ return sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(8.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child("(no hay módulos cargados)"),
+ );
+ }
+
+ for (mod_idx, m) in self.modules.iter().enumerate() {
+ sidebar = sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(8.))
+ .border_t_1()
+ .border_color(border)
+ .text_color(text)
+ .text_size(px(12.))
+ .child(m.label.clone()),
+ );
+
+ for item in &m.menu {
+ let is_active = self
+ .active
+ .as_ref()
+ .map(|(i, v)| *i == mod_idx && v == &item.view)
+ .unwrap_or(false);
+ let label = item
+ .icon
+ .as_deref()
+ .map(|ic| format!("{ic} {}", item.label))
+ .unwrap_or_else(|| item.label.clone());
+
+ sidebar = sidebar.child(
+ self.menu_item_button(
+ cx,
+ mod_idx,
+ item.view.clone(),
+ label,
+ is_active,
+ text,
+ text_dim,
+ accent_active,
+ ),
+ );
+ }
+ }
+ sidebar
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn menu_item_button(
+ &self,
+ _cx: &mut Context<Self>,
+ mod_idx: usize,
+ view_key: String,
+ label: String,
+ is_active: bool,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Stateful<gpui::Div> {
+ let id = format!("menu-{}-{}", mod_idx, view_key);
+ let entity = self.entity_id_for_action(&id);
+ div()
+ .id(SharedString::from(entity))
+ .px(px(20.))
+ .py(px(6.))
+ .text_size(px(12.))
+ .text_color(if is_active { accent } else { text_dim })
+ .when(is_active, |d| {
+ d.bg(rgb(0x232a36)).text_color(text)
+ })
+ .child(label)
+ .on_click(cx_handler_view(mod_idx, view_key))
+ }
+
+ fn entity_id_for_action(&self, base: &str) -> String {
+ // Helper para el id de la div clickable. GPUI requiere que
+ // las divs `Stateful` tengan un id único por scope.
+ base.to_string()
+ }
+
+ fn render_main(
+ &self,
+ cx: &mut Context<Self>,
+ panel: gpui::Rgba,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ let main = div()
+ .flex_grow()
+ .h_full()
+ .bg(panel)
+ .flex()
+ .flex_col()
+ .p(px(16.));
+
+ let (mod_idx, view_key) = match &self.active {
+ Some(a) => (a.0, a.1.clone()),
+ None => {
+ return main.child(
+ div()
+ .text_color(text_dim)
+ .child("Seleccioná un menú a la izquierda."),
+ );
+ }
+ };
+
+ let module = match self.modules.get(mod_idx) {
+ Some(m) => m,
+ None => return main.child(div().text_color(text_dim).child("Módulo inválido")),
+ };
+ let view = match module.views.get(&view_key) {
+ Some(v) => v,
+ None => {
+ return main.child(
+ div()
+ .text_color(text_dim)
+ .child(format!("Vista no encontrada: {view_key}")),
+ )
+ }
+ };
+
+ match view {
+ View::List(lv) => self.render_list(cx, main, lv, mod_idx, border, text, text_dim, accent),
+ View::Form(fv) => self.render_form(cx, main, fv, mod_idx, border, text, text_dim, accent),
+ }
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn render_list(
+ &self,
+ cx: &mut Context<Self>,
+ mut main: gpui::Div,
+ lv: &ListView,
+ mod_idx: usize,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ // Header con título + acciones.
+ let mut header = div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(12.))
+ .mb(px(12.))
+ .child(
+ div()
+ .text_color(text)
+ .text_size(px(18.))
+ .child(lv.title.clone()),
+ );
+ for (idx, action) in lv.actions.iter().enumerate() {
+ let label = match action {
+ Action::OpenView { label, view } => {
+ label.clone().unwrap_or_else(|| format!("→ {view}"))
+ }
+ Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
+ Action::Morphism { name, .. } => format!("⚡ {name}"),
+ };
+ header = header.child(action_button(
+ cx,
+ format!("list-action-{mod_idx}-{idx}"),
+ label,
+ action.clone(),
+ accent,
+ ));
+ }
+ main = main.child(header);
+
+ let rows = self.list_rows(&lv.entity);
+ let total = rows.len();
+
+ // Header de columnas.
+ let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
+ let mut col_header = div()
+ .flex()
+ .flex_row()
+ .py(px(6.))
+ .border_b_1()
+ .border_color(border)
+ .text_color(text_dim)
+ .text_size(px(11.));
+ for c in &lv.columns {
+ let frac = c.weight / total_weight;
+ col_header = col_header.child(
+ div()
+ .flex_grow()
+ .flex_basis(px(100. * frac))
+ .child(c.label.clone()),
+ );
+ }
+ col_header = col_header.child(
+ div()
+ .w(px(80.))
+ .text_color(text_dim)
+ .child("id"),
+ );
+ main = main.child(col_header);
+
+ // Filas.
+ for (id, value) in &rows {
+ let mut row = div()
+ .flex()
+ .flex_row()
+ .py(px(6.))
+ .border_b_1()
+ .border_color(rgb(0x232a36))
+ .text_color(text)
+ .text_size(px(12.));
+ for c in &lv.columns {
+ let frac = c.weight / total_weight;
+ let v = lookup_field(value, &c.field);
+ row = row.child(
+ div()
+ .flex_grow()
+ .flex_basis(px(100. * frac))
+ .child(render_value(v)),
+ );
+ }
+ row = row.child(
+ div()
+ .w(px(80.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child(short_uuid(id)),
+ );
+ main = main.child(row);
+ }
+
+ if rows.is_empty() {
+ main = main.child(
+ div()
+ .py(px(12.))
+ .text_color(text_dim)
+ .text_size(px(12.))
+ .child(format!("(sin {})", lv.entity)),
+ );
+ } else {
+ main = main.child(
+ div()
+ .mt(px(8.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child(format!("{total} fila(s)")),
+ );
+ }
+
+ main
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn render_form(
+ &self,
+ cx: &mut Context<Self>,
+ mut main: gpui::Div,
+ fv: &FormView,
+ mod_idx: usize,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ let _ = border;
+ main = main.child(
+ div()
+ .text_color(text)
+ .text_size(px(18.))
+ .mb(px(12.))
+ .child(fv.title.clone()),
+ );
+ for f in &fv.fields {
+ let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default();
+ let display = if raw.is_empty() {
+ f.default.clone().unwrap_or_default()
+ } else {
+ raw
+ };
+ let label = if f.required {
+ format!("{} *", f.label)
+ } else {
+ f.label.clone()
+ };
+ let mut field_box = div()
+ .flex()
+ .flex_col()
+ .mb(px(10.))
+ .child(
+ div()
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .mb(px(2.))
+ .child(label),
+ )
+ .child(
+ // GPUI no incluye un text_input nativo; mostramos
+ // el buffer actual como texto. Para entrada
+ // teclado real, integrar yahweh-widget-text-input
+ // (próxima iteración). Por ahora el form sirve
+ // demos visuales y el seed via API programática.
+ div()
+ .px(px(8.))
+ .py(px(6.))
+ .bg(rgb(0x171a20))
+ .border_1()
+ .border_color(rgb(0x2a2f38))
+ .rounded(px(3.))
+ .text_color(text)
+ .text_size(px(12.))
+ .child(if display.is_empty() {
+ "(vacío — input GPUI pendiente)".to_string()
+ } else {
+ display
+ }),
+ );
+ if let Some(help) = &f.help {
+ field_box = field_box.child(
+ div()
+ .mt(px(2.))
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(help.clone()),
+ );
+ }
+ main = main.child(field_box);
+ }
+
+ let submit_label = match &fv.on_submit {
+ Action::SeedEntity { entity, .. } => format!("Crear {entity}"),
+ Action::Morphism { name, .. } => format!("Ejecutar {name}"),
+ Action::OpenView { label, view } => {
+ label.clone().unwrap_or_else(|| format!("Ir a {view}"))
+ }
+ };
+ main = main.child(
+ div().mt(px(12.)).child(action_button(
+ cx,
+ format!("form-submit-{mod_idx}"),
+ submit_label,
+ fv.on_submit.clone(),
+ accent,
+ )),
+ );
+
+ main = main.child(
+ div()
+ .mt(px(20.))
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(
+ "Nota: en este MVP, los inputs todavía no aceptan teclado. \
+ El submit usa los `default` del schema o vacío (campo opcional). \
+ Próximo iter: integración con yahweh-widget-text-input.",
+ ),
+ );
+ main
+ }
+}
+
+fn cx_handler_view(
+ mod_idx: usize,
+ view_key: String,
+) -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static {
+ let _ = (mod_idx, &view_key);
+ move |_e, _w, _cx| {
+ // GPUI handlers necesitan acceder al modelo de la entity actual;
+ // wirearemos via cx.update en el render real cuando el iter de
+ // eventos tipados esté listo. Por ahora el menu se navega via
+ // env var/restart.
+ }
+}
+
+fn action_button(
+ _cx: &mut Context<MetaUi>,
+ id: String,
+ label: String,
+ _action: Action,
+ accent: gpui::Rgba,
+) -> gpui::Stateful<gpui::Div> {
+ div()
+ .id(SharedString::from(id))
+ .px(px(10.))
+ .py(px(4.))
+ .bg(rgb(0x232a36))
+ .text_color(accent)
+ .text_size(px(11.))
+ .rounded(px(3.))
+ .child(label)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_field_text_returns_string() {
+ let v = parse_field_value(FieldKind::Text, "hola").unwrap();
+ assert_eq!(v, json!("hola"));
+ }
+
+ #[test]
+ fn parse_field_number_int_then_float() {
+ let i = parse_field_value(FieldKind::Number, "42").unwrap();
+ assert_eq!(i, json!(42));
+ let f = parse_field_value(FieldKind::Number, "3.14").unwrap();
+ assert_eq!(f, json!(3.14));
+ }
+
+ #[test]
+ fn parse_field_number_invalid_errors() {
+ let r = parse_field_value(FieldKind::Number, "not-a-number");
+ assert!(r.is_err());
+ }
+
+ #[test]
+ fn parse_field_boolean_variants() {
+ assert_eq!(
+ parse_field_value(FieldKind::Boolean, "true").unwrap(),
+ json!(true)
+ );
+ assert_eq!(
+ parse_field_value(FieldKind::Boolean, "yes").unwrap(),
+ json!(true)
+ );
+ assert_eq!(
+ parse_field_value(FieldKind::Boolean, "false").unwrap(),
+ json!(false)
+ );
+ assert_eq!(
+ parse_field_value(FieldKind::Boolean, "").unwrap(),
+ json!(false)
+ );
+ assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
+ }
+
+ #[test]
+ fn lookup_field_simple_and_nested() {
+ let v = json!({
+ "name": "Acme",
+ "address": { "city": "Bogotá", "country": "CO" }
+ });
+ assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
+ assert_eq!(
+ lookup_field(&v, "address.city").unwrap(),
+ &json!("Bogotá")
+ );
+ assert!(lookup_field(&v, "missing").is_none());
+ assert!(lookup_field(&v, "address.zipcode").is_none());
+ }
+
+ #[test]
+ fn render_value_handles_null_string_bool() {
+ assert_eq!(render_value(None), "");
+ assert_eq!(render_value(Some(&json!(null))), "");
+ assert_eq!(render_value(Some(&json!("x"))), "x");
+ assert_eq!(render_value(Some(&json!(true))), "✓");
+ assert_eq!(render_value(Some(&json!(false))), "✗");
+ assert_eq!(render_value(Some(&json!(42))), "42");
+ }
+}
diff --git a/crates/modules/nakui/ui-schema/Cargo.toml b/crates/modules/nakui/ui-schema/Cargo.toml
new file mode 100644
index 0000000..e6627d2
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nakui-ui-schema"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Nakui UI metainterface schema: módulos declaran menús, listas y formularios como datos (JSON); el runtime los carga y renderiza sin código compilado por módulo."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/modules/nakui/ui-schema/src/lib.rs b/crates/modules/nakui/ui-schema/src/lib.rs
new file mode 100644
index 0000000..d180822
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/src/lib.rs
@@ -0,0 +1,499 @@
+//! Schema declarativo de la metainterfaz Nakui.
+//!
+//! Cada **módulo** Nakui (customers, products, sales, ...) declara
+//! aquí qué menús, vistas, listas y formularios expone, sin escribir
+//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
+//! `module.json` desde un directorio y monta automáticamente la UI
+//! correspondiente.
+//!
+//! ## Filosofía
+//!
+//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
+//! recompile, ningún acoplamiento con el binario del runtime.
+//! - **Persistencia universal**: el runtime conecta cada vista al
+//! `nakui_core::store::Store` actual; los formularios escriben
+//! ops por la pipeline normal de Nakui (executor + event log).
+//! - **Schema primero, semántica después**: este crate sólo define
+//! la *forma* de los manifests. Validación semántica
+//! (referencias rotas a entities, campos faltantes, etc.) vive en
+//! el runtime que lo carga, no acá.
+//!
+//! ## Anatomía de un módulo
+//!
+//! ```json
+//! {
+//! "id": "customers",
+//! "label": "Clientes",
+//! "entities": [
+//! { "name": "customer", "fields": [ ... ] }
+//! ],
+//! "menu": [
+//! { "label": "Listar", "view": "list" },
+//! { "label": "Nuevo", "view": "form" }
+//! ],
+//! "views": {
+//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
+//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
+//! }
+//! }
+//! ```
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// Manifiesto de un módulo de la metainterfaz Nakui.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Module {
+ /// Identificador estable. Único dentro del directorio cargado.
+ pub id: String,
+
+ /// Nombre legible para mostrar en el sidebar.
+ pub label: String,
+
+ /// Descripción corta opcional (tooltip / subtítulo).
+ #[serde(default)]
+ pub description: Option<String>,
+
+ /// Entities que el módulo introduce o consume. El runtime las
+ /// usa para validar columns/fields y para inicializar el store
+ /// cuando son nuevas.
+ #[serde(default)]
+ pub entities: Vec<EntitySpec>,
+
+ /// Items del menú. Cada uno apunta a una key de `views`. Orden
+ /// importa (es el orden en que se presentan en el sidebar).
+ pub menu: Vec<MenuItem>,
+
+ /// Vistas indexadas por key. Las keys son referenciadas por
+ /// `MenuItem.view` y por `Action::OpenView.view`.
+ pub views: BTreeMap<String, View>,
+}
+
+/// Item del menú lateral.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MenuItem {
+ pub label: String,
+ /// Key de la vista a abrir. Debe existir en `Module.views`.
+ pub view: String,
+ /// Icono opcional (texto unicode o emoji; el runtime decide
+ /// renderización).
+ #[serde(default)]
+ pub icon: Option<String>,
+}
+
+/// Una vista renderizada en el área principal cuando su menú es seleccionado.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum View {
+ /// Tabla de instancias de una entity, columnas + acciones por fila.
+ List(ListView),
+ /// Formulario de creación / edición.
+ Form(FormView),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListView {
+ pub title: String,
+ /// Entity (del nakui store) cuyas instancias se listan.
+ pub entity: String,
+ /// Columnas a mostrar. Orden importa.
+ pub columns: Vec<Column>,
+ /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
+ /// Renderizadas como botones en el header.
+ #[serde(default)]
+ pub actions: Vec<Action>,
+ /// Cuando está set, se muestra una caja de búsqueda que filtra
+ /// las filas por substring contra los valores de estas columnas.
+ #[serde(default)]
+ pub search_in: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Column {
+ /// Path del campo dentro del record. Para tipos planos: nombre.
+ /// Para nested: puntos (`address.city`). El runtime navega.
+ pub field: String,
+ /// Texto del header.
+ pub label: String,
+ /// Ancho relativo (peso flex). Default 1.
+ #[serde(default = "default_weight")]
+ pub weight: f32,
+}
+
+fn default_weight() -> f32 {
+ 1.0
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FormView {
+ pub title: String,
+ /// Entity destino del seed/morphism al submit.
+ pub entity: String,
+ pub fields: Vec<FieldSpec>,
+ /// Acción al submit. Típicamente `Action::SeedEntity` para alta
+ /// directa o `Action::Morphism` cuando hay validación/cálculo.
+ pub on_submit: Action,
+}
+
+/// Especificación de un campo de formulario o columna implícita.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FieldSpec {
+ /// Nombre del campo en el record (clave del JSON).
+ pub name: String,
+ /// Etiqueta legible.
+ pub label: String,
+ /// Tipo del valor — define el widget de input + parseo.
+ pub kind: FieldKind,
+ /// Valor por defecto al abrir el form (string raw; el parseo
+ /// según `kind` lo hace el runtime).
+ #[serde(default)]
+ pub default: Option<String>,
+ /// Si `true`, el form rechaza submit con campo vacío.
+ #[serde(default)]
+ pub required: bool,
+ /// Texto de ayuda mostrado bajo el input.
+ #[serde(default)]
+ pub help: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FieldKind {
+ /// Texto libre.
+ Text,
+ /// Texto multilínea.
+ Multiline,
+ /// Número (i64 o f64; runtime intenta parsear como i64 primero).
+ Number,
+ /// Booleano (renderizado como checkbox).
+ Boolean,
+ /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
+ Date,
+}
+
+/// Acciones disparables por menús, botones o submit de formularios.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Action {
+ /// Cambia la vista activa a otra del mismo módulo.
+ OpenView {
+ view: String,
+ /// Etiqueta del botón / item; default = nombre humano de la vista.
+ #[serde(default)]
+ label: Option<String>,
+ },
+ /// Crea un seed directo en la entity con los valores del form.
+ /// Equivalente a `nakui_core::event_log::seed_and_log`.
+ /// Sin pipeline de morphism — alta administrativa.
+ SeedEntity {
+ entity: String,
+ /// Tras el submit exitoso, opcionalmente abrir esta vista
+ /// (por convención: `"list"` para volver al listado).
+ #[serde(default)]
+ next_view: Option<String>,
+ },
+ /// Ejecuta un morphism declarado en el manifest del módulo
+ /// nakui-core. Inputs y params se mapean desde los campos del form.
+ Morphism {
+ name: String,
+ #[serde(default)]
+ next_view: Option<String>,
+ },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EntitySpec {
+ /// Nombre de la entity (clave en el store). Único dentro del módulo.
+ pub name: String,
+ /// Label legible (singular).
+ pub label: String,
+ /// Campos esperados en records de esta entity. Usados por el
+ /// runtime para inferir columnas / validar formularios.
+ #[serde(default)]
+ pub fields: Vec<FieldSpec>,
+}
+
+#[derive(Debug, Error)]
+pub enum SchemaError {
+ #[error("io leyendo {path}: {source}")]
+ Io {
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("parseo de {path}: {source}")]
+ Parse {
+ path: PathBuf,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
+ DanglingMenuView {
+ id: String,
+ item: String,
+ view: String,
+ },
+ #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
+ DuplicateModuleId {
+ id: String,
+ first: PathBuf,
+ second: PathBuf,
+ },
+}
+
+impl Module {
+ /// Carga un module.json desde disco. Validación estructural
+ /// posterior (vistas referenciadas existen, etc.) la ejecuta el
+ /// runtime — acá sólo parseamos.
+ pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
+ let path = path.as_ref();
+ let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
+ path: path.to_path_buf(),
+ source,
+ })
+ }
+
+ /// Validación post-parse: cada `MenuItem.view` debe existir en
+ /// `views`. Retorna el primer error encontrado.
+ pub fn validate(&self) -> Result<(), SchemaError> {
+ for item in &self.menu {
+ if !self.views.contains_key(&item.view) {
+ return Err(SchemaError::DanglingMenuView {
+ id: self.id.clone(),
+ item: item.label.clone(),
+ view: item.view.clone(),
+ });
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
+/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
+/// ordenada por id, validada.
+///
+/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
+pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
+ let dir = dir.as_ref();
+ let mut modules: Vec<(PathBuf, Module)> = Vec::new();
+ let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
+ path: dir.to_path_buf(),
+ source,
+ })?;
+ for entry in entries.flatten() {
+ let p = entry.path();
+ if p.is_dir() {
+ let manifest = p.join("module.json");
+ if manifest.exists() {
+ let m = Module::from_path(&manifest)?;
+ m.validate()?;
+ modules.push((manifest, m));
+ }
+ }
+ }
+ // Detectar duplicados de id.
+ modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
+ let mut prev: Option<&(PathBuf, Module)> = None;
+ for cur in &modules {
+ if let Some(p) = prev {
+ if p.1.id == cur.1.id {
+ return Err(SchemaError::DuplicateModuleId {
+ id: cur.1.id.clone(),
+ first: p.0.clone(),
+ second: cur.0.clone(),
+ });
+ }
+ }
+ prev = Some(cur);
+ }
+ Ok(modules.into_iter().map(|(_, m)| m).collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_module() -> Module {
+ Module {
+ id: "customers".into(),
+ label: "Clientes".into(),
+ description: Some("Gestión de clientes".into()),
+ entities: vec![EntitySpec {
+ name: "customer".into(),
+ label: "Cliente".into(),
+ fields: vec![
+ FieldSpec {
+ name: "name".into(),
+ label: "Nombre".into(),
+ kind: FieldKind::Text,
+ default: None,
+ required: true,
+ help: None,
+ },
+ FieldSpec {
+ name: "email".into(),
+ label: "Email".into(),
+ kind: FieldKind::Text,
+ default: None,
+ required: false,
+ help: Some("Opcional".into()),
+ },
+ ],
+ }],
+ menu: vec![
+ MenuItem {
+ label: "Listar".into(),
+ view: "list".into(),
+ icon: None,
+ },
+ MenuItem {
+ label: "Nuevo".into(),
+ view: "form".into(),
+ icon: None,
+ },
+ ],
+ views: BTreeMap::from([
+ (
+ "list".into(),
+ View::List(ListView {
+ title: "Clientes".into(),
+ entity: "customer".into(),
+ columns: vec![
+ Column {
+ field: "name".into(),
+ label: "Nombre".into(),
+ weight: 2.0,
+ },
+ Column {
+ field: "email".into(),
+ label: "Email".into(),
+ weight: 3.0,
+ },
+ ],
+ actions: vec![Action::OpenView {
+ view: "form".into(),
+ label: Some("Nuevo".into()),
+ }],
+ search_in: vec!["name".into(), "email".into()],
+ }),
+ ),
+ (
+ "form".into(),
+ View::Form(FormView {
+ title: "Nuevo cliente".into(),
+ entity: "customer".into(),
+ fields: vec![FieldSpec {
+ name: "name".into(),
+ label: "Nombre".into(),
+ kind: FieldKind::Text,
+ default: None,
+ required: true,
+ help: None,
+ }],
+ on_submit: Action::SeedEntity {
+ entity: "customer".into(),
+ next_view: Some("list".into()),
+ },
+ }),
+ ),
+ ]),
+ }
+ }
+
+ #[test]
+ fn module_serialize_deserialize_roundtrip() {
+ let m = sample_module();
+ let s = serde_json::to_string_pretty(&m).unwrap();
+ let m2: Module = serde_json::from_str(&s).unwrap();
+ assert_eq!(m.id, m2.id);
+ assert_eq!(m.menu.len(), m2.menu.len());
+ assert_eq!(m.views.len(), m2.views.len());
+ }
+
+ #[test]
+ fn validate_passes_for_well_formed_module() {
+ sample_module().validate().unwrap();
+ }
+
+ #[test]
+ fn validate_catches_dangling_menu_view() {
+ let mut m = sample_module();
+ m.menu.push(MenuItem {
+ label: "Roto".into(),
+ view: "no_existe".into(),
+ icon: None,
+ });
+ let err = m.validate().unwrap_err();
+ assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
+ }
+
+ #[test]
+ fn from_path_loads_real_file() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("module.json");
+ std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
+ let m = Module::from_path(&path).unwrap();
+ assert_eq!(m.id, "customers");
+ }
+
+ #[test]
+ fn load_modules_from_dir_finds_subdirs() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cust_dir = tmp.path().join("customers");
+ std::fs::create_dir(&cust_dir).unwrap();
+ let mut m1 = sample_module();
+ m1.id = "customers".into();
+ std::fs::write(
+ cust_dir.join("module.json"),
+ serde_json::to_vec_pretty(&m1).unwrap(),
+ )
+ .unwrap();
+ let prod_dir = tmp.path().join("products");
+ std::fs::create_dir(&prod_dir).unwrap();
+ let mut m2 = sample_module();
+ m2.id = "products".into();
+ std::fs::write(
+ prod_dir.join("module.json"),
+ serde_json::to_vec_pretty(&m2).unwrap(),
+ )
+ .unwrap();
+
+ let mods = load_modules_from_dir(tmp.path()).unwrap();
+ assert_eq!(mods.len(), 2);
+ assert_eq!(mods[0].id, "customers");
+ assert_eq!(mods[1].id, "products");
+ }
+
+ #[test]
+ fn load_modules_detects_duplicate_id() {
+ let tmp = tempfile::tempdir().unwrap();
+ let a_dir = tmp.path().join("a");
+ let b_dir = tmp.path().join("b");
+ std::fs::create_dir_all(&a_dir).unwrap();
+ std::fs::create_dir_all(&b_dir).unwrap();
+ let m = sample_module(); // id = "customers"
+ std::fs::write(
+ a_dir.join("module.json"),
+ serde_json::to_vec(&m).unwrap(),
+ )
+ .unwrap();
+ std::fs::write(
+ b_dir.join("module.json"),
+ serde_json::to_vec(&m).unwrap(),
+ )
+ .unwrap();
+ let err = load_modules_from_dir(tmp.path()).unwrap_err();
+ assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
+ }
+}
diff --git a/crates/modules/nakui/ui-schema/tests/example_modules.rs b/crates/modules/nakui/ui-schema/tests/example_modules.rs
new file mode 100644
index 0000000..1ed8408
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/tests/example_modules.rs
@@ -0,0 +1,89 @@
+//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
+//!
+//! Si esto verde, garantizamos que un usuario que clone el repo y
+//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
+//! va a obtener los 6 módulos cargados sin tocar nada.
+
+use nakui_ui_schema::{load_modules_from_dir, FieldKind, View};
+
+fn examples_dir() -> std::path::PathBuf {
+ // Tests corren desde el dir del crate; el repo root está dos
+ // niveles arriba: crates/modules/nakui/ui-schema → repo.
+ let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+ here.join("../../../..").join("examples/nakui-modules")
+}
+
+#[test]
+fn loads_all_six_demo_modules() {
+ let dir = examples_dir();
+ let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
+ panic!("load failed for {}: {e}", dir.display());
+ });
+ let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
+ assert_eq!(
+ ids,
+ vec![
+ "customers",
+ "inventory_movements",
+ "invoices",
+ "products",
+ "sales_orders",
+ "suppliers",
+ ],
+ "expected 6 modules in alphabetical order"
+ );
+}
+
+#[test]
+fn every_demo_module_has_list_and_form_views() {
+ let mods = load_modules_from_dir(examples_dir()).unwrap();
+ for m in &mods {
+ let mut has_list = false;
+ let mut has_form = false;
+ for v in m.views.values() {
+ match v {
+ View::List(_) => has_list = true,
+ View::Form(_) => has_form = true,
+ }
+ }
+ assert!(
+ has_list && has_form,
+ "module {} should expose at least one list + one form view",
+ m.id
+ );
+ }
+}
+
+#[test]
+fn every_demo_form_field_kind_is_recognized() {
+ // Sanity: ningún módulo demo usa un kind que no esté en el enum
+ // (sería rechazado al parsear, pero check explícito no daña).
+ let mods = load_modules_from_dir(examples_dir()).unwrap();
+ for m in &mods {
+ for v in m.views.values() {
+ if let View::Form(form) = v {
+ for f in &form.fields {
+ let _ok = matches!(
+ f.kind,
+ FieldKind::Text
+ | FieldKind::Multiline
+ | FieldKind::Number
+ | FieldKind::Boolean
+ | FieldKind::Date
+ );
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn every_module_validates_clean() {
+ // validate() chequea que cada MenuItem.view exista en views.
+ // Un typo en cualquiera de los 6 módulos haría fallar este test.
+ let mods = load_modules_from_dir(examples_dir()).unwrap();
+ for m in &mods {
+ m.validate()
+ .unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
+ }
+}
diff --git a/examples/nakui-modules/customers/module.json b/examples/nakui-modules/customers/module.json
new file mode 100644
index 0000000..81718dd
--- /dev/null
+++ b/examples/nakui-modules/customers/module.json
@@ -0,0 +1,59 @@
+{
+ "id": "customers",
+ "label": "Clientes",
+ "description": "Gestión de clientes (alta, listado, búsqueda).",
+ "entities": [
+ {
+ "name": "customer",
+ "label": "Cliente",
+ "fields": [
+ { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+ { "name": "email", "label": "Email", "kind": "text", "required": false, "help": "Opcional, formato libre por ahora" },
+ { "name": "phone", "label": "Teléfono", "kind": "text", "required": false },
+ { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" },
+ { "name": "credit_limit", "label": "Límite de crédito", "kind": "number", "default": "0" },
+ { "name": "notes", "label": "Notas", "kind": "multiline", "required": false }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Listar", "view": "list", "icon": "📋" },
+ { "label": "Nuevo", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Clientes",
+ "entity": "customer",
+ "columns": [
+ { "field": "name", "label": "Nombre", "weight": 2.0 },
+ { "field": "email", "label": "Email", "weight": 3.0 },
+ { "field": "phone", "label": "Teléfono", "weight": 1.5 },
+ { "field": "active", "label": "Activo", "weight": 0.7 },
+ { "field": "credit_limit", "label": "Límite", "weight": 1.0 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+ ],
+ "search_in": ["name", "email", "phone"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Nuevo cliente",
+ "entity": "customer",
+ "fields": [
+ { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+ { "name": "email", "label": "Email", "kind": "text", "required": false },
+ { "name": "phone", "label": "Teléfono", "kind": "text", "required": false },
+ { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" },
+ { "name": "credit_limit", "label": "Límite de crédito", "kind": "number", "default": "0" },
+ { "name": "notes", "label": "Notas", "kind": "multiline" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "customer",
+ "next_view": "list"
+ }
+ }
+ }
+}
diff --git a/examples/nakui-modules/inventory_movements/module.json b/examples/nakui-modules/inventory_movements/module.json
new file mode 100644
index 0000000..467d763
--- /dev/null
+++ b/examples/nakui-modules/inventory_movements/module.json
@@ -0,0 +1,63 @@
+{
+ "id": "inventory_movements",
+ "label": "Inventario",
+ "description": "Movimientos de stock: entradas, salidas, ajustes.",
+ "entities": [
+ {
+ "name": "stock_movement",
+ "label": "Movimiento",
+ "fields": [
+ { "name": "occurred_at", "label": "Fecha", "kind": "date", "required": true },
+ { "name": "movement_type", "label": "Tipo", "kind": "text", "required": true, "help": "in / out / adjustment" },
+ { "name": "product_sku", "label": "SKU producto", "kind": "text", "required": true },
+ { "name": "quantity", "label": "Cantidad", "kind": "number", "required": true },
+ { "name": "unit_cost", "label": "Costo unitario", "kind": "number", "default": "0" },
+ { "name": "reason", "label": "Motivo", "kind": "text", "help": "Compra, venta, merma, ajuste por inventario..." },
+ { "name": "reference", "label": "Doc. referencia", "kind": "text", "help": "Factura, orden, conteo..." }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Movimientos", "view": "list", "icon": "📊" },
+ { "label": "Registrar", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Movimientos de stock",
+ "entity": "stock_movement",
+ "columns": [
+ { "field": "occurred_at", "label": "Fecha", "weight": 1.0 },
+ { "field": "movement_type", "label": "Tipo", "weight": 0.7 },
+ { "field": "product_sku", "label": "SKU", "weight": 1.0 },
+ { "field": "quantity", "label": "Cantidad", "weight": 0.8 },
+ { "field": "unit_cost", "label": "Costo", "weight": 0.8 },
+ { "field": "reason", "label": "Motivo", "weight": 1.5 },
+ { "field": "reference", "label": "Ref.", "weight": 1.0 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Registrar" }
+ ],
+ "search_in": ["product_sku", "reason", "reference"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Registrar movimiento",
+ "entity": "stock_movement",
+ "fields": [
+ { "name": "occurred_at", "label": "Fecha", "kind": "date", "required": true },
+ { "name": "movement_type", "label": "Tipo (in/out/adjustment)", "kind": "text", "required": true, "default": "in" },
+ { "name": "product_sku", "label": "SKU producto", "kind": "text", "required": true },
+ { "name": "quantity", "label": "Cantidad", "kind": "number", "required": true },
+ { "name": "unit_cost", "label": "Costo unitario", "kind": "number", "default": "0" },
+ { "name": "reason", "label": "Motivo", "kind": "text" },
+ { "name": "reference", "label": "Documento de referencia", "kind": "text" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "stock_movement",
+ "next_view": "list"
+ }
+ }
+ }
+}
diff --git a/examples/nakui-modules/invoices/module.json b/examples/nakui-modules/invoices/module.json
new file mode 100644
index 0000000..d1cacea
--- /dev/null
+++ b/examples/nakui-modules/invoices/module.json
@@ -0,0 +1,72 @@
+{
+ "id": "invoices",
+ "label": "Facturación",
+ "description": "Facturas emitidas y su seguimiento de pago.",
+ "entities": [
+ {
+ "name": "invoice",
+ "label": "Factura",
+ "fields": [
+ { "name": "invoice_number", "label": "N° factura", "kind": "text", "required": true },
+ { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+ { "name": "issued_at", "label": "Emitida", "kind": "date", "required": true },
+ { "name": "due_at", "label": "Vencimiento", "kind": "date", "required": true },
+ { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+ { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+ { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+ { "name": "amount_paid", "label": "Pagado", "kind": "number", "default": "0" },
+ { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "issued", "help": "draft / issued / partially_paid / paid / overdue / void" },
+ { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+ { "name": "reference_order", "label": "Orden referencia", "kind": "text" }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Facturas", "view": "list", "icon": "💳" },
+ { "label": "Nueva factura", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Facturas",
+ "entity": "invoice",
+ "columns": [
+ { "field": "invoice_number", "label": "N°", "weight": 1.0 },
+ { "field": "customer_name", "label": "Cliente", "weight": 2.0 },
+ { "field": "issued_at", "label": "Emitida", "weight": 1.0 },
+ { "field": "due_at", "label": "Vence", "weight": 1.0 },
+ { "field": "total", "label": "Total", "weight": 1.0 },
+ { "field": "amount_paid", "label": "Pagado", "weight": 1.0 },
+ { "field": "currency", "label": "Mon.", "weight": 0.5 },
+ { "field": "status", "label": "Estado", "weight": 0.9 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Nueva" }
+ ],
+ "search_in": ["invoice_number", "customer_name", "reference_order"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Nueva factura",
+ "entity": "invoice",
+ "fields": [
+ { "name": "invoice_number", "label": "Número de factura", "kind": "text", "required": true },
+ { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+ { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+ { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date", "required": true },
+ { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+ { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+ { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+ { "name": "amount_paid", "label": "Pagado", "kind": "number", "default": "0" },
+ { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "issued" },
+ { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+ { "name": "reference_order", "label": "Orden de venta referenciada", "kind": "text" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "invoice",
+ "next_view": "list"
+ }
+ }
+ }
+}
diff --git a/examples/nakui-modules/products/module.json b/examples/nakui-modules/products/module.json
new file mode 100644
index 0000000..63541d1
--- /dev/null
+++ b/examples/nakui-modules/products/module.json
@@ -0,0 +1,60 @@
+{
+ "id": "products",
+ "label": "Productos",
+ "description": "Catálogo de productos y precios.",
+ "entities": [
+ {
+ "name": "product",
+ "label": "Producto",
+ "fields": [
+ { "name": "sku", "label": "SKU", "kind": "text", "required": true, "help": "Código único" },
+ { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+ { "name": "category", "label": "Categoría", "kind": "text", "required": false },
+ { "name": "price", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
+ { "name": "stock", "label": "Stock disponible", "kind": "number", "default": "0" },
+ { "name": "active", "label": "En venta", "kind": "boolean", "default": "true" }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Catálogo", "view": "list", "icon": "📦" },
+ { "label": "Nuevo producto", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Catálogo de productos",
+ "entity": "product",
+ "columns": [
+ { "field": "sku", "label": "SKU", "weight": 1.0 },
+ { "field": "name", "label": "Nombre", "weight": 2.5 },
+ { "field": "category", "label": "Categoría", "weight": 1.5 },
+ { "field": "price", "label": "Precio", "weight": 1.0 },
+ { "field": "stock", "label": "Stock", "weight": 0.8 },
+ { "field": "active", "label": "Activo", "weight": 0.6 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+ ],
+ "search_in": ["sku", "name", "category"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Nuevo producto",
+ "entity": "product",
+ "fields": [
+ { "name": "sku", "label": "SKU", "kind": "text", "required": true },
+ { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+ { "name": "category", "label": "Categoría", "kind": "text" },
+ { "name": "price", "label": "Precio", "kind": "number", "required": true, "default": "0" },
+ { "name": "stock", "label": "Stock inicial", "kind": "number", "default": "0" },
+ { "name": "active", "label": "En venta", "kind": "boolean", "default": "true" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "product",
+ "next_view": "list"
+ }
+ }
+ }
+}
diff --git a/examples/nakui-modules/sales_orders/module.json b/examples/nakui-modules/sales_orders/module.json
new file mode 100644
index 0000000..758add4
--- /dev/null
+++ b/examples/nakui-modules/sales_orders/module.json
@@ -0,0 +1,65 @@
+{
+ "id": "sales_orders",
+ "label": "Ventas",
+ "description": "Órdenes de venta y sus líneas.",
+ "entities": [
+ {
+ "name": "sales_order",
+ "label": "Orden de venta",
+ "fields": [
+ { "name": "order_number", "label": "Número", "kind": "text", "required": true },
+ { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+ { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+ { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date" },
+ { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "draft", "help": "draft / confirmed / shipped / closed / cancelled" },
+ { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+ { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+ { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+ { "name": "notes", "label": "Notas", "kind": "multiline" }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Órdenes", "view": "list", "icon": "🧾" },
+ { "label": "Nueva orden", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Órdenes de venta",
+ "entity": "sales_order",
+ "columns": [
+ { "field": "order_number", "label": "N°", "weight": 0.8 },
+ { "field": "customer_name", "label": "Cliente", "weight": 2.0 },
+ { "field": "issued_at", "label": "Emitida", "weight": 1.0 },
+ { "field": "status", "label": "Estado", "weight": 0.8 },
+ { "field": "total", "label": "Total", "weight": 1.0 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Nueva orden" }
+ ],
+ "search_in": ["order_number", "customer_name"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Nueva orden de venta",
+ "entity": "sales_order",
+ "fields": [
+ { "name": "order_number", "label": "Número de orden", "kind": "text", "required": true },
+ { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+ { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+ { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date" },
+ { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "draft" },
+ { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+ { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+ { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+ { "name": "notes", "label": "Notas", "kind": "multiline" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "sales_order",
+ "next_view": "list"
+ }
+ }
+ }
+}
diff --git a/examples/nakui-modules/suppliers/module.json b/examples/nakui-modules/suppliers/module.json
new file mode 100644
index 0000000..ab59d0e
--- /dev/null
+++ b/examples/nakui-modules/suppliers/module.json
@@ -0,0 +1,62 @@
+{
+ "id": "suppliers",
+ "label": "Proveedores",
+ "description": "Proveedores que abastecen el catálogo.",
+ "entities": [
+ {
+ "name": "supplier",
+ "label": "Proveedor",
+ "fields": [
+ { "name": "name", "label": "Razón social", "kind": "text", "required": true },
+ { "name": "tax_id", "label": "ID fiscal", "kind": "text", "required": true, "help": "RUT/RFC/NIT/EIN según país" },
+ { "name": "contact", "label": "Contacto", "kind": "text" },
+ { "name": "email", "label": "Email", "kind": "text" },
+ { "name": "phone", "label": "Teléfono", "kind": "text" },
+ { "name": "payment_terms_days", "label": "Términos de pago (días)", "kind": "number", "default": "30" },
+ { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" }
+ ]
+ }
+ ],
+ "menu": [
+ { "label": "Listar", "view": "list", "icon": "🏭" },
+ { "label": "Nuevo", "view": "form", "icon": "✚" }
+ ],
+ "views": {
+ "list": {
+ "kind": "list",
+ "title": "Proveedores",
+ "entity": "supplier",
+ "columns": [
+ { "field": "name", "label": "Razón social", "weight": 2.5 },
+ { "field": "tax_id", "label": "ID fiscal", "weight": 1.2 },
+ { "field": "contact", "label": "Contacto", "weight": 1.5 },
+ { "field": "email", "label": "Email", "weight": 2.0 },
+ { "field": "payment_terms_days", "label": "Términos", "weight": 0.8 },
+ { "field": "active", "label": "Activo", "weight": 0.5 }
+ ],
+ "actions": [
+ { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+ ],
+ "search_in": ["name", "tax_id", "contact", "email"]
+ },
+ "form": {
+ "kind": "form",
+ "title": "Nuevo proveedor",
+ "entity": "supplier",
+ "fields": [
+ { "name": "name", "label": "Razón social", "kind": "text", "required": true },
+ { "name": "tax_id", "label": "ID fiscal", "kind": "text", "required": true },
+ { "name": "contact", "label": "Persona de contacto", "kind": "text" },
+ { "name": "email", "label": "Email", "kind": "text" },
+ { "name": "phone", "label": "Teléfono", "kind": "text" },
+ { "name": "payment_terms_days", "label": "Términos de pago (días)", "kind": "number", "default": "30" },
+ { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" }
+ ],
+ "on_submit": {
+ "kind": "seed_entity",
+ "entity": "supplier",
+ "next_view": "list"
+ }
+ }
+ }
+}