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 `. ## 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//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); 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` + 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, + /// Store compartido. Mutado por el submit de los forms. + store: Arc>, + /// 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, + /// Mensaje toast al pie (success de submit, error de carga, etc.). + toast: Option, + /// Si la carga de módulos falló al inicio, lo guardamos para + /// mostrarlo como banner de error permanente. + load_error: Option, +} + +impl MetaUi { + fn new(_cx: &mut Context) -> 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 { + let module = &self.modules[mod_idx]; + // Recoge la spec del FormView activo para conocer field kinds. + let spec_fields: Vec = 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 { + 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::() { + Ok(json!(i)) + } else if let Ok(f) = raw.parse::() { + 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) -> 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, + 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, + mod_idx: usize, + view_key: String, + label: String, + is_active: bool, + text: gpui::Rgba, + text_dim: gpui::Rgba, + accent: gpui::Rgba, + ) -> gpui::Stateful { + 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, + 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, + 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::().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, + 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, + id: String, + label: String, + _action: Action, + accent: gpui::Rgba, +) -> gpui::Stateful { + 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, + + /// 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, + + /// 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, + + /// Vistas indexadas por key. Las keys son referenciadas por + /// `MenuItem.view` y por `Action::OpenView.view`. + pub views: BTreeMap, +} + +/// 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, +} + +/// 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, + /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form). + /// Renderizadas como botones en el header. + #[serde(default)] + pub actions: Vec, + /// 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, +} + +#[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, + /// 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, + /// 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, +} + +#[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, + }, + /// 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, + }, + /// 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, + }, +} + +#[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, +} + +#[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) -> Result { + 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//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) -> Result, 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" + } + } + } +}