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.
This commit is contained in:
Sergio
2026-05-09 19:54:21 +00:00
parent 5b8f71e0de
commit 06c4fb9130
14 changed files with 1919 additions and 0 deletions
+96
View File
@@ -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
Generated
+22
View File
@@ -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"
+2
View File
@@ -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]
+20
View File
@@ -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 }
+796
View File
@@ -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");
}
}
+14
View File
@@ -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 }
+499
View File
@@ -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 { .. }));
}
}
@@ -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));
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}