@axzydev/axzy_ui_system 1.7.3 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.cjs +1621 -594
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +366 -12
  5. package/dist/index.d.ts +366 -12
  6. package/dist/index.js +1331 -313
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/flex/flex.props.ts +3 -1
  12. package/src/components/atoms/flex/flex.tsx +11 -3
  13. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  14. package/src/components/atoms/textarea/textarea.tsx +13 -3
  15. package/src/components/molecules/card/card.props.ts +2 -0
  16. package/src/components/molecules/card/card.tsx +15 -2
  17. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  18. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  19. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  20. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  21. package/src/i18n/locales/en.ts +7 -0
  22. package/src/i18n/locales/es.ts +7 -0
  23. package/src/index.ts +31 -3
  24. package/src/json-renderer/actions/effects.test.tsx +115 -0
  25. package/src/json-renderer/actions/effects.ts +291 -0
  26. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  27. package/src/json-renderer/binding/binding.test.tsx +168 -0
  28. package/src/json-renderer/binding/resolveValue.ts +193 -0
  29. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  30. package/src/json-renderer/form/FormNode.tsx +92 -0
  31. package/src/json-renderer/form/form.test.tsx +148 -0
  32. package/src/json-renderer/form/formLength.test.tsx +99 -0
  33. package/src/json-renderer/form/rules.ts +162 -0
  34. package/src/json-renderer/index.ts +21 -0
  35. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  36. package/src/json-renderer/kernel/capabilities.ts +30 -0
  37. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  38. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  39. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  40. package/src/json-renderer/kernel/slots.ts +34 -0
  41. package/src/json-renderer/kernel/types.ts +4 -0
  42. package/src/json-renderer/routing/match.ts +82 -0
  43. package/src/json-renderer/routing/navigation.ts +71 -0
  44. package/src/json-renderer/routing/routes.ts +179 -0
  45. package/src/json-renderer/routing/routing.test.ts +147 -0
  46. package/src/json-renderer/seguridad.test.tsx +299 -0
  47. package/src/json-renderer/shared/errors.ts +1 -0
  48. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  49. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  50. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  51. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  52. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  53. package/src/json-renderer/validation/validate.ts +17 -0
  54. package/src/sandbox/i18n.ts +38 -0
  55. package/src/sandbox/useHashRoute.ts +2 -1
  56. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  57. package/src/showcases/DemoJsonDocs.tsx +11 -11
  58. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  59. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  60. package/src/showcases/demoApp/routes.test.ts +63 -0
  61. package/src/showcases/demoApp/schemas.test.ts +6 -17
@@ -4,6 +4,8 @@ import { FiHome } from "react-icons/fi";
4
4
  import { AxzyLogo } from "../sandbox/AxzyLogo";
