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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user