mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-24 09:40:21 +00:00
trabajando en el pdf
This commit is contained in:
@ -2,8 +2,10 @@ package com.imprimelibros.erp;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
||||||
public class ErpApplication {
|
public class ErpApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
|
// com.imprimelibros.erp.pdf.PdfModuleConfig.java
|
||||||
package com.imprimelibros.erp.pdf;
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@ConfigurationProperties(prefix = "imprimelibros.pdf")
|
@ConfigurationProperties(prefix = "imprimelibros.pdf")
|
||||||
public class PdfModuleConfig {
|
public class PdfModuleConfig {
|
||||||
/**
|
private Map<String, String> templates = new HashMap<>();
|
||||||
* Mapa: "TYPE:templateId" -> "ruta thymeleaf" (sin extensión).
|
|
||||||
* Ej: "PRESUPUESTO:presupuesto-a4" -> "pdf/presupuesto-a4"
|
|
||||||
*/
|
|
||||||
private Map<String, String> templates;
|
|
||||||
|
|
||||||
public Map<String, String> getTemplates() { return templates; }
|
public Map<String, String> getTemplates() { return templates; }
|
||||||
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
|
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.imprimelibros.erp.pdf;
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
|
import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
|
||||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -11,34 +12,33 @@ import java.io.ByteArrayOutputStream;
|
|||||||
public class PdfRenderer {
|
public class PdfRenderer {
|
||||||
|
|
||||||
@Value("classpath:/static/")
|
@Value("classpath:/static/")
|
||||||
private Resource staticRoot;
|
private org.springframework.core.io.Resource staticRoot;
|
||||||
|
|
||||||
public byte[] renderHtmlToPdf(String html) {
|
public byte[] renderHtmlToPdf(String html) {
|
||||||
try (var baos = new ByteArrayOutputStream()) {
|
try (var baos = new ByteArrayOutputStream()) {
|
||||||
var builder = new PdfRendererBuilder();
|
var builder = new com.openhtmltopdf.pdfboxout.PdfRendererBuilder();
|
||||||
|
|
||||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"), "Open Sans",
|
|
||||||
400, com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true);
|
|
||||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"), "Open Sans",
|
|
||||||
600, com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true);
|
|
||||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"), "Open Sans", 700,
|
|
||||||
com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true);
|
|
||||||
|
|
||||||
builder.useFastMode();
|
builder.useFastMode();
|
||||||
builder.withHtmlContent(html, baseUrl());
|
|
||||||
|
// 👇 Base URL para que pueda resolver /assets/css/ y /img/
|
||||||
|
builder.withHtmlContent(html, staticRoot.getURL().toString()); // .../target/classes/static/
|
||||||
|
|
||||||
|
|
||||||
|
// (Opcional) Registrar fuentes TTF
|
||||||
|
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"),
|
||||||
|
"Open Sans", 400, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||||
|
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"),
|
||||||
|
"Open Sans", 600, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||||
|
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"),
|
||||||
|
"Open Sans", 700, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||||
|
|
||||||
builder.toStream(baos);
|
builder.toStream(baos);
|
||||||
builder.run();
|
builder.run();
|
||||||
|
|
||||||
return baos.toByteArray();
|
return baos.toByteArray();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException("Error generando PDF", e);
|
throw new IllegalStateException("Error generando PDF", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String baseUrl() {
|
|
||||||
try {
|
|
||||||
return staticRoot.getURL().toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// com.imprimelibros.erp.pdf.TemplateRegistry.java
|
|
||||||
package com.imprimelibros.erp.pdf;
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -9,10 +8,15 @@ public class TemplateRegistry {
|
|||||||
|
|
||||||
public TemplateRegistry(PdfModuleConfig config) {
|
public TemplateRegistry(PdfModuleConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
System.out.println("PDF templates registrados => " + config.getTemplates());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolve(DocumentType type, String templateId) {
|
public String resolve(DocumentType type, String templateId) {
|
||||||
|
String key = type.name() + ":" + templateId;
|
||||||
|
String keyAlt = type.name() + "_" + templateId; // compatibilidad con properties
|
||||||
if (config.getTemplates() == null) return null;
|
if (config.getTemplates() == null) return null;
|
||||||
return config.getTemplates().get(type.name() + ":" + templateId);
|
String value = config.getTemplates().get(key);
|
||||||
|
if (value == null) value = config.getTemplates().get(keyAlt);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -89,5 +89,6 @@ spring.jpa.properties.hibernate.jdbc.time_zone=UTC
|
|||||||
#
|
#
|
||||||
# PDF Templates
|
# PDF Templates
|
||||||
#
|
#
|
||||||
imprimelibros.pdf.templates.PRESUPUESTO\:presupuesto-a4=imprimelibros/pdf/presupuesto-a4
|
# PDF Templates
|
||||||
imprimelibros.pdf.templates.FACTURA\:factura-a4=imprimelibros/pdf/factura-a4
|
imprimelibros.pdf.templates.PRESUPUESTO_presupuesto-a4=imprimelibros/pdf/presupuesto-a4
|
||||||
|
imprimelibros.pdf.templates.FACTURA_factura-a4=imprimelibros/pdf/factura-a4
|
||||||
|
|||||||
@ -1,225 +1,144 @@
|
|||||||
/* === Fuentes ===
|
/* Open Sans (rutas relativas desde css → fonts) */
|
||||||
* Para HTML→PDF con OpenHTMLtoPDF, es preferible registrar las TTF en el renderer.
|
|
||||||
* Aun así, incluimos @font-face por si renderizas en navegador.
|
|
||||||
* Coloca los TTF en /static/fonts/ y ajusta los nombres si procede.
|
|
||||||
*/
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Open Sans";
|
font-family: "Open Sans";
|
||||||
src: url("/assets/fonts/OpenSans-Regular.ttf") format("truetype");
|
src: url("../fonts/OpenSans-Regular.ttf") format("truetype");
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Open Sans";
|
font-family: "Open Sans";
|
||||||
src: url("/assets/fonts/OpenSans-SemiBold.ttf") format("truetype");
|
src: url("../fonts/OpenSans-SemiBold.ttf") format("truetype");
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Open Sans";
|
font-family: "Open Sans";
|
||||||
src: url("/assets/fonts/OpenSans-Bold.ttf") format("truetype");
|
src: url("../fonts/OpenSans-Bold.ttf") format("truetype");
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--text: #1f2937;
|
--ink: #1b1e28;
|
||||||
--muted: #6b7280;
|
--muted: #5b6472;
|
||||||
--border: #e5e7eb;
|
--accent: #0ea5e9; /* azul tira a cyan */
|
||||||
--brand: #0ea5e9; /* azul suave */
|
--line: #e6e8ef;
|
||||||
--bg-light: #f8fafc; /* gris muy claro */
|
--bg-tag: #f4f7fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 18mm 15mm 22mm 15mm;
|
margin: 15mm 14mm 18mm 14mm;
|
||||||
@bottom-right {
|
@bottom-right { content: "Página " counter(page) " / " counter(pages); }
|
||||||
content: "Página " counter(page) " / " counter(pages);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: "Open Sans", Arial, sans-serif;
|
font-family: "Open Sans", Arial, sans-serif;
|
||||||
color: var(--text);
|
color: var(--ink);
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
.doc-header {
|
/* Top band */
|
||||||
|
.topbar {
|
||||||
display: table;
|
display: table;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 10mm;
|
border-bottom: 2px solid var(--accent);
|
||||||
border-bottom: 2px solid var(--brand);
|
|
||||||
padding-bottom: 6mm;
|
padding-bottom: 6mm;
|
||||||
|
margin-bottom: 6mm;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand { display: table-cell; width: 60%; vertical-align: top; }
|
||||||
display: table-cell;
|
.logo { height: 30px; display: block; margin-bottom: 4px; }
|
||||||
vertical-align: top;
|
.brand-name { font-weight: 700; }
|
||||||
width: 60%;
|
.brand-meta { color: var(--muted); font-size: 9.5pt; }
|
||||||
}
|
.doc-title { display: table-cell; width: 40%; text-align: right; font-weight: 700; letter-spacing: 3px; }
|
||||||
.logo {
|
|
||||||
height: 34px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.brand-meta { margin-top: 4px; }
|
|
||||||
.company-name {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
.company-meta {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 9.5pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-title {
|
/* ficha superior */
|
||||||
display: table-cell;
|
.sheet-info {
|
||||||
vertical-align: top;
|
|
||||||
width: 40%;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.title-main {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.doc-info {
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 10pt;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.doc-info td {
|
|
||||||
padding: 3px 0 3px 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.doc-info td:first-child {
|
|
||||||
color: var(--muted);
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.right { text-align: right; }
|
|
||||||
|
|
||||||
.blocks {
|
|
||||||
display: table;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
border-collapse: collapse;
|
||||||
margin-bottom: 8mm;
|
margin: 4mm 0 6mm 0;
|
||||||
|
font-size: 10.5pt;
|
||||||
}
|
}
|
||||||
.block {
|
.sheet-info td {
|
||||||
display: table-cell;
|
border: 1px solid var(--line);
|
||||||
width: 50%;
|
padding: 4px 6px;
|
||||||
padding-right: 6mm;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
.block:last-child { padding-right: 0; }
|
.sheet-info .lbl { color: var(--muted); margin-right: 4px; }
|
||||||
|
.sheet-info .val { font-weight: 700; }
|
||||||
|
|
||||||
|
/* Línea título libro */
|
||||||
|
.line-title {
|
||||||
|
margin: 3mm 0 5mm 0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--bg-tag);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
font-size: 10.5pt;
|
||||||
|
}
|
||||||
|
.line-title .lbl { color: var(--muted); margin-right: 6px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Specs 2 columnas */
|
||||||
|
.specs { display: table; width: 100%; table-layout: fixed; margin-bottom: 6mm; }
|
||||||
|
.specs .col { display: table-cell; width: 50%; padding-right: 6mm; vertical-align: top; }
|
||||||
|
.specs .col:last-child { padding-right: 0; }
|
||||||
.block-title {
|
.block-title {
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
color: var(--brand);
|
margin: 2mm 0 1mm 0;
|
||||||
margin-bottom: 2mm;
|
|
||||||
}
|
|
||||||
.block-body {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 4mm;
|
|
||||||
background: var(--bg-light);
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
margin-bottom: 2mm;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.row .label {
|
|
||||||
display: inline-block;
|
|
||||||
width: 27%;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.row .value {
|
|
||||||
display: inline-block;
|
|
||||||
width: 70%;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
|
.kv { margin: 1mm 0; }
|
||||||
|
.kv span { color: var(--muted); display: inline-block; min-width: 55%; }
|
||||||
|
.kv b { font-weight: 600; }
|
||||||
|
.subblock { margin-top: 3mm; }
|
||||||
|
|
||||||
.table-section .section-title,
|
.services { margin: 0; padding-left: 14px; }
|
||||||
.notes .section-title {
|
.services li { margin: 1mm 0; }
|
||||||
font-weight: 600;
|
|
||||||
font-size: 10.5pt;
|
|
||||||
margin: 6mm 0 2mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items {
|
/* Bloque marcapáginas */
|
||||||
width: 100%;
|
.bookmark {
|
||||||
border-collapse: collapse;
|
margin-top: 4mm;
|
||||||
font-size: 10.5pt;
|
border: 1px dashed var(--line);
|
||||||
}
|
padding: 3mm;
|
||||||
.items thead th {
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 2px solid var(--brand);
|
|
||||||
padding: 6px 6px;
|
|
||||||
background: #eef8fe;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.items tbody td {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 6px 6px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.items .col-right { text-align: right; }
|
|
||||||
.items .col-center { text-align: center; }
|
|
||||||
.items .col-desc .desc { font-weight: 600; }
|
|
||||||
.items .col-desc .meta { color: var(--muted); font-size: 9.5pt; margin-top: 2px; }
|
|
||||||
.items .group-header td {
|
|
||||||
background: var(--bg-light);
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totals {
|
|
||||||
margin-top: 6mm;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.totals-table {
|
|
||||||
margin-left: auto;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 70mm;
|
|
||||||
}
|
|
||||||
.totals-table td {
|
|
||||||
padding: 4px 0 4px 14px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.totals-table td:first-child {
|
|
||||||
color: var(--muted);
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.totals-table .total-row td {
|
|
||||||
border-bottom: 2px solid var(--brand);
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
.totals-table .right { text-align: right; }
|
|
||||||
|
|
||||||
.notes .note-text {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 4mm;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 10pt;
|
}
|
||||||
white-space: pre-wrap;
|
.bookmark .bk-title {
|
||||||
|
font-weight: 700; margin-bottom: 2mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-footer {
|
/* Tabla de precios (tiradas) */
|
||||||
|
.prices {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 6mm;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
}
|
||||||
|
.prices thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
background: #eef8fe;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.prices tbody td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.prices .col-tirada { width: 22%; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
margin-top: 8mm;
|
margin-top: 8mm;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
padding-top: 4mm;
|
padding-top: 4mm;
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: table;
|
display: table;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
font-size: 9.5pt;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
.footer-left { display: table-cell; }
|
|
||||||
.footer-right {
|
|
||||||
display: table-cell;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
.footer .address { display: table-cell; width: 45%; }
|
||||||
|
.footer .privacy { display: table-cell; width: 55%; }
|
||||||
|
.pv-title { font-weight: 700; margin-bottom: 1mm; color: var(--ink); }
|
||||||
|
.pv-text { line-height: 1.25; }
|
||||||
|
|
||||||
/* Compatibilidad PDF (OpenHTMLtoPDF) */
|
.page-count { margin-top: 2mm; text-align: right; font-size: 9pt; color: var(--muted); }
|
||||||
.page-number::after { content: counter(page); }
|
.page::after { content: counter(page); }
|
||||||
.page-count::after { content: counter(pages); }
|
.pages::after { content: counter(pages); }
|
||||||
|
|||||||
@ -3,193 +3,140 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title th:text="'Presupuesto ' + ${numero}">Presupuesto</title>
|
<title th:text="'Presupuesto ' + ${numero}">Presupuesto</title>
|
||||||
<link rel="stylesheet" href="/assets/css/presupuesto.css" />
|
<link rel="stylesheet" href="assets/css/presupuestopdf.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- BANDA SUPERIOR -->
|
||||||
<header class="doc-header">
|
<div class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/img/logo-light.png" alt="ImprimeLibros" class="logo" />
|
<img src="assets/img/logo-imprimelibros.png" alt="ImprimeLibros" class="logo"/>
|
||||||
<div class="brand-meta">
|
<div class="brand-lines">
|
||||||
<div class="company-name" th:text="${empresa?.nombre} ?: 'ImprimeLibros ERP'">ImprimeLibros ERP</div>
|
<div class="brand-name" th:text="${empresa?.nombre} ?: 'ImprimeLibros ERP'">ImprimeLibros ERP</div>
|
||||||
<div class="company-meta">
|
<div class="brand-meta">
|
||||||
<span th:text="${empresa?.direccion} ?: 'C/ Dirección 123, 28000 Madrid'">C/ Dirección 123, 28000 Madrid</span><br/>
|
<span th:text="${empresa?.direccion} ?: 'C. José Picón, 28 local A · 28028 Madrid'">C. José Picón, 28 local A · 28028 Madrid</span>
|
||||||
<span th:text="${empresa?.telefono} ?: '+34 600 000 000'">+34 600 000 000</span> ·
|
· <span th:text="${empresa?.telefono} ?: '910052574'">910052574</span>
|
||||||
<span th:text="${empresa?.email} ?: 'info@imprimelibros.com'">info@imprimelibros.com</span><br/>
|
· <span th:text="${empresa?.email} ?: 'info@imprimelibros.com'">info@imprimelibros.com</span>
|
||||||
<span th:text="${empresa?.cif} ?: 'B-12345678'">B-12345678</span>
|
· <span th:text="${empresa?.cif} ?: 'B04998886'">B04998886</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="doc-title">
|
<div class="doc-title">P R E S U P U E S T O</div>
|
||||||
<div class="title-main">PRESUPUESTO</div>
|
</div>
|
||||||
<table class="doc-info">
|
|
||||||
<tr>
|
|
||||||
<td>Nº</td>
|
|
||||||
<td class="right" th:text="${numero}">2025-00123</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Fecha</td>
|
|
||||||
<td class="right" th:text="${#temporals.format(fecha, 'dd/MM/yyyy')}">12/10/2025</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${validezDias != null}">
|
|
||||||
<td>Validez</td>
|
|
||||||
<td class="right" th:text="${validezDias} + ' días'">30 días</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Datos cliente / proyecto -->
|
<!-- FICHA Nº / CLIENTE / FECHA -->
|
||||||
<section class="blocks">
|
<table class="sheet-info">
|
||||||
<div class="block">
|
<tr>
|
||||||
<div class="block-title">Cliente</div>
|
<td><span class="lbl">PRESUPUESTO Nº:</span> <span class="val" th:text="${numero}">153153</span></td>
|
||||||
<div class="block-body">
|
<td><span class="lbl">CLIENTE:</span> <span class="val" th:text="${cliente?.nombre} ?: '-'">JUAN JOSÉ MÉNDEZ</span></td>
|
||||||
<div class="row">
|
<td><span class="lbl">FECHA:</span> <span class="val" th:text="${#temporals.format(fecha, 'dd/MM/yyyy')}">10/10/2025</span></td>
|
||||||
<span class="label">Nombre:</span>
|
</tr>
|
||||||
<span class="value" th:text="${cliente?.nombre} ?: '-'">Editorial Ejemplo S.L.</span>
|
</table>
|
||||||
</div>
|
|
||||||
<div class="row">
|
<!-- TÍTULO DEL LIBRO -->
|
||||||
<span class="label">CIF/NIF:</span>
|
<div class="line-title">
|
||||||
<span class="value" th:text="${cliente?.cif} ?: '-'">B-00000000</span>
|
<span class="lbl">Título:</span>
|
||||||
</div>
|
<span class="val" th:text="${titulo} ?: '-'">Libro de prueba</span>
|
||||||
<div class="row">
|
</div>
|
||||||
<span class="label">Dirección:</span>
|
|
||||||
<span class="value" th:text="${cliente?.direccion} ?: '-'">Av. de los Libros, 45</span>
|
<!-- DATOS TÉCNICOS EN 2 COLUMNAS -->
|
||||||
</div>
|
<div class="specs">
|
||||||
<div class="row" th:if="${cliente?.cp != null or cliente?.poblacion != null or cliente?.provincia != null}">
|
<div class="col">
|
||||||
<span class="label">Localidad:</span>
|
<div class="block-title">Encuadernación</div>
|
||||||
<span class="value">
|
<div class="kv"><span>Encuadernación:</span><b th:text="${encuadernacion} ?: 'Fresado'">Fresado</b></div>
|
||||||
<span th:text="${cliente?.cp} ?: ''"></span>
|
<div class="kv"><span>Formato:</span><b>
|
||||||
<span th:text="${cliente?.poblacion} ?: ''"></span>
|
<span th:text="${ancho}">148</span>x<span th:text="${alto}">210</span> mm
|
||||||
<span th:text="${cliente?.provincia} ?: ''"></span>
|
</b></div>
|
||||||
</span>
|
<div class="kv"><span>Páginas:</span><b th:text="${paginasTotales} ?: 132">132</b></div>
|
||||||
</div>
|
<div class="kv"><span>Páginas Negro:</span><b th:text="${paginasNegro} ?: 100">100</b></div>
|
||||||
<div class="row" th:if="${cliente?.email != null}">
|
<div class="kv"><span>Páginas Color:</span><b th:text="${paginasColor} ?: 32">32</b></div>
|
||||||
<span class="label">Email:</span>
|
|
||||||
<span class="value" th:text="${cliente?.email}">comercial@editorial.com</span>
|
<div class="subblock">
|
||||||
</div>
|
<div class="block-title">Interior</div>
|
||||||
|
<div class="kv"><span>Tipo de impresión:</span><b th:text="${interior?.tipoImpresion} ?: 'Color Premium'">Color Premium</b></div>
|
||||||
|
<div class="kv"><span>Papel interior:</span><b th:text="${interior?.papel} ?: 'Estucado Mate'">Estucado Mate</b></div>
|
||||||
|
<div class="kv"><span>Gramaje interior:</span><b th:text="${interior?.gramaje} ?: 115">115</b></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block">
|
<div class="col">
|
||||||
<div class="block-title">Proyecto</div>
|
<div class="subblock">
|
||||||
<div class="block-body">
|
<div class="block-title">Cubierta</div>
|
||||||
<div class="row" th:if="${titulo != null}">
|
<div class="kv"><span>Tipo de cubierta:</span><b th:text="${cubierta?.tipo} ?: 'Tapa blanda'">Tapa blanda</b></div>
|
||||||
<span class="label">Título:</span>
|
<div class="kv"><span>Solapas:</span><b th:text="${cubierta?.solapas} ?: 'Sí'">Sí</b></div>
|
||||||
<span class="value" th:text="${titulo}">Libro de Ejemplo</span>
|
<div class="kv"><span>Tamaño solapas:</span><b th:text="${cubierta?.tamSolapas} ?: '80 mm'">80 mm</b></div>
|
||||||
|
<div class="kv"><span>Impresión:</span><b th:text="${cubierta?.impresion} ?: 'Una cara'">Una cara</b></div>
|
||||||
|
<div class="kv"><span>Papel cubierta:</span><b th:text="${cubierta?.papel} ?: 'Estucado mate'">Estucado mate</b></div>
|
||||||
|
<div class="kv"><span>Gramaje cubierta:</span><b th:text="${cubierta?.gramaje} ?: 250">250</b></div>
|
||||||
|
<div class="kv"><span>Acabado:</span><b th:text="${cubierta?.acabado} ?: 'Plastificado Brillo 1/C'">Plastificado Brillo 1/C</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subblock">
|
||||||
|
<div class="block-title">Servicios Extras</div>
|
||||||
|
<!-- Ejemplos específicos -->
|
||||||
|
<div class="kv" th:if="${servicios != null}">
|
||||||
|
<ul class="services">
|
||||||
|
<li th:each="s : ${servicios}">
|
||||||
|
<span th:text="${s.descripcion}">Ferro Digital</span>
|
||||||
|
<span th:if="${s.precio != null}" th:text="${#numbers.formatDecimal(s.precio,1,'POINT',2,'COMMA')} + ' €'">0,00 €</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" th:if="${autor != null}">
|
|
||||||
<span class="label">Autor:</span>
|
<!-- Bloque marcapáginas (si existe en servicios) -->
|
||||||
<span class="value" th:text="${autor}">Autor/a</span>
|
<div class="bookmark" th:if="${marcapaginas != null}">
|
||||||
</div>
|
<div class="bk-title">Marcapáginas</div>
|
||||||
<div class="row" th:if="${isbn != null}">
|
<div class="kv"><span>Tamaño:</span><b th:text="${marcapaginas.tamano} ?: '50x210'">50x210</b></div>
|
||||||
<span class="label">ISBN:</span>
|
<div class="kv"><span>Papel:</span><b th:text="${marcapaginas.papel} ?: 'Estucado mate 300 g'">Estucado mate 300 g</b></div>
|
||||||
<span class="value" th:text="${isbn}">978-1-2345-6789-0</span>
|
<div class="kv"><span>Impresión:</span><b th:text="${marcapaginas.impresion} ?: 'Una cara'">Una cara</b></div>
|
||||||
</div>
|
<div class="kv"><span>Plastificado:</span><b th:text="${marcapaginas.plastificado} ?: 'Brillo 1/C'">Brillo 1/C</b></div>
|
||||||
<div class="row" th:if="${ancho != null and alto != null}">
|
|
||||||
<span class="label">Formato:</span>
|
|
||||||
<span class="value">
|
|
||||||
<span th:text="${ancho}">150</span> × <span th:text="${alto}">210</span> mm
|
|
||||||
<span th:if="${formatoPersonalizado == true}">(personalizado)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row" th:if="${paginasNegro != null or paginasColor != null}">
|
|
||||||
<span class="label">Páginas:</span>
|
|
||||||
<span class="value">
|
|
||||||
<span th:if="${paginasNegro != null}" th:text="'B/N ' + ${paginasNegro}"></span>
|
|
||||||
<span th:if="${paginasColor != null}" th:text="' · Color ' + ${paginasColor}"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row" th:if="${tiradas != null}">
|
|
||||||
<span class="label">Tiradas:</span>
|
|
||||||
<span class="value" th:text="${#strings.arrayJoin(tiradas, ', ')}">300, 500, 1000</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<!-- Conceptos principales -->
|
<!-- TABLA TIRADAS -->
|
||||||
<section class="table-section">
|
<table class="prices">
|
||||||
<div class="section-title">Detalle del presupuesto</div>
|
<thead>
|
||||||
<table class="items">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-desc">Descripción</th>
|
|
||||||
<th class="col-center">Uds</th>
|
|
||||||
<th class="col-right">Precio unit.</th>
|
|
||||||
<th class="col-right">Dto.</th>
|
|
||||||
<th class="col-right">Importe</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- Línea tipo: interior, cubierta, manipulado, etc. -->
|
|
||||||
<tr th:each="l : ${lineas}">
|
|
||||||
<td class="col-desc">
|
|
||||||
<div class="desc" th:text="${l.descripcion}">Impresión interior B/N 80 g</div>
|
|
||||||
<div class="meta" th:if="${l.meta != null}" th:text="${l.meta}">
|
|
||||||
300 páginas · offset 80 g · tinta negra
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="col-center" th:text="${l.uds}">1000</td>
|
|
||||||
<td class="col-right" th:text="${#numbers.formatDecimal(l.precio, 1, 'POINT', 2, 'COMMA')}">2,1500</td>
|
|
||||||
<td class="col-right" th:text="${l.dto != null ? #numbers.formatDecimal(l.dto, 1, 'POINT', 2, 'COMMA') + '%':'-'}">-</td>
|
|
||||||
<td class="col-right" th:text="${#numbers.formatDecimal(l.importe, 1, 'POINT', 2, 'COMMA')}">2.150,00</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Servicios extra (si vienen) -->
|
|
||||||
<tr class="group-header" th:if="${servicios != null and !#lists.isEmpty(servicios)}">
|
|
||||||
<td colspan="5">Servicios adicionales</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:each="s : ${servicios}">
|
|
||||||
<td class="col-desc">
|
|
||||||
<div class="desc" th:text="${s.descripcion}">Transporte</div>
|
|
||||||
</td>
|
|
||||||
<td class="col-center" th:text="${s.unidades}">1</td>
|
|
||||||
<td class="col-right" th:text="${#numbers.formatDecimal(s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
|
|
||||||
<td class="col-right">-</td>
|
|
||||||
<td class="col-right" th:text="${#numbers.formatDecimal(s.unidades * s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Totales -->
|
|
||||||
<section class="totals">
|
|
||||||
<table class="totals-table">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Base imponible</td>
|
<th class="col-tirada">TIRADA</th>
|
||||||
<td class="right" th:text="${#numbers.formatDecimal(baseImponible, 1, 'POINT', 2, 'COMMA')}">2.180,00</td>
|
<th>IMPRESIÓN</th>
|
||||||
|
<th>IVA</th>
|
||||||
|
<th>TOTAL</th>
|
||||||
|
<th>UNIDAD</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td th:text="'IVA (' + ${ivaTipo} + '%)'">IVA (21%)</td>
|
<tbody>
|
||||||
<td class="right" th:text="${#numbers.formatDecimal(ivaImporte, 1, 'POINT', 2, 'COMMA')}">457,80</td>
|
<!-- Espera un modelo { tiradas:[...], impresion:[...], iva:[...], total:[...], unidad:[...] } -->
|
||||||
|
<tr th:each="i,idx : ${#numbers.sequence(0, #lists.size(pricing?.tiradas) - 1)}">
|
||||||
|
<td class="col-tirada" th:text="${pricing.tiradas[i]} + ' uds.'">100 uds.</td>
|
||||||
|
<td th:text="${#numbers.formatDecimal(pricing.impresion[i],1,'POINT',2,'COMMA')} + '€'">152,15€</td>
|
||||||
|
<td th:text="${#numbers.formatDecimal(pricing.iva[i],1,'POINT',2,'COMMA')} + '€'">7,68€</td>
|
||||||
|
<td th:text="${#numbers.formatDecimal(pricing.total[i],1,'POINT',2,'COMMA')} + '€'">159,99€</td>
|
||||||
|
<td th:text="${#numbers.formatDecimal(pricing.unidad[i],1,'POINT',2,'COMMA')} + '€'">1,52€</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="total-row">
|
</tbody>
|
||||||
<td><strong>Total</strong></td>
|
</table>
|
||||||
<td class="right"><strong th:text="${#numbers.formatDecimal(totalConIva, 1, 'POINT', 2, 'COMMA')}">2.637,80</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${peso != null}">
|
|
||||||
<td>Peso estimado</td>
|
|
||||||
<td class="right" th:text="${#numbers.formatDecimal(peso, 1, 'POINT', 2, 'COMMA')} + ' kg'">120,00 kg</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Observaciones / condiciones -->
|
<!-- PIE -->
|
||||||
<section class="notes" th:if="${observaciones != null or condiciones != null}">
|
<div class="footer">
|
||||||
<div class="section-title">Observaciones</div>
|
<div class="address">
|
||||||
<div class="note-text" th:utext="${observaciones}">Presupuesto válido 30 días.</div>
|
<div th:text="${empresa?.nombre} ?: 'Impresión Imprime libros SL'">Impresión Imprime libros SL</div>
|
||||||
|
<div th:text="${empresa?.direccion} ?: 'C. José Picón, 28 local A'">C. José Picón, 28 local A</div>
|
||||||
<div class="section-title" th:if="${condiciones != null}">Condiciones</div>
|
<div>28028 Madrid</div>
|
||||||
<div class="note-text" th:utext="${condiciones}">
|
<div th:text="${empresa?.telefono} ?: '910052574'">910052574</div>
|
||||||
Entrega estimada 7-10 días laborables tras confirmación de artes finales.
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="privacy">
|
||||||
|
<div class="pv-title">Política de privacidad</div>
|
||||||
|
<div class="pv-text">
|
||||||
|
Responsable: <span th:text="${empresa?.nombre} ?: 'Impresión Imprime Libros'">Impresión Imprime Libros</span> -
|
||||||
|
CIF: <span th:text="${empresa?.cif} ?: 'B04998886'">B04998886</span> -
|
||||||
|
Email: <span th:text="${empresa?.email} ?: 'info@imprimelibros.com'">info@imprimelibros.com</span> ·
|
||||||
|
Dirección: <span th:text="${empresa?.direccion} ?: 'Calle José Picón, Nº 28 Local A, 28028, Madrid'">Calle José Picón, Nº 28 Local A, 28028, Madrid</span>.
|
||||||
|
Sus datos se tratarán para la adecuada gestión fiscal y contable…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<div class="page-count">Página <span class="page"></span> / <span class="pages"></span></div>
|
||||||
<footer class="doc-footer">
|
|
||||||
<div class="footer-left" th:text="${empresa?.web} ?: 'www.imprimelibros.com'">www.imprimelibros.com</div>
|
|
||||||
<div class="footer-right">Página <span class="page-number"></span> / <span class="page-count"></span></div>
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user