Ir al contenido

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.

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."
/>
</>
);
}

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>
);
}

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>
);
}

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>
);
}