5
5
  import {
6
6
  ITAlert,
7
+ ITConfirmDialog,
8
+ ITLineBarChart,
7
9
  ITButton,
8
10
  ITDialog,
9
11
  ITFlex,
@@ -26,6 +28,18 @@ import {
26
28
  parsePartialSchema,
27
29
  useITTranslation,
28
30
  validateSchema,
31
+ type DataHandler,
32
+ type EffectRegistry,
33
+ type ITLineBarDatum,
34
+ type ITDataTableFetchParams,
35
+ type NavNode,
36
+ type RoutesSchema,
37
+ buildNavigationTree,
38
+ flattenRoutes,
39
+ matchRoute,
40
+ navContainsRoute,
41
+ type SlotComponent,
42
+ type SlotRegistry,
29
43
  type JsonEngineIssue,
30
44
  type NodeSchema,
31
45
  type SchemaValue,
@@ -36,16 +50,16 @@ import {
36
50
  actualizarUsuario,
37
51
  crearUsuario,
38
52
  createDemoFetcher,
53
+ PEDIDOS,
39
54
  setEstadoFiltro,
40
55
  eliminarUsuario,
41
- isDemoSource,
42
56
  obtenerUsuario,
43
57
  type DemoUsuario,
44
58
  type DemoUsuarioInput,
45
59
  } from "./demoApp/demoData";
46
60
 
47
61
  /** Páginas del dashboard, en orden de navegación. */
48
- export const DEMO_APP_PAGES = ["inicio", "usuarios", "pedidos", "productos", "reportes", "docs"] as const;
62
+ export const DEMO_APP_PAGES = ["inicio", "usuarios", "pedidos", "productos", "reportes", "docs", "usuario-detalle"] as const;
49
63
 
50
64
  /** Nombre de una página del dashboard. */
51
65
  export type DemoAppPage = (typeof DEMO_APP_PAGES)[number];
@@ -58,6 +72,7 @@ const PAGE_LABEL: Record<DemoAppPage, string> = {
58
72
  productos: "sandbox.app.nav.productos",
59
73
  reportes: "sandbox.app.nav.reportes",
60
74
  docs: "sandbox.app.nav.docs",
75
+ "usuario-detalle": "sandbox.app.detail.title",
61
76
  };
62
77
 
63
78
  /** Registro de iconos: el JSON nombra, el host decide qué componente es. */
@@ -70,48 +85,117 @@ const ICONOS: Record<string, React.ReactNode> = {
70
85
  book: <FaBook size={12} />,
71
86
  };
72
87
 
73
- /** Módulo del sidebar tal como lo declara `layout.json`. */
74
- interface LayoutModule {
75
- id: string;
76
- label: string | { i18n: string };
77
- icon?: string;
78
- page: string;
79
- badge?: number | string;
80
- subitems?: { id: string; label: string | { i18n: string }; page: string; query?: Record<string, string> }[];
81
- }
82
-
83
- /** Shell declarado en JSON. */
84
- interface LayoutFile {
85
- layout: { brand?: { logo?: string; text?: string }; topBarNav?: string[]; modules: LayoutModule[] };
86
- }
87
-
88
88
  /** Estado del formulario de usuario. */
89
89
  const ROLES = ["Admin", "Operador", "Auditor", "Soporte"];
90
90
  const ESTADOS = ["Activo", "Inactivo"];
91
91
  const EMPRESAS = ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud", "Ferretería Sur"];
92
92
  const FORM_VACIO: DemoUsuarioInput = { nombre: "", correo: "", rol: "Operador", estado: "Activo", edad: 30, empresa: "CheckApp", turno: "09:00", alta: new Date().toISOString().slice(0, 10) };
93
93
 
94
- /** `true` cuando el valor es una referencia de fuente (`{ $source }`). */
95
- function isSourceRef(value: unknown): value is { $source: string } {
96
- return Boolean(value) && typeof value === "object" && typeof (value as { $source?: unknown }).$source === "string";
97
- }
98
-
99
94
  /** `true` cuando el valor es una clave de traducción (`{ i18n }`). */
100
95
  function isI18nRef(value: unknown): value is { i18n: string; params?: Record<string, SchemaValue> } {
101
96
  return Boolean(value) && typeof value === "object" && typeof (value as { i18n?: unknown }).i18n === "string";
102
97
  }
103
98
 
99
+ /**
100
+ * Slot del host: un bloque JSX que el JSON nombra (`{ "type": "slot", "props": { "name": "avisoInicio" } }`).
101
+ * Los textos llegan ya traducidos por `resolveValue`.
102
+ */
103
+ const AvisoInicio: SlotComponent = ({ titulo, mensaje }) => (
104
+ <ITAlert variant="info" title={String(titulo ?? "")}>
105
+ {String(mensaje ?? "")}
106
+ </ITAlert>
107
+ );
108
+
109
+ /**
110
+ * Slot del host para `/usuarios/:id`: busca el usuario por id y pinta sus datos.
111
+ * El id llega ya resuelto desde `$param`; el JSON no puede elegir qué usuario mostrar más allá de la dirección.
112
+ */
113
+ const UsuarioDetalle: SlotComponent = ({ id }) => {
114
+ const { t } = useITTranslation();
115
+ const usuario = obtenerUsuario(Number(id));
116
+ if (!usuario) {
117
+ return <ITAlert variant="warning" title={t("sandbox.app.detail.notFound")} />;
118
+ }
119
+ const campos: [string, unknown][] = [
120
+ [t("sandbox.app.form.email"), usuario.correo],
121
+ [t("sandbox.app.form.role"), usuario.rol],
122
+ [t("sandbox.app.form.status"), usuario.estado],
123
+ [t("sandbox.app.form.company"), usuario.empresa],
124
+ [t("sandbox.app.form.since"), usuario.alta],
125
+ ];
126
+ return (
127
+ <ITStack direction="column" spacing={3}>
128
+ <ITText as="span" className="text-lg font-semibold">
129
+ {String(usuario.nombre)}
130
+ </ITText>
131
+ {campos.map(([etiqueta, valor]) => (
132
+ <ITFlex key={etiqueta} gap={3} align="center">
133
+ <ITText as="span" muted className="w-28">
134
+ {etiqueta}
135
+ </ITText>
136
+ <ITText as="span">{String(valor)}</ITText>
137
+ </ITFlex>
138
+ ))}
139
+ </ITStack>
140
+ );
141
+ };
142
+
143
+ /** Gráfica de ingresos y pedidos por mes, calculada sobre los pedidos del demo. */
144
+ const GraficaIngresos: SlotComponent = () => {
145
+ const { t, formatCurrency } = useITTranslation();
146
+ const datos = useMemo<ITLineBarDatum[]>(() => {
147
+ const meses = new Map<string, ITLineBarDatum>();
148
+ for (const pedido of PEDIDOS) {
149
+ const mes = String(pedido.fecha).slice(0, 7);
150
+ const acumulado = meses.get(mes) ?? { label: mes.slice(5), line: 0, bar: 0 };
151
+ acumulado.line += Number(pedido.total);
152
+ acumulado.bar += 1;
153
+ meses.set(mes, acumulado);
154
+ }
155
+ return [...meses.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, dato]) => dato);
156
+ }, []);
157
+
158
+ return (
159
+ <ITStack direction="column" spacing={2}>
160
+ <ITText as="span" className="font-semibold">
161
+ {t("sandbox.app.reportes.chartTitle")}
162
+ </ITText>
163
+ <ITLineBarChart
164
+ data={datos}
165
+ ariaLabel={t("sandbox.app.reportes.chartAria")}
166
+ lineLabel={t("sandbox.app.reportes.chartLine")}
167
+ barLabel={t("sandbox.app.reportes.chartBar")}
168
+ formatValue={(valor) => formatCurrency(valor)}
169
+ height={220}
170
+ />
171
+ </ITStack>
172
+ );
173
+ };
174
+
175
+ /** Slots que registra el host para las páginas del demo. Constante: identidad estable. */
176
+ const DEMO_SLOTS: SlotRegistry = {
177
+ avisoInicio: AvisoInicio,
178
+ usuarioDetalle: UsuarioDetalle,
179
+ graficaIngresos: GraficaIngresos,
180
+ };
181
+
182
+ /** Nombres de slot que registra el host. El JSON solo puede nombrar estos. */
183
+ export const DEMO_SLOT_NAMES = Object.keys(DEMO_SLOTS);
184
+
185
+ /** Acciones que el host resuelve por nombre. El JSON solo puede usar estas. */
186
+ export const DEMO_ACTIONS = ["ir-pagina", "nuevo-registro", "ver-usuario", "editar-usuario", "exportar-reporte", "enviar-contacto"] as const;
187
+
104
188
  /** Props del dashboard. */
