Compare commits
12
Commits
722699f5d0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc129b9c23 | ||
|
|
dcf68fede6 | ||
|
|
60aba03633 | ||
|
|
c95cd99be7 | ||
|
|
fd71c272fa | ||
|
|
fa0d9da2b1 | ||
|
|
28f62cee06 | ||
|
|
6838ce3b20 | ||
|
|
5bcaf97eba | ||
|
|
4d16455d17 | ||
|
|
9891a58fad | ||
|
|
48a5831600 |
@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admi
|
|||||||
|
|
||||||
# HTTP-Port der Anwendung (Standard: 8083)
|
# HTTP-Port der Anwendung (Standard: 8083)
|
||||||
SERVER_PORT=8083
|
SERVER_PORT=8083
|
||||||
|
|
||||||
|
# Basis-URL der PDF-Service-API (ZUGFeRD-Umwandlung); die Endpoints
|
||||||
|
# /invoices/sign und /invoices/validate liegen unterhalb dieser URL.
|
||||||
|
PDF_SERVICE_URL=http://localhost:8084/api/v1
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "disabled"
|
||||||
|
}
|
||||||
@@ -7,6 +7,14 @@ ENV TZ=Europe/Berlin
|
|||||||
ENV LC_TIME=de_DE.UTF-8
|
ENV LC_TIME=de_DE.UTF-8
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# Schriften für den PDF-Renderer: Microsoft Core Fonts (Arial, Times New Roman,
|
||||||
|
# Courier New, Verdana) sowie DejaVu als Fallback. Die EULA der MS-Fonts wird
|
||||||
|
# vorab bestätigt; der Installer lädt die Fonts beim Image-Build herunter.
|
||||||
|
RUN echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core ttf-mscorefonts-installer \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY ${JAR_FILE} app.jar
|
COPY ${JAR_FILE} app.jar
|
||||||
EXPOSE 8083
|
EXPOSE 8083
|
||||||
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]
|
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# PDF Tool — ZUGFeRD E-Rechnungs-Konverter
|
# PDF Tool — ZUGFeRD E-Rechnungs-Konverter
|
||||||
|
|
||||||
Spring Boot + Vaadin Flow Anwendung, die ein herkömmliches B2B-Rechnungs-PDF in eine
|
Spring Boot + Vaadin Flow Anwendung, die aus gespeicherten Rechnungstemplates
|
||||||
**rechtskonforme ZUGFeRD-E-Rechnung** umwandelt (PDF/A-3 mit eingebettetem EN16931-XML,
|
**rechtskonforme ZUGFeRD-E-Rechnungen** erzeugt (PDF/A-3 mit eingebettetem EN16931-XML,
|
||||||
Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als
|
Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als
|
||||||
**ZIP-Datei** bereitstellt.
|
**ZIP-Datei** bereitstellt.
|
||||||
|
|
||||||
@@ -26,7 +26,8 @@ mvn spring-boot:run
|
|||||||
```
|
```
|
||||||
|
|
||||||
Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar:
|
Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar:
|
||||||
PDF hochladen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken, ZIP herunterladen.
|
Rechnungstemplate auswählen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken —
|
||||||
|
das ZIP wird automatisch heruntergeladen.
|
||||||
|
|
||||||
Produktions-Build:
|
Produktions-Build:
|
||||||
|
|
||||||
@@ -35,94 +36,21 @@ mvn clean package -Pproduction
|
|||||||
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
|
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
## REST-API
|
## PDF Service
|
||||||
|
|
||||||
### E-Rechnung erzeugen
|
Die ZUGFeRD-Umwandlung (PDF/A-3 + EN16931-XML, Mustangproject) übernimmt das
|
||||||
|
eigenständige Projekt **PDF Service** (`../PDF Service`, Port 8084). Dieses Tool
|
||||||
`POST /api/invoices/sign` (multipart/form-data) mit zwei Parts:
|
rendert die Rechnung aus dem Template und ruft dann dessen REST-API
|
||||||
|
(`POST /api/v1/invoices/sign`) auf — der Service muss dafür laufen. Die Basis-URL
|
||||||
| Part | Inhalt |
|
der API ist über `PDF_SERVICE_URL` konfigurierbar (Standard
|
||||||
|------------|-------------------------------------------|
|
`http://localhost:8084/api/v1`).
|
||||||
| `file` | das Rechnungs-PDF |
|
|
||||||
| `metadata` | Rechnungsdaten als JSON (siehe unten) |
|
|
||||||
|
|
||||||
Antwort: `application/zip` mit `<Rechnungsnummer>-zugferd.pdf`, `factur-x.xml` und
|
|
||||||
`validation-report.xml` (Mustang-Prüfbericht). Der Header `X-Zugferd-Valid: true|false`
|
|
||||||
meldet das Validierungsergebnis. Bei Fehlern: HTTP 400 mit `{"error": "..."}`.
|
|
||||||
|
|
||||||
> Häufigste Ursache für `X-Zugferd-Valid: false`: Das Quell-PDF bettet seine
|
|
||||||
> Schriften nicht ein. Das kann die Konvertierung nicht reparieren — das PDF muss
|
|
||||||
> dann mit eingebetteten Schriften neu erzeugt werden (Standard bei den meisten
|
|
||||||
> ERP-/Reporting-Systemen konfigurierbar).
|
|
||||||
|
|
||||||
### Bestehende E-Rechnung prüfen
|
|
||||||
|
|
||||||
`POST /api/invoices/validate` (multipart/form-data, Part `file` = ZUGFeRD-PDF oder
|
|
||||||
Factur-X-XML). Antwort: der Mustang-Prüfbericht als XML — HTTP 200 bei gültiger,
|
|
||||||
HTTP 422 bei ungültiger Rechnung, ebenfalls mit `X-Zugferd-Valid`-Header.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8083/api/invoices/validate \
|
|
||||||
-F "file=@rechnung-zugferd.pdf"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Beispiel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8083/api/invoices/sign \
|
|
||||||
-F "file=@rechnung.pdf" \
|
|
||||||
-F "metadata=@metadata.json;type=application/json" \
|
|
||||||
-o rechnung-zugferd.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
`metadata.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"invoiceNumber": "RE-2026-0815",
|
|
||||||
"issueDate": "2026-07-08",
|
|
||||||
"deliveryDate": "2026-07-01",
|
|
||||||
"dueDate": "2026-07-22",
|
|
||||||
"currency": "EUR",
|
|
||||||
"paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.",
|
|
||||||
"sender": {
|
|
||||||
"name": "Assecutor GmbH",
|
|
||||||
"street": "Musterstrasse 1",
|
|
||||||
"zip": "20095",
|
|
||||||
"city": "Hamburg",
|
|
||||||
"countryCode": "DE",
|
|
||||||
"vatId": "DE123456789"
|
|
||||||
},
|
|
||||||
"recipient": {
|
|
||||||
"name": "Kunde AG",
|
|
||||||
"street": "Beispielweg 2",
|
|
||||||
"zip": "10115",
|
|
||||||
"city": "Berlin",
|
|
||||||
"countryCode": "DE"
|
|
||||||
},
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"description": "Beratungsleistung",
|
|
||||||
"quantity": 1,
|
|
||||||
"unitPriceNet": 1500.00,
|
|
||||||
"vatPercent": 19
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Pflichtfelder: `invoiceNumber`, `issueDate`, `sender` (inkl. Adresse), `recipient`,
|
|
||||||
mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum zurück,
|
|
||||||
`dueDate` auf Rechnungsdatum + 14 Tage, `currency` auf `EUR`.
|
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik:
|
- [TemplatePdfService](src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java) — rendert
|
||||||
PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP.
|
die Canvas-Templates (JSON aus MongoDB) über iText html2pdf zu einem PDF
|
||||||
Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert.
|
- [PdfServiceClient](src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java) — ruft den
|
||||||
- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator
|
PDF Service auf, der daraus die ZUGFeRD-E-Rechnung als ZIP erzeugt
|
||||||
(XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht
|
|
||||||
- [InvoiceSigningController](src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java) — REST-Endpunkte `/sign` und `/validate`
|
|
||||||
- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche
|
- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche
|
||||||
- [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten
|
- [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten
|
||||||
|
|
||||||
@@ -132,6 +60,6 @@ mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum
|
|||||||
mvn test
|
mvn test
|
||||||
```
|
```
|
||||||
|
|
||||||
[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein
|
Die Tests decken das Template-Rendering ([TemplatePdfServiceTest](src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java),
|
||||||
Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem
|
[TemplateVariablesTest](src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java)) und die
|
||||||
`factur-x.xml` (EN16931-Profil) enthält.
|
USt-IdNr.-Validierung ab; die ZUGFeRD-Umwandlung wird im Projekt PDF Service getestet.
|
||||||
|
|||||||
Generated
+9537
File diff suppressed because it is too large
Load Diff
+104
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"name": "no-name",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@polymer/polymer": "3.5.2",
|
||||||
|
"@vaadin/bundles": "24.7.6",
|
||||||
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "24.7.6",
|
||||||
|
"@vaadin/react-components": "24.7.6",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "24.7.6",
|
||||||
|
"@vaadin/vaadin-material-styles": "24.7.6",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "24.7.6",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
|
"lit": "3.3.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-router": "7.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"@preact/signals-react-transform": "0.5.1",
|
||||||
|
"@rollup/plugin-replace": "6.0.2",
|
||||||
|
"@rollup/pluginutils": "5.1.4",
|
||||||
|
"@types/react": "18.3.20",
|
||||||
|
"@types/react-dom": "18.3.6",
|
||||||
|
"@vitejs/plugin-react": "4.4.1",
|
||||||
|
"async": "3.2.6",
|
||||||
|
"glob": "11.0.2",
|
||||||
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
|
"strip-css-comments": "5.0.0",
|
||||||
|
"transform-ast": "2.4.4",
|
||||||
|
"typescript": "5.7.3",
|
||||||
|
"vite": "6.3.4",
|
||||||
|
"vite-plugin-checker": "0.9.1",
|
||||||
|
"workbox-build": "7.3.0",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
|
},
|
||||||
|
"vaadin": {
|
||||||
|
"dependencies": {
|
||||||
|
"@polymer/polymer": "3.5.2",
|
||||||
|
"@vaadin/bundles": "24.7.6",
|
||||||
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "24.7.6",
|
||||||
|
"@vaadin/react-components": "24.7.6",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "24.7.6",
|
||||||
|
"@vaadin/vaadin-material-styles": "24.7.6",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "24.7.6",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
|
"lit": "3.3.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-router": "7.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"@preact/signals-react-transform": "0.5.1",
|
||||||
|
"@rollup/plugin-replace": "6.0.2",
|
||||||
|
"@rollup/pluginutils": "5.1.4",
|
||||||
|
"@types/react": "18.3.20",
|
||||||
|
"@types/react-dom": "18.3.6",
|
||||||
|
"@vitejs/plugin-react": "4.4.1",
|
||||||
|
"async": "3.2.6",
|
||||||
|
"glob": "11.0.2",
|
||||||
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
|
"strip-css-comments": "5.0.0",
|
||||||
|
"transform-ast": "2.4.4",
|
||||||
|
"typescript": "5.7.3",
|
||||||
|
"vite": "6.3.4",
|
||||||
|
"vite-plugin-checker": "0.9.1",
|
||||||
|
"workbox-build": "7.3.0",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
|
},
|
||||||
|
"hash": "79560e4ddc395b0ad64481d9a3f56513be65cec5fb700f13f4c25c4dd181fa94"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@vaadin/bundles": "$@vaadin/bundles",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
|
||||||
|
"@vaadin/react-components": "$@vaadin/react-components",
|
||||||
|
"@vaadin/common-frontend": "$@vaadin/common-frontend",
|
||||||
|
"react-dom": "$react-dom",
|
||||||
|
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
|
||||||
|
"lit": "$lit",
|
||||||
|
"@polymer/polymer": "$@polymer/polymer",
|
||||||
|
"react": "$react",
|
||||||
|
"react-router": "$react-router",
|
||||||
|
"date-fns": "$date-fns",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
||||||
|
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<vaadin.version>24.7.4</vaadin.version>
|
<vaadin.version>24.7.4</vaadin.version>
|
||||||
<mustang.version>2.24.0</mustang.version>
|
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -54,13 +53,6 @@
|
|||||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Mustangproject: Open-Source-Referenzimplementierung von ZUGFeRD / Factur-X -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.mustangproject</groupId>
|
|
||||||
<artifactId>library</artifactId>
|
|
||||||
<version>${mustang.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators
|
<!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators
|
||||||
(HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. -->
|
(HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -68,11 +60,6 @@
|
|||||||
<artifactId>html2pdf</artifactId>
|
<artifactId>html2pdf</artifactId>
|
||||||
<version>5.0.5</version>
|
<version>5.0.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.mustangproject</groupId>
|
|
||||||
<artifactId>validator</artifactId>
|
|
||||||
<version>${mustang.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -73,7 +73,6 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var isDragging = false;
|
var isDragging = false;
|
||||||
var dragStart = { x: 0, y: 0 };
|
var dragStart = { x: 0, y: 0 };
|
||||||
var elementStart = { x: 0, y: 0 };
|
var elementStart = { x: 0, y: 0 };
|
||||||
var gridSize = 5;
|
|
||||||
|
|
||||||
// Image cache to prevent flickering during resize
|
// Image cache to prevent flickering during resize
|
||||||
var imageCache = {};
|
var imageCache = {};
|
||||||
@@ -102,8 +101,43 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
pageY = (h - pageHeight) / 2;
|
pageY = (h - pageHeight) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kein Raster mehr: Positionen werden pixelgenau übernommen
|
||||||
function snapToGrid(value) {
|
function snapToGrid(value) {
|
||||||
return Math.round(value / gridSize) * gridSize;
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freie Textelemente bleiben beim Verändern der Größe zentriert: Der Text
|
||||||
|
// wird mittig im Rahmen dargestellt (Canvas und PDF nutzen dieselbe
|
||||||
|
// textAlign-Eigenschaft, die Darstellung bleibt also identisch).
|
||||||
|
function keepTextCentered(el) {
|
||||||
|
if (el.type !== 'line' && el.type !== 'image' && !el.isStatic) {
|
||||||
|
el.textAlign = 'center';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestgröße eines Textelements (Basis-px): der dargestellte Text darf
|
||||||
|
// beim Verkleinern nicht abgeschnitten werden. Für andere Elementtypen
|
||||||
|
// gibt es keine inhaltsabhängige Untergrenze (null).
|
||||||
|
function minTextSize(el) {
|
||||||
|
if (el.type === 'line' || el.type === 'image' || el.variable === 'services.list') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var fontSize = el.fontSize || 14;
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = ((el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial')).trim();
|
||||||
|
var lines = (el.text || '').split('\n');
|
||||||
|
var maxLineWidth = 0;
|
||||||
|
lines.forEach(function(line) {
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
// Gleiche Polsterung wie der Auswahlrahmen (Breite +10, Höhe +6): so kann
|
||||||
|
// das Element nie kleiner werden als der gezeichnete Rahmen und der Text
|
||||||
|
// bleibt darin zentriert.
|
||||||
|
return {
|
||||||
|
width: Math.max(20, Math.ceil(maxLineWidth + 10)),
|
||||||
|
height: Math.max(20, Math.ceil(lines.length * fontSize * 1.2 + 6))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Java about element selection
|
// Notify Java about element selection
|
||||||
@@ -120,7 +154,8 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
el.width || 100,
|
el.width || 100,
|
||||||
el.height || 30,
|
el.height || 30,
|
||||||
el.isStatic || false,
|
el.isStatic || false,
|
||||||
el.variable || null
|
el.variable || null,
|
||||||
|
el.fontFamily || 'Arial'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +166,39 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen in der Sidebar anzeigen
|
||||||
|
function notifyCanvasSelected() {
|
||||||
|
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
|
||||||
|
window.invoiceGeneratorViewProfile.$server.showCanvasProperties(
|
||||||
|
!!window.profileInvoiceState.backgroundImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entprellter Autosave: sichert den Canvas-Stand serverseitig, damit nach
|
||||||
|
// einem Seiten-Reload (z. B. durch Session-Ablauf) nichts verloren geht.
|
||||||
|
// Erst aktiv, nachdem der initiale Stand geladen wurde, damit ein leeres
|
||||||
|
// Canvas keinen vorhandenen Autosave überschreibt.
|
||||||
|
var autosaveTimer = null;
|
||||||
|
var lastAutosaved = null;
|
||||||
|
var autosaveEnabled = false;
|
||||||
|
|
||||||
|
function scheduleAutosave() {
|
||||||
|
if (!autosaveEnabled) return;
|
||||||
|
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = setTimeout(function() {
|
||||||
|
try {
|
||||||
|
var snapshot = JSON.stringify(window.getProfileCanvasData());
|
||||||
|
if (snapshot === lastAutosaved) return;
|
||||||
|
lastAutosaved = snapshot;
|
||||||
|
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
|
||||||
|
window.invoiceGeneratorViewProfile.$server.autosaveCanvas(snapshot);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Autosave fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw function
|
// Draw function
|
||||||
function draw() {
|
function draw() {
|
||||||
console.log('draw() called, elements:', elements.length);
|
console.log('draw() called, elements:', elements.length);
|
||||||
@@ -156,21 +224,19 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
|
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
|
||||||
|
|
||||||
// Grid (scaled by zoom factor)
|
// Hintergrundbild: auf Seitengröße skaliert, hinter allen Elementen
|
||||||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
|
var backgroundImage = window.profileInvoiceState.backgroundImage;
|
||||||
ctx.lineWidth = 0.5;
|
if (backgroundImage) {
|
||||||
var scaledGridSize = gridSize * zoomFactor;
|
if (imageCache[backgroundImage]) {
|
||||||
for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) {
|
ctx.drawImage(imageCache[backgroundImage], pageX, pageY, pageWidth, pageHeight);
|
||||||
ctx.beginPath();
|
} else {
|
||||||
ctx.moveTo(x, pageY);
|
var bgImg = new Image();
|
||||||
ctx.lineTo(x, pageY + pageHeight);
|
bgImg.onload = function() {
|
||||||
ctx.stroke();
|
imageCache[backgroundImage] = bgImg;
|
||||||
}
|
draw();
|
||||||
for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) {
|
};
|
||||||
ctx.beginPath();
|
bgImg.src = backgroundImage;
|
||||||
ctx.moveTo(pageX, y);
|
}
|
||||||
ctx.lineTo(pageX + pageWidth, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw elements
|
// Draw elements
|
||||||
@@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
drawSelection(selectedElement);
|
drawSelection(selectedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElement(el) {
|
function drawElement(el) {
|
||||||
@@ -277,28 +345,41 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Text elements
|
// Text elements
|
||||||
var lines = (el.text || '').split('\n');
|
var lines = (el.text || '').split('\n');
|
||||||
var lineHeight = fontSize * 1.2;
|
var lineHeight = fontSize * 1.2;
|
||||||
var totalTextHeight = lines.length * lineHeight;
|
|
||||||
|
|
||||||
// Vertically center the text in the element
|
// Oben ausrichten statt zentrieren — wie im gerenderten PDF, damit die
|
||||||
var ty = y + (h - totalTextHeight) / 2;
|
// erste Zeile unabhängig von der Zeilenzahl an der Box-Oberkante beginnt
|
||||||
|
var ty = y;
|
||||||
|
|
||||||
// Draw background highlight for static elements
|
// Masterdata elements are never bold; other elements respect their fontStyle
|
||||||
|
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
|
||||||
|
// Set the font BEFORE measuring text, so the highlight width matches
|
||||||
|
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||||
|
|
||||||
|
// Draw background highlight for static elements, fitted to the widest
|
||||||
|
// text line (not the element width) and following the text alignment
|
||||||
if (el.isStatic) {
|
if (el.isStatic) {
|
||||||
var textWidth = ctx.measureText(el.text || '').width + (10 * zoomFactor);
|
var maxLineWidth = 0;
|
||||||
|
lines.forEach(function(line) {
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||||
|
});
|
||||||
|
var bgWidth = maxLineWidth + (10 * zoomFactor);
|
||||||
|
var bgX = x - (3 * zoomFactor);
|
||||||
|
if (textAlign === 'center') {
|
||||||
|
bgX = x + (w - bgWidth) / 2;
|
||||||
|
} else if (textAlign === 'right') {
|
||||||
|
bgX = x + w - bgWidth + (3 * zoomFactor);
|
||||||
|
}
|
||||||
// Different background colors: green for customer, blue for masterdata
|
// Different background colors: green for customer, blue for masterdata
|
||||||
if (el.isCustomer) {
|
if (el.isCustomer) {
|
||||||
ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer
|
ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata
|
ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata
|
||||||
}
|
}
|
||||||
ctx.fillRect(x - (3 * zoomFactor), y - (2 * zoomFactor), Math.max(w, textWidth), h);
|
ctx.fillRect(bgX, y - (2 * zoomFactor), bgWidth, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use black text for static elements, otherwise use element color
|
// Die gewählte Elementfarbe verwenden — wie im gerenderten PDF
|
||||||
ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333');
|
ctx.fillStyle = el.color || '#333333';
|
||||||
// Masterdata elements are never bold; other elements respect their fontStyle
|
|
||||||
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
|
|
||||||
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial';
|
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.textAlign = textAlign;
|
ctx.textAlign = textAlign;
|
||||||
|
|
||||||
@@ -318,140 +399,94 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw services list as a table with columns: Name, Steuersatz, Nettobetrag
|
// Positionsliste im Briefbogen-Layout: Spalten Menge, Bezeichnung,
|
||||||
// Plus summary section below: Nettosumme, USt, Gesamtsumme
|
// Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum Summenblock
|
||||||
|
// und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
|
||||||
function drawServicesTable(el, x, y, w, h, fontSize) {
|
function drawServicesTable(el, x, y, w, h, fontSize) {
|
||||||
|
var fontFamily = el.fontFamily || 'Arial';
|
||||||
var lineHeight = fontSize * 1.4;
|
var lineHeight = fontSize * 1.4;
|
||||||
var padding = 4 * zoomFactor;
|
var padding = 4 * zoomFactor;
|
||||||
var rowHeight = lineHeight + padding * 2;
|
var rowHeight = lineHeight + padding * 2;
|
||||||
var summaryRowHeight = fontSize * 1.6;
|
var summaryRowHeight = fontSize * 1.6;
|
||||||
var summaryGap = fontSize * 0.5;
|
var summaryGap = fontSize * 0.5;
|
||||||
|
|
||||||
// Column widths (percentages of total width)
|
// Spaltengrenzen wie im PDF-Renderer (9 / 61 / 15 / 15 Prozent)
|
||||||
var colNameWidth = w * 0.55; // 55% for Name (left-aligned)
|
var xName = x + w * 0.09;
|
||||||
var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned)
|
var xUnit = x + w * 0.70;
|
||||||
var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned)
|
var xTotal = x + w * 0.85;
|
||||||
|
|
||||||
var colNameX = x;
|
|
||||||
var colVatX = x + colNameWidth;
|
|
||||||
var colNetX = colVatX + colVatWidth;
|
|
||||||
|
|
||||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||||
|
|
||||||
// Rows come from window.profileInvoiceServiceData when provided (system
|
|
||||||
// invoice template: the three admin price table positions); otherwise
|
|
||||||
// fall back to sample data (per-user profile invoice generator)
|
|
||||||
var serviceData = window.profileInvoiceServiceData;
|
var serviceData = window.profileInvoiceServiceData;
|
||||||
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
|
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
|
||||||
? serviceData.rows
|
? serviceData.rows
|
||||||
: [
|
: [
|
||||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
{ quantity: '1', name: 'Umzugsleistung inkl. Verpackung', unitPrice: '450,00 \u20ac', net: '450,00 \u20ac' },
|
||||||
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
{ quantity: '1', name: 'Entsorgung M\u00f6bel', unitPrice: '85,00 \u20ac', net: '85,00 \u20ac' },
|
||||||
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
{ quantity: '1', name: 'Montage/De-Montage', unitPrice: '120,00 \u20ac', net: '120,00 \u20ac' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Calculate actual content height based on table + summary
|
// Mindesthoehe: Kopf + Zeilen + Summenblock; ist das Element hoeher,
|
||||||
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
|
// laufen die Spaltenlinien entsprechend weiter nach unten
|
||||||
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||||
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
var minHeight = rowHeight * (rows.length + 1) + summaryHeight;
|
||||||
// Ensure background covers at least the element height or the calculated content
|
var totalHeight = Math.max(h, minHeight);
|
||||||
var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor);
|
var bodyBottom = y + totalHeight - summaryHeight;
|
||||||
|
|
||||||
// Draw orange background highlight for entire service variable element
|
// Orangefarbene Markierung des gesamten Bausteins (nur im Designer)
|
||||||
var bgPadding = 3 * zoomFactor;
|
var bgPadding = 3 * zoomFactor;
|
||||||
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
|
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
|
||||||
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor));
|
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), totalHeight + (4 * zoomFactor));
|
||||||
|
|
||||||
// Draw table header (overlays the background)
|
// Kopfzeile: graue Flaeche mit kleinen Beschriftungen
|
||||||
ctx.fillStyle = '#f5f5f5';
|
ctx.fillStyle = '#eeeeee';
|
||||||
ctx.fillRect(x, y, w, rowHeight);
|
ctx.fillRect(x, y, w, rowHeight);
|
||||||
|
|
||||||
// Header border
|
|
||||||
ctx.strokeStyle = '#cccccc';
|
|
||||||
ctx.lineWidth = Math.max(0.5, zoomFactor);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, y + rowHeight);
|
|
||||||
ctx.lineTo(x + w, y + rowHeight);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Header text
|
|
||||||
ctx.fillStyle = '#333333';
|
ctx.fillStyle = '#333333';
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = (fontSize * 0.75) + 'px ' + fontFamily;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
// Name column header (left-aligned)
|
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2);
|
ctx.fillText('Menge', x + padding, y + rowHeight / 2);
|
||||||
|
ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2);
|
||||||
// Steuersatz column header (right-aligned)
|
ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2);
|
||||||
ctx.textAlign = 'right';
|
ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2);
|
||||||
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
|
|
||||||
|
|
||||||
// Nettobetrag column header (right-aligned)
|
|
||||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
|
||||||
|
|
||||||
|
// Datenzeilen
|
||||||
var currentY = y + rowHeight;
|
var currentY = y + rowHeight;
|
||||||
|
ctx.font = fontSize + 'px ' + fontFamily;
|
||||||
// Draw data rows
|
rows.forEach(function(row) {
|
||||||
ctx.font = fontSize + 'px Arial';
|
|
||||||
rows.forEach(function(row, index) {
|
|
||||||
// Draw row background (alternating)
|
|
||||||
if (index % 2 === 1) {
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
|
||||||
ctx.fillRect(x, currentY, w, rowHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row bottom border
|
|
||||||
ctx.strokeStyle = '#eeeeee';
|
|
||||||
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, currentY + rowHeight);
|
|
||||||
ctx.lineTo(x + w, currentY + rowHeight);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw cell text
|
|
||||||
ctx.fillStyle = '#333333';
|
ctx.fillStyle = '#333333';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
// Name (left-aligned)
|
ctx.fillText(row.quantity || '', x + (xName - x) / 2, currentY + rowHeight / 2);
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2);
|
ctx.fillText(row.name || '', xName + padding, currentY + rowHeight / 2);
|
||||||
|
|
||||||
// Steuersatz (right-aligned)
|
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2);
|
ctx.fillText(row.unitPrice || '', xTotal - padding, currentY + rowHeight / 2);
|
||||||
|
ctx.fillText(row.net || '', x + w - padding, currentY + rowHeight / 2);
|
||||||
// Nettobetrag (right-aligned)
|
|
||||||
ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2);
|
|
||||||
|
|
||||||
currentY += rowHeight;
|
currentY += rowHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw column separator lines
|
// Spaltenlinien vom Kopf bis zum Summenblock (inkl. Abstand davor,
|
||||||
ctx.strokeStyle = '#e0e0e0';
|
// damit keine Luecke zur ersten Summenzeile entsteht). X-Koordinaten
|
||||||
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
|
// aufs Pixelraster einrasten (n + 0,5), sonst verwischt der Browser
|
||||||
|
// die 1px-Linien je nach Subpixel-Lage zu breiten grauen Baendern.
|
||||||
|
var linesBottom = bodyBottom + summaryGap;
|
||||||
|
var lineXName = Math.round(xName) + 0.5;
|
||||||
|
var lineXUnit = Math.round(xUnit) + 0.5;
|
||||||
|
var lineXTotal = Math.round(xTotal) + 0.5;
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
// Line between Name and Steuersatz
|
ctx.moveTo(lineXName, y);
|
||||||
ctx.moveTo(colVatX, y);
|
ctx.lineTo(lineXName, linesBottom);
|
||||||
ctx.lineTo(colVatX, currentY);
|
ctx.moveTo(lineXUnit, y);
|
||||||
// Line between Steuersatz and Nettobetrag
|
ctx.lineTo(lineXUnit, linesBottom);
|
||||||
ctx.moveTo(colNetX, y);
|
ctx.moveTo(lineXTotal, y);
|
||||||
ctx.lineTo(colNetX, currentY);
|
ctx.lineTo(lineXTotal, linesBottom);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw outer border around table
|
// Summen: Nettobetrag, MwSt., Endbetrag rechts unten
|
||||||
ctx.strokeStyle = '#cccccc';
|
|
||||||
ctx.lineWidth = Math.max(0.5, zoomFactor);
|
|
||||||
ctx.strokeRect(x, y, w, currentY - y);
|
|
||||||
|
|
||||||
// Draw summary section below the table
|
|
||||||
var summaryY = currentY + summaryGap;
|
|
||||||
|
|
||||||
// Column positions for summary section
|
|
||||||
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
|
||||||
var valueX = x + w - padding; // Value column (right-aligned)
|
|
||||||
|
|
||||||
// Totals: provided with the service data or calculated from sample data
|
|
||||||
var netTotalLabel, vatTotalLabel, grossTotalLabel;
|
var netTotalLabel, vatTotalLabel, grossTotalLabel;
|
||||||
if (serviceData && serviceData.netTotal) {
|
if (serviceData && serviceData.netTotal) {
|
||||||
netTotalLabel = serviceData.netTotal;
|
netTotalLabel = serviceData.netTotal;
|
||||||
@@ -460,41 +495,41 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
} else {
|
} else {
|
||||||
var netTotal = 655.00; // 450 + 85 + 120
|
var netTotal = 655.00; // 450 + 85 + 120
|
||||||
var vatTotal = netTotal * vatRate;
|
var vatTotal = netTotal * vatRate;
|
||||||
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €';
|
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' \u20ac';
|
||||||
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €';
|
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' \u20ac';
|
||||||
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €';
|
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' \u20ac';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw summary lines
|
var summaryRows = [
|
||||||
|
{ label: 'Nettobetrag', value: netTotalLabel, shaded: true },
|
||||||
|
{ label: '+ ' + vatPctLabel + ' MwSt.', value: vatTotalLabel, shaded: false },
|
||||||
|
{ label: 'Endbetrag', value: grossTotalLabel, shaded: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
var summaryY = bodyBottom + summaryGap;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
summaryRows.forEach(function(row) {
|
||||||
|
if (row.shaded) {
|
||||||
|
ctx.fillStyle = '#eeeeee';
|
||||||
|
ctx.fillRect(xUnit, summaryY, x + w - xUnit, summaryRowHeight);
|
||||||
|
}
|
||||||
|
// Fortsetzung der Spaltenlinie links neben den Summen-Labels
|
||||||
|
// (gleiche eingerastete X-Koordinate wie die Spaltenlinie darueber)
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(Math.round(xUnit) + 0.5, summaryY);
|
||||||
|
ctx.lineTo(Math.round(xUnit) + 0.5, summaryY + summaryRowHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
// Nettosumme - label left, value right
|
ctx.fillStyle = '#333333';
|
||||||
ctx.fillStyle = '#333333';
|
ctx.font = fontSize + 'px ' + fontFamily;
|
||||||
ctx.font = fontSize + 'px Arial';
|
ctx.textAlign = 'left';
|
||||||
ctx.textAlign = 'left';
|
ctx.fillText(row.label, xUnit + padding, summaryY + summaryRowHeight / 2);
|
||||||
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.textAlign = 'right';
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.fillText(row.value, x + w - padding, summaryY + summaryRowHeight / 2);
|
||||||
ctx.textAlign = 'right';
|
summaryY += summaryRowHeight;
|
||||||
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
});
|
||||||
summaryY += summaryRowHeight;
|
|
||||||
|
|
||||||
// Umsatzsteuer - label left, value right
|
|
||||||
ctx.font = fontSize + 'px Arial';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
|
||||||
ctx.textAlign = 'right';
|
|
||||||
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
|
||||||
summaryY += summaryRowHeight;
|
|
||||||
|
|
||||||
// Gesamtsumme - label left, value right
|
|
||||||
summaryY += summaryGap; // Extra gap before total
|
|
||||||
ctx.fillStyle = '#000000';
|
|
||||||
ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
|
||||||
ctx.textAlign = 'right';
|
|
||||||
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSelection(el) {
|
function drawSelection(el) {
|
||||||
@@ -506,7 +541,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// For text elements, calculate actual text dimensions
|
// For text elements, calculate actual text dimensions
|
||||||
if (el.type !== 'line' && el.type !== 'image') {
|
if (el.type !== 'line' && el.type !== 'image') {
|
||||||
var fontSize = (el.fontSize || 14) * zoomFactor;
|
var fontSize = (el.fontSize || 14) * zoomFactor;
|
||||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial';
|
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||||
|
|
||||||
// For services.list, calculate table height including summary section
|
// For services.list, calculate table height including summary section
|
||||||
if (el.variable === 'services.list') {
|
if (el.variable === 'services.list') {
|
||||||
@@ -516,7 +551,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||||
var summaryRowHeight = fontSize * 1.6;
|
var summaryRowHeight = fontSize * 1.6;
|
||||||
var summaryGap = fontSize * 0.5;
|
var summaryGap = fontSize * 0.5;
|
||||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||||
var totalHeight = tableHeight + summaryHeight;
|
var totalHeight = tableHeight + summaryHeight;
|
||||||
h = Math.max(h, totalHeight);
|
h = Math.max(h, totalHeight);
|
||||||
} else {
|
} else {
|
||||||
@@ -571,44 +606,60 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der anklickbare Bereich entspricht dem dargestellten Inhalt, nicht dem
|
||||||
|
// (ggf. größeren) Elementrahmen.
|
||||||
function hitTest(x, y, el) {
|
function hitTest(x, y, el) {
|
||||||
var ex = pageX + (el.x * zoomFactor);
|
var ex = pageX + (el.x * zoomFactor);
|
||||||
var ey = pageY + (el.y * zoomFactor);
|
var ey = pageY + (el.y * zoomFactor);
|
||||||
var ew = (el.width || 100) * zoomFactor;
|
var ew = (el.width || 100) * zoomFactor;
|
||||||
var eh = (el.height || 30) * zoomFactor;
|
var eh = (el.height || 30) * zoomFactor;
|
||||||
|
|
||||||
// For text elements, calculate actual text dimensions for hit testing
|
if (el.type === 'line') {
|
||||||
if (el.type !== 'line' && el.type !== 'image') {
|
// Nur die gezeichnete Linie, mit kleiner vertikaler Toleranz
|
||||||
var fontSize = (el.fontSize || 14) * zoomFactor;
|
var tolerance = Math.max(3, 4 * zoomFactor);
|
||||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial';
|
return x >= ex && x <= ex + ew && y >= ey - tolerance && y <= ey + tolerance;
|
||||||
|
}
|
||||||
// For services.list, calculate table height
|
if (el.type === 'image') {
|
||||||
if (el.variable === 'services.list') {
|
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
||||||
var lineHeight = fontSize * 1.4;
|
|
||||||
var padding = 4 * zoomFactor;
|
|
||||||
var rowHeight = lineHeight + padding * 2;
|
|
||||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
|
||||||
var summaryRowHeight = fontSize * 1.6;
|
|
||||||
var summaryGap = fontSize * 0.5;
|
|
||||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
|
||||||
eh = Math.max(eh, tableHeight + summaryHeight);
|
|
||||||
} else {
|
|
||||||
var lines = (el.text || '').split('\n');
|
|
||||||
var maxLineWidth = 0;
|
|
||||||
lines.forEach(function(line) {
|
|
||||||
var lineWidth = ctx.measureText(line).width;
|
|
||||||
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
|
||||||
});
|
|
||||||
|
|
||||||
ew = Math.max(ew, maxLineWidth + (10 * zoomFactor));
|
|
||||||
|
|
||||||
var lineHeight = fontSize * 1.2;
|
|
||||||
var textHeight = lines.length * lineHeight;
|
|
||||||
eh = Math.max(eh, textHeight + (6 * zoomFactor));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
var fontSize = (el.fontSize || 14) * zoomFactor;
|
||||||
|
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||||
|
|
||||||
|
// services.list: die Tabelle füllt die Elementbreite, Höhe wie gezeichnet
|
||||||
|
if (el.variable === 'services.list') {
|
||||||
|
var lineHeight = fontSize * 1.4;
|
||||||
|
var padding = 4 * zoomFactor;
|
||||||
|
var rowHeight = lineHeight + padding * 2;
|
||||||
|
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||||
|
var summaryRowHeight = fontSize * 1.6;
|
||||||
|
var summaryGap = fontSize * 0.5;
|
||||||
|
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||||
|
eh = Math.max(eh, tableHeight + summaryHeight);
|
||||||
|
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textelemente: Maße des gezeichneten Texts (breiteste Zeile, Zeilenhöhe),
|
||||||
|
// horizontal gemäß Ausrichtung, vertikal zentriert wie beim Zeichnen
|
||||||
|
var lines = (el.text || '').split('\n');
|
||||||
|
var maxLineWidth = 0;
|
||||||
|
lines.forEach(function(line) {
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||||
|
});
|
||||||
|
var textHeight = lines.length * fontSize * 1.2;
|
||||||
|
|
||||||
|
var textAlign = el.textAlign || 'left';
|
||||||
|
var contentX = ex;
|
||||||
|
if (textAlign === 'center') {
|
||||||
|
contentX = ex + (ew - maxLineWidth) / 2;
|
||||||
|
} else if (textAlign === 'right') {
|
||||||
|
contentX = ex + ew - maxLineWidth;
|
||||||
|
}
|
||||||
|
var contentY = ey + (eh - textHeight) / 2;
|
||||||
|
|
||||||
|
var pad = 3 * zoomFactor;
|
||||||
|
return x >= contentX - pad && x <= contentX + maxLineWidth + pad
|
||||||
|
&& y >= contentY - pad && y <= contentY + textHeight + pad;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resizing state
|
// Resizing state
|
||||||
@@ -636,7 +687,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||||
var summaryRowHeight = fontSize * 1.6;
|
var summaryRowHeight = fontSize * 1.6;
|
||||||
var summaryGap = fontSize * 0.5;
|
var summaryGap = fontSize * 0.5;
|
||||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||||
eh = Math.max(eh, tableHeight + summaryHeight);
|
eh = Math.max(eh, tableHeight + summaryHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,8 +759,9 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
canvas.style.cursor = 'move';
|
canvas.style.cursor = 'move';
|
||||||
notifyElementSelected(selectedElement);
|
notifyElementSelected(selectedElement);
|
||||||
} else {
|
} else {
|
||||||
|
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen anzeigen
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
notifyElementDeselected();
|
notifyCanvasSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
@@ -730,41 +782,47 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var newX = resizeStart.elemX;
|
var newX = resizeStart.elemX;
|
||||||
var newY = resizeStart.elemY;
|
var newY = resizeStart.elemY;
|
||||||
|
|
||||||
|
// Textelemente lassen sich nur bis zur Größe des dargestellten Texts
|
||||||
|
// verkleinern; alle anderen Typen behalten das bisherige Minimum.
|
||||||
|
var minSize = minTextSize(selectedElement);
|
||||||
|
var minW = minSize ? minSize.width : 20;
|
||||||
|
var minH = minSize ? minSize.height : 20;
|
||||||
|
|
||||||
// Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
|
// Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
|
||||||
switch(resizeHandle) {
|
switch(resizeHandle) {
|
||||||
case 0: // Top-left
|
case 0: // Top-left
|
||||||
newWidth = Math.max(20, resizeStart.width - dx);
|
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||||
newHeight = Math.max(20, resizeStart.height - dy);
|
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||||
break;
|
break;
|
||||||
case 1: // Top-center
|
case 1: // Top-center
|
||||||
newHeight = Math.max(20, resizeStart.height - dy);
|
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||||
break;
|
break;
|
||||||
case 2: // Top-right
|
case 2: // Top-right
|
||||||
newWidth = Math.max(20, resizeStart.width + dx);
|
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||||
newHeight = Math.max(20, resizeStart.height - dy);
|
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||||
break;
|
break;
|
||||||
case 3: // Middle-left
|
case 3: // Middle-left
|
||||||
newWidth = Math.max(20, resizeStart.width - dx);
|
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||||
break;
|
break;
|
||||||
case 4: // Middle-right
|
case 4: // Middle-right
|
||||||
newWidth = Math.max(20, resizeStart.width + dx);
|
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||||
break;
|
break;
|
||||||
case 5: // Bottom-left
|
case 5: // Bottom-left
|
||||||
newWidth = Math.max(20, resizeStart.width - dx);
|
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||||
newHeight = Math.max(20, resizeStart.height + dy);
|
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||||
break;
|
break;
|
||||||
case 6: // Bottom-center
|
case 6: // Bottom-center
|
||||||
newHeight = Math.max(20, resizeStart.height + dy);
|
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||||
break;
|
break;
|
||||||
case 7: // Bottom-right
|
case 7: // Bottom-right
|
||||||
newWidth = Math.max(20, resizeStart.width + dx);
|
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||||
newHeight = Math.max(20, resizeStart.height + dy);
|
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,6 +830,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
selectedElement.height = snapToGrid(newHeight);
|
selectedElement.height = snapToGrid(newHeight);
|
||||||
selectedElement.x = snapToGrid(newX);
|
selectedElement.x = snapToGrid(newX);
|
||||||
selectedElement.y = snapToGrid(newY);
|
selectedElement.y = snapToGrid(newY);
|
||||||
|
keepTextCentered(selectedElement);
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
notifyElementSelected(selectedElement);
|
notifyElementSelected(selectedElement);
|
||||||
@@ -856,40 +915,29 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
var moved = false;
|
var moved = false;
|
||||||
|
|
||||||
|
// Pfeiltasten bewegen um 0,1 mm, mit Shift um 0,5 mm (A4: 210 x 297 mm)
|
||||||
|
var stepMm = e.shiftKey ? 0.5 : 0.1;
|
||||||
|
var stepX = stepMm / 210 * basePageWidth;
|
||||||
|
var stepY = stepMm / 297 * basePageHeight;
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
if (e.shiftKey) {
|
selectedElement.y = Math.max(0, selectedElement.y - stepY);
|
||||||
selectedElement.y = Math.max(0, selectedElement.y - 1);
|
|
||||||
} else {
|
|
||||||
selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize);
|
|
||||||
}
|
|
||||||
moved = true;
|
moved = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
if (e.shiftKey) {
|
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + stepY);
|
||||||
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + 1);
|
|
||||||
} else {
|
|
||||||
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), Math.ceil((selectedElement.y + 1) / gridSize) * gridSize);
|
|
||||||
}
|
|
||||||
moved = true;
|
moved = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
if (e.shiftKey) {
|
selectedElement.x = Math.max(0, selectedElement.x - stepX);
|
||||||
selectedElement.x = Math.max(0, selectedElement.x - 1);
|
|
||||||
} else {
|
|
||||||
selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize);
|
|
||||||
}
|
|
||||||
moved = true;
|
moved = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
if (e.shiftKey) {
|
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + stepX);
|
||||||
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + 1);
|
|
||||||
} else {
|
|
||||||
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), Math.ceil((selectedElement.x + 1) / gridSize) * gridSize);
|
|
||||||
}
|
|
||||||
moved = true;
|
moved = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
@@ -933,6 +981,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
width: 150,
|
width: 150,
|
||||||
height: 30,
|
height: 30,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
fontFamily: 'Arial',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
isStatic: isStatic || false,
|
isStatic: isStatic || false,
|
||||||
variable: variable || null,
|
variable: variable || null,
|
||||||
@@ -959,7 +1008,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows
|
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows
|
||||||
var summaryRowHeight = el.fontSize * 1.6;
|
var summaryRowHeight = el.fontSize * 1.6;
|
||||||
var summaryGap = el.fontSize * 0.5;
|
var summaryGap = el.fontSize * 0.5;
|
||||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||||
var totalHeight = tableHeight + summaryHeight;
|
var totalHeight = tableHeight + summaryHeight;
|
||||||
el.height = Math.round(totalHeight);
|
el.height = Math.round(totalHeight);
|
||||||
} else {
|
} else {
|
||||||
@@ -1019,6 +1068,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
elements.push(el);
|
elements.push(el);
|
||||||
selectedElement = el;
|
selectedElement = el;
|
||||||
|
autosaveEnabled = true;
|
||||||
draw();
|
draw();
|
||||||
notifyElementSelected(el);
|
notifyElementSelected(el);
|
||||||
|
|
||||||
@@ -1032,6 +1082,46 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
if (el) {
|
if (el) {
|
||||||
pushUndoState();
|
pushUndoState();
|
||||||
el.text = text;
|
el.text = text;
|
||||||
|
// Höhe an die Zeilenzahl anpassen, damit mehrzeiliger Text ins Element
|
||||||
|
// passt; Breite mindestens auf die breiteste Zeile aufweiten
|
||||||
|
if (el.type !== 'line' && el.type !== 'image') {
|
||||||
|
var lines = (text || '').split('\n');
|
||||||
|
var lineHeight = (el.fontSize || 14) * 1.2;
|
||||||
|
el.height = Math.round(lines.length * lineHeight + 6);
|
||||||
|
var minSize = minTextSize(el);
|
||||||
|
if (minSize) {
|
||||||
|
el.width = Math.max(el.width || 0, minSize.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.updateProfileElementSize = function(id, width, height) {
|
||||||
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
|
var minSize = minTextSize(el);
|
||||||
|
var minW = minSize ? minSize.width : 1;
|
||||||
|
var minH = minSize ? minSize.height : 1;
|
||||||
|
if (width !== null) el.width = Math.max(minW, width);
|
||||||
|
if (height !== null) el.height = Math.max(minH, height);
|
||||||
|
keepTextCentered(el);
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.updateProfileElementFontFamily = function(id, family) {
|
||||||
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
|
el.fontFamily = family;
|
||||||
|
// Breite mindestens auf die breiteste Zeile in der neuen Schrift aufweiten
|
||||||
|
var minSize = minTextSize(el);
|
||||||
|
if (minSize) {
|
||||||
|
el.width = Math.max(el.width || 0, minSize.width);
|
||||||
|
el.height = Math.max(el.height || 0, minSize.height);
|
||||||
|
}
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1051,12 +1141,17 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
if (el) {
|
if (el) {
|
||||||
pushUndoState();
|
pushUndoState();
|
||||||
el.fontSize = size;
|
el.fontSize = size;
|
||||||
// Update height based on text content and new font size
|
// Update height based on text content and new font size;
|
||||||
|
// Breite mindestens auf die breiteste Zeile aufweiten
|
||||||
if (el.type !== 'line' && el.type !== 'image') {
|
if (el.type !== 'line' && el.type !== 'image') {
|
||||||
var lines = (el.text || '').split('\n');
|
var lines = (el.text || '').split('\n');
|
||||||
var lineHeight = size * 1.2;
|
var lineHeight = size * 1.2;
|
||||||
var textHeight = lines.length * lineHeight;
|
var textHeight = lines.length * lineHeight;
|
||||||
el.height = Math.max(textHeight + 6, 20); // Minimum height of 20
|
el.height = Math.max(textHeight + 6, 20); // Minimum height of 20
|
||||||
|
var minSize = minTextSize(el);
|
||||||
|
if (minSize) {
|
||||||
|
el.width = Math.max(el.width || 0, minSize.width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1287,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
heightPercent: toPercentY(el.height),
|
heightPercent: toPercentY(el.height),
|
||||||
fontSize: el.fontSize,
|
fontSize: el.fontSize,
|
||||||
fontStyle: el.fontStyle,
|
fontStyle: el.fontStyle,
|
||||||
|
fontFamily: el.fontFamily,
|
||||||
textAlign: el.textAlign,
|
textAlign: el.textAlign,
|
||||||
color: el.color,
|
color: el.color,
|
||||||
isStatic: el.isStatic,
|
isStatic: el.isStatic,
|
||||||
@@ -1200,11 +1296,19 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
imageData: el.imageData
|
imageData: el.imageData
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// Das Hintergrundbild wird bewusst NICHT mitgeschickt: Es wird serverseitig
|
||||||
|
// gehalten und dort ins Template-JSON übernommen — die große Bild-Payload
|
||||||
|
// würde sonst das Limit für Client-zu-Server-Aufrufe sprengen.
|
||||||
return {
|
return {
|
||||||
elements: elementsWithPercent
|
elements: elementsWithPercent
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.updateProfileCanvasBackground = function(dataUrl) {
|
||||||
|
window.profileInvoiceState.backgroundImage = dataUrl || null;
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
window.updateProfileVatRate = function(rate) {
|
window.updateProfileVatRate = function(rate) {
|
||||||
if (rate == null || isNaN(rate)) return;
|
if (rate == null || isNaN(rate)) return;
|
||||||
window.profileInvoiceVatRate = rate;
|
window.profileInvoiceVatRate = rate;
|
||||||
@@ -1275,6 +1379,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
try {
|
try {
|
||||||
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
|
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
|
||||||
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
|
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
|
||||||
|
window.profileInvoiceState.backgroundImage = data.backgroundImage || null;
|
||||||
if (data.elements && Array.isArray(data.elements)) {
|
if (data.elements && Array.isArray(data.elements)) {
|
||||||
console.log('Loading ' + data.elements.length + ' elements');
|
console.log('Loading ' + data.elements.length + ' elements');
|
||||||
console.log('Current elements array before clear:', elements.length);
|
console.log('Current elements array before clear:', elements.length);
|
||||||
@@ -1387,6 +1492,10 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Save to global state
|
// Save to global state
|
||||||
saveState();
|
saveState();
|
||||||
|
|
||||||
|
// Autosave erst ab jetzt: der geladene Stand ist die neue Referenz
|
||||||
|
lastAutosaved = JSON.stringify(window.getProfileCanvasData());
|
||||||
|
autosaveEnabled = true;
|
||||||
|
|
||||||
console.log('Calling draw(), elements count:', elements.length);
|
console.log('Calling draw(), elements count:', elements.length);
|
||||||
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
|
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
|
||||||
draw();
|
draw();
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public class Address {
|
|||||||
private String vatId;
|
private String vatId;
|
||||||
private String email;
|
private String email;
|
||||||
private String phone;
|
private String phone;
|
||||||
|
private String website;
|
||||||
|
|
||||||
|
// Bankverbindung (v. a. für Rechnungssteller relevant)
|
||||||
|
private String bankName;
|
||||||
|
private String iban;
|
||||||
|
private String bic;
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
@@ -114,6 +120,38 @@ public class Address {
|
|||||||
this.phone = phone;
|
this.phone = phone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getWebsite() {
|
||||||
|
return website;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWebsite(String website) {
|
||||||
|
this.website = website;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBankName() {
|
||||||
|
return bankName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBankName(String bankName) {
|
||||||
|
this.bankName = bankName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIban() {
|
||||||
|
return iban;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIban(String iban) {
|
||||||
|
this.iban = iban;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBic() {
|
||||||
|
return bic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBic(String bic) {
|
||||||
|
this.bic = bic;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package de.assecutor.pdftool.api;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
|
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdResult;
|
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdService;
|
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdValidationService;
|
|
||||||
import jakarta.validation.ConstraintViolation;
|
|
||||||
import jakarta.validation.Validator;
|
|
||||||
import org.springframework.http.ContentDisposition;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* curl -X POST http://localhost:8083/api/invoices/sign \
|
|
||||||
* -F "file=@rechnung.pdf" \
|
|
||||||
* -F "metadata=@metadata.json;type=application/json" \
|
|
||||||
* -o rechnung-zugferd.zip
|
|
||||||
*
|
|
||||||
* curl -X POST http://localhost:8083/api/invoices/validate \
|
|
||||||
* -F "file=@rechnung-zugferd.pdf"
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/invoices")
|
|
||||||
public class InvoiceSigningController {
|
|
||||||
|
|
||||||
/** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */
|
|
||||||
public static final String VALIDATION_HEADER = "X-Zugferd-Valid";
|
|
||||||
|
|
||||||
private final ZugferdService zugferdService;
|
|
||||||
private final ZugferdValidationService validationService;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final Validator validator;
|
|
||||||
|
|
||||||
public InvoiceSigningController(ZugferdService zugferdService,
|
|
||||||
ZugferdValidationService validationService,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
Validator validator) {
|
|
||||||
this.zugferdService = zugferdService;
|
|
||||||
this.validationService = validationService;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.validator = validator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Erzeugt aus PDF und Metadaten eine ZUGFeRD-Rechnung als ZIP (PDF + Prüfbericht).
|
|
||||||
* HTTP 200 bei gültiger, HTTP 422 bei ungültiger Rechnung; das ZIP wird in
|
|
||||||
* beiden Fällen geliefert.
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/sign", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
||||||
public ResponseEntity<byte[]> sign(@RequestPart("file") MultipartFile file,
|
|
||||||
@RequestPart("metadata") String metadataJson) throws IOException {
|
|
||||||
InvoiceMetadata metadata = parseAndValidate(metadataJson);
|
|
||||||
ZugferdResult result = zugferdService.createZugferdZip(file.getBytes(), metadata);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.parseMediaType("application/zip"));
|
|
||||||
headers.setContentDisposition(ContentDisposition.attachment().filename(result.zipFileName()).build());
|
|
||||||
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
|
|
||||||
return new ResponseEntity<>(result.zip(), headers,
|
|
||||||
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validiert eine bestehende ZUGFeRD-Rechnung (PDF oder Factur-X-XML).
|
|
||||||
* Antwort ist der Mustang-Prüfbericht als XML; HTTP 200 bei gültiger,
|
|
||||||
* HTTP 422 bei ungültiger Rechnung.
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
|
||||||
produces = MediaType.APPLICATION_XML_VALUE)
|
|
||||||
public ResponseEntity<String> validate(@RequestPart("file") MultipartFile file) throws IOException {
|
|
||||||
ZugferdValidationService.ValidationResult result =
|
|
||||||
validationService.validate(file.getBytes(), file.getOriginalFilename());
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_XML);
|
|
||||||
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
|
|
||||||
return new ResponseEntity<>(result.reportXml(), headers,
|
|
||||||
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private InvoiceMetadata parseAndValidate(String metadataJson) {
|
|
||||||
InvoiceMetadata metadata;
|
|
||||||
try {
|
|
||||||
metadata = objectMapper.readValue(metadataJson, InvoiceMetadata.class);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ZugferdConversionException("Metadaten sind kein gültiges JSON: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
Set<ConstraintViolation<InvoiceMetadata>> violations = validator.validate(metadata);
|
|
||||||
if (!violations.isEmpty()) {
|
|
||||||
String details = violations.stream()
|
|
||||||
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
|
|
||||||
.sorted()
|
|
||||||
.collect(Collectors.joining("; "));
|
|
||||||
throw new ZugferdConversionException("Metadaten unvollständig: " + details);
|
|
||||||
}
|
|
||||||
return metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(ZugferdConversionException.class)
|
|
||||||
public ResponseEntity<Map<String, String>> handleConversionError(ZugferdConversionException e) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.assecutor.pdftool.invoice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft IBANs nach ISO 13616 (MOD 97-10).
|
||||||
|
*/
|
||||||
|
public final class IbanValidator {
|
||||||
|
|
||||||
|
private IbanValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(String value) {
|
||||||
|
String normalized = normalize(value);
|
||||||
|
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
|
||||||
|
int mod = 0;
|
||||||
|
for (char c : rearranged.toCharArray()) {
|
||||||
|
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
|
||||||
|
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
|
||||||
|
}
|
||||||
|
return mod == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt Leerzeichen und wandelt in Großbuchstaben um. */
|
||||||
|
public static String normalize(String value) {
|
||||||
|
return value.replaceAll("\\s", "").toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public record InvoiceDraft(
|
|||||||
String buyerReference,
|
String buyerReference,
|
||||||
String iban,
|
String iban,
|
||||||
String bic,
|
String bic,
|
||||||
|
String website,
|
||||||
InvoiceMetadata.Party sender,
|
InvoiceMetadata.Party sender,
|
||||||
InvoiceMetadata.Party recipient,
|
InvoiceMetadata.Party recipient,
|
||||||
List<InvoiceMetadata.LineItem> items,
|
List<InvoiceMetadata.LineItem> items,
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ public class InvoiceDraftService {
|
|||||||
return repository.existsByName(name);
|
return repository.existsByName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Löscht die unter dem Namen gespeicherten Eingaben; ohne Treffer passiert nichts. */
|
||||||
|
public void delete(String name) {
|
||||||
|
repository.findByName(name).ifPresent(existing -> repository.deleteById(existing.id()));
|
||||||
|
}
|
||||||
|
|
||||||
/** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */
|
/** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */
|
||||||
public List<String> names() {
|
public List<String> names() {
|
||||||
return repository.findAll().stream()
|
return repository.findAll().stream()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package de.assecutor.pdftool.pdfservice;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client für die REST-API des PDF Service (eigenständiges Projekt), der aus
|
||||||
|
* einem Rechnungs-PDF und den Metadaten die ZUGFeRD-E-Rechnung als ZIP erzeugt
|
||||||
|
* ({@code POST {pdfservice.url}/invoices/sign}, Standard-Basis
|
||||||
|
* {@code http://localhost:8084/api/v1}).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PdfServiceClient {
|
||||||
|
|
||||||
|
/** Response-Header des PDF Service mit dem Ergebnis der Mustang-Validierung. */
|
||||||
|
private static final String VALIDATION_HEADER = "X-Zugferd-Valid";
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
/** Ergebnis der Umwandlung: das ZIP, sein Dateiname und das Validierungsergebnis. */
|
||||||
|
public record SignResult(byte[] zip, String zipFileName, boolean valid) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public PdfServiceClient(ObjectMapper objectMapper,
|
||||||
|
@Value("${pdfservice.url:http://localhost:8084/api/v1}") String baseUrl) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout(Duration.ofSeconds(5));
|
||||||
|
// Die Umwandlung (PDF/A-Konvertierung + Validierung) kann einige Sekunden dauern
|
||||||
|
requestFactory.setReadTimeout(Duration.ofSeconds(120));
|
||||||
|
this.restClient = RestClient.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.requestFactory(requestFactory)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt das PDF über den PDF Service in eine ZUGFeRD-E-Rechnung um. Das
|
||||||
|
* ZIP wird auch bei fehlgeschlagener Validierung (HTTP 422) geliefert;
|
||||||
|
* {@link SignResult#valid()} meldet das Ergebnis.
|
||||||
|
*
|
||||||
|
* @throws PdfServiceException wenn der Service nicht erreichbar ist oder
|
||||||
|
* die Anfrage ablehnt (z. B. HTTP 400)
|
||||||
|
*/
|
||||||
|
public SignResult sign(byte[] pdf, InvoiceMetadata metadata) {
|
||||||
|
String metadataJson;
|
||||||
|
try {
|
||||||
|
metadataJson = objectMapper.writeValueAsString(metadata);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new PdfServiceException("Die Rechnungsdaten konnten nicht serialisiert werden: "
|
||||||
|
+ e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
|
||||||
|
parts.add("file", new ByteArrayResource(pdf) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return "rechnung.pdf";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
HttpHeaders metadataHeaders = new HttpHeaders();
|
||||||
|
metadataHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
parts.add("metadata", new HttpEntity<>(metadataJson, metadataHeaders));
|
||||||
|
|
||||||
|
ResponseEntity<byte[]> response;
|
||||||
|
try {
|
||||||
|
response = restClient.post()
|
||||||
|
.uri("/invoices/sign")
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
.body(parts)
|
||||||
|
.retrieve()
|
||||||
|
// 422 = ungültige Rechnung, das ZIP kommt trotzdem mit
|
||||||
|
.onStatus(status -> status == HttpStatus.UNPROCESSABLE_ENTITY, (request, res) -> {
|
||||||
|
})
|
||||||
|
.onStatus(org.springframework.http.HttpStatusCode::isError, (request, res) -> {
|
||||||
|
throw new PdfServiceException(errorMessage(res.getStatusCode().value(),
|
||||||
|
new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||||
|
})
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
} catch (ResourceAccessException e) {
|
||||||
|
throw new PdfServiceException("Der PDF Service ist nicht erreichbar (" + baseUrl + "): "
|
||||||
|
+ e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] zip = response.getBody();
|
||||||
|
if (zip == null || zip.length == 0) {
|
||||||
|
throw new PdfServiceException("Der PDF Service hat ein leeres ZIP geliefert.");
|
||||||
|
}
|
||||||
|
boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER));
|
||||||
|
return new SignResult(zip, zipFileName(response.getHeaders(), metadata), valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname aus dem Content-Disposition-Header, sonst aus der Rechnungsnummer. */
|
||||||
|
private static String zipFileName(HttpHeaders headers, InvoiceMetadata metadata) {
|
||||||
|
ContentDisposition disposition = headers.getContentDisposition();
|
||||||
|
if (disposition.getFilename() != null && !disposition.getFilename().isBlank()) {
|
||||||
|
return disposition.getFilename();
|
||||||
|
}
|
||||||
|
return metadata.invoiceNumber() + "-zugferd.zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehlermeldung aus dem JSON-Body ({@code {"error": "..."}}) des PDF Service. */
|
||||||
|
private String errorMessage(int status, String body) {
|
||||||
|
try {
|
||||||
|
Map<?, ?> json = objectMapper.readValue(body, Map.class);
|
||||||
|
Object error = json.get("error");
|
||||||
|
if (error != null) {
|
||||||
|
return "Der PDF Service hat die Anfrage abgelehnt: " + error;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Body war kein JSON — Rohtext verwenden
|
||||||
|
}
|
||||||
|
return "Der PDF Service hat die Anfrage abgelehnt (HTTP " + status + "): " + body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.assecutor.pdftool.pdfservice;
|
||||||
|
|
||||||
|
/** Fehler beim Aufruf des PDF Service (nicht erreichbar oder Ablehnung der Anfrage). */
|
||||||
|
public class PdfServiceException extends RuntimeException {
|
||||||
|
|
||||||
|
public PdfServiceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PdfServiceException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,12 @@ public class InvoiceTemplateService {
|
|||||||
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
|
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
|
||||||
public static final String DEFAULT_TEMPLATE_NAME = "standard";
|
public static final String DEFAULT_TEMPLATE_NAME = "standard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reservierter Name für den automatisch gesicherten Arbeitsstand des
|
||||||
|
* Rechnungsgenerators; taucht in der Template-Auswahl nicht auf.
|
||||||
|
*/
|
||||||
|
public static final String AUTOSAVE_NAME = "__autosave__";
|
||||||
|
|
||||||
private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
|
private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
|
||||||
|
|
||||||
private final InvoiceTemplateRepository repository;
|
private final InvoiceTemplateRepository repository;
|
||||||
@@ -64,10 +70,28 @@ public class InvoiceTemplateService {
|
|||||||
migrateLegacyTemplateIfMissing();
|
migrateLegacyTemplateIfMissing();
|
||||||
return repository.findAll().stream()
|
return repository.findAll().stream()
|
||||||
.map(template -> template.getName())
|
.map(template -> template.getName())
|
||||||
|
.filter(name -> !AUTOSAVE_NAME.equals(name))
|
||||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sichert den aktuellen Arbeitsstand des Rechnungsgenerators. */
|
||||||
|
public void saveAutosave(String templateData) {
|
||||||
|
saveTemplate(AUTOSAVE_NAME, templateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return der zuletzt gesicherte Arbeitsstand oder leer, wenn keiner existiert. */
|
||||||
|
public Optional<String> loadAutosave() {
|
||||||
|
return repository.findByName(AUTOSAVE_NAME)
|
||||||
|
.map(template -> template.getTemplateData())
|
||||||
|
.filter(data -> !data.isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verwirft den gesicherten Arbeitsstand, z. B. nach dem Speichern eines Templates. */
|
||||||
|
public void clearAutosave() {
|
||||||
|
repository.findByName(AUTOSAVE_NAME).ifPresent(repository::delete);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */
|
/** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */
|
||||||
public Optional<String> loadTemplate(String name) {
|
public Optional<String> loadTemplate(String name) {
|
||||||
if (DEFAULT_TEMPLATE_NAME.equals(name)) {
|
if (DEFAULT_TEMPLATE_NAME.equals(name)) {
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package de.assecutor.pdftool.template;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.itextpdf.html2pdf.ConverterProperties;
|
||||||
import com.itextpdf.html2pdf.HtmlConverter;
|
import com.itextpdf.html2pdf.HtmlConverter;
|
||||||
|
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -48,6 +52,20 @@ public class TemplatePdfService {
|
|||||||
JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||||
JsonNode elements = rootNode.get("elements");
|
JsonNode elements = rootNode.get("elements");
|
||||||
|
|
||||||
|
// Hintergrundbild: als body-Hintergrund auf Seitengröße skaliert; alle
|
||||||
|
// Elemente liegen darüber. (Ein <img> wird von html2pdf an dieser
|
||||||
|
// Stelle nicht unterstützt, background-image dagegen schon.)
|
||||||
|
String backgroundCss = "";
|
||||||
|
JsonNode background = rootNode.get("backgroundImage");
|
||||||
|
if (background != null && !background.isNull() && !background.asText().isEmpty()) {
|
||||||
|
String imageData = background.asText();
|
||||||
|
if (!imageData.startsWith("data:")) {
|
||||||
|
imageData = "data:image/png;base64," + imageData;
|
||||||
|
}
|
||||||
|
backgroundCss = " background-image: url('" + imageData.replace("'", "%27") + "');"
|
||||||
|
+ " background-size: 210mm 297mm; background-repeat: no-repeat;";
|
||||||
|
}
|
||||||
|
|
||||||
StringBuilder htmlBuilder = new StringBuilder();
|
StringBuilder htmlBuilder = new StringBuilder();
|
||||||
htmlBuilder.append("<!DOCTYPE html>");
|
htmlBuilder.append("<!DOCTYPE html>");
|
||||||
htmlBuilder.append("<html><head>");
|
htmlBuilder.append("<html><head>");
|
||||||
@@ -55,7 +73,8 @@ public class TemplatePdfService {
|
|||||||
htmlBuilder.append("<style>");
|
htmlBuilder.append("<style>");
|
||||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||||
htmlBuilder.append(
|
htmlBuilder.append(
|
||||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif;"
|
||||||
|
+ backgroundCss + " }");
|
||||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||||
@@ -72,8 +91,22 @@ public class TemplatePdfService {
|
|||||||
|
|
||||||
htmlBuilder.append("</body></html>");
|
htmlBuilder.append("</body></html>");
|
||||||
|
|
||||||
|
// Standard-PDF-Fonts + mitgelieferte Noto-Fonts + Systemschriften, damit
|
||||||
|
// auch Schriften wie Verdana oder echtes Arial aufgelöst werden können.
|
||||||
|
// Der FontProvider darf nicht über Konvertierungen hinweg wiederverwendet
|
||||||
|
// werden, daher pro Aufruf eine neue Instanz.
|
||||||
|
ConverterProperties converterProperties = new ConverterProperties();
|
||||||
|
DefaultFontProvider fontProvider = new DefaultFontProvider(true, true, true);
|
||||||
|
// macOS legt viele Schriften (u. a. Verdana) unter "Supplemental" ab;
|
||||||
|
// dieses Verzeichnis gehört nicht zu den von iText gescannten Pfaden.
|
||||||
|
Path supplementalFonts = Path.of("/System/Library/Fonts/Supplemental");
|
||||||
|
if (Files.isDirectory(supplementalFonts)) {
|
||||||
|
fontProvider.addDirectory(supplementalFonts.toString());
|
||||||
|
}
|
||||||
|
converterProperties.setFontProvider(fontProvider);
|
||||||
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
HtmlConverter.convertToPdf(htmlBuilder.toString(), out);
|
HtmlConverter.convertToPdf(htmlBuilder.toString(), out, converterProperties);
|
||||||
return out.toByteArray();
|
return out.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +136,7 @@ public class TemplatePdfService {
|
|||||||
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
|
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
|
||||||
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
|
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
|
||||||
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
|
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
|
||||||
|
String fontFamily = element.has("fontFamily") ? element.get("fontFamily").asText("Arial") : "Arial";
|
||||||
|
|
||||||
// Prozent -> mm (A4: 210mm x 297mm)
|
// Prozent -> mm (A4: 210mm x 297mm)
|
||||||
double mmX = xPercent / 100.0 * 210.0;
|
double mmX = xPercent / 100.0 * 210.0;
|
||||||
@@ -126,11 +160,15 @@ public class TemplatePdfService {
|
|||||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||||
htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
||||||
htmlBuilder.append("color:").append(color).append(";");
|
htmlBuilder.append("color:").append(color).append(";");
|
||||||
|
htmlBuilder.append("font-family:").append(cssFontStack(fontFamily)).append(";");
|
||||||
// services.list als Block, damit die Tabelle die Breite füllen kann
|
// services.list als Block, damit die Tabelle die Breite füllen kann
|
||||||
if ("services.list".equals(variable)) {
|
if ("services.list".equals(variable)) {
|
||||||
htmlBuilder.append("display:block;overflow:visible;padding:0;");
|
htmlBuilder.append("display:block;overflow:visible;padding:0;");
|
||||||
} else {
|
} else {
|
||||||
htmlBuilder.append("display:flex;align-items:center;");
|
// Oben ausrichten statt zentrieren: Die erste Zeile muss an der
|
||||||
|
// Box-Oberkante beginnen, sonst verschiebt sich mehrzeiliger
|
||||||
|
// Variablentext gegenüber einzeiligen Nachbar-Elementen.
|
||||||
|
htmlBuilder.append("display:flex;align-items:flex-start;");
|
||||||
switch (textAlign) {
|
switch (textAlign) {
|
||||||
case "center":
|
case "center":
|
||||||
htmlBuilder.append("justify-content:center;text-align:center;");
|
htmlBuilder.append("justify-content:center;text-align:center;");
|
||||||
@@ -184,9 +222,9 @@ public class TemplatePdfService {
|
|||||||
}
|
}
|
||||||
} else if ("services.list".equals(variable)) {
|
} else if ("services.list".equals(variable)) {
|
||||||
if (variables.containsKey("services.json")) {
|
if (variables.containsKey("services.json")) {
|
||||||
htmlBuilder.append(generateServicesTableHtmlWithData(variables));
|
htmlBuilder.append(generateServicesTableHtmlWithData(variables, fontSize, mmHeight));
|
||||||
} else {
|
} else {
|
||||||
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate));
|
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate, fontSize, mmHeight));
|
||||||
}
|
}
|
||||||
} else if (text.contains("<br>")) {
|
} else if (text.contains("<br>")) {
|
||||||
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
|
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
|
||||||
@@ -199,7 +237,7 @@ public class TemplatePdfService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */
|
/** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */
|
||||||
private String generateServicesTableHtml(BigDecimal vatRate) {
|
private String generateServicesTableHtml(BigDecimal vatRate, int fontSize, double mmHeight) {
|
||||||
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP)
|
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP)
|
||||||
.stripTrailingZeros();
|
.stripTrailingZeros();
|
||||||
if (pct.scale() < 0) {
|
if (pct.scale() < 0) {
|
||||||
@@ -207,47 +245,23 @@ public class TemplatePdfService {
|
|||||||
}
|
}
|
||||||
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
|
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
|
||||||
|
|
||||||
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" },
|
List<Map<String, String>> rows = List.of(
|
||||||
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00 €" } };
|
Map.of("quantity", "1", "name", "Beratungsleistung", "unitPrice", "450,00", "netAmount", "450,00"),
|
||||||
|
Map.of("quantity", "1", "name", "Softwareentwicklung", "unitPrice", "1.200,00", "netAmount",
|
||||||
|
"1.200,00"),
|
||||||
|
Map.of("quantity", "1", "name", "Support-Pauschale", "unitPrice", "150,00", "netAmount", "150,00"));
|
||||||
|
|
||||||
double netTotal = 1800.00;
|
double netTotal = 1800.00;
|
||||||
double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
|
double vatTotal = netTotal * vatRate.doubleValue();
|
||||||
|
return renderServicesTable(rows,
|
||||||
StringBuilder html = new StringBuilder();
|
String.format(Locale.GERMANY, "%,.2f €", netTotal),
|
||||||
html.append("<div style='width:100%;box-sizing:border-box;'>");
|
String.format(Locale.GERMANY, "%,.2f €", vatTotal),
|
||||||
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
String.format(Locale.GERMANY, "%,.2f €", netTotal + vatTotal),
|
||||||
html.append(tableHeaderRow());
|
vatLabel, fontSize, mmHeight);
|
||||||
for (int i = 0; i < sampleData.length; i++) {
|
|
||||||
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
|
|
||||||
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
|
|
||||||
html.append(
|
|
||||||
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>")
|
|
||||||
.append(sampleData[i][0]).append("</td>");
|
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][1])
|
|
||||||
.append("</td>");
|
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][2])
|
|
||||||
.append("</td>");
|
|
||||||
html.append("</tr>");
|
|
||||||
}
|
|
||||||
html.append("</table>");
|
|
||||||
|
|
||||||
html.append("<div style='margin-top:8px;width:100%;'>");
|
|
||||||
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
|
||||||
html.append(summaryRow("Nettosumme:", String.format(Locale.GERMANY, "%,.2f €", netTotal), false));
|
|
||||||
html.append(summaryRow("Gesamtsumme:", String.format(Locale.GERMANY, "%,.2f €", grossTotal), true));
|
|
||||||
html.append("</table>");
|
|
||||||
html.append("</div>");
|
|
||||||
html.append("</div>");
|
|
||||||
return html.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
|
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
|
||||||
private String generateServicesTableHtmlWithData(Map<String, String> variables) {
|
private String generateServicesTableHtmlWithData(Map<String, String> variables, int fontSize, double mmHeight) {
|
||||||
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
|
|
||||||
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
|
|
||||||
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
|
|
||||||
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
|
|
||||||
|
|
||||||
List<Map<String, String>> servicesData = new ArrayList<>();
|
List<Map<String, String>> servicesData = new ArrayList<>();
|
||||||
String servicesJson = variables.get("services.json");
|
String servicesJson = variables.get("services.json");
|
||||||
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
|
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
|
||||||
@@ -258,72 +272,120 @@ public class TemplatePdfService {
|
|||||||
log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage());
|
log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return renderServicesTable(servicesData,
|
||||||
|
variables.getOrDefault("invoice.net_total", "0,00 €"),
|
||||||
|
variables.getOrDefault("invoice.vat_total", "0,00 €"),
|
||||||
|
variables.getOrDefault("invoice.gross_total", "0,00 €"),
|
||||||
|
variables.getOrDefault("invoice.vat_rate", "19%"),
|
||||||
|
fontSize, mmHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert die Positionstabelle im Briefbogen-Layout: Spalten Menge,
|
||||||
|
* Bezeichnung, Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum
|
||||||
|
* Summenblock und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
|
||||||
|
*/
|
||||||
|
private String renderServicesTable(List<Map<String, String>> rows, String netTotal, String vatTotal,
|
||||||
|
String grossTotal, String vatRateLabel, int fontSize, double mmHeight) {
|
||||||
|
String border = "0.5px solid #000000";
|
||||||
|
|
||||||
|
// Höhe der Füllzeile: Spaltenlinien bis zum Summenblock durchziehen,
|
||||||
|
// damit die Tabelle die Bausteinhöhe füllt (pt -> mm: Faktor 0,3528)
|
||||||
|
double lineMm = fontSize * 1.2 * 0.3528;
|
||||||
|
double headerMm = fontSize * 0.9 * 0.3528 + 1.6;
|
||||||
|
double rowMm = lineMm + 1.6;
|
||||||
|
double summaryMm = 3 * (lineMm + 1.2) + 2.0;
|
||||||
|
double fillerMm = Math.max(0, mmHeight - headerMm - rows.size() * rowMm - summaryMm);
|
||||||
|
|
||||||
StringBuilder html = new StringBuilder();
|
StringBuilder html = new StringBuilder();
|
||||||
html.append("<div style='width:100%;box-sizing:border-box;'>");
|
html.append("<div style='width:100%;box-sizing:border-box;'>");
|
||||||
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
||||||
html.append(tableHeaderRow());
|
html.append("<colgroup><col style='width:9%;'/><col style='width:61%;'/>")
|
||||||
|
.append("<col style='width:15%;'/><col style='width:15%;'/></colgroup>");
|
||||||
|
|
||||||
if (servicesData.isEmpty()) {
|
String headStyle = "background-color:#eeeeee;font-size:0.75em;font-weight:normal;color:#333333;"
|
||||||
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
|
+ "text-align:left;padding:2px 6px;white-space:nowrap;";
|
||||||
html.append(
|
html.append("<tr>");
|
||||||
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>");
|
html.append("<th style='").append(headStyle).append("'>Menge</th>");
|
||||||
html.append("</tr>");
|
html.append("<th style='").append(headStyle).append("border-left:").append(border)
|
||||||
} else {
|
.append(";'>Bezeichnung</th>");
|
||||||
for (int i = 0; i < servicesData.size(); i++) {
|
html.append("<th style='").append(headStyle).append("border-left:").append(border)
|
||||||
Map<String, String> service = servicesData.get(i);
|
.append(";'>Einzelpreis</th>");
|
||||||
String name = service.getOrDefault("name", "Unbekannte Position");
|
html.append("<th style='").append(headStyle).append("border-left:").append(border).append(";'>Gesamt</th>");
|
||||||
String netAmount = service.getOrDefault("netAmount", "0,00");
|
html.append("</tr>");
|
||||||
// USt-Satz pro Position, Fallback: einheitlicher Satz der Rechnung
|
|
||||||
String rowVat = service.getOrDefault("vat", vatRateLabel);
|
|
||||||
|
|
||||||
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
|
if (rows.isEmpty()) {
|
||||||
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
|
html.append("<tr><td style='text-align:center;padding:2px 6px;'></td>")
|
||||||
html.append(
|
.append("<td style='padding:2px 6px;border-left:").append(border)
|
||||||
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>")
|
.append(";'>Keine Positionen vorhanden</td>")
|
||||||
.append(escapeHtml(name)).append("</td>");
|
.append("<td style='border-left:").append(border).append(";'></td>")
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
.append("<td style='border-left:").append(border).append(";'></td></tr>");
|
||||||
.append(escapeHtml(rowVat)).append("</td>");
|
|
||||||
// € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert
|
|
||||||
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + " €" : escapeHtml(netAmount);
|
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
|
||||||
.append(amountDisplay).append("</td>");
|
|
||||||
html.append("</tr>");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (Map<String, String> row : rows) {
|
||||||
|
html.append("<tr>");
|
||||||
|
html.append("<td style='text-align:center;padding:2px 6px;white-space:nowrap;'>")
|
||||||
|
.append(escapeHtml(row.getOrDefault("quantity", ""))).append("</td>");
|
||||||
|
html.append("<td style='text-align:left;padding:2px 6px;white-space:nowrap;overflow:hidden;")
|
||||||
|
.append("text-overflow:ellipsis;border-left:").append(border).append(";'>")
|
||||||
|
.append(escapeHtml(row.getOrDefault("name", ""))).append("</td>");
|
||||||
|
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
|
||||||
|
.append(";'>").append(amountDisplay(row.getOrDefault("unitPrice", ""))).append("</td>");
|
||||||
|
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
|
||||||
|
.append(";'>").append(amountDisplay(row.getOrDefault("netAmount", ""))).append("</td>");
|
||||||
|
html.append("</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füllzeile: hält die Spaltenlinien bis zum Summenblock durch
|
||||||
|
html.append("<tr>");
|
||||||
|
html.append("<td style='height:").append(String.format(Locale.US, "%.2f", fillerMm)).append("mm;'></td>");
|
||||||
|
html.append("<td style='border-left:").append(border).append(";'></td>");
|
||||||
|
html.append("<td style='border-left:").append(border).append(";'></td>");
|
||||||
|
html.append("<td style='border-left:").append(border).append(";'></td>");
|
||||||
|
html.append("</tr>");
|
||||||
html.append("</table>");
|
html.append("</table>");
|
||||||
|
|
||||||
html.append("<div style='margin-top:8px;width:100%;'>");
|
// Summenblock rechts unten: Nettobetrag, MwSt., Endbetrag
|
||||||
|
String vatLabelText = vatRateLabel == null || vatRateLabel.isEmpty()
|
||||||
|
? "+ MwSt."
|
||||||
|
: "+ " + escapeHtml(vatRateLabel) + " MwSt.";
|
||||||
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
|
||||||
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften
|
html.append("<colgroup><col style='width:70%;'/><col style='width:15%;'/><col style='width:15%;'/>")
|
||||||
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:"
|
.append("</colgroup>");
|
||||||
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:";
|
html.append(summaryRow("Nettobetrag", escapeHtml(netTotal), true, border));
|
||||||
html.append(summaryRow("Nettosumme:", netTotal, false));
|
html.append(summaryRow(vatLabelText, escapeHtml(vatTotal), false, border));
|
||||||
html.append(summaryRow(vatSummaryLabel, vatTotal, false));
|
html.append(summaryRow("Endbetrag", escapeHtml(grossTotal), true, border));
|
||||||
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
|
|
||||||
html.append("</table>");
|
html.append("</table>");
|
||||||
html.append("</div>");
|
html.append("</div>");
|
||||||
html.append("</div>");
|
|
||||||
return html.toString();
|
return html.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String tableHeaderRow() {
|
/** Eine Zeile des Summenblocks; jede zweite Zeile ist grau hinterlegt. */
|
||||||
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>"
|
private String summaryRow(String label, String value, boolean shaded, String border) {
|
||||||
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>"
|
String bg = shaded ? "background-color:#eeeeee;" : "";
|
||||||
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>"
|
return "<tr>"
|
||||||
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>"
|
+ "<td></td>"
|
||||||
|
+ "<td style='" + bg + "padding:2px 6px;white-space:nowrap;border-left:" + border + ";'>"
|
||||||
|
+ label + "</td>"
|
||||||
|
+ "<td style='" + bg + "padding:2px 6px;text-align:right;white-space:nowrap;'>" + value + "</td>"
|
||||||
+ "</tr>";
|
+ "</tr>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String summaryRow(String label, String value, boolean emphasized) {
|
/** € nur an rein numerische Beträge anhängen; andere Werte escaped übernehmen. */
|
||||||
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;";
|
private String amountDisplay(String amount) {
|
||||||
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;"
|
return amount.matches("[0-9.,]+") ? amount + " €" : escapeHtml(amount);
|
||||||
: "padding:2px 8px;font-weight:bold;";
|
}
|
||||||
return "<tr>"
|
|
||||||
+ "<td style='width:55%;padding:2px 0;'></td>"
|
/**
|
||||||
+ "<td style='width:20%;text-align:left;white-space:nowrap;" + labelStyle + "'>" + label + "</td>"
|
* CSS-Schriftstapel zur Schriftart des Elements; nur bekannte Werte werden
|
||||||
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>"
|
* übernommen (Whitelist), alles andere fällt auf Arial zurück.
|
||||||
+ "</tr>";
|
*/
|
||||||
|
private static String cssFontStack(String fontFamily) {
|
||||||
|
return switch (fontFamily) {
|
||||||
|
case "Times New Roman" -> "'Times New Roman', Times, serif";
|
||||||
|
case "Courier New" -> "'Courier New', Courier, monospace";
|
||||||
|
case "Verdana" -> "Verdana, 'DejaVu Sans', Arial, sans-serif";
|
||||||
|
default -> "Arial, Helvetica, sans-serif";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private String escapeHtml(String input) {
|
private String escapeHtml(String input) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public final class TemplateVariables {
|
|||||||
BigDecimal net = lineNet(item);
|
BigDecimal net = lineNet(item);
|
||||||
Map<String, String> position = new HashMap<>();
|
Map<String, String> position = new HashMap<>();
|
||||||
position.put("name", item.description());
|
position.put("name", item.description());
|
||||||
|
position.put("quantity", quantityLabel(item.quantity()));
|
||||||
|
position.put("unitPrice", formatAmount(item.unitPriceNet()));
|
||||||
position.put("netAmount", formatAmount(net));
|
position.put("netAmount", formatAmount(net));
|
||||||
position.put("vat", vatLabel(item.vatPercent()));
|
position.put("vat", vatLabel(item.vatPercent()));
|
||||||
positions.add(position);
|
positions.add(position);
|
||||||
@@ -71,6 +73,8 @@ public final class TemplateVariables {
|
|||||||
BigDecimal net = lineNet(item);
|
BigDecimal net = lineNet(item);
|
||||||
Map<String, String> row = new HashMap<>();
|
Map<String, String> row = new HashMap<>();
|
||||||
row.put("name", item.description());
|
row.put("name", item.description());
|
||||||
|
row.put("quantity", quantityLabel(item.quantity()));
|
||||||
|
row.put("unitPrice", formatAmount(item.unitPriceNet()) + " €");
|
||||||
row.put("vat", vatLabel(item.vatPercent()));
|
row.put("vat", vatLabel(item.vatPercent()));
|
||||||
row.put("net", formatAmount(net) + " €");
|
row.put("net", formatAmount(net) + " €");
|
||||||
rows.add(row);
|
rows.add(row);
|
||||||
@@ -97,6 +101,15 @@ public final class TemplateVariables {
|
|||||||
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
|
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Menge als Anzeige-Label ohne überflüssige Nullen, z. B. "1" oder "2,5". */
|
||||||
|
public static String quantityLabel(BigDecimal quantity) {
|
||||||
|
BigDecimal normalized = quantity.stripTrailingZeros();
|
||||||
|
if (normalized.scale() < 0) {
|
||||||
|
normalized = normalized.setScale(0);
|
||||||
|
}
|
||||||
|
return normalized.toPlainString().replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
/** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */
|
/** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */
|
||||||
public static String vatLabel(BigDecimal percent) {
|
public static String vatLabel(BigDecimal percent) {
|
||||||
BigDecimal normalized = percent.stripTrailingZeros();
|
BigDecimal normalized = percent.stripTrailingZeros();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.vaadin.flow.router.Route;
|
|||||||
import de.assecutor.pdftool.address.Address;
|
import de.assecutor.pdftool.address.Address;
|
||||||
import de.assecutor.pdftool.address.AddressRepository;
|
import de.assecutor.pdftool.address.AddressRepository;
|
||||||
import de.assecutor.pdftool.address.AddressType;
|
import de.assecutor.pdftool.address.AddressType;
|
||||||
|
import de.assecutor.pdftool.invoice.IbanValidator;
|
||||||
import de.assecutor.pdftool.invoice.VatIdValidator;
|
import de.assecutor.pdftool.invoice.VatIdValidator;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
|
||||||
@@ -84,6 +85,8 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true);
|
grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true);
|
||||||
grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true);
|
grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true);
|
||||||
|
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
|
||||||
|
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
|
||||||
grid.addComponentColumn(address -> {
|
grid.addComponentColumn(address -> {
|
||||||
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address));
|
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address));
|
||||||
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
@@ -92,8 +95,11 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
|
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
|
||||||
ButtonVariant.LUMO_SMALL);
|
ButtonVariant.LUMO_SMALL);
|
||||||
delete.setTooltipText("Löschen");
|
delete.setTooltipText("Löschen");
|
||||||
return new HorizontalLayout(edit, delete);
|
HorizontalLayout actions = new HorizontalLayout(edit, delete);
|
||||||
}).setHeader("").setFlexGrow(0).setAutoWidth(true);
|
actions.setSpacing(false);
|
||||||
|
actions.setPadding(false);
|
||||||
|
return actions;
|
||||||
|
}).setHeader("").setFlexGrow(0).setWidth("110px");
|
||||||
grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem()));
|
grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem()));
|
||||||
grid.setSizeFull();
|
grid.setSizeFull();
|
||||||
add(grid);
|
add(grid);
|
||||||
@@ -122,7 +128,7 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
Address target = address == null ? new Address() : address;
|
Address target = address == null ? new Address() : address;
|
||||||
|
|
||||||
Dialog dialog = new Dialog();
|
Dialog dialog = new Dialog();
|
||||||
dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten");
|
dialog.setHeaderTitle(isNew ? "Neues Unternehmen" : "Unternehmen bearbeiten");
|
||||||
|
|
||||||
ComboBox<AddressType> type = new ComboBox<>("Typ");
|
ComboBox<AddressType> type = new ComboBox<>("Typ");
|
||||||
type.setItems(AddressType.values());
|
type.setItems(AddressType.values());
|
||||||
@@ -141,7 +147,6 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
countryCode.setPattern("[A-Za-z]{2}");
|
countryCode.setPattern("[A-Za-z]{2}");
|
||||||
TextField vatId = new TextField("USt-IdNr.");
|
TextField vatId = new TextField("USt-IdNr.");
|
||||||
vatId.setValue(target.getVatId() != null ? target.getVatId() : "");
|
vatId.setValue(target.getVatId() != null ? target.getVatId() : "");
|
||||||
vatId.setHelperText("z. B. DE261094748");
|
|
||||||
EmailField email = new EmailField("E-Mail");
|
EmailField email = new EmailField("E-Mail");
|
||||||
email.setValue(target.getEmail() != null ? target.getEmail() : "");
|
email.setValue(target.getEmail() != null ? target.getEmail() : "");
|
||||||
// E-Mail direkt beim Verlassen des Feldes prüfen
|
// E-Mail direkt beim Verlassen des Feldes prüfen
|
||||||
@@ -153,8 +158,19 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
});
|
});
|
||||||
TextField phone = new TextField("Telefon");
|
TextField phone = new TextField("Telefon");
|
||||||
phone.setValue(target.getPhone() != null ? target.getPhone() : "");
|
phone.setValue(target.getPhone() != null ? target.getPhone() : "");
|
||||||
|
TextField website = new TextField("Webseite");
|
||||||
|
website.setValue(target.getWebsite() != null ? target.getWebsite() : "");
|
||||||
|
|
||||||
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone);
|
// Bankverbindung (optional)
|
||||||
|
TextField bankName = new TextField("Bank");
|
||||||
|
bankName.setValue(target.getBankName() != null ? target.getBankName() : "");
|
||||||
|
TextField iban = new TextField("IBAN");
|
||||||
|
iban.setValue(target.getIban() != null ? target.getIban() : "");
|
||||||
|
TextField bic = new TextField("BIC");
|
||||||
|
bic.setValue(target.getBic() != null ? target.getBic() : "");
|
||||||
|
|
||||||
|
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone,
|
||||||
|
website, bankName, iban, bic);
|
||||||
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2));
|
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2));
|
||||||
form.setWidth("560px");
|
form.setWidth("560px");
|
||||||
dialog.add(form);
|
dialog.add(form);
|
||||||
@@ -189,6 +205,17 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
email.setInvalid(true);
|
email.setInvalid(true);
|
||||||
errors.add("E-Mail-Adresse ist ungültig.");
|
errors.add("E-Mail-Adresse ist ungültig.");
|
||||||
}
|
}
|
||||||
|
if (!iban.getValue().isBlank() && !IbanValidator.isValid(iban.getValue())) {
|
||||||
|
iban.setErrorMessage("IBAN ist ungültig (Prüfsumme).");
|
||||||
|
iban.setInvalid(true);
|
||||||
|
errors.add("IBAN ist ungültig (Prüfsumme).");
|
||||||
|
}
|
||||||
|
if (!bic.getValue().isBlank()
|
||||||
|
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
|
||||||
|
bic.setErrorMessage("BIC ist ungültig (8 oder 11 Stellen).");
|
||||||
|
bic.setInvalid(true);
|
||||||
|
errors.add("BIC ist ungültig (8 oder 11 Stellen).");
|
||||||
|
}
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
|
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
@@ -204,6 +231,10 @@ public class AddressBookView extends VerticalLayout {
|
|||||||
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
|
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
|
||||||
target.setEmail(email.getValue().trim());
|
target.setEmail(email.getValue().trim());
|
||||||
target.setPhone(phone.getValue().trim());
|
target.setPhone(phone.getValue().trim());
|
||||||
|
target.setWebsite(website.getValue().trim());
|
||||||
|
target.setBankName(bankName.getValue().trim());
|
||||||
|
target.setIban(iban.getValue().isBlank() ? "" : IbanValidator.normalize(iban.getValue()));
|
||||||
|
target.setBic(bic.getValue().trim().toUpperCase());
|
||||||
target.touch();
|
target.touch();
|
||||||
repository.save(target);
|
repository.save(target);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.vaadin.flow.component.html.Anchor;
|
|||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.component.html.H3;
|
import com.vaadin.flow.component.html.H3;
|
||||||
import com.vaadin.flow.component.html.Paragraph;
|
import com.vaadin.flow.component.html.Paragraph;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.icon.Icon;
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
@@ -22,17 +23,15 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
|||||||
import com.vaadin.flow.component.textfield.BigDecimalField;
|
import com.vaadin.flow.component.textfield.BigDecimalField;
|
||||||
import com.vaadin.flow.component.textfield.EmailField;
|
import com.vaadin.flow.component.textfield.EmailField;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.component.upload.Upload;
|
import com.vaadin.flow.data.renderer.ComponentRenderer;
|
||||||
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
|
||||||
import com.vaadin.flow.router.PageTitle;
|
import com.vaadin.flow.router.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
import org.springframework.core.env.Environment;
|
|
||||||
import org.springframework.core.env.Profiles;
|
|
||||||
import de.assecutor.pdftool.address.Address;
|
import de.assecutor.pdftool.address.Address;
|
||||||
import de.assecutor.pdftool.address.AddressRepository;
|
import de.assecutor.pdftool.address.AddressRepository;
|
||||||
import de.assecutor.pdftool.address.AddressType;
|
import de.assecutor.pdftool.address.AddressType;
|
||||||
import de.assecutor.pdftool.invoice.CapturedInvoiceData;
|
import de.assecutor.pdftool.invoice.CapturedInvoiceData;
|
||||||
|
import de.assecutor.pdftool.invoice.IbanValidator;
|
||||||
import de.assecutor.pdftool.invoice.InvoiceDraft;
|
import de.assecutor.pdftool.invoice.InvoiceDraft;
|
||||||
import de.assecutor.pdftool.invoice.InvoiceDraftService;
|
import de.assecutor.pdftool.invoice.InvoiceDraftService;
|
||||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||||
@@ -41,13 +40,10 @@ import de.assecutor.pdftool.template.InvoiceTemplateService;
|
|||||||
import de.assecutor.pdftool.template.TemplatePdfService;
|
import de.assecutor.pdftool.template.TemplatePdfService;
|
||||||
import de.assecutor.pdftool.template.TemplateRenderException;
|
import de.assecutor.pdftool.template.TemplateRenderException;
|
||||||
import de.assecutor.pdftool.template.TemplateVariables;
|
import de.assecutor.pdftool.template.TemplateVariables;
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
|
import de.assecutor.pdftool.pdfservice.PdfServiceClient;
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdResult;
|
import de.assecutor.pdftool.pdfservice.PdfServiceException;
|
||||||
import de.assecutor.pdftool.zugferd.ZugferdService;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.UncheckedIOException;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -66,15 +62,12 @@ public class MainView extends VerticalLayout {
|
|||||||
|
|
||||||
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
|
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
|
||||||
|
|
||||||
private final ZugferdService zugferdService;
|
private final PdfServiceClient pdfServiceClient;
|
||||||
private final CapturedInvoiceData capturedInvoiceData;
|
private final CapturedInvoiceData capturedInvoiceData;
|
||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private final TemplatePdfService templatePdfService;
|
private final TemplatePdfService templatePdfService;
|
||||||
|
|
||||||
private final MemoryBuffer uploadBuffer = new MemoryBuffer();
|
/** Grundlage der Rechnung: ein gespeichertes Rechnungstemplate. */
|
||||||
private byte[] uploadedPdf;
|
|
||||||
|
|
||||||
/** Alternative zum PDF-Upload: ein gespeichertes Rechnungstemplate. */
|
|
||||||
private final ComboBox<String> templateSelect = new ComboBox<>("Rechnungstemplate");
|
private final ComboBox<String> templateSelect = new ComboBox<>("Rechnungstemplate");
|
||||||
|
|
||||||
/** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */
|
/** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */
|
||||||
@@ -107,9 +100,6 @@ public class MainView extends VerticalLayout {
|
|||||||
private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum");
|
private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum");
|
||||||
private final TextField currency = new TextField("Währung");
|
private final TextField currency = new TextField("Währung");
|
||||||
private final TextField paymentTerms = new TextField("Zahlungsbedingungen");
|
private final TextField paymentTerms = new TextField("Zahlungsbedingungen");
|
||||||
private final TextField buyerReference = new TextField("Käuferreferenz / Leitweg-ID");
|
|
||||||
private final TextField iban = new TextField("IBAN (Rechnungssteller)");
|
|
||||||
private final TextField bic = new TextField("BIC");
|
|
||||||
|
|
||||||
private final PartyForm sender = new PartyForm(true);
|
private final PartyForm sender = new PartyForm(true);
|
||||||
private final PartyForm recipient = new PartyForm(false);
|
private final PartyForm recipient = new PartyForm(false);
|
||||||
@@ -121,16 +111,15 @@ public class MainView extends VerticalLayout {
|
|||||||
private final Anchor downloadLink = new Anchor();
|
private final Anchor downloadLink = new Anchor();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Controls unterhalb der Quellenwahl; sie sind erst aktiv, wenn ein PDF
|
* Alle Controls unterhalb der Template-Auswahl; sie sind erst aktiv, wenn
|
||||||
* hochgeladen oder ein Template ausgewählt wurde.
|
* ein Template ausgewählt wurde.
|
||||||
*/
|
*/
|
||||||
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
|
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
|
||||||
|
|
||||||
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData,
|
public MainView(PdfServiceClient pdfServiceClient, CapturedInvoiceData capturedInvoiceData,
|
||||||
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
|
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
|
||||||
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService,
|
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
|
||||||
Environment environment) {
|
this.pdfServiceClient = pdfServiceClient;
|
||||||
this.zugferdService = zugferdService;
|
|
||||||
this.capturedInvoiceData = capturedInvoiceData;
|
this.capturedInvoiceData = capturedInvoiceData;
|
||||||
this.invoiceTemplateService = invoiceTemplateService;
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
this.templatePdfService = templatePdfService;
|
this.templatePdfService = templatePdfService;
|
||||||
@@ -139,15 +128,33 @@ public class MainView extends VerticalLayout {
|
|||||||
|
|
||||||
setMaxWidth("960px");
|
setMaxWidth("960px");
|
||||||
|
|
||||||
add(new Paragraph("Laden Sie ein Rechnungs-PDF hoch oder wählen Sie ein gespeichertes "
|
add(new Paragraph("Wählen Sie ein gespeichertes Rechnungstemplate und ergänzen Sie die "
|
||||||
+ "Rechnungstemplate und ergänzen Sie die Rechnungsdaten. Die Rechnung wird als "
|
+ "Rechnungsdaten. Die Rechnung wird als rechtskonforme ZUGFeRD-Rechnung "
|
||||||
+ "rechtskonforme ZUGFeRD-Rechnung (PDF/A-3 mit eingebettetem EN16931-XML) erzeugt "
|
+ "(PDF/A-3 mit eingebettetem EN16931-XML) erzeugt und als ZIP-Datei bereitgestellt."));
|
||||||
+ "und als ZIP-Datei bereitgestellt."));
|
|
||||||
|
|
||||||
// Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden.
|
// Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden.
|
||||||
draftSelect.setPlaceholder("Vorbelegung laden");
|
draftSelect.setPlaceholder("Vorbelegung laden");
|
||||||
draftSelect.setClearButtonVisible(true);
|
draftSelect.setClearButtonVisible(true);
|
||||||
draftSelect.setWidth("300px");
|
draftSelect.setWidth("300px");
|
||||||
|
// Jeder Eintrag zeigt rechts ein Löschen-Icon, das die Vorbelegung
|
||||||
|
// nach einer Sicherheitsabfrage aus MongoDB entfernt.
|
||||||
|
draftSelect.setRenderer(new ComponentRenderer<>(name -> {
|
||||||
|
Span label = new Span(name);
|
||||||
|
Icon delete = VaadinIcon.TRASH.create();
|
||||||
|
delete.setSize("var(--lumo-icon-size-s)");
|
||||||
|
delete.getStyle().set("color", "var(--lumo-error-color)").set("cursor", "pointer");
|
||||||
|
delete.getElement().setAttribute("title", "Vorbelegung löschen");
|
||||||
|
// Der Klick auf das Icon darf den Eintrag nicht auswählen; der
|
||||||
|
// Server-Listener auf dem Icon selbst wird davon nicht berührt.
|
||||||
|
delete.getElement().executeJs("this.addEventListener('click', function(e) { e.stopPropagation(); })");
|
||||||
|
delete.getElement().addEventListener("click", e -> confirmDeleteDraft(name));
|
||||||
|
HorizontalLayout row = new HorizontalLayout(label, delete);
|
||||||
|
row.setJustifyContentMode(JustifyContentMode.BETWEEN);
|
||||||
|
row.setAlignItems(Alignment.CENTER);
|
||||||
|
row.setWidthFull();
|
||||||
|
row.setPadding(false);
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
draftSelect.addValueChangeListener(event -> {
|
draftSelect.addValueChangeListener(event -> {
|
||||||
if (event.isFromClient() && event.getValue() != null) {
|
if (event.isFromClient() && event.getValue() != null) {
|
||||||
loadDraft(event.getValue());
|
loadDraft(event.getValue());
|
||||||
@@ -158,46 +165,24 @@ public class MainView extends VerticalLayout {
|
|||||||
});
|
});
|
||||||
add(draftSelect);
|
add(draftSelect);
|
||||||
|
|
||||||
Upload upload = new Upload(uploadBuffer);
|
|
||||||
upload.setAcceptedFileTypes("application/pdf", ".pdf");
|
|
||||||
upload.setMaxFiles(1);
|
|
||||||
upload.setMaxFileSize(25 * 1024 * 1024);
|
|
||||||
upload.addSucceededListener(event -> {
|
|
||||||
try {
|
|
||||||
uploadedPdf = uploadBuffer.getInputStream().readAllBytes();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new UncheckedIOException(e);
|
|
||||||
}
|
|
||||||
// Entweder PDF oder Template: hochgeladenes PDF hebt die Template-Auswahl auf.
|
|
||||||
templateSelect.clear();
|
|
||||||
updateControlsEnabled();
|
|
||||||
});
|
|
||||||
upload.addFileRemovedListener(event -> {
|
|
||||||
uploadedPdf = null;
|
|
||||||
updateControlsEnabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
templateSelect.setItems(invoiceTemplateService.templateNames());
|
templateSelect.setItems(invoiceTemplateService.templateNames());
|
||||||
templateSelect.setPlaceholder("Template auswählen");
|
templateSelect.setPlaceholder("Template auswählen");
|
||||||
templateSelect.setClearButtonVisible(true);
|
templateSelect.setClearButtonVisible(true);
|
||||||
templateSelect.setWidth("300px");
|
templateSelect.setWidth("300px");
|
||||||
templateSelect.addValueChangeListener(event -> {
|
templateSelect.addValueChangeListener(event -> {
|
||||||
if (event.isFromClient() && event.getValue() != null) {
|
if (event.isFromClient() && event.getValue() != null) {
|
||||||
// Entweder PDF oder Template: Auswahl entfernt ein hochgeladenes PDF.
|
// Rechnungssteller-Felder nur bei der ersten Template-Auswahl
|
||||||
upload.clearFileList();
|
// leer beginnen lassen; beim Wechsel zwischen Templates bleiben
|
||||||
uploadedPdf = null;
|
// die Eingaben erhalten (sonst wäre u. a. der Speichern-Button
|
||||||
// Rechnungssteller-Felder leer beginnen lassen.
|
// wegen leerer Pflichtfelder nie aktiv).
|
||||||
senderAddressSelect.clear();
|
if (event.getOldValue() == null) {
|
||||||
sender.clearFields();
|
senderAddressSelect.clear();
|
||||||
|
sender.clearFields();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateControlsEnabled();
|
updateControlsEnabled();
|
||||||
});
|
});
|
||||||
HorizontalLayout sourceLayout = new HorizontalLayout(upload, templateSelect);
|
add(new H3("1. Template auswählen"), templateSelect);
|
||||||
sourceLayout.setAlignItems(Alignment.CENTER);
|
|
||||||
// Das Label der ComboBox ausgleichen, damit das Eingabefeld selbst
|
|
||||||
// mittig zur Upload-Fläche steht.
|
|
||||||
templateSelect.getStyle().set("margin-top", "-1.25em");
|
|
||||||
add(new H3("1. Rechnungs-PDF hochladen oder Template auswählen"), sourceLayout);
|
|
||||||
|
|
||||||
invoiceNumber.setRequired(true);
|
invoiceNumber.setRequired(true);
|
||||||
invoiceNumber.setErrorMessage("Rechnungsnummer ist ein Pflichtfeld.");
|
invoiceNumber.setErrorMessage("Rechnungsnummer ist ein Pflichtfeld.");
|
||||||
@@ -209,10 +194,8 @@ public class MainView extends VerticalLayout {
|
|||||||
currency.setPattern("[A-Za-z]{3}");
|
currency.setPattern("[A-Za-z]{3}");
|
||||||
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR.");
|
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR.");
|
||||||
currency.setValue("EUR");
|
currency.setValue("EUR");
|
||||||
buyerReference.setHelperText("BT-10 — für Rechnungen an Behörden erforderlich");
|
|
||||||
iban.setHelperText("Für die Zahlungsangaben (BG-16) empfohlen");
|
|
||||||
FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency,
|
FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency,
|
||||||
paymentTerms, buyerReference, iban, bic);
|
paymentTerms);
|
||||||
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
|
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
|
||||||
add(new H3("2. Rechnungsdaten"), invoiceForm);
|
add(new H3("2. Rechnungsdaten"), invoiceForm);
|
||||||
|
|
||||||
@@ -233,17 +216,16 @@ public class MainView extends VerticalLayout {
|
|||||||
recipientAddressSelect, recipient, itemsGrid, addItem, generate));
|
recipientAddressSelect, recipient, itemsGrid, addItem, generate));
|
||||||
updateControlsEnabled();
|
updateControlsEnabled();
|
||||||
|
|
||||||
|
// Unsichtbarer Anchor: dient nur als Träger für den automatischen
|
||||||
|
// Download nach dem Erzeugen (display:none statt setVisible, damit
|
||||||
|
// callJsFunction("click") den Klick sofort ausführen kann).
|
||||||
downloadLink.getElement().setAttribute("download", true);
|
downloadLink.getElement().setAttribute("download", true);
|
||||||
downloadLink.setVisible(false);
|
downloadLink.getStyle().set("display", "none");
|
||||||
|
|
||||||
add(new HorizontalLayout(generate, saveDraftButton, downloadLink));
|
add(new HorizontalLayout(generate, saveDraftButton, downloadLink));
|
||||||
|
|
||||||
registerDraftDirtyTracking();
|
registerDraftDirtyTracking();
|
||||||
updateSaveDraftEnabled();
|
updateSaveDraftEnabled();
|
||||||
|
|
||||||
if (environment.acceptsProfiles(Profiles.of("dev"))) {
|
|
||||||
prefillDevData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -301,6 +283,39 @@ public class MainView extends VerticalLayout {
|
|||||||
confirm.open();
|
confirm.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sicherheitsabfrage vor dem endgültigen Löschen einer Vorbelegung. */
|
||||||
|
private void confirmDeleteDraft(String name) {
|
||||||
|
ConfirmDialog confirm = new ConfirmDialog();
|
||||||
|
confirm.setHeader("Vorbelegung löschen?");
|
||||||
|
confirm.setText("Sollen die gespeicherten Eingaben \"" + name + "\" endgültig gelöscht werden?");
|
||||||
|
confirm.setConfirmText("Löschen");
|
||||||
|
confirm.setConfirmButtonTheme("error primary");
|
||||||
|
confirm.setCancelable(true);
|
||||||
|
confirm.setCancelText("Abbrechen");
|
||||||
|
confirm.addConfirmListener(event -> deleteDraft(name));
|
||||||
|
confirm.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteDraft(String name) {
|
||||||
|
try {
|
||||||
|
invoiceDraftService.delete(name);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
showErrors(List.of("Die Vorbelegung \"" + name + "\" konnte nicht gelöscht werden: " + ex.getMessage()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean wasSelected = name.equals(draftSelect.getValue());
|
||||||
|
draftSelect.setItems(invoiceDraftService.names());
|
||||||
|
if (wasSelected) {
|
||||||
|
// Die geladene Vorbelegung existiert nicht mehr; das Formular
|
||||||
|
// behält seine Werte, gilt aber wieder als ungespeichert.
|
||||||
|
draftSelect.clear();
|
||||||
|
loadedDraftState = null;
|
||||||
|
updateSaveDraftEnabled();
|
||||||
|
}
|
||||||
|
Notification.show("Vorbelegung \"" + name + "\" gelöscht.", 3000, Notification.Position.BOTTOM_START)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
private void saveDraft(String name) {
|
private void saveDraft(String name) {
|
||||||
try {
|
try {
|
||||||
invoiceDraftService.save(buildDraft(name));
|
invoiceDraftService.save(buildDraft(name));
|
||||||
@@ -317,7 +332,7 @@ public class MainView extends VerticalLayout {
|
|||||||
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sammelt den aktuellen Formularstand; ein hochgeladenes PDF wird nicht mitgespeichert. */
|
/** Sammelt den aktuellen Formularstand. */
|
||||||
private InvoiceDraft buildDraft(String name) {
|
private InvoiceDraft buildDraft(String name) {
|
||||||
return new InvoiceDraft(null, name,
|
return new InvoiceDraft(null, name,
|
||||||
templateSelect.getValue(),
|
templateSelect.getValue(),
|
||||||
@@ -325,9 +340,10 @@ public class MainView extends VerticalLayout {
|
|||||||
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
||||||
currency.getValue().trim(),
|
currency.getValue().trim(),
|
||||||
paymentTerms.getValue(),
|
paymentTerms.getValue(),
|
||||||
buyerReference.getValue().trim(),
|
"",
|
||||||
iban.getValue().trim(),
|
sender.ibanNormalized(),
|
||||||
bic.getValue().trim(),
|
sender.bicNormalized(),
|
||||||
|
sender.websiteValue(),
|
||||||
sender.toParty(), recipient.toParty(),
|
sender.toParty(), recipient.toParty(),
|
||||||
List.copyOf(lineItems),
|
List.copyOf(lineItems),
|
||||||
LocalDateTime.now());
|
LocalDateTime.now());
|
||||||
@@ -346,8 +362,7 @@ public class MainView extends VerticalLayout {
|
|||||||
|
|
||||||
/** Überträgt gespeicherte Eingaben in das Formular. */
|
/** Überträgt gespeicherte Eingaben in das Formular. */
|
||||||
private void applyDraft(InvoiceDraft draft) {
|
private void applyDraft(InvoiceDraft draft) {
|
||||||
// Die Template-Auswahl wiederherstellen, sofern das Template noch existiert;
|
// Die Template-Auswahl wiederherstellen, sofern das Template noch existiert.
|
||||||
// ein hochgeladenes PDF ist nicht Teil der gespeicherten Eingaben.
|
|
||||||
if (draft.templateName() != null && !draft.templateName().isBlank()
|
if (draft.templateName() != null && !draft.templateName().isBlank()
|
||||||
&& invoiceTemplateService.templateExists(draft.templateName())) {
|
&& invoiceTemplateService.templateExists(draft.templateName())) {
|
||||||
templateSelect.setValue(draft.templateName());
|
templateSelect.setValue(draft.templateName());
|
||||||
@@ -358,10 +373,10 @@ public class MainView extends VerticalLayout {
|
|||||||
dueDate.setValue(draft.dueDate());
|
dueDate.setValue(draft.dueDate());
|
||||||
currency.setValue(nullToEmpty(draft.currency()));
|
currency.setValue(nullToEmpty(draft.currency()));
|
||||||
paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
|
paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
|
||||||
buyerReference.setValue(nullToEmpty(draft.buyerReference()));
|
|
||||||
iban.setValue(nullToEmpty(draft.iban()));
|
|
||||||
bic.setValue(nullToEmpty(draft.bic()));
|
|
||||||
applyParty(sender, draft.sender());
|
applyParty(sender, draft.sender());
|
||||||
|
sender.prefillContact(draft.sender() != null ? nullToEmpty(draft.sender().phone()) : "",
|
||||||
|
nullToEmpty(draft.website()));
|
||||||
|
sender.prefillBank(nullToEmpty(draft.iban()), nullToEmpty(draft.bic()));
|
||||||
applyParty(recipient, draft.recipient());
|
applyParty(recipient, draft.recipient());
|
||||||
lineItems.clear();
|
lineItems.clear();
|
||||||
if (draft.items() != null) {
|
if (draft.items() != null) {
|
||||||
@@ -382,7 +397,7 @@ public class MainView extends VerticalLayout {
|
|||||||
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
|
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
|
||||||
nullToEmpty(party.zip()), nullToEmpty(party.city()),
|
nullToEmpty(party.zip()), nullToEmpty(party.city()),
|
||||||
nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()),
|
nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()),
|
||||||
nullToEmpty(party.email()), nullToEmpty(party.phone()));
|
nullToEmpty(party.email()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */
|
/** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */
|
||||||
@@ -397,7 +412,9 @@ public class MainView extends VerticalLayout {
|
|||||||
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
|
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
|
||||||
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
|
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
|
||||||
nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()),
|
nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()),
|
||||||
nullToEmpty(address.getEmail()), nullToEmpty(address.getPhone()));
|
nullToEmpty(address.getEmail()));
|
||||||
|
form.prefillContact(nullToEmpty(address.getPhone()), nullToEmpty(address.getWebsite()));
|
||||||
|
form.prefillBank(nullToEmpty(address.getIban()), nullToEmpty(address.getBic()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -415,22 +432,6 @@ public class MainView extends VerticalLayout {
|
|||||||
return List.copyOf(lineItems);
|
return List.copyOf(lineItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Belegt das Formular im dev-Profil mit Testdaten vor, um manuelles Tippen zu sparen. */
|
|
||||||
private void prefillDevData() {
|
|
||||||
invoiceNumber.setValue("11111");
|
|
||||||
deliveryDate.setValue(LocalDate.now());
|
|
||||||
dueDate.setValue(LocalDate.now().plusDays(14));
|
|
||||||
paymentTerms.setValue("14 Tage");
|
|
||||||
buyerReference.setValue("11111");
|
|
||||||
iban.setValue("DE64 6001 0070 0379 0907 00");
|
|
||||||
bic.setValue("PBNKDEFFXXX");
|
|
||||||
// Rechnungssteller und -empfänger bleiben leer und werden über die
|
|
||||||
// Adressbuch-Auswahl befüllt.
|
|
||||||
lineItems.add(new InvoiceMetadata.LineItem("sdf", BigDecimal.ONE, new BigDecimal("100"),
|
|
||||||
new BigDecimal("19")));
|
|
||||||
refreshItemsGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */
|
/** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */
|
||||||
private void configureItemsGrid() {
|
private void configureItemsGrid() {
|
||||||
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
|
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
|
||||||
@@ -440,6 +441,8 @@ public class MainView extends VerticalLayout {
|
|||||||
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
|
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
|
||||||
itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %")
|
itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %")
|
||||||
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
|
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
|
||||||
|
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
|
||||||
|
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
|
||||||
itemsGrid.addComponentColumn(item -> {
|
itemsGrid.addComponentColumn(item -> {
|
||||||
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item));
|
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item));
|
||||||
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
@@ -451,8 +454,11 @@ public class MainView extends VerticalLayout {
|
|||||||
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
|
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
|
||||||
ButtonVariant.LUMO_SMALL);
|
ButtonVariant.LUMO_SMALL);
|
||||||
delete.setTooltipText("Löschen");
|
delete.setTooltipText("Löschen");
|
||||||
return new HorizontalLayout(edit, delete);
|
HorizontalLayout actions = new HorizontalLayout(edit, delete);
|
||||||
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
actions.setSpacing(false);
|
||||||
|
actions.setPadding(false);
|
||||||
|
return actions;
|
||||||
|
}).setHeader("").setWidth("110px").setFlexGrow(0);
|
||||||
itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem()));
|
itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem()));
|
||||||
itemsGrid.setAllRowsVisible(true);
|
itemsGrid.setAllRowsVisible(true);
|
||||||
itemsGrid.setWidthFull();
|
itemsGrid.setWidthFull();
|
||||||
@@ -471,20 +477,28 @@ public class MainView extends VerticalLayout {
|
|||||||
dueDate.addValueChangeListener(event -> updateSaveDraftEnabled());
|
dueDate.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||||
currency.addValueChangeListener(event -> updateSaveDraftEnabled());
|
currency.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||||
paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled());
|
paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||||
buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled());
|
|
||||||
iban.addValueChangeListener(event -> updateSaveDraftEnabled());
|
|
||||||
bic.addValueChangeListener(event -> updateSaveDraftEnabled());
|
|
||||||
templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled());
|
templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||||
sender.onAnyChange(this::updateSaveDraftEnabled);
|
sender.onAnyChange(this::updateSaveDraftEnabled);
|
||||||
recipient.onAnyChange(this::updateSaveDraftEnabled);
|
recipient.onAnyChange(this::updateSaveDraftEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde
|
* Der Speichern-Button ist nur aktiv, wenn alle Pflichtfelder gefüllt sind
|
||||||
* oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben.
|
* und keine Vorbelegung geladen wurde oder sich die Eingaben seit dem Laden
|
||||||
|
* bzw. Speichern geändert haben.
|
||||||
*/
|
*/
|
||||||
private void updateSaveDraftEnabled() {
|
private void updateSaveDraftEnabled() {
|
||||||
saveDraftButton.setEnabled(loadedDraftState == null || !comparableState().equals(loadedDraftState));
|
boolean changed = loadedDraftState == null || !comparableState().equals(loadedDraftState);
|
||||||
|
saveDraftButton.setEnabled(changed && requiredFieldsFilled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true, wenn alle Pflichtfelder der Maske gefüllt sind. */
|
||||||
|
private boolean requiredFieldsFilled() {
|
||||||
|
return !invoiceNumber.getValue().isBlank()
|
||||||
|
&& issueDate.getValue() != null
|
||||||
|
&& !currency.getValue().isBlank()
|
||||||
|
&& sender.requiredFieldsFilled()
|
||||||
|
&& recipient.requiredFieldsFilled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */
|
/** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */
|
||||||
@@ -495,9 +509,10 @@ public class MainView extends VerticalLayout {
|
|||||||
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
||||||
currency.getValue().trim(),
|
currency.getValue().trim(),
|
||||||
paymentTerms.getValue(),
|
paymentTerms.getValue(),
|
||||||
buyerReference.getValue().trim(),
|
"",
|
||||||
iban.getValue().trim(),
|
sender.ibanNormalized(),
|
||||||
bic.getValue().trim(),
|
sender.bicNormalized(),
|
||||||
|
sender.websiteValue(),
|
||||||
sender.toParty(), recipient.toParty(),
|
sender.toParty(), recipient.toParty(),
|
||||||
List.copyOf(lineItems),
|
List.copyOf(lineItems),
|
||||||
null);
|
null);
|
||||||
@@ -573,24 +588,19 @@ public class MainView extends VerticalLayout {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Das Rechnungsformular und der Erzeugen-Button sind nur aktiv, wenn ein
|
* Das Rechnungsformular und der Erzeugen-Button sind nur aktiv, wenn ein
|
||||||
* PDF hochgeladen oder ein Template ausgewählt wurde.
|
* Template ausgewählt wurde.
|
||||||
*/
|
*/
|
||||||
private void updateControlsEnabled() {
|
private void updateControlsEnabled() {
|
||||||
boolean sourceSelected = uploadedPdf != null || templateSelect.getValue() != null;
|
boolean sourceSelected = templateSelect.getValue() != null;
|
||||||
sourceDependentControls.forEach(control -> control.setEnabled(sourceSelected));
|
sourceDependentControls.forEach(control -> control.setEnabled(sourceSelected));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void generate() {
|
private void generate() {
|
||||||
downloadLink.setVisible(false);
|
|
||||||
List<String> errors = validateForm();
|
List<String> errors = validateForm();
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
showErrors(errors);
|
showErrors(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (uploadedPdf != null) {
|
|
||||||
convertAndOfferDownload(uploadedPdf);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String templateName = templateSelect.getValue();
|
String templateName = templateSelect.getValue();
|
||||||
Optional<String> templateData = invoiceTemplateService.loadTemplate(templateName);
|
Optional<String> templateData = invoiceTemplateService.loadTemplate(templateName);
|
||||||
if (templateData.isEmpty()) {
|
if (templateData.isEmpty()) {
|
||||||
@@ -615,12 +625,20 @@ public class MainView extends VerticalLayout {
|
|||||||
private Map<String, String> buildTemplateVariables(InvoiceMetadata metadata) {
|
private Map<String, String> buildTemplateVariables(InvoiceMetadata metadata) {
|
||||||
Map<String, String> variables = new LinkedHashMap<>();
|
Map<String, String> variables = new LinkedHashMap<>();
|
||||||
InvoiceMetadata.Party senderParty = metadata.sender();
|
InvoiceMetadata.Party senderParty = metadata.sender();
|
||||||
|
// Telefon und Webseite kommen aus den Formularfeldern des
|
||||||
|
// Rechnungsstellers; sind sie leer (z. B. alte Vorbelegung), springt
|
||||||
|
// der Adressbuch-Eintrag des Rechnungsstellers ein.
|
||||||
|
Optional<Address> senderAddress = findSenderAddress(senderParty.name());
|
||||||
|
String phone = !senderParty.phone().isBlank() ? senderParty.phone()
|
||||||
|
: senderAddress.map(address -> nullToEmpty(address.getPhone())).orElse("");
|
||||||
|
String website = !sender.websiteValue().isBlank() ? sender.websiteValue()
|
||||||
|
: senderAddress.map(address -> nullToEmpty(address.getWebsite())).orElse("");
|
||||||
variables.put("masterdata.company_name", senderParty.name());
|
variables.put("masterdata.company_name", senderParty.name());
|
||||||
variables.put("masterdata.street", senderParty.street());
|
variables.put("masterdata.street", senderParty.street());
|
||||||
variables.put("masterdata.city", senderParty.zip() + " " + senderParty.city());
|
variables.put("masterdata.city", senderParty.zip() + " " + senderParty.city());
|
||||||
variables.put("masterdata.phone", senderParty.phone());
|
variables.put("masterdata.phone", phone);
|
||||||
variables.put("masterdata.email", senderParty.email());
|
variables.put("masterdata.email", senderParty.email());
|
||||||
variables.put("masterdata.website", "");
|
variables.put("masterdata.website", website);
|
||||||
variables.put("masterdata.sender_line",
|
variables.put("masterdata.sender_line",
|
||||||
senderParty.name() + " · " + senderParty.street() + " · " + senderParty.zip() + " " + senderParty.city());
|
senderParty.name() + " · " + senderParty.street() + " · " + senderParty.zip() + " " + senderParty.city());
|
||||||
variables.put("masterdata.payment_terms", metadata.paymentTerms());
|
variables.put("masterdata.payment_terms", metadata.paymentTerms());
|
||||||
@@ -651,31 +669,46 @@ public class MainView extends VerticalLayout {
|
|||||||
return variables;
|
return variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wandelt das Quell-PDF in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */
|
/**
|
||||||
|
* @return der Adressbuch-Eintrag des Rechnungsstellers: die Auswahl in
|
||||||
|
* "Aus dem Adressbuch übernehmen", sonst der Namenstreffer unter
|
||||||
|
* den Rechnungssteller-Adressen (z. B. nach Laden einer Vorbelegung).
|
||||||
|
*/
|
||||||
|
private Optional<Address> findSenderAddress(String senderName) {
|
||||||
|
Address selected = senderAddressSelect.getValue();
|
||||||
|
if (selected != null) {
|
||||||
|
return Optional.of(selected);
|
||||||
|
}
|
||||||
|
String normalized = senderName == null ? "" : senderName.trim();
|
||||||
|
return addressRepository.findByType(AddressType.RECHNUNGSSTELLER).stream()
|
||||||
|
.filter(address -> normalized.equalsIgnoreCase(nullToEmpty(address.getName()).trim()))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wandelt das Quell-PDF über den PDF Service in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */
|
||||||
private void convertAndOfferDownload(byte[] sourcePdf) {
|
private void convertAndOfferDownload(byte[] sourcePdf) {
|
||||||
try {
|
try {
|
||||||
InvoiceMetadata metadata = collectMetadata();
|
InvoiceMetadata metadata = collectMetadata();
|
||||||
capturedInvoiceData.setItems(metadata.items());
|
capturedInvoiceData.setItems(metadata.items());
|
||||||
ZugferdResult result = zugferdService.createZugferdZip(sourcePdf, metadata);
|
PdfServiceClient.SignResult result = pdfServiceClient.sign(sourcePdf, metadata);
|
||||||
|
|
||||||
StreamResource resource = new StreamResource(result.zipFileName(),
|
StreamResource resource = new StreamResource(result.zipFileName(),
|
||||||
() -> new ByteArrayInputStream(result.zip()));
|
() -> new ByteArrayInputStream(result.zip()));
|
||||||
resource.setContentType("application/zip");
|
resource.setContentType("application/zip");
|
||||||
downloadLink.setHref(resource);
|
downloadLink.setHref(resource);
|
||||||
downloadLink.setText("ZIP herunterladen (" + result.zipFileName() + ")");
|
|
||||||
downloadLink.setVisible(true);
|
|
||||||
|
|
||||||
if (result.valid()) {
|
if (result.valid()) {
|
||||||
Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000,
|
Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000,
|
||||||
Notification.Position.BOTTOM_START)
|
Notification.Position.BOTTOM_START)
|
||||||
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
downloadLink.getElement().callJsFunction("click");
|
||||||
} else {
|
} else {
|
||||||
Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. "
|
Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. "
|
||||||
+ "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000,
|
+ "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000,
|
||||||
Notification.Position.MIDDLE)
|
Notification.Position.MIDDLE)
|
||||||
.addThemeVariants(NotificationVariant.LUMO_WARNING);
|
.addThemeVariants(NotificationVariant.LUMO_WARNING);
|
||||||
}
|
}
|
||||||
} catch (ZugferdConversionException e) {
|
} catch (PdfServiceException e) {
|
||||||
showErrors(List.of(e.getMessage()));
|
showErrors(List.of(e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,16 +727,6 @@ public class MainView extends VerticalLayout {
|
|||||||
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) {
|
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) {
|
||||||
markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors);
|
markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors);
|
||||||
}
|
}
|
||||||
if (!iban.isEmpty() && !isValidIban(iban.getValue())) {
|
|
||||||
markInvalid(iban, "IBAN ist ungültig (Prüfsumme).", errors);
|
|
||||||
} else {
|
|
||||||
iban.setInvalid(false);
|
|
||||||
}
|
|
||||||
if (!bic.isEmpty() && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
|
|
||||||
markInvalid(bic, "BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
|
|
||||||
} else {
|
|
||||||
bic.setInvalid(false);
|
|
||||||
}
|
|
||||||
errors.addAll(sender.validateFields("Rechnungssteller"));
|
errors.addAll(sender.validateFields("Rechnungssteller"));
|
||||||
errors.addAll(recipient.validateFields("Rechnungsempfänger"));
|
errors.addAll(recipient.validateFields("Rechnungsempfänger"));
|
||||||
if (lineItems.isEmpty()) {
|
if (lineItems.isEmpty()) {
|
||||||
@@ -720,9 +743,9 @@ public class MainView extends VerticalLayout {
|
|||||||
dueDate.getValue(),
|
dueDate.getValue(),
|
||||||
currency.getValue().trim().toUpperCase(),
|
currency.getValue().trim().toUpperCase(),
|
||||||
paymentTerms.getValue(),
|
paymentTerms.getValue(),
|
||||||
buyerReference.getValue().trim(),
|
"",
|
||||||
iban.getValue().replaceAll("\\s", "").toUpperCase(),
|
sender.ibanNormalized(),
|
||||||
bic.getValue().trim().toUpperCase(),
|
sender.bicNormalized(),
|
||||||
sender.toParty(),
|
sender.toParty(),
|
||||||
recipient.toParty(),
|
recipient.toParty(),
|
||||||
lineItems
|
lineItems
|
||||||
@@ -760,21 +783,6 @@ public class MainView extends VerticalLayout {
|
|||||||
errors.add(message);
|
errors.add(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IBAN-Prüfung nach ISO 13616 (MOD 97-10). */
|
|
||||||
private static boolean isValidIban(String value) {
|
|
||||||
String normalized = value.replaceAll("\\s", "").toUpperCase();
|
|
||||||
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
|
|
||||||
int mod = 0;
|
|
||||||
for (char c : rearranged.toCharArray()) {
|
|
||||||
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
|
|
||||||
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
|
|
||||||
}
|
|
||||||
return mod == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showErrors(List<String> messages) {
|
private void showErrors(List<String> messages) {
|
||||||
Notification notification = new Notification();
|
Notification notification = new Notification();
|
||||||
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
@@ -802,7 +810,14 @@ public class MainView extends VerticalLayout {
|
|||||||
private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)");
|
private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)");
|
||||||
private final TextField vatId = new TextField("USt-IdNr.");
|
private final TextField vatId = new TextField("USt-IdNr.");
|
||||||
private final EmailField email = new EmailField("E-Mail");
|
private final EmailField email = new EmailField("E-Mail");
|
||||||
|
|
||||||
|
// Kontaktangaben; nur beim Rechnungssteller sichtbar und optional
|
||||||
private final TextField phone = new TextField("Telefon");
|
private final TextField phone = new TextField("Telefon");
|
||||||
|
private final TextField website = new TextField("Webseite");
|
||||||
|
|
||||||
|
// Bankverbindung; nur beim Rechnungssteller sichtbar und Pflicht
|
||||||
|
private final TextField iban = new TextField("IBAN");
|
||||||
|
private final TextField bic = new TextField("BIC");
|
||||||
|
|
||||||
PartyForm(boolean vatRequired) {
|
PartyForm(boolean vatRequired) {
|
||||||
this.vatRequired = vatRequired;
|
this.vatRequired = vatRequired;
|
||||||
@@ -816,7 +831,6 @@ public class MainView extends VerticalLayout {
|
|||||||
countryCode.setPattern("[A-Za-z]{2}");
|
countryCode.setPattern("[A-Za-z]{2}");
|
||||||
countryCode.setErrorMessage("Zweistelliger ISO-Code, z. B. DE.");
|
countryCode.setErrorMessage("Zweistelliger ISO-Code, z. B. DE.");
|
||||||
vatId.setRequired(vatRequired);
|
vatId.setRequired(vatRequired);
|
||||||
vatId.setHelperText("z. B. DE261094748");
|
|
||||||
// USt-IdNr. direkt beim Verlassen des Feldes prüfen
|
// USt-IdNr. direkt beim Verlassen des Feldes prüfen
|
||||||
vatId.addValueChangeListener(event -> checkVatId());
|
vatId.addValueChangeListener(event -> checkVatId());
|
||||||
// Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht
|
// Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht
|
||||||
@@ -824,15 +838,43 @@ public class MainView extends VerticalLayout {
|
|||||||
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
|
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
|
||||||
// E-Mail direkt beim Verlassen des Feldes prüfen
|
// E-Mail direkt beim Verlassen des Feldes prüfen
|
||||||
email.addValueChangeListener(event -> checkEmail());
|
email.addValueChangeListener(event -> checkEmail());
|
||||||
|
add(name, street, zip, city, countryCode, vatId, email);
|
||||||
if (vatRequired) {
|
if (vatRequired) {
|
||||||
phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen");
|
iban.setRequired(true);
|
||||||
|
bic.setRequired(true);
|
||||||
|
add(phone, website, iban, bic);
|
||||||
}
|
}
|
||||||
add(name, street, zip, city, countryCode, vatId, email, phone);
|
|
||||||
setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3));
|
setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Belegt die Bankverbindung vor, z. B. aus dem Adressbuch. */
|
||||||
|
void prefillBank(String ibanValue, String bicValue) {
|
||||||
|
iban.setValue(ibanValue);
|
||||||
|
bic.setValue(bicValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Belegt Telefon und Webseite vor, z. B. aus dem Adressbuch. */
|
||||||
|
void prefillContact(String phoneValue, String websiteValue) {
|
||||||
|
phone.setValue(phoneValue);
|
||||||
|
website.setValue(websiteValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
String websiteValue() {
|
||||||
|
return website.getValue().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IBAN ohne Leerzeichen in Großschreibung. */
|
||||||
|
String ibanNormalized() {
|
||||||
|
return iban.getValue().replaceAll("\\s", "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BIC ohne führende/abschließende Leerzeichen in Großschreibung. */
|
||||||
|
String bicNormalized() {
|
||||||
|
return bic.getValue().trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
void prefill(String nameValue, String streetValue, String zipValue, String cityValue,
|
void prefill(String nameValue, String streetValue, String zipValue, String cityValue,
|
||||||
String countryValue, String vatIdValue, String emailValue, String phoneValue) {
|
String countryValue, String vatIdValue, String emailValue) {
|
||||||
name.setValue(nameValue);
|
name.setValue(nameValue);
|
||||||
street.setValue(streetValue);
|
street.setValue(streetValue);
|
||||||
zip.setValue(zipValue);
|
zip.setValue(zipValue);
|
||||||
@@ -840,7 +882,18 @@ public class MainView extends VerticalLayout {
|
|||||||
countryCode.setValue(countryValue);
|
countryCode.setValue(countryValue);
|
||||||
vatId.setValue(vatIdValue);
|
vatId.setValue(vatIdValue);
|
||||||
email.setValue(emailValue);
|
email.setValue(emailValue);
|
||||||
phone.setValue(phoneValue);
|
}
|
||||||
|
|
||||||
|
/** @return true, wenn alle Pflichtfelder des Adressformulars gefüllt sind. */
|
||||||
|
boolean requiredFieldsFilled() {
|
||||||
|
return !name.getValue().isBlank()
|
||||||
|
&& !street.getValue().isBlank()
|
||||||
|
&& !zip.getValue().isBlank()
|
||||||
|
&& !city.getValue().isBlank()
|
||||||
|
&& !countryCode.getValue().isBlank()
|
||||||
|
&& (!vatRequired || !vatId.getValue().isBlank())
|
||||||
|
&& !email.getValue().isBlank()
|
||||||
|
&& (!vatRequired || (!iban.getValue().isBlank() && !bic.getValue().isBlank()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */
|
/** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */
|
||||||
@@ -853,11 +906,16 @@ public class MainView extends VerticalLayout {
|
|||||||
vatId.addValueChangeListener(event -> listener.run());
|
vatId.addValueChangeListener(event -> listener.run());
|
||||||
email.addValueChangeListener(event -> listener.run());
|
email.addValueChangeListener(event -> listener.run());
|
||||||
phone.addValueChangeListener(event -> listener.run());
|
phone.addValueChangeListener(event -> listener.run());
|
||||||
|
website.addValueChangeListener(event -> listener.run());
|
||||||
|
iban.addValueChangeListener(event -> listener.run());
|
||||||
|
bic.addValueChangeListener(event -> listener.run());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leert alle Felder; das Land behält den Standardwert "DE". */
|
/** Leert alle Felder; das Land behält den Standardwert "DE". */
|
||||||
void clearFields() {
|
void clearFields() {
|
||||||
prefill("", "", "", "", "DE", "", "", "");
|
prefill("", "", "", "", "DE", "", "");
|
||||||
|
prefillContact("", "");
|
||||||
|
prefillBank("", "");
|
||||||
name.setInvalid(false);
|
name.setInvalid(false);
|
||||||
street.setInvalid(false);
|
street.setInvalid(false);
|
||||||
zip.setInvalid(false);
|
zip.setInvalid(false);
|
||||||
@@ -865,6 +923,8 @@ public class MainView extends VerticalLayout {
|
|||||||
countryCode.setInvalid(false);
|
countryCode.setInvalid(false);
|
||||||
vatId.setInvalid(false);
|
vatId.setInvalid(false);
|
||||||
email.setInvalid(false);
|
email.setInvalid(false);
|
||||||
|
iban.setInvalid(false);
|
||||||
|
bic.setInvalid(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */
|
/** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */
|
||||||
@@ -915,6 +975,16 @@ public class MainView extends VerticalLayout {
|
|||||||
} else if (!checkEmail()) {
|
} else if (!checkEmail()) {
|
||||||
errors.add(partyLabel + ": E-Mail-Adresse ist ungültig.");
|
errors.add(partyLabel + ": E-Mail-Adresse ist ungültig.");
|
||||||
}
|
}
|
||||||
|
if (vatRequired) {
|
||||||
|
if (requireFilled(iban, partyLabel + ": IBAN ist ein Pflichtfeld.", errors)
|
||||||
|
&& !IbanValidator.isValid(iban.getValue())) {
|
||||||
|
markInvalid(iban, partyLabel + ": IBAN ist ungültig (Prüfsumme).", errors);
|
||||||
|
}
|
||||||
|
if (requireFilled(bic, partyLabel + ": BIC ist ein Pflichtfeld.", errors)
|
||||||
|
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
|
||||||
|
markInvalid(bic, partyLabel + ": BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.assecutor.pdftool.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.server.CustomizedSystemMessages;
|
||||||
|
import com.vaadin.flow.server.ServiceInitEvent;
|
||||||
|
import com.vaadin.flow.server.VaadinServiceInitListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert das stille Neuladen der Seite durch Vaadin: Standardmäßig lädt
|
||||||
|
* Vaadin bei abgelaufener Session (z. B. nach einem Neustart der Anwendung)
|
||||||
|
* oder internen Fehlern die Seite sofort und ohne Hinweis neu — mitten in der
|
||||||
|
* Arbeit im Rechnungsgenerator. Stattdessen wird jetzt eine Meldung angezeigt;
|
||||||
|
* die Seite lädt erst neu, wenn der Benutzer die Meldung bestätigt.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SystemMessagesConfiguration implements VaadinServiceInitListener {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceInit(ServiceInitEvent event) {
|
||||||
|
event.getSource().setSystemMessagesProvider(info -> {
|
||||||
|
CustomizedSystemMessages messages = new CustomizedSystemMessages();
|
||||||
|
|
||||||
|
messages.setSessionExpiredNotificationEnabled(true);
|
||||||
|
messages.setSessionExpiredCaption("Sitzung abgelaufen");
|
||||||
|
messages.setSessionExpiredMessage("Die Sitzung ist abgelaufen, z. B. durch einen Neustart der "
|
||||||
|
+ "Anwendung. Zum Fortfahren klicken — nicht gespeicherte Änderungen im "
|
||||||
|
+ "Rechnungsgenerator werden wiederhergestellt.");
|
||||||
|
|
||||||
|
messages.setInternalErrorNotificationEnabled(true);
|
||||||
|
messages.setInternalErrorCaption("Interner Fehler");
|
||||||
|
messages.setInternalErrorMessage("Es ist ein interner Fehler aufgetreten. "
|
||||||
|
+ "Zum Fortfahren klicken; die Seite wird danach neu geladen.");
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import com.vaadin.flow.component.icon.VaadinIcon;
|
|||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.component.upload.Upload;
|
import com.vaadin.flow.component.upload.Upload;
|
||||||
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
||||||
@@ -38,6 +39,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -58,6 +60,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
|
|
||||||
private static final BigDecimal VAT_RATE = new BigDecimal("0.19");
|
private static final BigDecimal VAT_RATE = new BigDecimal("0.19");
|
||||||
|
|
||||||
|
// Canvas-Basisgröße (px) und A4-Maße (mm) zur Umrechnung der
|
||||||
|
// Positionsangaben in der Eigenschaften-Sidebar
|
||||||
|
private static final double BASE_PAGE_WIDTH_PX = 595;
|
||||||
|
private static final double BASE_PAGE_HEIGHT_PX = 842;
|
||||||
|
private static final double A4_WIDTH_MM = 210;
|
||||||
|
private static final double A4_HEIGHT_MM = 297;
|
||||||
|
|
||||||
private final TemplatePdfService templatePdfService;
|
private final TemplatePdfService templatePdfService;
|
||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private final CapturedInvoiceData capturedInvoiceData;
|
private final CapturedInvoiceData capturedInvoiceData;
|
||||||
@@ -70,6 +79,15 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */
|
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */
|
||||||
private String currentTemplateName;
|
private String currentTemplateName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrundbild der Zeichenfläche als Data-URL. Es wird serverseitig
|
||||||
|
* gehalten und erst beim Speichern bzw. für die Vorschau ins Template-JSON
|
||||||
|
* übernommen — die große Bild-Payload würde sonst bei jedem
|
||||||
|
* getProfileCanvasData-Aufruf das Limit für Client-zu-Server-Übertragungen
|
||||||
|
* sprengen.
|
||||||
|
*/
|
||||||
|
private String backgroundImage;
|
||||||
|
|
||||||
public TemplateGeneratorView(TemplatePdfService templatePdfService,
|
public TemplateGeneratorView(TemplatePdfService templatePdfService,
|
||||||
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
|
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
|
||||||
this.templatePdfService = templatePdfService;
|
this.templatePdfService = templatePdfService;
|
||||||
@@ -141,18 +159,67 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
loadInitialTemplate();
|
loadInitialTemplate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt beim Start das Standard-Template bzw. das erste vorhandene Template. */
|
/**
|
||||||
|
* Beim Öffnen der Seite wird kein Template geladen; die Zeichenfläche
|
||||||
|
* bleibt leer, bis der Benutzer ein Template in der Combobox auswählt.
|
||||||
|
* Nur ein automatisch gesicherter Arbeitsstand (z. B. nach einem
|
||||||
|
* Seiten-Reload durch Session-Ablauf) wird wiederhergestellt.
|
||||||
|
*/
|
||||||
private void loadInitialTemplate() {
|
private void loadInitialTemplate() {
|
||||||
List<String> names = invoiceTemplateService.templateNames();
|
List<String> names = invoiceTemplateService.templateNames();
|
||||||
templateSelect.setItems(names);
|
templateSelect.setItems(names);
|
||||||
if (names.isEmpty()) {
|
|
||||||
return;
|
Optional<String> autosave = invoiceTemplateService.loadAutosave();
|
||||||
|
if (autosave.isPresent()) {
|
||||||
|
restoreAutosave(autosave.get(), names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt den automatisch gesicherten Arbeitsstand auf der Zeichenfläche wieder her. */
|
||||||
|
private void restoreAutosave(String autosaveData, List<String> names) {
|
||||||
|
String templateName = readJsonTextField(autosaveData, "autosaveOf");
|
||||||
|
if (templateName != null && names.contains(templateName)) {
|
||||||
|
currentTemplateName = templateName;
|
||||||
|
templateSelect.setValue(templateName);
|
||||||
|
}
|
||||||
|
backgroundImage = readBackgroundImage(autosaveData);
|
||||||
|
getElement().executeJs("setTimeout(function() { "
|
||||||
|
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||||
|
+ " window.loadProfileTemplate(JSON.parse($0)); "
|
||||||
|
+ " } else { console.error('loadProfileTemplate or canvas not available'); } "
|
||||||
|
+ "}, 300);", autosaveData);
|
||||||
|
showNotification("Nicht gespeicherte Änderungen wurden wiederhergestellt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichert den Canvas-Stand als Autosave (Aufruf aus dem JavaScript,
|
||||||
|
* zeitversetzt nach jeder Änderung). Das Hintergrundbild und der Name des
|
||||||
|
* zugrunde liegenden Templates werden serverseitig ergänzt.
|
||||||
|
*/
|
||||||
|
@ClientCallable
|
||||||
|
public void autosaveCanvas(String templateData) {
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(withBackgroundImage(templateData));
|
||||||
|
if (currentTemplateName != null && !currentTemplateName.isBlank()) {
|
||||||
|
root.put("autosaveOf", currentTemplateName);
|
||||||
|
}
|
||||||
|
invoiceTemplateService.saveAutosave(mapper.writeValueAsString(root));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Autosave des Canvas fehlgeschlagen: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest ein Textfeld aus dem Template-JSON; {@code null}, wenn nicht vorhanden. */
|
||||||
|
private static String readJsonTextField(String templateData, String fieldName) {
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode node =
|
||||||
|
new ObjectMapper().readTree(templateData).get(fieldName);
|
||||||
|
return node == null || node.isNull() || node.asText().isEmpty() ? null : node.asText();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
String initial = names.contains(InvoiceTemplateService.DEFAULT_TEMPLATE_NAME)
|
|
||||||
? InvoiceTemplateService.DEFAULT_TEMPLATE_NAME
|
|
||||||
: names.get(0);
|
|
||||||
templateSelect.setValue(initial);
|
|
||||||
loadTemplateIntoCanvas(initial);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt das Template mit dem Namen auf die Zeichenfläche. */
|
/** Lädt das Template mit dem Namen auf die Zeichenfläche. */
|
||||||
@@ -164,6 +231,7 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentTemplateName = name;
|
currentTemplateName = name;
|
||||||
|
backgroundImage = readBackgroundImage(templateData.get());
|
||||||
getElement().executeJs("setTimeout(function() { "
|
getElement().executeJs("setTimeout(function() { "
|
||||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||||
+ " window.loadProfileTemplate(JSON.parse($0)); "
|
+ " window.loadProfileTemplate(JSON.parse($0)); "
|
||||||
@@ -234,6 +302,32 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
return TemplateVariables.canvasPositionsJson(effectiveItems());
|
return TemplateVariables.canvasPositionsJson(effectiveItems());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Liest das Hintergrundbild aus dem Template-JSON; {@code null}, wenn keines gesetzt ist. */
|
||||||
|
private static String readBackgroundImage(String templateData) {
|
||||||
|
return readJsonTextField(templateData, "backgroundImage");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergänzt das serverseitig gehaltene Hintergrundbild im Template-JSON aus
|
||||||
|
* der Zeichenfläche (das JavaScript liefert es aus Größengründen nicht mit).
|
||||||
|
*/
|
||||||
|
private String withBackgroundImage(String templateData) {
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(templateData);
|
||||||
|
if (backgroundImage != null && !backgroundImage.isEmpty()) {
|
||||||
|
root.put("backgroundImage", backgroundImage);
|
||||||
|
} else {
|
||||||
|
root.remove("backgroundImage");
|
||||||
|
}
|
||||||
|
return mapper.writeValueAsString(root);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Hintergrundbild konnte nicht ins Template übernommen werden: {}", ex.getMessage());
|
||||||
|
return templateData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String buildMasterdataJson() {
|
private String buildMasterdataJson() {
|
||||||
try {
|
try {
|
||||||
return new ObjectMapper().writeValueAsString(buildVariables());
|
return new ObjectMapper().writeValueAsString(buildVariables());
|
||||||
@@ -518,9 +612,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die im Designer und im PDF-Renderer verfügbaren Schriftarten. */
|
||||||
|
private static final List<String> FONT_FAMILIES = List.of("Arial", "Times New Roman", "Courier New", "Verdana");
|
||||||
|
|
||||||
@ClientCallable
|
@ClientCallable
|
||||||
public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y,
|
public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y,
|
||||||
Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable) {
|
Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable,
|
||||||
|
String fontFamily) {
|
||||||
getUI().ifPresent(ui -> ui.access(() -> {
|
getUI().ifPresent(ui -> ui.access(() -> {
|
||||||
propertiesPanel.removeAll();
|
propertiesPanel.removeAll();
|
||||||
|
|
||||||
@@ -573,11 +671,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
propertiesPanel.add(upload);
|
propertiesPanel.add(upload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textfeld (nur für Text-Elemente)
|
// Textfeld (nur für Text-Elemente); mehrzeilig, Umbrüche werden
|
||||||
|
// als Zeilenumbrüche in das Element übernommen
|
||||||
if (!"line".equals(elementType) && !"image".equals(elementType)) {
|
if (!"line".equals(elementType) && !"image".equals(elementType)) {
|
||||||
TextField textField = new TextField("Text");
|
TextArea textField = new TextArea("Text");
|
||||||
textField.setValue(text != null ? text : "");
|
textField.setValue(text != null ? text : "");
|
||||||
textField.setWidthFull();
|
textField.setWidthFull();
|
||||||
|
textField.setMinHeight("6em");
|
||||||
if (Boolean.TRUE.equals(isStatic)) {
|
if (Boolean.TRUE.equals(isStatic)) {
|
||||||
textField.setReadOnly(true);
|
textField.setReadOnly(true);
|
||||||
textField.setHelperText("Wert wird aus den Stammdaten befüllt");
|
textField.setHelperText("Wert wird aus den Stammdaten befüllt");
|
||||||
@@ -589,40 +689,90 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
propertiesPanel.add(textField);
|
propertiesPanel.add(textField);
|
||||||
}
|
}
|
||||||
|
|
||||||
// X Position
|
// X Position (Anzeige in mm, Canvas rechnet intern in px)
|
||||||
TextField xField = new TextField("X Position");
|
TextField xField = new TextField("X Position (mm)");
|
||||||
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0");
|
xField.setValue(formatMm(x != null ? pxToMm(x, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
|
||||||
xField.setWidthFull();
|
xField.setWidthFull();
|
||||||
xField.addValueChangeListener(e -> {
|
xField.addValueChangeListener(e -> {
|
||||||
try {
|
try {
|
||||||
double newX = Double.parseDouble(e.getValue());
|
double newXPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
|
||||||
getElement().executeJs(
|
getElement().executeJs(
|
||||||
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
||||||
+ elementId + "', $0, null); }",
|
+ elementId + "', $0, null); }",
|
||||||
newX);
|
newXPx);
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
propertiesPanel.add(xField);
|
propertiesPanel.add(xField);
|
||||||
|
|
||||||
// Y Position
|
// Y Position (Anzeige in mm, Canvas rechnet intern in px)
|
||||||
TextField yField = new TextField("Y Position");
|
TextField yField = new TextField("Y Position (mm)");
|
||||||
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0");
|
yField.setValue(formatMm(y != null ? pxToMm(y, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
|
||||||
yField.setWidthFull();
|
yField.setWidthFull();
|
||||||
yField.addValueChangeListener(e -> {
|
yField.addValueChangeListener(e -> {
|
||||||
try {
|
try {
|
||||||
double newY = Double.parseDouble(e.getValue());
|
double newYPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
|
||||||
getElement().executeJs(
|
getElement().executeJs(
|
||||||
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
||||||
+ elementId + "', null, $0); }",
|
+ elementId + "', null, $0); }",
|
||||||
newY);
|
newYPx);
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
propertiesPanel.add(yField);
|
propertiesPanel.add(yField);
|
||||||
|
|
||||||
// Schriftgröße und Farbe (nur für Text-Elemente)
|
// Breite (Anzeige in mm); bei Linien bestimmt sie die Länge
|
||||||
|
TextField widthField = new TextField("Breite (mm)");
|
||||||
|
widthField.setValue(formatMm(width != null ? pxToMm(width, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
|
||||||
|
widthField.setWidthFull();
|
||||||
|
widthField.addValueChangeListener(e -> {
|
||||||
|
try {
|
||||||
|
double newWidthPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
|
||||||
|
getElement().executeJs(
|
||||||
|
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
|
||||||
|
+ elementId + "', $0, null); }",
|
||||||
|
newWidthPx);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
propertiesPanel.add(widthField);
|
||||||
|
|
||||||
|
// Höhe (Anzeige in mm); für Linien ohne Bedeutung
|
||||||
|
if (!"line".equals(elementType)) {
|
||||||
|
TextField heightField = new TextField("Höhe (mm)");
|
||||||
|
heightField.setValue(formatMm(height != null ? pxToMm(height, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
|
||||||
|
heightField.setWidthFull();
|
||||||
|
heightField.addValueChangeListener(e -> {
|
||||||
|
try {
|
||||||
|
double newHeightPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
|
||||||
|
getElement().executeJs(
|
||||||
|
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
|
||||||
|
+ elementId + "', null, $0); }",
|
||||||
|
newHeightPx);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
propertiesPanel.add(heightField);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schriftart, Schriftgröße und Farbe (nur für Text-Elemente)
|
||||||
if (!"line".equals(elementType) && !"image".equals(elementType)) {
|
if (!"line".equals(elementType) && !"image".equals(elementType)) {
|
||||||
|
ComboBox<String> fontFamilySelect = new ComboBox<>("Schriftart");
|
||||||
|
fontFamilySelect.setItems(FONT_FAMILIES);
|
||||||
|
fontFamilySelect.setValue(fontFamily != null && FONT_FAMILIES.contains(fontFamily)
|
||||||
|
? fontFamily
|
||||||
|
: "Arial");
|
||||||
|
fontFamilySelect.setWidthFull();
|
||||||
|
fontFamilySelect.addValueChangeListener(e -> {
|
||||||
|
if (e.getValue() != null) {
|
||||||
|
getElement().executeJs(
|
||||||
|
"if (window.updateProfileElementFontFamily) { window.updateProfileElementFontFamily('"
|
||||||
|
+ elementId + "', $0); }",
|
||||||
|
e.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
propertiesPanel.add(fontFamilySelect);
|
||||||
|
|
||||||
TextField fontSizeField = new TextField("Schriftgröße");
|
TextField fontSizeField = new TextField("Schriftgröße");
|
||||||
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
|
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
|
||||||
fontSizeField.setWidthFull();
|
fontSizeField.setWidthFull();
|
||||||
@@ -692,6 +842,73 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Einstellungen der Zeichenfläche in der Sidebar an (Aufruf aus
|
||||||
|
* dem JavaScript bei Klick auf die leere Zeichenfläche). Einzige
|
||||||
|
* Einstellung ist derzeit das Hintergrundbild: Es wird auf Seitengröße
|
||||||
|
* skaliert und liegt immer hinter allen Elementen.
|
||||||
|
*/
|
||||||
|
@ClientCallable
|
||||||
|
public void showCanvasProperties(Boolean hasBackground) {
|
||||||
|
getUI().ifPresent(ui -> ui.access(() -> {
|
||||||
|
propertiesPanel.removeAll();
|
||||||
|
|
||||||
|
Span typeLabel = new Span("Zeichenfläche");
|
||||||
|
typeLabel.addClassName("invoice-generator-info");
|
||||||
|
propertiesPanel.add(propertiesHeader(), typeLabel);
|
||||||
|
|
||||||
|
Span backgroundLabel = new Span("Hintergrundbild");
|
||||||
|
backgroundLabel.getStyle().set("font-weight", "bold")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
propertiesPanel.add(backgroundLabel);
|
||||||
|
|
||||||
|
MemoryBuffer buffer = new MemoryBuffer();
|
||||||
|
Upload upload = new Upload(buffer);
|
||||||
|
upload.setAcceptedFileTypes("image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp");
|
||||||
|
upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB
|
||||||
|
upload.setDropLabel(new Span("Bild hierher ziehen oder klicken"));
|
||||||
|
upload.setWidthFull();
|
||||||
|
|
||||||
|
upload.addSucceededListener(event -> {
|
||||||
|
try {
|
||||||
|
byte[] bytes = buffer.getInputStream().readAllBytes();
|
||||||
|
String dataUrl = "data:" + event.getMIMEType() + ";base64,"
|
||||||
|
+ Base64.getEncoder().encodeToString(bytes);
|
||||||
|
backgroundImage = dataUrl;
|
||||||
|
getElement().executeJs(
|
||||||
|
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground($0); }",
|
||||||
|
dataUrl);
|
||||||
|
showNotification("Hintergrundbild übernommen.");
|
||||||
|
showCanvasProperties(true);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
showNotification("Bild konnte nicht geladen werden: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upload.addFileRejectedListener(event ->
|
||||||
|
showNotification("Datei abgelehnt: " + event.getErrorMessage()));
|
||||||
|
propertiesPanel.add(upload);
|
||||||
|
|
||||||
|
Div info = new Div();
|
||||||
|
info.setText("Das Bild wird auf die Seitengröße skaliert und hinter allen Elementen angezeigt.");
|
||||||
|
info.addClassName("invoice-generator-info");
|
||||||
|
propertiesPanel.add(info);
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(hasBackground)) {
|
||||||
|
Button removeBackground = new Button("Hintergrundbild entfernen", new Icon(VaadinIcon.TRASH));
|
||||||
|
removeBackground.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
||||||
|
removeBackground.setWidthFull();
|
||||||
|
removeBackground.addClickListener(e -> {
|
||||||
|
backgroundImage = null;
|
||||||
|
getElement().executeJs(
|
||||||
|
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground(null); }");
|
||||||
|
showNotification("Hintergrundbild entfernt.");
|
||||||
|
showCanvasProperties(false);
|
||||||
|
});
|
||||||
|
propertiesPanel.add(removeBackground);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
@ClientCallable
|
@ClientCallable
|
||||||
public void resetPropertiesPanel() {
|
public void resetPropertiesPanel() {
|
||||||
getUI().ifPresent(ui -> ui.access(() -> {
|
getUI().ifPresent(ui -> ui.access(() -> {
|
||||||
@@ -703,6 +920,26 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rechnet eine Canvas-Position (px) in mm auf der A4-Seite um. */
|
||||||
|
private static double pxToMm(double px, double basePx, double sizeMm) {
|
||||||
|
return px / basePx * sizeMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rechnet eine mm-Angabe in die Canvas-Position (px) um. */
|
||||||
|
private static double mmToPx(double mm, double basePx, double sizeMm) {
|
||||||
|
return mm / sizeMm * basePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert eine mm-Angabe mit einer Nachkommastelle, z. B. "20,5". */
|
||||||
|
private static String formatMm(double mm) {
|
||||||
|
return String.format(Locale.GERMANY, "%.1f", mm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine mm-Angabe; Komma und Punkt sind als Dezimaltrenner erlaubt. */
|
||||||
|
private static double parseMm(String value) {
|
||||||
|
return Double.parseDouble(value.trim().replace(',', '.'));
|
||||||
|
}
|
||||||
|
|
||||||
private String getVariableDescription(String variable) {
|
private String getVariableDescription(String variable) {
|
||||||
return switch (variable) {
|
return switch (variable) {
|
||||||
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
|
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
|
||||||
@@ -778,7 +1015,7 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
showNotification("Zeichenfläche ist nicht bereit.");
|
showNotification("Zeichenfläche ist nicht bereit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openSaveDialog(templateData);
|
openSaveDialog(withBackgroundImage(templateData));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +1068,8 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
private void doSaveTemplate(String name, String templateData) {
|
private void doSaveTemplate(String name, String templateData) {
|
||||||
try {
|
try {
|
||||||
invoiceTemplateService.saveTemplate(name, templateData);
|
invoiceTemplateService.saveTemplate(name, templateData);
|
||||||
|
// Explizit gespeichert: der Autosave-Arbeitsstand ist damit erledigt.
|
||||||
|
invoiceTemplateService.clearAutosave();
|
||||||
currentTemplateName = name;
|
currentTemplateName = name;
|
||||||
templateSelect.setItems(invoiceTemplateService.templateNames());
|
templateSelect.setItems(invoiceTemplateService.templateNames());
|
||||||
templateSelect.setValue(name);
|
templateSelect.setValue(name);
|
||||||
@@ -849,7 +1088,8 @@ public class TemplateGeneratorView extends VerticalLayout {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE);
|
byte[] pdfBytes = templatePdfService.generatePdf(withBackgroundImage(templateData),
|
||||||
|
buildVariables(), VAT_RATE);
|
||||||
showPdfInDialog(pdfBytes);
|
showPdfInDialog(pdfBytes);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());
|
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package de.assecutor.pdftool.zugferd;
|
|
||||||
|
|
||||||
public class ZugferdConversionException extends RuntimeException {
|
|
||||||
|
|
||||||
public ZugferdConversionException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ZugferdConversionException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package de.assecutor.pdftool.zugferd;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ergebnis einer ZUGFeRD-Konvertierung: die ZIP-Datei (ZUGFeRD-PDF, Factur-X-XML,
|
|
||||||
* Prüfbericht) sowie das Ergebnis der Mustang-Validierung.
|
|
||||||
*/
|
|
||||||
public record ZugferdResult(byte[] zip, String zipFileName, boolean valid, String validationReport) {
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package de.assecutor.pdftool.zugferd;
|
|
||||||
|
|
||||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
|
||||||
import org.apache.pdfbox.Loader;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDResources;
|
|
||||||
import org.mustangproject.BankDetails;
|
|
||||||
import org.mustangproject.Contact;
|
|
||||||
import org.mustangproject.Invoice;
|
|
||||||
import org.mustangproject.Item;
|
|
||||||
import org.mustangproject.Product;
|
|
||||||
import org.mustangproject.SchemedID;
|
|
||||||
import org.mustangproject.TradeParty;
|
|
||||||
import org.mustangproject.ZUGFeRD.IZUGFeRDExporter;
|
|
||||||
import org.mustangproject.ZUGFeRD.Profiles;
|
|
||||||
import org.mustangproject.ZUGFeRD.ZUGFeRD2PullProvider;
|
|
||||||
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1;
|
|
||||||
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromPDFA;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipOutputStream;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wandelt ein herkömmliches Rechnungs-PDF in eine rechtskonforme ZUGFeRD-Rechnung
|
|
||||||
* (PDF/A-3 mit eingebettetem EN16931-XML, Profil EN 16931) um, validiert das
|
|
||||||
* Ergebnis mit dem Mustang-Validator und verpackt PDF, Factur-X-XML und
|
|
||||||
* Prüfbericht in eine ZIP-Datei.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class ZugferdService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ZugferdService.class);
|
|
||||||
|
|
||||||
private static final String PROFILE = "EN16931";
|
|
||||||
private static final String PRODUCER = "Assecutor Data Service GmbH Invoice Tool";
|
|
||||||
|
|
||||||
/** Business Process (BT-23), von PEPPOL-EN16931-R001 gefordert. */
|
|
||||||
private static final String BUSINESS_PROCESS = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0";
|
|
||||||
|
|
||||||
/** EAS-Schema "EM" = E-Mail-Adresse für die elektronische Adresse (BT-34/BT-49). */
|
|
||||||
private static final String EAS_EMAIL = "EM";
|
|
||||||
|
|
||||||
private final ZugferdValidationService validationService;
|
|
||||||
|
|
||||||
public ZugferdService(ZugferdValidationService validationService) {
|
|
||||||
this.validationService = validationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ZugferdResult createZugferdZip(byte[] sourcePdf, InvoiceMetadata metadata) {
|
|
||||||
validatePdf(sourcePdf);
|
|
||||||
sourcePdf = ensurePageResources(sourcePdf);
|
|
||||||
Invoice invoice = buildInvoice(metadata);
|
|
||||||
|
|
||||||
byte[] zugferdPdf = embedXmlIntoPdf(sourcePdf, invoice);
|
|
||||||
byte[] facturXml = generateXml(invoice);
|
|
||||||
|
|
||||||
String baseName = sanitizeFileName(metadata.invoiceNumber());
|
|
||||||
String pdfName = baseName + "-zugferd.pdf";
|
|
||||||
ZugferdValidationService.ValidationResult validation = validationService.validate(zugferdPdf, pdfName);
|
|
||||||
|
|
||||||
byte[] zip = zip(
|
|
||||||
pdfName, zugferdPdf,
|
|
||||||
"factur-x.xml", facturXml,
|
|
||||||
"validation-report.xml", validation.reportXml().getBytes(StandardCharsets.UTF_8));
|
|
||||||
return new ZugferdResult(zip, baseName + ".zip", validation.valid(), validation.reportXml());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validatePdf(byte[] pdf) {
|
|
||||||
if (pdf == null || pdf.length < 5
|
|
||||||
|| !"%PDF-".equals(new String(pdf, 0, 5, StandardCharsets.US_ASCII))) {
|
|
||||||
throw new ZugferdConversionException("Die hochgeladene Datei ist kein gültiges PDF.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mustang setzt bei jeder Seite ein Resources-Dictionary voraus und stürzt
|
|
||||||
* sonst mit einer NullPointerException ab. Seiten ohne Resources (z.B. aus
|
|
||||||
* manchen PDF-Generatoren) erhalten deshalb vorab ein leeres Dictionary.
|
|
||||||
*/
|
|
||||||
private byte[] ensurePageResources(byte[] pdf) {
|
|
||||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
|
||||||
boolean changed = false;
|
|
||||||
for (PDPage page : doc.getPages()) {
|
|
||||||
if (page.getResources() == null) {
|
|
||||||
page.setResources(new PDResources());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!changed) {
|
|
||||||
return pdf;
|
|
||||||
}
|
|
||||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
||||||
doc.save(out);
|
|
||||||
return out.toByteArray();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ZugferdConversionException("Das PDF konnte nicht gelesen werden: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Invoice buildInvoice(InvoiceMetadata m) {
|
|
||||||
LocalDate delivery = m.deliveryDate() != null ? m.deliveryDate() : m.issueDate();
|
|
||||||
LocalDate due = m.dueDate() != null ? m.dueDate() : m.issueDate().plusDays(14);
|
|
||||||
String currency = m.currency() != null && !m.currency().isBlank() ? m.currency() : "EUR";
|
|
||||||
|
|
||||||
Invoice invoice = new Invoice()
|
|
||||||
.setDocumentName("Rechnung")
|
|
||||||
.setBusinessProcessId(BUSINESS_PROCESS)
|
|
||||||
.setNumber(m.invoiceNumber())
|
|
||||||
.setIssueDate(toDate(m.issueDate()))
|
|
||||||
.setDeliveryDate(toDate(delivery))
|
|
||||||
.setDueDate(toDate(due))
|
|
||||||
.setCurrency(currency)
|
|
||||||
.setSender(toSender(m))
|
|
||||||
.setRecipient(toTradeParty(m.recipient()));
|
|
||||||
|
|
||||||
if (m.paymentTerms() != null && !m.paymentTerms().isBlank()) {
|
|
||||||
invoice.setPaymentTermDescription(m.paymentTerms());
|
|
||||||
}
|
|
||||||
if (m.buyerReference() != null && !m.buyerReference().isBlank()) {
|
|
||||||
// Käuferreferenz / Leitweg-ID (BT-10)
|
|
||||||
invoice.setReferenceNumber(m.buyerReference().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (InvoiceMetadata.LineItem li : m.items()) {
|
|
||||||
// Einheit "C62" = Stück (UN/ECE Recommendation 20)
|
|
||||||
Product product = new Product(li.description(), "", "C62", li.vatPercent());
|
|
||||||
invoice.addItem(new Item(product, li.unitPriceNet(), li.quantity()));
|
|
||||||
}
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rechnungssteller inkl. Verkäufer-Kontakt (BG-6) und Zahlungsverbindung (BG-16). */
|
|
||||||
private TradeParty toSender(InvoiceMetadata m) {
|
|
||||||
InvoiceMetadata.Party p = m.sender();
|
|
||||||
TradeParty party = toTradeParty(p);
|
|
||||||
party.setContact(new Contact(p.name(), p.phone(), p.email()));
|
|
||||||
if (m.iban() != null && !m.iban().isBlank()) {
|
|
||||||
String iban = m.iban().replaceAll("\\s", "").toUpperCase();
|
|
||||||
party.addBankDetails(m.bic() != null && !m.bic().isBlank()
|
|
||||||
? new BankDetails(iban, m.bic().trim().toUpperCase())
|
|
||||||
: new BankDetails(iban));
|
|
||||||
}
|
|
||||||
return party;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TradeParty toTradeParty(InvoiceMetadata.Party p) {
|
|
||||||
TradeParty party = new TradeParty(p.name(), p.street(), p.zip(), p.city(), p.countryCode());
|
|
||||||
if (p.vatId() != null && !p.vatId().isBlank()) {
|
|
||||||
party.addVATID(p.vatId());
|
|
||||||
}
|
|
||||||
if (p.email() != null && !p.email().isBlank()) {
|
|
||||||
party.setEmail(p.email());
|
|
||||||
party.addUriUniversalCommunicationID(new SchemedID(EAS_EMAIL, p.email()));
|
|
||||||
}
|
|
||||||
return party;
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] embedXmlIntoPdf(byte[] sourcePdf, Invoice invoice) {
|
|
||||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
IZUGFeRDExporter exporter = loadExporter(sourcePdf)) {
|
|
||||||
exporter.setProducer(PRODUCER)
|
|
||||||
.setCreator(PRODUCER)
|
|
||||||
.setProfile(Profiles.getByName(PROFILE))
|
|
||||||
.setTransaction(invoice);
|
|
||||||
exporter.export(out);
|
|
||||||
return out.toByteArray();
|
|
||||||
} catch (IOException | RuntimeException e) {
|
|
||||||
// Mustang wirft bei problematischen PDFs auch unchecked Exceptions —
|
|
||||||
// ohne diesen Catch würde daraus ein HTTP 500 statt einer Fehlermeldung.
|
|
||||||
throw new ZugferdConversionException(
|
|
||||||
"Das PDF konnte nicht in eine ZUGFeRD-Rechnung umgewandelt werden: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Versucht zuerst die automatische PDF/A-Erkennung; ist das PDF kein PDF/A
|
|
||||||
* (der Normalfall bei ERP-Ausdrucken), wird es tolerant als PDF/A-1
|
|
||||||
* interpretiert und nach PDF/A-3 konvertiert.
|
|
||||||
*/
|
|
||||||
private IZUGFeRDExporter loadExporter(byte[] sourcePdf) throws IOException {
|
|
||||||
ZUGFeRDExporterFromPDFA exporter = new ZUGFeRDExporterFromPDFA();
|
|
||||||
try {
|
|
||||||
exporter.load(new ByteArrayInputStream(sourcePdf));
|
|
||||||
return exporter;
|
|
||||||
} catch (IOException | IllegalArgumentException e) {
|
|
||||||
// Nicht exporter.close(): vor erfolgreichem load() hält er keine Ressourcen
|
|
||||||
// und close() würde eine RuntimeException werfen.
|
|
||||||
log.info("Eingabe ist kein PDF/A, konvertiere tolerant: {}", e.getMessage());
|
|
||||||
ZUGFeRDExporterFromA1 fallback = new ZUGFeRDExporterFromA1();
|
|
||||||
fallback.ignorePDFAErrors();
|
|
||||||
fallback.load(new ByteArrayInputStream(sourcePdf));
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] generateXml(Invoice invoice) {
|
|
||||||
ZUGFeRD2PullProvider provider = new ZUGFeRD2PullProvider();
|
|
||||||
provider.setProfile(Profiles.getByName(PROFILE));
|
|
||||||
provider.generateXML(invoice);
|
|
||||||
return provider.getXML();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Packt die übergebenen Einträge (abwechselnd Dateiname als String und Inhalt
|
|
||||||
* als byte[]) in eine ZIP-Datei.
|
|
||||||
*/
|
|
||||||
private byte[] zip(Object... namesAndContents) {
|
|
||||||
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
||||||
ZipOutputStream zos = new ZipOutputStream(bos)) {
|
|
||||||
for (int i = 0; i < namesAndContents.length; i += 2) {
|
|
||||||
zos.putNextEntry(new ZipEntry((String) namesAndContents[i]));
|
|
||||||
zos.write((byte[]) namesAndContents[i + 1]);
|
|
||||||
zos.closeEntry();
|
|
||||||
}
|
|
||||||
zos.finish();
|
|
||||||
return bos.toByteArray();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ZugferdConversionException("ZIP-Datei konnte nicht erstellt werden.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sanitizeFileName(String name) {
|
|
||||||
String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_");
|
|
||||||
return cleaned.isBlank() ? "rechnung" : cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Date toDate(LocalDate date) {
|
|
||||||
return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package de.assecutor.pdftool.zugferd;
|
|
||||||
|
|
||||||
import org.mustangproject.validator.ZUGFeRDValidator;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validiert ZUGFeRD-Rechnungen (PDF oder XML) mit dem Mustang-Validator gegen
|
|
||||||
* XSD-Schema und die EN16931-Schematron-Regeln sowie das PDF gegen PDF/A-3
|
|
||||||
* (veraPDF). Liefert das Prüfergebnis samt XML-Prüfbericht.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class ZugferdValidationService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ZugferdValidationService.class);
|
|
||||||
|
|
||||||
public ValidationResult validate(byte[] content, String fileName) {
|
|
||||||
try {
|
|
||||||
// ZUGFeRDValidator hält Zustand pro Prüfung und ist nicht threadsicher,
|
|
||||||
// daher pro Aufruf eine neue Instanz
|
|
||||||
ZUGFeRDValidator validator = new ZUGFeRDValidator();
|
|
||||||
String report = validator.validate(content, fileName);
|
|
||||||
boolean valid = validator.wasCompletelyValid();
|
|
||||||
if (!valid) {
|
|
||||||
log.warn("ZUGFeRD-Validierung von {} fehlgeschlagen:\n{}", fileName, report);
|
|
||||||
}
|
|
||||||
return new ValidationResult(valid, report);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.error("Validierung von {} nicht durchführbar", fileName, e);
|
|
||||||
return new ValidationResult(false,
|
|
||||||
"<validation><error>Validierung nicht durchführbar: " + e.getMessage() + "</error></validation>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record ValidationResult(boolean valid, String reportXml) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
spring.application.name=pdf-tool
|
spring.application.name=pdf-tool
|
||||||
server.port=${SERVER_PORT:8083}
|
server.port=${SERVER_PORT:8083}
|
||||||
|
|
||||||
|
# Session erst nach 12 h Inaktivität verwerfen: Der Standard von 30 Minuten
|
||||||
|
# führte dazu, dass Vaadin nach längerer Untätigkeit (Tab inaktiv, Standby)
|
||||||
|
# die Seite neu lädt und ungespeicherte Canvas-Änderungen verloren gehen.
|
||||||
|
server.servlet.session.timeout=12h
|
||||||
|
|
||||||
|
# Eigener Name für den Session-Cookie: Der Standardname JSESSIONID gilt pro
|
||||||
|
# Hostname (nicht pro Port). Andere lokal laufende Java-Anwendungen auf
|
||||||
|
# demselben Rechner überschreiben sich sonst gegenseitig den Cookie, und die
|
||||||
|
# Session des PDF-Tools ist scheinbar sofort "abgelaufen".
|
||||||
|
server.servlet.session.cookie.name=PDFTOOL_SESSION
|
||||||
|
|
||||||
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
|
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
|
||||||
pdftool.version=@project.version@
|
pdftool.version=@project.version@
|
||||||
|
|
||||||
spring.servlet.multipart.max-file-size=25MB
|
# PDF Service: erzeugt aus dem gerenderten Rechnungs-PDF die ZUGFeRD-E-Rechnung
|
||||||
spring.servlet.multipart.max-request-size=30MB
|
# (eigenständiges Projekt "PDF Service", POST {pdfservice.url}/invoices/sign)
|
||||||
|
pdfservice.url=${PDF_SERVICE_URL:http://localhost:8084/api/v1}
|
||||||
|
|
||||||
|
# Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit
|
||||||
|
# eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB
|
||||||
|
# überschreiten; ein abgelehnter Request führt im Browser sonst zu einem
|
||||||
|
# Vaadin-Kommunikationsfehler mit anschließendem Seiten-Reload.
|
||||||
|
server.tomcat.max-http-form-post-size=30MB
|
||||||
|
|
||||||
vaadin.launch-browser=false
|
vaadin.launch-browser=false
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
package de.assecutor.pdftool.zugferd;
|
|
||||||
|
|
||||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
|
||||||
import org.apache.pdfbox.Loader;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
|
||||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipInputStream;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class ZugferdServiceTest {
|
|
||||||
|
|
||||||
private final ZugferdService service = new ZugferdService(new ZugferdValidationService());
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createsZipWithZugferdPdfAndFacturXml() throws Exception {
|
|
||||||
byte[] sourcePdf = createSamplePdf();
|
|
||||||
|
|
||||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
|
||||||
|
|
||||||
assertEquals("RE-2026-0815.zip", result.zipFileName());
|
|
||||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
|
||||||
|
|
||||||
Map<String, byte[]> entries = readZip(result.zip());
|
|
||||||
assertEquals(3, entries.size());
|
|
||||||
byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf");
|
|
||||||
byte[] xml = entries.get("factur-x.xml");
|
|
||||||
byte[] report = entries.get("validation-report.xml");
|
|
||||||
assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP");
|
|
||||||
assertNotNull(xml, "Factur-X-XML fehlt im ZIP");
|
|
||||||
assertNotNull(report, "Prüfbericht fehlt im ZIP");
|
|
||||||
|
|
||||||
String xmlText = new String(xml, StandardCharsets.UTF_8);
|
|
||||||
assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML");
|
|
||||||
assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML");
|
|
||||||
assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt");
|
|
||||||
|
|
||||||
// Das erzeugte PDF muss das XML als eingebettete Datei enthalten
|
|
||||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
|
||||||
String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString();
|
|
||||||
assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void convertsPdfWhosePageHasNoResources() throws Exception {
|
|
||||||
// Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab —
|
|
||||||
// der Service muss solche PDFs vorab reparieren.
|
|
||||||
byte[] sourcePdf;
|
|
||||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
||||||
doc.addPage(new PDPage());
|
|
||||||
doc.save(out);
|
|
||||||
sourcePdf = out.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
|
||||||
|
|
||||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsNonPdfInput() {
|
|
||||||
assertThrows(ZugferdConversionException.class,
|
|
||||||
() -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static InvoiceMetadata sampleMetadata() {
|
|
||||||
return new InvoiceMetadata(
|
|
||||||
"RE-2026-0815",
|
|
||||||
LocalDate.of(2026, 7, 8),
|
|
||||||
LocalDate.of(2026, 7, 1),
|
|
||||||
LocalDate.of(2026, 7, 22),
|
|
||||||
"EUR",
|
|
||||||
"Zahlbar innerhalb von 14 Tagen ohne Abzug.",
|
|
||||||
"KR-2026-042",
|
|
||||||
"DE75512108001245126199",
|
|
||||||
null,
|
|
||||||
new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14",
|
|
||||||
"21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com",
|
|
||||||
"+49 40 18 123 771 0"),
|
|
||||||
new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null,
|
|
||||||
"einkauf@example.com", null),
|
|
||||||
List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE,
|
|
||||||
new BigDecimal("1500.00"), new BigDecimal("19")))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSamplePdf() throws Exception {
|
|
||||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
||||||
PDPage page = new PDPage();
|
|
||||||
doc.addPage(page);
|
|
||||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
|
||||||
// PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei
|
|
||||||
PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream(
|
|
||||||
"/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true);
|
|
||||||
cs.beginText();
|
|
||||||
cs.setFont(font, 12);
|
|
||||||
cs.newLineAtOffset(50, 700);
|
|
||||||
cs.showText("Rechnung RE-2026-0815");
|
|
||||||
cs.endText();
|
|
||||||
}
|
|
||||||
doc.save(out);
|
|
||||||
return out.toByteArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, byte[]> readZip(byte[] zip) throws Exception {
|
|
||||||
Map<String, byte[]> entries = new HashMap<>();
|
|
||||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
|
||||||
ZipEntry entry;
|
|
||||||
while ((entry = zis.getNextEntry()) != null) {
|
|
||||||
entries.put(entry.getName(), zis.readAllBytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// This TypeScript configuration file is generated by vaadin-maven-plugin.
|
||||||
|
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
|
||||||
|
// It is recommended to commit this file to the VCS.
|
||||||
|
// You might want to change the configurations to fit your preferences
|
||||||
|
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
|
||||||
|
{
|
||||||
|
"_version": "9.1",
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "esNext",
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"ignoreDeprecations": "5.0",
|
||||||
|
"baseUrl": "src/main/frontend",
|
||||||
|
"paths": {
|
||||||
|
"@vaadin/flow-frontend": ["generated/jar-resources"],
|
||||||
|
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
|
||||||
|
"Frontend/*": ["*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/main/frontend/**/*",
|
||||||
|
"types.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/main/frontend/generated/jar-resources/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
|
||||||
|
// You can not directly import your different static files into TypeScript,
|
||||||
|
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
|
||||||
|
// It is recommended to commit this file to the VCS.
|
||||||
|
// You might want to change the configurations to fit your preferences
|
||||||
|
declare module '*.css?inline' {
|
||||||
|
import type { CSSResultGroup } from 'lit';
|
||||||
|
const content: CSSResultGroup;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow any CSS Custom Properties
|
||||||
|
declare module 'csstype' {
|
||||||
|
interface Properties {
|
||||||
|
[index: `--${string}`]: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { UserConfigFn } from 'vite';
|
||||||
|
import { overrideVaadinConfig } from './vite.generated';
|
||||||
|
|
||||||
|
const customConfig: UserConfigFn = (env) => ({
|
||||||
|
// Here you can add custom Vite parameters
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
});
|
||||||
|
|
||||||
|
export default overrideVaadinConfig(customConfig);
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
/**
|
||||||
|
* NOTICE: this is an auto-generated file
|
||||||
|
*
|
||||||
|
* This file has been generated by the `flow:prepare-frontend` maven goal.
|
||||||
|
* This file will be overwritten on every run. Any custom changes should be made to vite.config.ts
|
||||||
|
*/
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, Stats } from 'fs';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
import { processThemeResources } from './target/plugins/application-theme-plugin/theme-handle.js';
|
||||||
|
import { rewriteCssUrls } from './target/plugins/theme-loader/theme-loader-utils.js';
|
||||||
|
import { addFunctionComponentSourceLocationBabel } from './target/plugins/react-function-location-plugin/react-function-location-plugin.js';
|
||||||
|
import settings from './target/vaadin-dev-server-settings.json';
|
||||||
|
import {
|
||||||
|
AssetInfo,
|
||||||
|
ChunkInfo,
|
||||||
|
build,
|
||||||
|
defineConfig,
|
||||||
|
mergeConfig,
|
||||||
|
OutputOptions,
|
||||||
|
PluginOption,
|
||||||
|
InlineConfig,
|
||||||
|
UserConfigFn
|
||||||
|
} from 'vite';
|
||||||
|
import { getManifest, type ManifestTransform } from 'workbox-build';
|
||||||
|
|
||||||
|
import * as rollup from 'rollup';
|
||||||
|
import brotli from 'rollup-plugin-brotli';
|
||||||
|
import checker from 'vite-plugin-checker';
|
||||||
|
import postcssLit from './target/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
|
import reactPlugin from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Make `require` compatible with ES modules
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const appShellUrl = '.';
|
||||||
|
|
||||||
|
const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
|
||||||
|
const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
|
||||||
|
const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput);
|
||||||
|
const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput);
|
||||||
|
const devBundle = !!process.env.devBundle;
|
||||||
|
const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder);
|
||||||
|
const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder);
|
||||||
|
const projectPackageJsonFile = path.resolve(__dirname, 'package.json');
|
||||||
|
|
||||||
|
const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder;
|
||||||
|
const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput);
|
||||||
|
const statsFile = path.resolve(statsFolder, 'stats.json');
|
||||||
|
const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html');
|
||||||
|
const nodeModulesFolder = path.resolve(__dirname, 'node_modules');
|
||||||
|
const webComponentTags = '';
|
||||||
|
|
||||||
|
const projectIndexHtml = path.resolve(frontendFolder, 'index.html');
|
||||||
|
|
||||||
|
const projectStaticAssetsFolders = [
|
||||||
|
path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'),
|
||||||
|
path.resolve(__dirname, 'src', 'main', 'resources', 'static'),
|
||||||
|
frontendFolder
|
||||||
|
];
|
||||||
|
|
||||||
|
// Folders in the project which can contain application themes
|
||||||
|
const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.resolve(folder, settings.themeFolder));
|
||||||
|
|
||||||
|
const themeOptions = {
|
||||||
|
devMode: false,
|
||||||
|
useDevBundle: devBundle,
|
||||||
|
// The following matches folder 'frontend/generated/themes/'
|
||||||
|
// (not 'frontend/themes') for theme in JAR that is copied there
|
||||||
|
themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder),
|
||||||
|
themeProjectFolders: themeProjectFolders,
|
||||||
|
projectStaticAssetsOutputFolder: devBundle
|
||||||
|
? path.resolve(devBundleFolder, '../assets')
|
||||||
|
: path.resolve(__dirname, settings.staticOutput),
|
||||||
|
frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder)
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
|
||||||
|
|
||||||
|
// Block debug and trace logs.
|
||||||
|
console.trace = () => {};
|
||||||
|
console.debug = () => {};
|
||||||
|
|
||||||
|
function injectManifestToSWPlugin(): rollup.Plugin {
|
||||||
|
const rewriteManifestIndexHtmlUrl: ManifestTransform = (manifest) => {
|
||||||
|
const indexEntry = manifest.find((entry) => entry.url === 'index.html');
|
||||||
|
if (indexEntry) {
|
||||||
|
indexEntry.url = appShellUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { manifest, warnings: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vaadin:inject-manifest-to-sw',
|
||||||
|
async transform(code, id) {
|
||||||
|
if (/sw\.(ts|js)$/.test(id)) {
|
||||||
|
const { manifestEntries } = await getManifest({
|
||||||
|
globDirectory: buildOutputFolder,
|
||||||
|
globPatterns: ['**/*'],
|
||||||
|
globIgnores: ['**/*.br', 'pwa-icons/**'],
|
||||||
|
manifestTransforms: [rewriteManifestIndexHtmlUrl],
|
||||||
|
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb,
|
||||||
|
});
|
||||||
|
|
||||||
|
return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSWPlugin(opts: { devMode: boolean }): PluginOption {
|
||||||
|
let buildConfig: InlineConfig;
|
||||||
|
let buildOutput: rollup.RollupOutput;
|
||||||
|
const devMode = opts.devMode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vaadin:build-sw',
|
||||||
|
enforce: 'post',
|
||||||
|
async configResolved(viteConfig) {
|
||||||
|
buildConfig = {
|
||||||
|
base: viteConfig.base,
|
||||||
|
root: viteConfig.root,
|
||||||
|
mode: viteConfig.mode,
|
||||||
|
resolve: viteConfig.resolve,
|
||||||
|
define: {
|
||||||
|
...viteConfig.define,
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(viteConfig.mode),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
write: !devMode,
|
||||||
|
minify: viteConfig.build.minify,
|
||||||
|
outDir: viteConfig.build.outDir,
|
||||||
|
sourcemap: viteConfig.command === 'serve' || viteConfig.build.sourcemap,
|
||||||
|
emptyOutDir: false,
|
||||||
|
modulePreload: false,
|
||||||
|
target: ['safari15', 'es2022'],
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
sw: settings.clientServiceWorkerSource
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
exports: 'none',
|
||||||
|
entryFileNames: 'sw.js',
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async buildStart() {
|
||||||
|
if (devMode) {
|
||||||
|
buildOutput = await build(buildConfig) as rollup.RollupOutput;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(id) {
|
||||||
|
if (id.endsWith('sw.js')) {
|
||||||
|
return buildOutput.output[0].code;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async closeBundle() {
|
||||||
|
if (!devMode) {
|
||||||
|
await build({
|
||||||
|
...buildConfig,
|
||||||
|
plugins: [injectManifestToSWPlugin(), brotli()]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsExtracterPlugin(): PluginOption {
|
||||||
|
function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) {
|
||||||
|
const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json');
|
||||||
|
if (existsSync(themeJson)) {
|
||||||
|
const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
|
||||||
|
themeJsonContents[themeName] = themeJsonContent;
|
||||||
|
const themeJsonObject = JSON.parse(themeJsonContent);
|
||||||
|
if (themeJsonObject.parent) {
|
||||||
|
collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vaadin:stats',
|
||||||
|
enforce: 'post',
|
||||||
|
async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) {
|
||||||
|
const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : []));
|
||||||
|
const nodeModulesFolders = modules
|
||||||
|
.map((id) => id.replace(/\\/g, '/'))
|
||||||
|
.filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/')))
|
||||||
|
.map((id) => id.substring(nodeModulesFolder.length + 1));
|
||||||
|
const npmModules = nodeModulesFolders
|
||||||
|
.map((id) => id.replace(/\\/g, '/'))
|
||||||
|
.map((id) => {
|
||||||
|
const parts = id.split('/');
|
||||||
|
if (id.startsWith('@')) {
|
||||||
|
return parts[0] + '/' + parts[1];
|
||||||
|
} else {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.filter((value, index, self) => self.indexOf(value) === index);
|
||||||
|
const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)]));
|
||||||
|
const cvdls = Object.fromEntries(
|
||||||
|
npmModules
|
||||||
|
.filter((module) => getCvdlName(module) != null)
|
||||||
|
.map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }])
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(statsFile), { recursive: true });
|
||||||
|
const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' }));
|
||||||
|
|
||||||
|
const entryScripts = Object.values(bundle)
|
||||||
|
.filter((bundle) => bundle.isEntry)
|
||||||
|
.map((bundle) => bundle.fileName);
|
||||||
|
|
||||||
|
const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html');
|
||||||
|
const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' });
|
||||||
|
const generatedIndexData: string = readFileSync(generatedIndexHtml, {
|
||||||
|
encoding: 'utf-8'
|
||||||
|
});
|
||||||
|
|
||||||
|
const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''));
|
||||||
|
const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '');
|
||||||
|
|
||||||
|
const rowsGenerated: string[] = [];
|
||||||
|
generatedIndexRows.forEach((row) => {
|
||||||
|
if (!customIndexRows.has(row)) {
|
||||||
|
rowsGenerated.push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport
|
||||||
|
|
||||||
|
const parseImports = (filename: string, result: Set<string>): void => {
|
||||||
|
const content: string = readFileSync(filename, { encoding: 'utf-8' });
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const staticImports = lines
|
||||||
|
.filter((line) => line.startsWith('import '))
|
||||||
|
.map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'")))
|
||||||
|
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
|
||||||
|
const dynamicImports = lines
|
||||||
|
.filter((line) => line.includes('import('))
|
||||||
|
.map((line) => line.replace(/.*import\(/, ''))
|
||||||
|
.map((line) => line.split(/'/)[1])
|
||||||
|
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
|
||||||
|
|
||||||
|
staticImports.forEach((staticImport) => result.add(staticImport));
|
||||||
|
|
||||||
|
dynamicImports.map((dynamicImport) => {
|
||||||
|
const importedFile = path.resolve(path.dirname(filename), dynamicImport);
|
||||||
|
parseImports(importedFile, result);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatedImportsSet = new Set<string>();
|
||||||
|
parseImports(
|
||||||
|
path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'),
|
||||||
|
generatedImportsSet
|
||||||
|
);
|
||||||
|
const generatedImports = Array.from(generatedImportsSet).sort();
|
||||||
|
|
||||||
|
const frontendFiles: Record<string, string> = {};
|
||||||
|
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
|
||||||
|
|
||||||
|
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
|
||||||
|
|
||||||
|
const isThemeComponentsResource = (id: string) =>
|
||||||
|
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
|
||||||
|
&& id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//);
|
||||||
|
|
||||||
|
const isGeneratedWebComponentResource = (id: string) =>
|
||||||
|
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
|
||||||
|
&& id.match(/.*\/flow\/web-components\//);
|
||||||
|
|
||||||
|
const isFrontendResourceCollected = (id: string) =>
|
||||||
|
!id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
|
||||||
|
|| isThemeComponentsResource(id)
|
||||||
|
|| isGeneratedWebComponentResource(id);
|
||||||
|
|
||||||
|
// collects project's frontend resources in frontend folder, excluding
|
||||||
|
// 'generated' sub-folder, except for legacy shadow DOM stylesheets
|
||||||
|
// packaged in `theme/components/` folder
|
||||||
|
// and generated web component resources in `flow/web-components` folder.
|
||||||
|
modules
|
||||||
|
.map((id) => id.replace(/\\/g, '/'))
|
||||||
|
.filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/')))
|
||||||
|
.filter(isFrontendResourceCollected)
|
||||||
|
.map((id) => id.substring(frontendFolder.length + 1))
|
||||||
|
.map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line))
|
||||||
|
.forEach((line: string) => {
|
||||||
|
// \r\n from windows made files may be used so change to \n
|
||||||
|
const filePath = path.resolve(frontendFolder, line);
|
||||||
|
if (projectFileExtensions.includes(path.extname(filePath))) {
|
||||||
|
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
|
||||||
|
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// collects frontend resources from the JARs
|
||||||
|
generatedImports
|
||||||
|
.filter((line: string) => line.includes('generated/jar-resources'))
|
||||||
|
.forEach((line: string) => {
|
||||||
|
let filename = line.substring(line.indexOf('generated'));
|
||||||
|
// \r\n from windows made files may be used ro remove to be only \n
|
||||||
|
const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace(
|
||||||
|
/\r\n/g,
|
||||||
|
'\n'
|
||||||
|
);
|
||||||
|
const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
const fileKey = line.substring(line.indexOf('jar-resources/') + 14);
|
||||||
|
frontendFiles[fileKey] = hash;
|
||||||
|
});
|
||||||
|
// collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/
|
||||||
|
// and files already in frontendFiles.
|
||||||
|
let frontendFolderAlias = "Frontend";
|
||||||
|
generatedImports
|
||||||
|
.filter((line: string) => line.startsWith(frontendFolderAlias + '/'))
|
||||||
|
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/'))
|
||||||
|
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/'))
|
||||||
|
.map((line) => line.substring(frontendFolderAlias.length + 1))
|
||||||
|
.filter((line: string) => !frontendFiles[line])
|
||||||
|
.forEach((line: string) => {
|
||||||
|
const filePath = path.resolve(frontendFolder, line);
|
||||||
|
if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) {
|
||||||
|
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
|
||||||
|
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// If a index.ts exists hash it to be able to see if it changes.
|
||||||
|
if (existsSync(path.resolve(frontendFolder, 'index.ts'))) {
|
||||||
|
const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace(
|
||||||
|
/\r\n/g,
|
||||||
|
'\n'
|
||||||
|
);
|
||||||
|
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeJsonContents: Record<string, string> = {};
|
||||||
|
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
|
||||||
|
if (existsSync(themesFolder)) {
|
||||||
|
readdirSync(themesFolder).forEach((themeFolder) => {
|
||||||
|
const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json');
|
||||||
|
if (existsSync(themeJson)) {
|
||||||
|
themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace(
|
||||||
|
/\r\n/g,
|
||||||
|
'\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collectThemeJsonsInFrontend(themeJsonContents, settings.themeName);
|
||||||
|
|
||||||
|
let webComponents: string[] = [];
|
||||||
|
if (webComponentTags) {
|
||||||
|
webComponents = webComponentTags.split(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
packageJsonDependencies: projectPackageJson.dependencies,
|
||||||
|
npmModules: npmModuleAndVersion,
|
||||||
|
bundleImports: generatedImports,
|
||||||
|
frontendHashes: frontendFiles,
|
||||||
|
themeJsonContents: themeJsonContents,
|
||||||
|
entryScripts,
|
||||||
|
webComponents,
|
||||||
|
cvdlModules: cvdls,
|
||||||
|
packageJsonHash: projectPackageJson?.vaadin?.hash,
|
||||||
|
indexHtmlGenerated: rowsGenerated
|
||||||
|
};
|
||||||
|
writeFileSync(statsFile, JSON.stringify(stats, null, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function vaadinBundlesPlugin(): PluginOption {
|
||||||
|
type ExportInfo =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
namespace?: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExposeInfo = {
|
||||||
|
exports: ExportInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PackageInfo = {
|
||||||
|
version: string;
|
||||||
|
exposes: Record<string, ExposeInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BundleJson = {
|
||||||
|
packages: Record<string, PackageInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledMessage = 'Vaadin component dependency bundles are disabled.';
|
||||||
|
|
||||||
|
const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
let vaadinBundleJson: BundleJson;
|
||||||
|
|
||||||
|
function parseModuleId(id: string): { packageName: string; modulePath: string } {
|
||||||
|
const [scope, scopedPackageName] = id.split('/', 3);
|
||||||
|
const packageName = scope.startsWith('@') ? `${scope}/${scopedPackageName}` : scope;
|
||||||
|
const modulePath = `.${id.substring(packageName.length)}`;
|
||||||
|
return {
|
||||||
|
packageName,
|
||||||
|
modulePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExports(id: string): string[] | undefined {
|
||||||
|
const { packageName, modulePath } = parseModuleId(id);
|
||||||
|
const packageInfo = vaadinBundleJson.packages[packageName];
|
||||||
|
|
||||||
|
if (!packageInfo) return;
|
||||||
|
|
||||||
|
const exposeInfo: ExposeInfo = packageInfo.exposes[modulePath];
|
||||||
|
if (!exposeInfo) return;
|
||||||
|
|
||||||
|
const exportsSet = new Set<string>();
|
||||||
|
for (const e of exposeInfo.exports) {
|
||||||
|
if (typeof e === 'string') {
|
||||||
|
exportsSet.add(e);
|
||||||
|
} else {
|
||||||
|
const { namespace, source } = e;
|
||||||
|
if (namespace) {
|
||||||
|
exportsSet.add(namespace);
|
||||||
|
} else {
|
||||||
|
const sourceExports = getExports(source);
|
||||||
|
if (sourceExports) {
|
||||||
|
sourceExports.forEach((e) => exportsSet.add(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(exportsSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExportBinding(binding: string) {
|
||||||
|
return binding === 'default' ? '_default as default' : binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportAssigment(binding: string) {
|
||||||
|
return binding === 'default' ? 'default: _default' : binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vaadin:bundles',
|
||||||
|
enforce: 'pre',
|
||||||
|
apply(config, { command }) {
|
||||||
|
if (command !== 'serve') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vaadinBundleJsonPath = require.resolve('@vaadin/bundles/vaadin-bundle.json');
|
||||||
|
vaadinBundleJson = JSON.parse(readFileSync(vaadinBundleJsonPath, { encoding: 'utf8' }));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (typeof e === 'object' && (e as { code: string }).code === 'MODULE_NOT_FOUND') {
|
||||||
|
vaadinBundleJson = { packages: {} };
|
||||||
|
console.info(`@vaadin/bundles npm package is not found, ${disabledMessage}`);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionMismatches: Array<{ name: string; bundledVersion: string; installedVersion: string }> = [];
|
||||||
|
for (const [name, packageInfo] of Object.entries(vaadinBundleJson.packages)) {
|
||||||
|
let installedVersion: string | undefined = undefined;
|
||||||
|
try {
|
||||||
|
const { version: bundledVersion } = packageInfo;
|
||||||
|
const installedPackageJsonFile = path.resolve(modulesDirectory, name, 'package.json');
|
||||||
|
const packageJson = JSON.parse(readFileSync(installedPackageJsonFile, { encoding: 'utf8' }));
|
||||||
|
installedVersion = packageJson.version;
|
||||||
|
if (installedVersion && installedVersion !== bundledVersion) {
|
||||||
|
versionMismatches.push({
|
||||||
|
name,
|
||||||
|
bundledVersion,
|
||||||
|
installedVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore package not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (versionMismatches.length) {
|
||||||
|
console.info(`@vaadin/bundles has version mismatches with installed packages, ${disabledMessage}`);
|
||||||
|
console.info(`Packages with version mismatches: ${JSON.stringify(versionMismatches, undefined, 2)}`);
|
||||||
|
vaadinBundleJson = { packages: {} };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async config(config) {
|
||||||
|
return mergeConfig(
|
||||||
|
{
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: [
|
||||||
|
// Vaadin bundle
|
||||||
|
'@vaadin/bundles',
|
||||||
|
...Object.keys(vaadinBundleJson.packages),
|
||||||
|
'@vaadin/vaadin-material-styles'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
},
|
||||||
|
load(rawId) {
|
||||||
|
const [path, params] = rawId.split('?');
|
||||||
|
if (!path.startsWith(modulesDirectory)) return;
|
||||||
|
|
||||||
|
const id = path.substring(modulesDirectory.length + 1);
|
||||||
|
const bindings = getExports(id);
|
||||||
|
if (bindings === undefined) return;
|
||||||
|
|
||||||
|
const cacheSuffix = params ? `?${params}` : '';
|
||||||
|
const bundlePath = `@vaadin/bundles/vaadin.js${cacheSuffix}`;
|
||||||
|
|
||||||
|
return `import { init as VaadinBundleInit, get as VaadinBundleGet } from '${bundlePath}';
|
||||||
|
await VaadinBundleInit('default');
|
||||||
|
const { ${bindings.map(getImportAssigment).join(', ')} } = (await VaadinBundleGet('./node_modules/${id}'))();
|
||||||
|
export { ${bindings.map(getExportBinding).join(', ')} };`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function themePlugin(opts: { devMode: boolean }): PluginOption {
|
||||||
|
const fullThemeOptions = { ...themeOptions, devMode: opts.devMode };
|
||||||
|
return {
|
||||||
|
name: 'vaadin:theme',
|
||||||
|
config() {
|
||||||
|
processThemeResources(fullThemeOptions, console);
|
||||||
|
},
|
||||||
|
configureServer(server) {
|
||||||
|
function handleThemeFileCreateDelete(themeFile: string, stats?: Stats) {
|
||||||
|
if (themeFile.startsWith(themeFolder)) {
|
||||||
|
const changed = path.relative(themeFolder, themeFile);
|
||||||
|
console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed);
|
||||||
|
processThemeResources(fullThemeOptions, console);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.watcher.on('add', handleThemeFileCreateDelete);
|
||||||
|
server.watcher.on('unlink', handleThemeFileCreateDelete);
|
||||||
|
},
|
||||||
|
handleHotUpdate(context) {
|
||||||
|
const contextPath = path.resolve(context.file);
|
||||||
|
const themePath = path.resolve(themeFolder);
|
||||||
|
if (contextPath.startsWith(themePath)) {
|
||||||
|
const changed = path.relative(themePath, contextPath);
|
||||||
|
|
||||||
|
console.debug('Theme file changed', changed);
|
||||||
|
|
||||||
|
if (changed.startsWith(settings.themeName)) {
|
||||||
|
processThemeResources(fullThemeOptions, console);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async resolveId(id, importer) {
|
||||||
|
// force theme generation if generated theme sources does not yet exist
|
||||||
|
// this may happen for example during Java hot reload when updating
|
||||||
|
// @Theme annotation value
|
||||||
|
if (
|
||||||
|
path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer &&
|
||||||
|
!existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id))
|
||||||
|
) {
|
||||||
|
console.debug('Generate theme file ' + id + ' not existing. Processing theme resource');
|
||||||
|
processThemeResources(fullThemeOptions, console);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!id.startsWith(settings.themeFolder)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const location of [themeResourceFolder, frontendFolder]) {
|
||||||
|
const result = await this.resolve(path.resolve(location, id));
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async transform(raw, id, options) {
|
||||||
|
// rewrite urls for the application theme css files
|
||||||
|
const [bareId, query] = id.split('?');
|
||||||
|
if (
|
||||||
|
(!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) ||
|
||||||
|
!bareId?.endsWith('.css')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceThemeFolder = bareId.startsWith(themeFolder) ? themeFolder : themeOptions.themeResourceFolder;
|
||||||
|
const [themeName] = bareId.substring(resourceThemeFolder.length + 1).split('/');
|
||||||
|
return rewriteCssUrls(raw, path.dirname(bareId), path.resolve(resourceThemeFolder, themeName), console, opts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWatchDog(watchDogPort: number, watchDogHost: string | undefined) {
|
||||||
|
const client = new net.Socket();
|
||||||
|
client.setEncoding('utf8');
|
||||||
|
client.on('error', function (err) {
|
||||||
|
console.log('Watchdog connection error. Terminating vite process...', err);
|
||||||
|
client.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
client.on('close', function () {
|
||||||
|
client.destroy();
|
||||||
|
runWatchDog(watchDogPort, watchDogHost);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect(watchDogPort, watchDogHost || 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFrontendFolders = [frontendFolder, nodeModulesFolder];
|
||||||
|
|
||||||
|
function showRecompileReason(): PluginOption {
|
||||||
|
return {
|
||||||
|
name: 'vaadin:why-you-compile',
|
||||||
|
handleHotUpdate(context) {
|
||||||
|
console.log('Recompiling because', context.file, 'changed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/;
|
||||||
|
const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i;
|
||||||
|
|
||||||
|
function preserveUsageStats() {
|
||||||
|
return {
|
||||||
|
name: 'vaadin:preserve-usage-stats',
|
||||||
|
|
||||||
|
transform(src: string, id: string) {
|
||||||
|
if (id.includes('vaadin-usage-statistics')) {
|
||||||
|
if (src.includes('vaadin-dev-mode:start')) {
|
||||||
|
const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start');
|
||||||
|
if (newSrc === src) {
|
||||||
|
console.error('Comment replacement failed to change anything');
|
||||||
|
} else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) {
|
||||||
|
console.error('New comment fails to match original regexp');
|
||||||
|
} else {
|
||||||
|
return { code: newSrc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: src };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vaadinConfig: UserConfigFn = (env) => {
|
||||||
|
const devMode = env.mode === 'development';
|
||||||
|
const productionMode = !devMode && !devBundle
|
||||||
|
|
||||||
|
if (devMode && process.env.watchDogPort) {
|
||||||
|
// Open a connection with the Java dev-mode handler in order to finish
|
||||||
|
// vite when it exits or crashes.
|
||||||
|
runWatchDog(parseInt(process.env.watchDogPort), process.env.watchDogHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: frontendFolder,
|
||||||
|
base: '',
|
||||||
|
publicDir: false,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@vaadin/flow-frontend': jarResourcesFolder,
|
||||||
|
Frontend: frontendFolder
|
||||||
|
},
|
||||||
|
preserveSymlinks: true
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
OFFLINE_PATH: settings.offlinePath,
|
||||||
|
VITE_ENABLED: 'true'
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
strictPort: true,
|
||||||
|
fs: {
|
||||||
|
allow: allowedFrontendFolders
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
minify: productionMode,
|
||||||
|
outDir: buildOutputFolder,
|
||||||
|
emptyOutDir: devBundle,
|
||||||
|
assetsDir: 'VAADIN/build',
|
||||||
|
target: ['safari15', 'es2022'],
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
indexhtml: projectIndexHtml,
|
||||||
|
|
||||||
|
...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {})
|
||||||
|
},
|
||||||
|
onwarn: (warning: rollup.RollupLog, defaultHandler: rollup.LoggingFunction) => {
|
||||||
|
const ignoreEvalWarning = [
|
||||||
|
'generated/jar-resources/FlowClient.js',
|
||||||
|
'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js',
|
||||||
|
'@vaadin/charts/src/helpers.js'
|
||||||
|
];
|
||||||
|
if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id?.endsWith(id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defaultHandler(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
entries: [
|
||||||
|
// Pre-scan entrypoints in Vite to avoid reloading on first open
|
||||||
|
'generated/vaadin.ts'
|
||||||
|
],
|
||||||
|
exclude: [
|
||||||
|
'@vaadin/router',
|
||||||
|
'@vaadin/vaadin-license-checker',
|
||||||
|
'@vaadin/vaadin-usage-statistics',
|
||||||
|
'workbox-core',
|
||||||
|
'workbox-precaching',
|
||||||
|
'workbox-routing',
|
||||||
|
'workbox-strategies'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
productionMode && brotli(),
|
||||||
|
devMode && vaadinBundlesPlugin(),
|
||||||
|
devMode && showRecompileReason(),
|
||||||
|
settings.offlineEnabled && buildSWPlugin({ devMode }),
|
||||||
|
!devMode && statsExtracterPlugin(),
|
||||||
|
!productionMode && preserveUsageStats(),
|
||||||
|
themePlugin({ devMode }),
|
||||||
|
postcssLit({
|
||||||
|
include: ['**/*.css', /.*\/.*\.css\?.*/],
|
||||||
|
exclude: [
|
||||||
|
`${themeFolder}/**/*.css`,
|
||||||
|
new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`),
|
||||||
|
`${themeResourceFolder}/**/*.css`,
|
||||||
|
new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`),
|
||||||
|
new RegExp('.*/.*\\?html-proxy.*')
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
// The React plugin provides fast refresh and debug source info
|
||||||
|
reactPlugin({
|
||||||
|
include: '**/*.tsx',
|
||||||
|
babel: {
|
||||||
|
// We need to use babel to provide the source information for it to be correct
|
||||||
|
// (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file)
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'@babel/preset-react',
|
||||||
|
{
|
||||||
|
runtime: 'automatic',
|
||||||
|
importSource: productionMode ? 'react' : 'Frontend/generated/jsx-dev-transform',
|
||||||
|
development: !productionMode
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
// React writes the source location for where components are used, this writes for where they are defined
|
||||||
|
plugins: [
|
||||||
|
!productionMode && addFunctionComponentSourceLocationBabel(),
|
||||||
|
[
|
||||||
|
'module:@preact/signals-react-transform',
|
||||||
|
{
|
||||||
|
mode: 'all' // Needed to include translations which do not use something.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'vaadin:force-remove-html-middleware',
|
||||||
|
configureServer(server) {
|
||||||
|
return () => {
|
||||||
|
server.middlewares.stack = server.middlewares.stack.filter((mw) => {
|
||||||
|
const handleName = `${mw.handle}`;
|
||||||
|
return !handleName.includes('viteHtmlFallbackMiddleware');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasExportedWebComponents && {
|
||||||
|
name: 'vaadin:inject-entrypoints-to-web-component-html',
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: 'pre',
|
||||||
|
handler(_html, { path, server }) {
|
||||||
|
if (path !== '/web-component.html') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
|
||||||
|
injectTo: 'head'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vaadin:inject-entrypoints-to-index-html',
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: 'pre',
|
||||||
|
handler(_html, { path, server }) {
|
||||||
|
if (path !== '/index.html') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = [];
|
||||||
|
|
||||||
|
if (devMode) {
|
||||||
|
scripts.push({
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module', src: `/generated/vite-devmode.ts`, onerror: "document.location.reload()" },
|
||||||
|
injectTo: 'head'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scripts.push({
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module', src: '/generated/vaadin.ts' },
|
||||||
|
injectTo: 'head'
|
||||||
|
});
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checker({
|
||||||
|
typescript: true
|
||||||
|
}),
|
||||||
|
productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile })
|
||||||
|
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const overrideVaadinConfig = (customConfig: UserConfigFn) => {
|
||||||
|
return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env)));
|
||||||
|
};
|
||||||
|
function getVersion(module: string): string {
|
||||||
|
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
|
||||||
|
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version;
|
||||||
|
}
|
||||||
|
function getCvdlName(module: string): string {
|
||||||
|
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
|
||||||
|
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user