4. Interfaz de usuario
En esta parte el plugin gana cara: un módulo propio en el sidebar del ERP, una página React con el historial de movimientos, y los endpoints HTTP que la alimentan.
El manifest
Sección titulada «El manifest»manifest.json declara los metadatos y la UI del plugin. Versión para esta parte:
{ "name": "fidelizacion", "version": "1.0.0", "description": "Puntos de fidelidad por compra para tus clientes", "author": "Tu Nombre", "logo": "Gift", "ui": { "modules": [ { "id": "fidelizacion", "label": "Fidelizacion", "icon": "Gift", "subTabs": [ { "label": "Panel", "path": "/plugin/fidelizacion", "icon": "LayoutDashboard" } ] } ], "subTabs": [ { "moduleId": "sales", "label": "Fidelizacion", "path": "/plugin/fidelizacion", "icon": "Gift" } ], "routes": [ { "path": "/plugin/fidelizacion", "title": "Fidelizacion", "type": "custom", "config": { "component": "ui/Page.tsx" } } ] }}Tres bloques de UI:
modules— un módulo top-level: icono nuevo (Gift, de lucide-react) en el sidebar, con sus propias pestañas.subTabs— además, inyectamos una pestaña en el módulo core Ventas (moduleId: "sales"), donde los usuarios de facturación ya trabajan.routes— asocia el path/plugin/fidelizacioncon nuestro componente React (type: "custom"+config.component).
Referencia completa de cada bloque en Manifest y UI.
El componente React
Sección titulada «El componente React»ui/Page.tsx debe hacer export default del componente. Los componentes visuales vienen de @openfactu/ui, el paquete que implementa Claritas, el sistema de diseño de Keirost:
import React, { useEffect, useState } from 'react';import { Card, Table, Loader, type TableColumn } from '@openfactu/ui';
interface Movimiento { id: number; partner_id: string; points: number; reason: string; created_at: string;}
const columnas: TableColumn<Movimiento>[] = [ { header: 'Cliente', accessor: 'partner_id', primary: true }, { header: 'Puntos', accessor: 'points', align: 'right', sortable: true }, { header: 'Motivo', accessor: 'reason' }, { header: 'Fecha', accessor: 'created_at', sortable: true },];
const Page = () => { const [movimientos, setMovimientos] = useState<Movimiento[] | null>(null);
useEffect(() => { fetch('/api/plugins/fidelizacion/movimientos') .then((r) => r.json()) .then(setMovimientos); }, []);
if (!movimientos) return <Loader />;
return ( <Card title="Movimientos de puntos"> <Table columns={columnas} data={movimientos} emptyMessage="Todavía no hay movimientos." /> </Card> );};
export default Page;Table recibe las columnas como objetos y los datos en data; la referencia completa
está en Datos y tablas.
Con hot reload activo, los cambios en componentes UI se recargan en el navegador sin refrescar la página.
Los endpoints del plugin
Sección titulada «Los endpoints del plugin»context.app es el servidor HTTP del ERP (estilo Express). Registramos las rutas en init(), con el prefijo /api/plugins/fidelizacion/:
export const init = async ({ app, migration, hooks, documents, factuApi, db }: PluginContext) => { // ... partes 2 y 3 ...
// Historial de movimientos (los últimos 50) app.get(`/api/plugins/${PLUGIN_ID}/movimientos`, async (req: any, res: any) => { const tenantId = req.headers['x-tenant-id']; const tenantDb = await db.forTenant(tenantId); const result = await tenantDb.execute(sql` SELECT id, partner_id, invoice_id, points, reason, created_at FROM fidelizacion_movimientos ORDER BY created_at DESC LIMIT 50 `); res.json(result); });
// Canje manual de puntos app.post(`/api/plugins/${PLUGIN_ID}/canje`, async (req: any, res: any) => { const tenantId = req.headers['x-tenant-id']; const { partnerId, puntos } = req.body; const tenantDb = await db.forTenant(tenantId);
// FactuAPI: getPartner acepta id o código const api = factuApi.connect(tenantId, tenantDb, null); const partner = await api.getPartner(partnerId); if (!partner) return res.status(404).json({ error: 'Cliente no encontrado' });
const saldo = Number(partner.loyalty_points ?? 0); if (puntos > saldo) return res.status(400).json({ error: `Saldo insuficiente (${saldo})` });
await tenantDb.execute(sql` INSERT INTO fidelizacion_movimientos (id, partner_id, points, reason) VALUES (gen_random_uuid(), ${partner.id}, ${-puntos}, 'Canje') `); await tenantDb .update(db.schema.businessPartners) .set({ loyalty_points: sql`COALESCE(loyalty_points, 0) - ${puntos}` }) .where(eq(db.schema.businessPartners.id, partner.id));
res.json({ ok: true, saldo: saldo - puntos }); });
// Top 5 clientes por puntos (lo usará el widget de la parte 5) app.get(`/api/plugins/${PLUGIN_ID}/top`, async (req: any, res: any) => { const tenantId = req.headers['x-tenant-id']; const tenantDb = await db.forTenant(tenantId); const top = await tenantDb .select() .from(db.schema.businessPartners) .orderBy(desc(db.schema.businessPartners.loyalty_points)) .limit(5); res.json(top); });};Añade desc al import de drizzle: import { eq, desc, sql } from 'drizzle-orm';
En el endpoint de canje aparece FactuAPI por primera vez: factuApi.connect(tenantId, db, user) devuelve una FactuApiTransaction con toda la API de negocio (getPartner acepta id o código, y hay factories de documentos, contabilidad, pagos…). Para operaciones multi-paso usa factuApi.transaction(...), que es atómica.
Checkpoint
Sección titulada «Checkpoint»- Guarda todo (hot reload mediante) y refresca el ERP.
- En el sidebar debería aparecer el módulo Fidelización con el icono del regalo, y en Ventas una pestaña nueva.
- Abre la página: la tabla debería listar los movimientos generados en la parte 3.