105
189
  export interface DemoJsonDashboardProps {
106
- /** Página activa. */
107
- page: DemoAppPage;
108
- /** Navega a otra página del dashboard. */
109
- onNavigate: (page: DemoAppPage) => void;
190
+ /** Dirección dentro del dashboard (`/usuarios`). La casa `routes.json`. */
191
+ path: string;
192
+ /** Query de la dirección (`{ estado: "Activo" }`). */
193
+ query: Record<string, string>;
194
+ /** Navega a una dirección del árbol de navegación (`/usuarios?estado=Activo`). */
195
+ onNavigate: (href: string) => void;
110
196
  /** Vuelve al portafolio de demostración. */
111
197
  onExit: () => void;
112
- /** Filtro de estado leído de la URL (`?estado=`). */
113
- filterEstado: string;
114
- /** Escribe el filtro en la URL. */
198
+ /** Escribe el filtro de estado en la URL. */
115
199
  onFilterChange: (estado: string) => void;
116
200
  }
117
201
 
@@ -123,16 +207,50 @@ export interface DemoJsonDashboardProps {
123
207
  * 1. Carga el JSON de la página (`/demo/app/<página>.json`).
124
208
  * 2. Lo valida y muestra el veredicto.
125
209
  * 3. Resuelve textos (`{ i18n }`).
126
- * 4. Resuelve fuentes de datos (`{ $source }` → `fetchData`).
127
- * 5. Resuelve acciones por nombre (`ir-pagina`, `nuevo-registro`, `editar-usuario`).
210
+ * 4. Registra los handlers de datos; el motor resuelve `{ $source }` contra `data`.
211
+ * 5. Resuelve acciones por nombre (`ir-pagina`, `nuevo-registro`, `ver-usuario`, `editar-usuario`).
128
212
  * 6. Guarda: el formulario es del host, porque escribir es autoridad del host.
129
213
  *
130
214
  * @example
131
- * // Ruta: #demo/app/usuarios
132
- * <DemoJsonDashboard page="usuarios" onNavigate={setPage} onExit={goToDemo} />
215
+ * // Ruta: #demo/app/usuarios?estado=Activo
216
+ * <DemoJsonDashboard path="/usuarios" query={{ estado: "Activo" }} onNavigate={go} onExit={goToDemo} />
133
217
  */
134
- export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNavigate, onExit, filterEstado, onFilterChange }) => {
218
+ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ path, query, onNavigate, onExit, onFilterChange }) => {
135
219
  const { t } = useITTranslation();
220
+
221
+ /** Rutas declaradas en JSON: de ellas salen el menú y la página de cada dirección. */
222
+ const [rutas, setRutas] = useState<RoutesSchema | null>(null);
223
+ useEffect(() => {
224
+ fetch("/demo/app/routes.json")
225
+ .then((respuesta) => respuesta.json() as Promise<RoutesSchema>)
226
+ .then(setRutas)
227
+ .catch(() => setRutas(null));
228
+ }, []);
229
+ const planas = useMemo(() => (rutas ? flattenRoutes(rutas.routes) : []), [rutas]);
230
+ const arbol = useMemo(() => (rutas ? buildNavigationTree(rutas.routes) : []), [rutas]);
231
+ const coincidencia = useMemo(() => matchRoute(planas, path.split("/").filter(Boolean), query), [planas, path, query]);
232
+ /** Parámetros de la dirección (`:id`), que el motor expone como `$param`. */
233
+ const parametros = useMemo(() => coincidencia?.params ?? {}, [coincidencia]);
234
+ /** Id de la ruta activa. Una dirección que no casa muestra el inicio. */
235
+ const rutaActiva = coincidencia?.route.id ?? "inicio";
236
+ const pagina: DemoAppPage = DEMO_APP_PAGES.includes(coincidencia?.route.page as DemoAppPage)
237
+ ? (coincidencia!.route.page as DemoAppPage)
238
+ : "inicio";
239
+ /** Filtro de estado: lo marca la query de la dirección. */
240
+ const filterEstado = query.estado ?? "Todos";
241
+
242
+ /** Etiqueta de una entrada: `{ i18n }` se traduce, el texto literal pasa tal cual. */
243
+ const etiqueta = (valor: unknown) => (isI18nRef(valor) ? t(valor.i18n) : String(valor));
244
+ /** Entrada del sidebar a partir del árbol. Solo el primer nivel lleva icono. */
245
+ const aItemSidebar = (entrada: NavNode, primerNivel: boolean) => ({
246
+ id: entrada.id,
247
+ label: etiqueta(entrada.label),
248
+ icon: primerNivel ? ICONOS[entrada.icon ?? "box"] : undefined,
249
+ isActive: navContainsRoute(entrada, rutaActiva),
250
+ badge: entrada.badge,
251
+ action: () => onNavigate(entrada.href ?? "/inicio"),
252
+ subitems: entrada.children?.map((hijo) => aItemSidebar(hijo, false)),
253
+ });
136
254
  const [esquema, setEsquema] = useState<ScreenSchema | null>(null);
137
255
  const [error, setError] = useState<string | null>(null);
138
256
  const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
@@ -140,43 +258,35 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
140
258
  // CRUD: el esquema pide; el host escribe.
141
259
  const [formulario, setFormulario] = useState<DemoUsuarioInput | null>(null);
142
260
  // El sidebar se colapsa solo en pantallas estrechas y se puede alternar.
143
- const [colapsado, setColapsado] = useState(false);
261
+ // El sidebar empieza colapsado (solo logo) y se abre al pasar el cursor.
262
+ const [colapsado, setColapsado] = useState(true);
144
263
  const [filtroEstado, setFiltroEstado] = useState<string>(filterEstado);
145
264
  // La URL manda: si cambia el `?estado=`, la tabla se ajusta.
146
265
  useEffect(() => {
147
266
  setFiltroEstado(filterEstado);
148
267
  }, [filterEstado]);
149
- const [shell, setShell] = useState<LayoutFile["layout"] | null>(null);
150
268
  const [aviso, setAviso] = useState<{ mensaje: string; tipo: "success" | "danger" } | null>(null);
269
+ // Diálogo de confirmación que abre la acción `exportar-reporte` del JSON.
270
+ const [exportando, setExportando] = useState(false);
151
271
  const [editandoId, setEditandoId] = useState<number | null>(null);
152
272
  const [version, setVersion] = useState(0);
153
273
 
154
- const pagina: DemoAppPage = DEMO_APP_PAGES.includes(page) ? page : "inicio";
155
-
156
- /** Auto-colapso: en cuanto el ancho baja de 1100px, el rail pasa a iconos. */
157
- useEffect(() => {
158
- if (typeof window === "undefined") return;
159
- const media = window.matchMedia("(max-width: 1100px)");
160
- const aplicar = () => setColapsado(media.matches);
161
- aplicar();
162
- media.addEventListener("change", aplicar);
163
- return () => media.removeEventListener("change", aplicar);
164
- }, []);
165
274
 
166
275
  /** Muestra un aviso y lo retira solo. */
167
276
  const toast = useCallback((mensaje: string, tipo: "success" | "danger" = "success") => {
168
277
  setAviso({ mensaje, tipo });
169
278
  window.setTimeout(() => setAviso(null), 3000);
170
279
  }, []);
280
+ // Efectos que cumple el host cuando una acción del JSON lo pide (`then`, `toast`, `navigate`).
281
+ const efectosDemo = useMemo<EffectRegistry>(
282
+ () => ({
283
+ toast: ({ variant, message }) => toast(message, variant === "error" || variant === "warning" ? "danger" : "success"),
284
+ navigate: (href) => onNavigate(href),
285
+ }),
286
+ [toast, onNavigate]
287
+ );
171
288
  const url = `/demo/app/${pagina}.json`;
172
289
 
173
- /** 0. El shell también es JSON: se carga una vez. */
174
- useEffect(() => {
175
- fetch("/demo/app/layout.json")
176
- .then((r) => r.json() as Promise<LayoutFile>)
177
- .then((archivo) => setShell(archivo.layout))
178
- .catch(() => setShell(null));
179
- }, []);
180
290
 
181
291
  /** 1. Carga del JSON de la página. */
182
292
  useEffect(() => {
@@ -253,24 +363,24 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
253
363
  /** El filtro triple se aplica en el fetcher y se remonta la tabla. */
254
364
  useEffect(() => {
255
365
  setEstadoFiltro(pagina === "docs" ? null : filtroEstado);
366
+ setVersion((n) => n + 1);
256
367
  }, [filtroEstado, pagina]);
257
368
 
258
- /** Fetchers estables: uno por fuente, no uno por render. */
259
- const fetchers = useMemo(
260
- () => ({
261
- usuarios: createDemoFetcher("usuarios"),
262
- pedidos: createDemoFetcher("pedidos"),
263
- productos: createDemoFetcher("productos"),
264
- }),
265
- []
266
- );
369
+ /** Handlers estables: el motor los referencia por nombre desde `data` del esquema. */
370
+ const dataHandlers = useMemo<Record<string, DataHandler>>(() => {
371
+ const usuarios = createDemoFetcher("usuarios");
372
+ const pedidos = createDemoFetcher("pedidos");
373
+ const productos = createDemoFetcher("productos");
374
+ return {
375
+ usuarios: (query) => usuarios(query as ITDataTableFetchParams),
376
+ pedidos: (query) => pedidos(query as ITDataTableFetchParams),
377
+ productos: (query) => productos(query as ITDataTableFetchParams),
378
+ };
379
+ }, []);
267
380
 
268
- /** 3 y 4. Textos y fuentes de datos. */
381
+ /** 3. Textos. Las fuentes de datos (`$source`) las resuelve el motor. */
269
382
  const resolveValue = useCallback(
270
383
  (value: unknown): unknown => {
271
- if (isSourceRef(value)) {
272
- return isDemoSource(value.$source) ? fetchers[value.$source] : () => Promise.resolve({ data: [], total: 0 });
273
- }
274
384
  if (isI18nRef(value)) {
275
385
  const params = value.params
276
386
  ? Object.fromEntries(Object.entries(value.params).map(([clave, dato]) => [clave, String(dato)]))
@@ -279,7 +389,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
279
389
  }
280
390
  return value;
281
391
  },
282
- [t, fetchers]
392
+ [t]
283
393
  );
284
394
 
285
395
  /** 5. Acciones por nombre. La navegación y la escritura son del host. */
@@ -287,22 +397,36 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
287
397
  (node: NodeSchema, _prop: string, action: unknown) => {
288
398
  const declarada = action as { action?: string; args?: Record<string, SchemaValue> } | undefined;
289
399
  const nombre = declarada?.action;
290
- if (!nombre) return undefined;
400
+ // Solo las acciones de DEMO_ACTIONS existen: lo demás avisa con JR040 en lugar de no hacer nada.
401
+ if (!nombre || !(DEMO_ACTIONS as readonly string[]).includes(nombre)) return undefined;
291
402
 
292
403
  return (...args: unknown[]) => {
293
404
  switch (nombre) {
294
405
  case "ir-pagina": {
295
406
  const destino = String(declarada?.args?.pagina ?? "");
296
- if (DEMO_APP_PAGES.includes(destino as DemoAppPage)) onNavigate(destino as DemoAppPage);
407
+ if (DEMO_APP_PAGES.includes(destino as DemoAppPage)) onNavigate(`/${destino}`);
297
408
  return;
298
409
  }
410
+ case "exportar-reporte":
411
+ setExportando(true);
412
+ return;
413
+ case "enviar-contacto":
414
+ // Los campos ya los validó el motor, y el aviso de éxito lo declara el JSON (`then`).
415
+ return;
299
416
  case "nuevo-registro":
300
417
  abrirFormulario();
301
418
  return;
419
+ case "ver-usuario": {
420
+ // La tabla pasa la fila como primer argumento: su detalle vive en la dirección /usuarios/:id.
421
+ const fila = args[0] as DemoUsuario | undefined;
422
+ if (fila && typeof fila.id === "number") onNavigate(`/usuarios/${fila.id}`);
423
+ return;
424
+ }
302
425
  case "editar-usuario": {
303
- // La tabla pasa la fila como primer argumento.
426
+ // Desde la tabla llega la fila; desde el detalle, el id de la dirección.
304
427
  const fila = args[0] as DemoUsuario | undefined;
305
- const usuario = fila && typeof fila.id === "number" ? obtenerUsuario(fila.id) : undefined;
428
+ const id = fila && typeof fila.id === "number" ? fila.id : Number(parametros.id);
429
+ const usuario = Number.isFinite(id) ? obtenerUsuario(id) : undefined;
306
430
  if (usuario) abrirFormulario(usuario);
307
431
  return;
308
432
  }
@@ -311,7 +435,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
311
435
  }
312
436
  };
313
437
  },
314
- [onNavigate, abrirFormulario, pagina, onFilterChange]
438
+ [onNavigate, abrirFormulario, pagina, onFilterChange, parametros, toast, t]
315
439
  );
316
440
 
317
441
  const cuerpo = error ? (
@@ -340,6 +464,10 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
340
464
  <ITSchemaRenderer
341
465
  key={version}
342
466
  schema={esquema}
467
+ params={parametros}
468
+ dataHandlers={dataHandlers}
469
+ slots={DEMO_SLOTS}
470
+ effects={efectosDemo}
343
471
  resolveValue={resolveValue}
344
472
  resolveAction={resolveAction}
345
473
  onIssue={(aviso) =>
@@ -356,42 +484,16 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
356
484
  <ITLayout
357
485
  topBar={{
358
486
  logo: <AxzyLogo variante="icono" tamano={20} />,
359
- logoText: shell?.brand?.text ?? "AXZY · demo",
360
- navItems: DEMO_APP_PAGES.map((item) => ({
361
- id: item,
362
- label: t(PAGE_LABEL[item]),
363
- isActive: item === pagina,
364
- action: () => onNavigate(item),
365
- })),
366
487
  children: (
367
488
  <ITButton label={t("sandbox.app.exit")} size="sm" variant="outlined" color="gray" onClick={onExit} />
368
489
  ),
369
490
  }}
370
491
  sidebar={{
371
492
  isCollapsed: colapsado,
493
+ expandOnHover: true,
372
494
  onToggleCollapse: () => setColapsado((previo) => !previo),
373
- brand: {
374
- logo: <AxzyLogo variante="icono" tamano={20} />,
375
- text: shell?.brand?.text ?? "AXZY · demo",
376
- },
377
- navigationItems: (shell?.modules ?? []).map((modulo) => ({
378
- id: modulo.id,
379
- label: isI18nRef(modulo.label) ? t(modulo.label.i18n) : String(modulo.label),
380
- icon: ICONOS[modulo.icon ?? "box"],
381
- isActive: modulo.page === pagina,
382
- badge: modulo.badge,
383
- action: () => onNavigate(modulo.page as DemoAppPage),
384
- // Los sub-items llevan `query`: filtran de verdad, no son decoración.
385
- subitems: modulo.subitems?.map((sub) => ({
386
- id: sub.id,
387
- label: isI18nRef(sub.label) ? t(sub.label.i18n) : String(sub.label),
388
- action: () => {
389
- if (sub.page !== pagina) onNavigate(sub.page as DemoAppPage);
390
- onFilterChange(sub.query?.estado ?? "Todos");
391
- setVersion((n) => n + 1);
392
- },
393
- })),
394
- })),
495
+ // Los sub-items son rutas con `query`: navegar a ellas filtra de verdad.
496
+ navigationItems: arbol.map((entrada) => aItemSidebar(entrada, true)),
395
497
  }}
396
498
  >
397
499
  <ITPage
@@ -403,7 +505,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
403
505
  ]}
404
506
  >
405
507
  <ITStack direction="column" spacing={4}>
406
- {pagina !== "docs" && (
508
+ {(pagina === "usuarios" || pagina === "pedidos") && (
407
509
  <ITTripleFilter
408
510
  value={filtroEstado}
409
511
  onChange={(valor) => {
@@ -473,6 +575,19 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
473
575
  )}
474
576
  </ITDialog>
475
577
 
578
+ <ITConfirmDialog
579
+ isOpen={exportando}
580
+ onClose={() => setExportando(false)}
581
+ onConfirm={() => {
582
+ setExportando(false);
583
+ toast(t("sandbox.app.export.done"));
584
+ }}
585
+ title={t("sandbox.app.export.title")}
586
+ message={t("sandbox.app.export.message")}
587
+ confirmLabel={t("sandbox.app.export.confirm")}
588
+ cancelLabel={t("sandbox.app.form.cancel")}
589
+ />
590
+
476
591
  {aviso && (
477
592
  <ITToast
478
593
  message={aviso.mensaje}
@@ -45,7 +45,7 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
45
45
  parrafos: [
46
46
  "El host (TSX, dev-only) es dueño del chrome: monta ITLayout e ITPage, traduce etiquetas, resuelve iconos, datos y acciones. Vive en src/showcases/DemoJsonDashboard.tsx.",
47
47
  "El motor (src/json-renderer/) pinta el cuerpo de cada página desde JSON. Nunca toca red, funciones ni nodos React.",
48
- "El JSON describe: public/demo/app/layout.json (shell) y public/demo/app/<página>.json (cuerpo).",
48
+ "El JSON describe: public/demo/app/layout.json (marca), public/demo/app/routes.json (rutas y menú) y public/demo/app/<página>.json (cuerpo).",
49
49
  ],
50
50
  },
51
51
  {
@@ -53,25 +53,25 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
53
53
  parrafos: [
54
54
  "DemoJsonDashboard.tsx:356 monta <ITLayout topBar sidebar>; :398 monta <ITPage>; :340 monta <ITSchemaRenderer>.",
55
55
  "ITLayout no puede ser un nodo del esquema: topBar.logo es un ReactNode, navigationItems[].action es una función y icon es un componente. El motor prohíbe las tres.",
56
- "Por eso el JSON nombra (icon: \"users\", page: \"usuarios\", query: {estado: \"Activo\"}) y el host resuelve con el registro ICONOS y la navegación.",
56
+ "Por eso el JSON nombra (icon: \"users\", page: \"usuarios\", query: {estado: \"Activo\"}) y el host resuelve con el registro ICONOS y el árbol que sale de routes.json.",
57
57
  ],
58
- codigo: '<ITLayout\n topBar={{ logo: <AxzyLogo/>, logoText: shell.brand.text }}\n sidebar={{ modules: shell.modules.map(\u2026) }}\n>',
58
+ codigo: '<ITLayout\n topBar={{ logo: <AxzyLogo/>, logoText: shell.brand.text }}\n sidebar={{ navigationItems: arbol.map(\u2026) }}\n>',
59
59
  },
60
60
  {
61
- titulo: "3 · Añadir un módulo",
61
+ titulo: "3 · Añadir una ruta",
62
62
  parrafos: [
63
- "Edita public/demo/app/layout.json y añade un objeto a layout.modules. No hay que tocar TSX.",
64
- "Si el módulo no tiene página propia, apunta page a una existente: la verás navegar igual.",
63
+ "Edita public/demo/app/routes.json y añade una ruta con path, page y nav. Sin nav, la ruta existe pero no aparece en el menú. No hay que tocar TSX.",
64
+ "Si la página no tiene JSON propio, apunta page a una existente: la verás navegar igual.",
65
65
  ],
66
- codigo: '{\n "id": "ajustes",\n "label": { "i18n": "sandbox.app.nav.ajustes" },\n "icon": "box",\n "page": "ajustes"\n}',
66
+ codigo: '{\n "id": "ajustes",\n "path": "/ajustes",\n "page": "ajustes",\n "nav": { "label": { "i18n": "sandbox.app.nav.ajustes" }, "icon": "box" }\n}',
67
67
  },
68
68
  {
69
69
  titulo: "4 · Sub-items que filtran de verdad",
70
70
  parrafos: [
71
- "Cada sub-item puede llevar query. El host lo traduce a ?estado= en la URL y la tabla se refiltra.",
72
- "Sin query, el sub-item solo navega. Nada de botones decorativos.",
71
+ "Un sub-item es una ruta hija con query. Navegar a ella cambia la dirección (?estado=) y la tabla se refiltra.",
72
+ "Sin query, el sub-item es la ruta base del grupo. Nada de botones decorativos.",
73
73
  ],
74
- codigo: '{\n "label": "Activos",\n "page": "usuarios",\n "query": { "estado": "Activo" }\n}',
74
+ codigo: '{\n "id": "u-activos",\n "page": "usuarios",\n "query": { "estado": "Activo" },\n "nav": { "label": { "i18n": "sandbox.app.sub.activos" } }\n}',
75
75
  },
76
76
  {
77
77
  titulo: "5 · Iconos y marca",
@@ -85,7 +85,7 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
85
85
  titulo: "6 · Una pantalla que NO es JSON",
86
86
  parrafos: [
87
87
  "Soportado a propósito: hay pantallas que necesitan código (un editor, un mapa, un flujo con estado propio). El motor no las debe impedir.",
88
- "Ruta 1: página del host. Crea el componente, regístralo en DEMO_APP_PAGES y en el router, y renderízalo en lugar de ITSchemaRenderer cuando la página sea esa.",
88
+ "Ruta 1: página del host. Crea el componente, regístralo en DEMO_APP_PAGES y en routes.json, y renderízalo en lugar de ITSchemaRenderer cuando la página sea esa.",
89
89
  "Ruta 2: híbrido. El esquema pinta el 90% y un nodo con acción abre la parte TSX (diálogo, editor, visor). Es lo que hace el CRUD con su formulario.",
90
90
  "Ruta 3: contenido embebido. El host expone un componente del catálogo que internamente ya es TSX (ITDataTable, ITJSONEditor). El esquema solo lo nombra.",
91
91
  "La regla: si la pantalla necesita funciones, es del host; si solo describe, es JSON.",