Recetas
Las páginas anteriores documentan cada componente por separado. Esta monta cuatro pantallas enteras, del tipo que se repite en cualquier ERP, para que se vea cómo encajan entre sí.
Todas asumen que tienes montados el ToastProvider y el PopupProvider en la raíz,
como se explica en Feedback y Overlays.
Listado de facturas
Sección titulada «Listado de facturas»El patrón más repetido: cabecera con acciones, barra de filtros, tabla con selección, acciones de fila, acciones masivas y paginación de servidor.
import * as React from 'react';import { PageHeader, FilterBar, Table, BulkActionsBar, Badge, Button, useToast, usePopup, type TableColumn, type FilterBarConfig,} from '@openfactu/ui';import { Plus, Receipt } from 'lucide-react';
interface Factura { id: number; numero: string; cliente: string; fecha: string; total: number; estado: 'borrador' | 'emitida' | 'pagada' | 'vencida';}
const ESTADO: Record<Factura['estado'], React.ReactNode> = { borrador: <Badge variant="neutral">Borrador</Badge>, emitida: <Badge variant="info">Emitida</Badge>, pagada: <Badge variant="success">Pagada</Badge>, vencida: <Badge variant="danger">Vencida</Badge>,};
const money = (v: number) => `${v.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €`;
const FILTROS: FilterBarConfig[] = [ { key: 'estado', label: 'Estado', type: 'select', options: [ { label: 'Borrador', value: 'borrador' }, { label: 'Emitida', value: 'emitida' }, { label: 'Pagada', value: 'pagada' }, { label: 'Vencida', value: 'vencida' }, ], }, { key: 'cliente', label: 'Cliente', type: 'search-select', width: 220, options: CLIENTES }, { key: 'desde', label: 'Desde', type: 'date' }, { key: 'hasta', label: 'Hasta', type: 'date' },];
export function ListadoDeFacturas() { const toast = useToast(); const popup = usePopup();
const [termino, setTermino] = React.useState(''); const [filtros, setFiltros] = React.useState<Record<string, any>>({}); const [pagina, setPagina] = React.useState(1); const [tamano, setTamano] = React.useState(25); const [seleccionadas, setSeleccionadas] = React.useState<Set<string | number>>(new Set());
const { filas, total, cargando } = useFacturas({ termino, filtros, pagina, tamano });
const columnas: TableColumn<Factura>[] = [ { header: 'Número', accessor: 'numero', primary: true, sortable: true, card: 'title' }, { header: 'Cliente', accessor: 'cliente', sortable: true, card: 'subtitle' }, { header: 'Fecha', accessor: 'fecha', sortable: true }, { header: 'Estado', cell: (f) => ESTADO[f.estado], align: 'center', card: 'status' }, { header: 'Total', accessor: (f) => money(f.total), sortAccessor: (f) => f.total, align: 'right', sortable: true, card: 'meta', }, ];
const eliminar = async (facturas: Factura[]) => { const seguro = await popup.confirm({ title: facturas.length === 1 ? 'Eliminar factura' : `Eliminar ${facturas.length} facturas`, message: 'Solo se pueden eliminar borradores. No se puede deshacer.', tone: 'danger', confirmLabel: 'Eliminar', }); if (!seguro) return;
await toast.promise(borrarFacturas(facturas.map((f) => f.id)), { loading: 'Eliminando…', success: 'Facturas eliminadas', error: (e) => `No se pudieron eliminar: ${e.message}`, }); setSeleccionadas(new Set()); };
return ( <> <PageHeader eyebrow="Ventas" title="Facturas" subtitle={`${total} en el periodo seleccionado`} icon={<Receipt className="h-5 w-5" />} actions={ <Button variant="accent" onClick={nuevaFactura}> <Plus className="h-3.5 w-3.5" /> Nueva factura </Button> } toolbar={ <FilterBar config={FILTROS} searchTerm={termino} onSearchChange={setTermino} activeFilters={filtros} onFilterChange={(k, v) => { setFiltros((p) => ({ ...p, [k]: v })); setPagina(1); }} onClear={() => { setTermino(''); setFiltros({}); setPagina(1); }} searchPlaceholder="Buscar por número o cliente…" /> } />
<BulkActionsBar count={seleccionadas.size} label="facturas seleccionadas" onClear={() => setSeleccionadas(new Set())} > <Button size="sm" variant="secondary" onClick={() => exportar([...seleccionadas])}> Exportar </Button> <Button size="sm" variant="danger" onClick={() => eliminar(filas.filter((f) => seleccionadas.has(f.id)))} > Eliminar </Button> </BulkActionsBar>
<Table columns={columnas} data={filas} isLoading={cargando} skeletonRowHeight={24} responsive="cards" selectable selectedKeys={seleccionadas} onSelectionChange={setSeleccionadas} onRowClick={abrirFactura} rowActions={(f) => [ { label: 'Abrir', onClick: () => abrirFactura(f) }, { label: 'Duplicar', onClick: () => duplicar(f) }, { label: 'Descargar PDF', onClick: () => descargar(f) }, { label: 'Eliminar', destructive: true, separatorBefore: true, disabled: f.estado !== 'borrador', onClick: () => eliminar([f]), }, ]} pagination={{ pageSize: tamano, page: pagina, total, onPageChange: setPagina, onPageSizeChange: setTamano, }} emptyMessage="Ninguna factura coincide con los filtros." /> </> );}Formulario maestro-detalle
Sección titulada «Formulario maestro-detalle»La ficha de un documento de venta: cabecera en FormGrid y líneas en una
EditableTable que escribe celda a celda, con los totales al pie.
import * as React from 'react';import { Card, FormSection, FormGrid, FormRow, Field, Input, Textarea, SearchableSelect, DatePicker, EditableTable, Button, useToast, type EditableColumn,} from '@openfactu/ui';
interface Linea { id: number; articulo: string; cantidad: number; precio: number; descuento: number; /** Si viene de un pedido, la línea no se toca. */ origen?: string;}
const money = (v: number) => `${v.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €`;const importe = (l: Linea) => l.cantidad * l.precio * (1 - l.descuento / 100);
export function FichaDeFactura({ factura }) { const toast = useToast(); const [cabecera, setCabecera] = React.useState(factura); const [lineas, setLineas] = React.useState<Linea[]>(factura.lineas); const [guardando, setGuardando] = React.useState(false);
const bloqueada = (l: Linea) => !!l.origen; const base = lineas.reduce((a, l) => a + importe(l), 0);
const columnas: EditableColumn<Linea>[] = [ { header: 'Artículo', accessor: (l) => ARTICULOS.find((a) => a.value === l.articulo)?.label ?? l.articulo, field: 'articulo', editor: { type: 'search-select', options: ARTICULOS, placeholder: 'Buscar artículo…' }, isDisabled: bloqueada, }, { header: 'Cantidad', accessor: 'cantidad', align: 'right', width: '120px', editor: { type: 'number', precision: 2, min: 0 }, isDisabled: bloqueada, }, { header: 'Precio', accessor: (l) => money(l.precio), field: 'precio', align: 'right', width: '130px', editor: { type: 'currency' }, isDisabled: bloqueada, }, { header: 'Dto.', accessor: (l) => `${l.descuento} %`, field: 'descuento', align: 'right', width: '110px', editor: { type: 'percent' }, isDisabled: bloqueada, }, { header: 'Importe', accessor: (l) => money(importe(l)), align: 'right', width: '130px' }, ];
const guardar = async () => { setGuardando(true); try { await guardarFactura({ ...cabecera, lineas }); toast.success('Factura guardada'); } catch (e) { toast.error(`No se pudo guardar: ${e.message}`); } finally { setGuardando(false); } };
return ( <div className="flex flex-col gap-6"> <Card title="Factura de venta" subtitle={cabecera.numero} footer={ <div className="flex justify-end gap-2"> <Button variant="secondary" size="sm" onClick={cancelar}>Cancelar</Button> <Button variant="accent" size="sm" isLoading={guardando} onClick={guardar}> Guardar </Button> </div> } > <div className="flex flex-col gap-6"> <FormSection title="Datos del documento"> <FormGrid columns={3}> <Field label="Cliente" required> <SearchableSelect options={CLIENTES} value={cabecera.cliente} onChange={(v) => setCabecera((c) => ({ ...c, cliente: v }))} /> </Field> <Field label="Fecha de emisión" required> <DatePicker value={cabecera.fecha} onChange={(v) => setCabecera((c) => ({ ...c, fecha: v }))} /> </Field> <Field label="Vencimiento"> <DatePicker value={cabecera.vencimiento} min={cabecera.fecha ?? undefined} clearable onChange={(v) => setCabecera((c) => ({ ...c, vencimiento: v }))} /> </Field> <FormRow> <Field label="Observaciones" hint="Se imprimen al pie de la factura."> <Textarea rows={2} value={cabecera.notas} onChange={(e) => setCabecera((c) => ({ ...c, notas: e.target.value }))} /> </Field> </FormRow> </FormGrid> </FormSection> </div> </Card>
<Card title="Líneas" noPadding> <EditableTable mode="cell" density="compact" columns={columnas} data={lineas} onChange={(fila) => setLineas((prev) => prev.map((l) => (l.id === fila.id ? fila : l)))} onDelete={(fila) => setLineas((prev) => prev.filter((l) => l.id !== fila.id))} onAddRow={() => { const nueva: Linea = { id: Date.now(), articulo: '', cantidad: 1, precio: 0, descuento: 0, }; setLineas((prev) => [...prev, nueva]); return nueva; }} isRowEditable={(l) => !bloqueada(l)} summaryRow={() => [null, null, null, null, money(base)]} summaryLabel="Base imponible" emptyMessage="Añade la primera línea del documento." /> </Card> </div> );}Dashboard
Sección titulada «Dashboard»Fila de KPIs y dos gráficos, con los estados de carga puestos para que no salte el layout.
import { KpiCard, Card, Ring, Sparkline, StackedBar } from '@openfactu/ui';import { Chart } from '@openfactu/ui/charts';import { Receipt, Wallet, Users } from 'lucide-react';
const money = (v: number) => `${v.toLocaleString('es-ES')} €`;
export function Dashboard() { const { kpis, tendencia, porEstado, cargando } = useDashboard();
return ( <div className="flex flex-col gap-6"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <KpiCard label="Facturado este mes" value={money(kpis.facturado)} sub={`${kpis.numFacturas} facturas`} trend={{ dir: 'up', text: '+12 % vs. mes anterior' }} icon={<Receipt className="h-4 w-4" />} isLoading={cargando} /> <KpiCard label="Pendiente de cobro" value={money(kpis.pendiente)} sub={`${kpis.vencidas} vencidas`} trend={{ dir: 'down', text: '-4 %' }} icon={<Wallet className="h-4 w-4" />} isLoading={cargando} /> <KpiCard label="Clientes activos" value={kpis.clientes} icon={<Users className="h-4 w-4" />} isLoading={cargando} /> <Card title="Cuota de almacenamiento"> <div className="flex items-center gap-4"> <Ring value={kpis.usoGb} max={kpis.cuotaGb} size={64} thickness={6} tone="auto" label /> <div className="text-[13px] text-[var(--fg-muted)]"> {kpis.usoGb} de {kpis.cuotaGb} GB </div> </div> </Card> </div>
<div className="grid gap-6 lg:grid-cols-2"> <Card title="Tendencia" subtitle="Ventas frente a compras, últimos 12 meses" isLoading={cargando}> <Chart type="line" data={tendencia} xKey="mes" series={[ { key: 'ventas', label: 'Ventas' }, { key: 'compras', label: 'Compras' }, ]} valueFormat={money} aria-label="Ventas y compras por mes" /> </Card>
<Card title="Cartera por estado" isLoading={cargando}> <StackedBar segments={[ { key: 'pagadas', label: 'Pagadas', value: porEstado.pagadas }, { key: 'emitidas', label: 'Emitidas', value: porEstado.emitidas }, { key: 'borrador', label: 'Borrador', value: porEstado.borrador }, { key: 'vencidas', label: 'Vencidas', value: porEstado.vencidas, color: 'var(--k-danger)' }, ]} valueFormat={(v) => `${v} facturas`} ariaLabel="Reparto de la cartera por estado" /> <div className="mt-6 flex items-center justify-between"> <span className="text-[12px] text-[var(--fg-muted)]">Cobros de los últimos 30 días</span> <Sparkline data={kpis.cobrosDiarios} height={36} showLastPoint label="Cobros de los últimos 30 días" /> </div> </Card> </div> </div> );}Asistente por pasos
Sección titulada «Asistente por pasos»Alta de empresa en cuatro pasos, con validación entre uno y otro y confirmación al final.
import * as React from 'react';import { Stepper, Card, FormGrid, FormRow, Field, Input, Select, Button, useToast, usePopup, type Step,} from '@openfactu/ui';
const PASOS: Step[] = [ { id: 'empresa', label: 'Datos de empresa', description: 'CIF, domicilio y actividad' }, { id: 'fiscal', label: 'Configuración fiscal', description: 'Régimen y series' }, { id: 'usuarios', label: 'Usuarios', description: 'Invita a tu equipo' }, { id: 'listo', label: 'Listo' },];
export function AltaDeEmpresa() { const toast = useToast(); const popup = usePopup();
const [paso, setPaso] = React.useState(0); const [datos, setDatos] = React.useState(INICIAL); const [errores, setErrores] = React.useState<Record<string, string>>({}); const [enviando, setEnviando] = React.useState(false);
const siguiente = () => { const fallos = validar(PASOS[paso].id, datos); setErrores(fallos); if (Object.keys(fallos).length) return; setPaso((p) => p + 1); };
const finalizar = async () => { const seguro = await popup.confirm({ title: 'Crear la empresa', message: 'Se creará la empresa con la configuración fiscal indicada. Podrás cambiarla después.', confirmLabel: 'Crear', }); if (!seguro) return;
setEnviando(true); try { await crearEmpresa(datos); toast.success('Empresa creada'); setPaso(PASOS.length - 1); } catch (e) { toast.error(`No se pudo crear: ${e.message}`); } finally { setEnviando(false); } };
return ( <div className="mx-auto flex max-w-3xl flex-col gap-6"> <Stepper steps={PASOS.map((p, i) => i === paso && Object.keys(errores).length ? { ...p, status: 'error' } : p, )} current={paso} onStepClick={(_, i) => setPaso(i)} />
<Card title={PASOS[paso].label} subtitle={PASOS[paso].description}> {paso === 0 && ( <FormGrid> <Field label="Razón social" required error={errores.razon}> <Input value={datos.razon} onChange={(e) => setDatos((d) => ({ ...d, razon: e.target.value }))} /> </Field> <Field label="CIF" required error={errores.cif}> <Input value={datos.cif} onChange={(e) => setDatos((d) => ({ ...d, cif: e.target.value.toUpperCase() }))} status={errores.cif ? 'error' : datos.cif ? 'success' : 'default'} /> </Field> <FormRow> <Field label="Domicilio fiscal" required error={errores.domicilio}> <Input value={datos.domicilio} onChange={(e) => setDatos((d) => ({ ...d, domicilio: e.target.value }))} /> </Field> </FormRow> </FormGrid> )}
{paso === 1 && ( <FormGrid columns={2}> <Field label="Régimen de IVA" required> <Select value={datos.regimen} onChange={(v) => setDatos((d) => ({ ...d, regimen: v }))} options={REGIMENES} /> </Field> <Field label="Serie de facturación" required> <Input value={datos.serie} onChange={(e) => setDatos((d) => ({ ...d, serie: e.target.value }))} /> </Field> </FormGrid> )}
{paso === 2 && <InvitarUsuarios datos={datos} onChange={setDatos} />}
{paso === 3 && ( <p className="text-[13px] text-[var(--fg-body)]"> Todo listo. Ya puedes empezar a emitir facturas. </p> )} </Card>
<div className="flex justify-between"> <Button variant="secondary" disabled={paso === 0} onClick={() => setPaso((p) => p - 1)}> Atrás </Button> {paso < PASOS.length - 2 ? ( <Button variant="accent" onClick={siguiente}>Siguiente</Button> ) : paso === PASOS.length - 2 ? ( <Button variant="accent" isLoading={enviando} onClick={finalizar}> Crear empresa </Button> ) : ( <Button variant="accent" onClick={irAlPanel}>Ir al panel</Button> )} </div> </div> );}