Informe de ventas imprimible: agrupación, formatos y CSS de impresión
Genera desde JavaScript el informe de ventas de un trimestre a partir de datos JSON: tabla agrupada por comercial con subtotales y total, importes y fechas en formato español con Intl, y una hoja de estilos de impresión que oculta los botones, repite la cabecera en cada página y no parte filas.
- Agrupar con un objeto
- Crear tablas con el DOM
- Intl.NumberFormat e Intl.DateTimeFormat
- caption, thead y tfoot
- @media print y @page
- CSSOM: document.styleSheets
Enunciado
El departamento comercial necesita cada trimestre un informe de ventas que se pueda ver en pantalla y también imprimir o guardar en PDF desde el navegador. Herramientas como JasperReports generan informes desde Java; en una aplicación web, lo habitual es generarlos con HTML y CSS, y el diálogo de impresión del navegador se encarga del PDF.
Los datos llegan como JSON (aquí, dentro de la propia página en un <script type="application/json">). El informe agrupa las ventas por comercial, con un subtotal por grupo y un total general: es la estructura clásica de un informe con grupos y sumas.
Lo que se ve bien en pantalla no tiene por qué imprimirse bien: los botones sobran en el papel, las tablas largas se parten entre páginas y los fondos gastan tinta. Para eso existe la hoja de estilos de impresión.
Qué hay que hacer
- Los datos se leen del elemento
#datosconJSON.parse. La tabla se añade dentro de#informe. - Cada apartado se corrige por separado con pruebas que se ejecutan dentro de la página (algunas a varios anchos de pantalla). Debajo del editor tienes el resultado en directo.
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Agrupar antes de pintar
Primero organiza los datos (un objeto con una lista por comercial, ordenada) y después recórrelos para crear las filas. Mezclar las dos cosas lleva a subtotales en el sitio equivocado.
2. Crear la tabla con el DOM
document.createElement y textContent evitan problemas de escape: si un cliente se llamara «<b>Bar</b>», con innerHTML se interpretaría como HTML. Una función auxiliar para las filas acorta mucho el código.
3. Formatos con Intl
Intl.NumberFormat e Intl.DateTimeFormat conocen las reglas de cada país (separadores, símbolo de moneda, orden de la fecha). Crea los formateadores una vez y reutilízalos.
const euros = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR" });
euros.format(1250.5); // "1250,50 €"4. Estructura de una tabla de informe
caption da el título, thead la cabecera (que al imprimir se puede repetir en cada página), tbody los datos y tfoot los totales. Cada parte se puede estilar e imprimir por separado.
5. CSS para el papel
En @media print se quita lo que no tiene sentido en papel y se ajustan tamaños y colores; break-inside y @page controlan cómo se reparte el informe en páginas. Comprueba el resultado con Ctrl+P y «Guardar como PDF».
Resuélvelo aquí
Cada apartado edita un fichero de la página (el HTML, el CSS o el JavaScript) y se corrige con pruebas que se ejecutan en la propia página, también a distintos anchos de pantalla. Debajo del editor ves el resultado en directo.
Genera la tabla: un thead con las columnas Comercial, Cliente, Fecha e Importe; en el tbody, las ventas agrupadas por comercial (los comerciales por orden alfabético y sus ventas por fecha), y detrás de cada grupo una fila con la clase subtotal: «Total Nombre», dos celdas vacías y la suma. Al final, un tfoot con una fila total: «Total general», dos celdas vacías y la suma de todo. En este apartado, las fechas tal como vienen (2026-07-03) y los importes con dos decimales y punto (1250.50).
Formato para personas: importes con Intl.NumberFormat en euros a la española (1.250,50 €) y fechas con Intl.DateTimeFormat como 03/07/2026. Añade un caption con el periodo y el número de ventas: Del 02/07/2026 al 22/09/2026 · 8 ventas, pon scope="col" en los encabezados y la clase importe en las celdas de importe.
La hoja de impresión: dentro de @media print, oculta .no-imprimir, pon el texto a 10pt y negro, haz que el thead se repita en cada página (display: table-header-group), evita que una fila se parta entre dos páginas (break-inside: avoid en tr) y quita el fondo de los subtotales. Fuera, una regla @page con tamaño A4 y márgenes de 2cm. Conserva los estilos de pantalla.
Solución explicada
Ver las soluciones de todos los apartados
Apartado 1 · JavaScript
const ventas = JSON.parse(document.getElementById("datos").textContent);
// Agrupa las ventas por comercial: { "Ana Gil": [venta, venta…], … }
const grupos = {};
for (const v of ventas) (grupos[v.comercial] ??= []).push(v);
const tabla = document.createElement("table");
tabla.innerHTML = "<thead><tr><th>Comercial</th><th>Cliente</th><th>Fecha</th><th>Importe</th></tr></thead>";
const cuerpo = document.createElement("tbody");
let total = 0;
const fila = (celdas, clase) => {
const tr = document.createElement("tr");
if (clase) tr.className = clase;
for (const c of celdas) {
const td = document.createElement("td");
td.textContent = c;
tr.append(td);
}
return tr;
};
for (const comercial of Object.keys(grupos).sort((a, b) => a.localeCompare(b, "es"))) {
const lista = grupos[comercial].sort((a, b) => a.fecha.localeCompare(b.fecha));
let subtotal = 0;
for (const v of lista) {
cuerpo.append(fila([comercial, v.cliente, v.fecha, v.importe.toFixed(2)]));
subtotal += v.importe;
}
cuerpo.append(fila([`Total ${comercial}`, "", "", subtotal.toFixed(2)], "subtotal"));
total += subtotal;
}
const pie = document.createElement("tfoot");
pie.append(fila(["Total general", "", "", total.toFixed(2)], "total"));
tabla.append(cuerpo, pie);
document.getElementById("informe").append(tabla);Apartado 2 · JavaScript
const ventas = JSON.parse(document.getElementById("datos").textContent);
// Formatos de España: 1.250,50 € y 03/07/2026
const euros = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR" });
const fechas = new Intl.DateTimeFormat("es-ES", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" });
const formatoFecha = (iso) => fechas.format(new Date(iso + "T00:00:00Z"));
const grupos = {};
for (const v of ventas) (grupos[v.comercial] ??= []).push(v);
const tabla = document.createElement("table");
const titulo = document.createElement("caption");
const dias = ventas.map((v) => v.fecha).sort();
titulo.textContent = `Del ${formatoFecha(dias[0])} al ${formatoFecha(dias[dias.length - 1])} · ${ventas.length} ventas`;
tabla.append(titulo);
tabla.insertAdjacentHTML("beforeend", "<thead><tr><th scope=\"col\">Comercial</th><th scope=\"col\">Cliente</th><th scope=\"col\">Fecha</th><th scope=\"col\">Importe</th></tr></thead>");
const cuerpo = document.createElement("tbody");
let total = 0;
const fila = (celdas, clase) => {
const tr = document.createElement("tr");
if (clase) tr.className = clase;
celdas.forEach((c, i) => {
const td = document.createElement("td");
td.textContent = c;
if (i === 3) td.className = "importe";
tr.append(td);
});
return tr;
};
for (const comercial of Object.keys(grupos).sort((a, b) => a.localeCompare(b, "es"))) {
const lista = grupos[comercial].sort((a, b) => a.fecha.localeCompare(b.fecha));
let subtotal = 0;
for (const v of lista) {
cuerpo.append(fila([comercial, v.cliente, formatoFecha(v.fecha), euros.format(v.importe)]));
subtotal += v.importe;
}
cuerpo.append(fila([`Total ${comercial}`, "", "", euros.format(subtotal)], "subtotal"));
total += subtotal;
}
const pie = document.createElement("tfoot");
pie.append(fila(["Total general", "", "", euros.format(total)], "total"));
tabla.append(cuerpo, pie);
document.getElementById("informe").append(tabla);Apartado 3 · CSS
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
th, td { border-bottom: 1px solid #ccc; padding: 4px 8px; text-align: left; }
.importe { text-align: right; }
.subtotal td { font-weight: bold; background: #f2f2f2; }
.total td { font-weight: bold; border-top: 2px solid #000; }
@media print {
.no-imprimir { display: none; }
body { font-size: 10pt; color: #000; }
/* La cabecera de la tabla se repite en cada página y ninguna fila se parte entre dos */
thead { display: table-header-group; }
tr { break-inside: avoid; }
.subtotal td { background: none; }
}
@page {
size: A4;
margin: 2cm;
}El informe separa datos, estructura y presentación: el JSON trae los datos, el JavaScript construye la tabla con su estructura semántica (caption, thead, tbody, tfoot) y el CSS decide cómo se ve en pantalla y en papel.
Agrupar con un objeto y ordenar las claves es el equivalente en JavaScript a los grupos de un generador de informes: cada grupo produce sus filas y su subtotal, y el total se acumula a la vez.
Intl evita formatear números y fechas a mano, con sus casos raros (separadores de miles, símbolos de moneda, zonas horarias). Crear la fecha en UTC y formatearla en UTC garantiza que el 3 de julio no se convierta en el 2 por la zona horaria del equipo.
La hoja de impresión es CSS normal dentro de @media print. Repetir la cabecera de la tabla y no partir filas son las dos reglas que más mejoran un informe largo; @page fija el tamaño del papel y los márgenes para quien guarde el PDF.
Para ir más allá
- Añade un gráfico de barras por comercial dibujado con divs y CSS (o con un
<canvas>) encima de la tabla. - Permite elegir el trimestre con un
<select>y regenerar el informe con los datos de una API confetch. - Añade números de página en el pie de cada página impresa con
@page { @bottom-right { content: counter(page) } }y comprueba qué navegadores lo admiten.