Apuntes DAM
Volver al inicio

Informe de ventas imprimible: agrupación, formatos y CSS de impresión

Ejercicio de JavaScriptDifícilUnos 75 minutos

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

  1. Los datos se leen del elemento #datos con JSON.parse. La tabla se añade dentro de #informe.
  2. 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.

javascript
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.

JS · DOMApartado 1 · JavaScriptDifícil

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).

index.htmlsolo lectura
script.js
Vista previa
JS · DOMApartado 2 · JavaScriptDifícil

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.

index.htmlsolo lectura
script.js
Vista previa
CSSApartado 3 · CSSDifícil

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.

index.htmlsolo lectura
estilos.css
Vista previa

Solución explicada

Ver las soluciones de todos los apartados

Apartado 1 · JavaScript

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

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

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 con fetch.
  • 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.

Dónde se explica