diff --git a/HANDBUCH.md b/HANDBUCH.md new file mode 100644 index 0000000..0d15159 --- /dev/null +++ b/HANDBUCH.md @@ -0,0 +1,441 @@ +# VotianLT - Benutzerhandbuch + +**Version:** 1.0 +**Stand:** Februar 2026 +**Anwendung:** VotianLT - Ihr digitaler Transportpartner + +--- + +## Inhaltsverzeichnis + +1. [Einleitung](#1-einleitung) +2. [Registrierung und Anmeldung](#2-registrierung-und-anmeldung) +3. [Dashboard](#3-dashboard) +4. [Auftragsverwaltung](#4-auftragsverwaltung) +5. [Kundenverwaltung](#5-kundenverwaltung) +6. [App-Nutzer verwalten](#6-app-nutzer-verwalten) +7. [Nachrichten](#7-nachrichten) +8. [Rechnungen](#8-rechnungen) +9. [KI-Statistiken](#9-ki-statistiken) +10. [Profil bearbeiten](#10-profil-bearbeiten) +11. [Administration](#11-administration) + +--- + +## 1. Einleitung + +VotianLT ist eine webbasierte Anwendung zur digitalen Verwaltung von Transportaufträgen. Die Software richtet sich an Solo-Selbstständige und Kleinunternehmer im Transportgewerbe und bietet folgende Kernfunktionen: + +- **Auftragserstellung und -verwaltung** mit vollständiger Abwicklung von der Erstellung bis zur Fertigstellung +- **Digitale Auftragsbearbeitung** über eine mobile App für Fahrer und Mitarbeiter im Außendienst +- **Echtzeit-Kommunikation** zwischen Disponent und App-Nutzern per Chat +- **Rechnungserstellung** für abgeschlossene Aufträge +- **KI-gestützte Statistiken** zur Auswertung von Geschäftsdaten +- **GPS-Tracking** zur Nachverfolgung von Lieferungen in Echtzeit + +--- + +## 2. Registrierung und Anmeldung + +### 2.1 Neues Konto registrieren + +1. Öffnen Sie die Startseite und klicken Sie auf **"Jetzt kostenlos testen"** oder **"Registrieren"**. +2. Füllen Sie das Registrierungsformular aus: + - **Firmenname** (Pflichtfeld) + - **E-Mail-Adresse** (wird als Benutzername verwendet) + - **Passwort** und **Passwort bestätigen** (mindestens 6 Zeichen) + - **Vorname** und **Nachname** + - **Telefonnummer** + - **Anschrift** (Straße, Hausnummer, PLZ, Ort) +3. Klicken Sie auf **"Registrieren"**. Ein 6-stelliger Bestätigungscode wird an Ihre E-Mail-Adresse gesendet. +4. Geben Sie den Code im angezeigten Feld ein und klicken Sie auf **"Code prüfen und registrieren"**. +5. Nach erfolgreicher Registrierung werden Sie zur Anmeldeseite weitergeleitet. + +> **Hinweis:** Der Bestätigungscode ist 10 Minuten gültig. Über den Button **"Code erneut senden"** können Sie nach 60 Sekunden einen neuen Code anfordern. + +### 2.2 Anmelden + +1. Geben Sie auf der Anmeldeseite Ihre **E-Mail-Adresse** und Ihr **Passwort** ein. +2. Klicken Sie auf **"Anmelden"**. +3. Falls die Zwei-Faktor-Authentifizierung (2FA) aktiviert ist, wird ein 6-stelliger Code an Ihre E-Mail gesendet. Geben Sie diesen im angezeigten Feld ein und klicken Sie auf **"Code prüfen"**. + +### 2.3 Passwort zurücksetzen + +1. Klicken Sie auf der Anmeldeseite auf **"Passwort vergessen?"**. +2. Geben Sie Ihre registrierte E-Mail-Adresse ein und klicken Sie auf **"E-Mail senden"**. +3. Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen. Klicken Sie auf den Link. +4. Vergeben Sie ein neues Passwort und bestätigen Sie es. Klicken Sie auf **"Passwort speichern"**. + +--- + +## 3. Dashboard + +Nach der Anmeldung gelangen Sie zum Dashboard. Es zeigt eine Willkommensnachricht und eine Übersicht der wichtigsten Funktionen: + +- **Einrichtungsassistent** - Hilfe beim Einstieg in die Anwendung +- **Kunden- und Auftragsverwaltung** - Schnellzugriff auf die Verwaltungsbereiche +- **Auftragserstellung** - Direkter Link zur Erstellung neuer Aufträge + +Die **Hauptnavigation** am linken Rand bietet Zugriff auf alle Bereiche der Anwendung. + +--- + +## 4. Auftragsverwaltung + +### 4.1 Aufträge anzeigen + +Öffnen Sie den Bereich **"Aufträge"** über die Navigation oder den Pfad `/jobs`. + +**Filtermöglichkeiten:** +- **Zeitraum**: Wählen Sie Start- und Enddatum (Standard: letzte 30 Tage) +- **Auftragsnummer**: Suchen Sie nach einer bestimmten Auftragsnummer +- **Status**: Filtern Sie nach "Alle", "Offen" oder "Erledigt" + - *Offen* umfasst: Erstellt, In Bearbeitung, Abholung geplant, Abgeholt, In Zustellung + - *Erledigt* umfasst: Zugestellt, Abgeschlossen, Storniert + +Klicken Sie auf **"Anwenden"**, um die Filter zu übernehmen. + +**Verfügbare Aktionen pro Auftrag:** + +| Symbol | Aktion | Sichtbar wenn | +|--------|--------|---------------| +| Häkchen | Auftrag manuell abschließen | Nicht-digitale Aufträge, die noch nicht abgeschlossen sind | +| Dollar | Rechnung erstellen | Auftrag ist abgeschlossen | +| Papierkorb | Auftrag löschen | Auftrag ist nicht abgeschlossen/storniert/zugestellt | + +**CSV-Export:** Klicken Sie auf **"CSV Export"**, um die aktuell gefilterte Auftragsliste als CSV-Datei herunterzuladen. + +Klicken Sie auf eine Zeile, um die **Auftragszusammenfassung** zu öffnen. + +### 4.2 Neuen Auftrag anlegen + +Navigieren Sie zu **"Auftragserstellung"** oder dem Pfad `/add_job`. Das Formular ist in fünf Tabs unterteilt: + +#### Tab 1: Auftraggeber & Adressen + +1. **Auftraggeber wählen**: Wählen Sie aus der Dropdown-Liste einen bestehenden Kunden. Die Abholadresse wird automatisch mit den Kundendaten vorbefüllt. +2. **Abholadresse**: Füllen Sie alle Pflichtfelder aus (Firma, Anrede, Vorname, Nachname, Telefon, Straße, Hausnummer, PLZ, Ort). Optional können Sie die Adresse für zukünftige Aufträge speichern. +3. **Zustelladresse**: Geben Sie die Lieferadresse ein. Firmennamen werden per Autovervollständigung vorgeschlagen. +4. Mit dem Button **"Vorbelegte Adressfelder leeren"** können Sie alle vorausgefüllten Felder zurücksetzen. + +> **Hinweis:** Beim Wechsel zum nächsten Tab werden die Adressen automatisch validiert und eine Routenberechnung (Entfernung und Dauer) durchgeführt. Bei ungültigen Adressen wird ein Hinweisdialog angezeigt. + +#### Tab 2: Termine & Verarbeitung + +1. **Abholtermin**: Wählen Sie Datum und Uhrzeit für die Abholung. +2. **Zustelltermin**: Wählen Sie Datum und Uhrzeit für die Zustellung. +3. **Digitale Abwicklung per App**: Aktivieren Sie diese Option, wenn der Auftrag über die mobile App abgewickelt werden soll. +4. **App-Nutzer**: Wählen Sie den zuständigen App-Nutzer aus (nur sichtbar bei digitaler Abwicklung). + +#### Tab 3: Ladung + +1. Klicken Sie auf **"Ladung hinzufügen"**, um eine neue Ladungsposition anzulegen. +2. Für jede Position geben Sie ein: + - **Beschreibung** der Ladung + - **Menge** + - **Maße** (Länge, Breite, Höhe in cm) + - **Gewicht** in kg +3. Über das Papierkorb-Symbol können Sie einzelne Positionen entfernen. + +#### Tab 4: Aufgaben (nur bei digitaler Abwicklung) + +Aufgaben definieren, welche Schritte der App-Nutzer bei der Ausführung des Auftrags erledigen muss. + +**Verfügbare Aufgabentypen:** + +| Typ | Beschreibung | +|-----|-------------| +| Bestätigung | Der App-Nutzer bestätigt eine Aktion (z.B. "Ware übernommen") | +| Unterschrift | Einholen einer digitalen Unterschrift | +| Foto | Aufnahme eines Fotos (z.B. Liefernachweis) | +| Barcode | Scannen eines Barcodes | +| Checkliste | Abarbeiten einer Aufgabenliste | +| Kommentar | Freitextfeld für Anmerkungen | + +1. Optional: Wählen Sie eine **Aufgabenvorlage** aus der Dropdown-Liste, um vordefinierte Aufgaben zu laden. +2. Klicken Sie auf **"Aufgabe hinzufügen"** und wählen Sie den gewünschten Typ. +3. Geben Sie eine Beschreibung für die Aufgabe ein. +4. Die Reihenfolge der Aufgaben kann per **Drag-and-Drop** angepasst werden. + +#### Tab 5: Leistungen und Preis + +1. **Pflichtleistungen** werden automatisch geladen. +2. Über die Dropdown-Liste können Sie weitere **Leistungen hinzufügen**. +3. Die Berechnung von **Netto**, **MwSt.** und **Brutto** erfolgt automatisch. +4. Die **Routeninformationen** (Entfernung und Fahrtdauer) werden aus der Adressvalidierung angezeigt. +5. Im Feld **Bemerkung** können Sie zusätzliche Anmerkungen zum Auftrag hinterlegen. + +Klicken Sie abschließend auf **"Auftrag anlegen"**, um den Auftrag zu erstellen. Bei digitaler Abwicklung wird der Auftrag automatisch an den ausgewählten App-Nutzer übermittelt. + +### 4.3 Auftragszusammenfassung + +Die Zusammenfassung zeigt alle Details eines Auftrags auf einen Blick: + +- **Abholinformationen**: Adresse, Ansprechpartner, Termin +- **Zustellinformationen**: Adresse, Ansprechpartner, Termin +- **Aufgaben**: Übersicht aller Aufgaben mit Erledigungsstatus. Abgeschlossene Aufgaben zeigen das Ergebnis (Foto, Barcode-Wert, Unterschrift-Vorschau etc.) +- **Ladung**: Liste aller Ladungspositionen mit Maßen und Gewicht +- **Auftragsinformationen**: Preis, Bemerkung, Verarbeitungsart, zugewiesener App-Nutzer +- **Routenkarte**: Google Maps mit der Route von Abhol- zu Zustelladresse. Bei aktiver digitaler Abwicklung wird die aktuelle **GPS-Position des App-Nutzers** in Echtzeit angezeigt. + +**Verfügbare Aktionen:** +- **"Nachricht senden"**: Öffnet den Chat mit dem zugewiesenen App-Nutzer im Kontext dieses Auftrags +- **"Job Historie"**: Zeigt den vollständigen Änderungsverlauf des Auftrags +- **"Auftrag manuell abschließen"**: Schließt nicht-digitale Aufträge manuell ab (nach Bestätigung) + +### 4.4 Auftragsverlauf (Job Historie) + +Die Job Historie zeigt eine chronologische Zeitleiste aller Ereignisse eines Auftrags: + +- **Statusänderungen** (z.B. "Erstellt", "In Bearbeitung", "Zugestellt") +- **Aufgabenabschlüsse** mit zugehörigen Daten (Fotos, Barcodes, Unterschriften) +- **Änderungen** an Auftragsdaten + +Jeder Eintrag zeigt den **Zeitstempel**, die **Art der Änderung** und ggf. **zugehörige Daten** (z.B. Foto-Vorschau). + +--- + +## 5. Kundenverwaltung + +### 5.1 Kunden anzeigen + +Navigieren Sie zu **"Kunden"** (`/customers`). Die Kundenliste zeigt alle Ihre Kunden mit folgenden Spalten: + +- Firma, Name, E-Mail, Telefon, Straße, Ort + +Die Liste ist **sortierbar** (Klick auf Spaltenüberschrift). Klicken Sie auf einen Eintrag, um den Kunden zu bearbeiten. + +### 5.2 Neuen Kunden anlegen + +1. Klicken Sie auf **"Kunde hinzufügen"** oder navigieren Sie zu `/add-customer`. +2. Füllen Sie das Formular aus: + - **Firmenname** (Pflichtfeld) + - **Anrede** (Herr / Frau / Divers) + - **Vorname** und **Nachname** (Pflichtfelder) + - **Telefon** (Pflichtfeld) + - **Fax** (optional) + - **E-Mail** (Pflichtfeld, wird auf Gültigkeit geprüft) + - **Straße** und **Hausnummer** (Pflichtfelder) + - **Adresszusatz** (optional) + - **PLZ** und **Ort** (Pflichtfelder) +3. Klicken Sie auf **"Kunden anlegen"**. + +### 5.3 Kunden bearbeiten + +1. Klicken Sie in der Kundenliste auf den gewünschten Kunden. +2. Ändern Sie die gewünschten Felder. +3. Klicken Sie auf **"Speichern"**, um die Änderungen zu übernehmen. +4. Über **"Löschen"** können Sie den Kunden nach Bestätigung endgültig entfernen. + +--- + +## 6. App-Nutzer verwalten + +App-Nutzer sind Fahrer oder Mitarbeiter, die die mobile VotianLT-App verwenden, um Aufträge digital abzuwickeln. + +### 6.1 App-Nutzer anzeigen + +Navigieren Sie zu **"App-Nutzer"** (`/app-user`). Die Liste zeigt alle Ihre App-Nutzer mit: + +- Bezeichnung (Kennzeichen/Code), Vorname, Nachname, Telefon, App-Code, E-Mail + +### 6.2 Neuen App-Nutzer anlegen + +1. Klicken Sie auf **"Neuen App-Nutzer anlegen"**. +2. Füllen Sie das Formular aus: + - **Bezeichnung** (z.B. Fahrzeugkennzeichen im Format "HH H 000", Pflichtfeld) + - **Vorname** und **Nachname** (Pflichtfelder) + - **Telefon** (Pflichtfeld) + - **E-Mail** (Pflichtfeld, muss eindeutig sein) + - **Passwort** und **Passwort bestätigen** (mindestens 6 Zeichen) +3. Klicken Sie auf **"App-Nutzer anlegen"**. + +> **Hinweis:** Der App-Nutzer meldet sich in der mobilen App mit seiner E-Mail-Adresse und dem vergebenen Passwort an. + +### 6.3 App-Nutzer bearbeiten + +1. Klicken Sie in der Liste auf den gewünschten App-Nutzer. +2. Ändern Sie die gewünschten Felder. +3. **Passwort ändern**: Lassen Sie die Passwortfelder leer, wenn das Passwort nicht geändert werden soll. Füllen Sie beide Felder aus, um ein neues Passwort zu vergeben. +4. Klicken Sie auf **"Speichern"**. +5. Über **"Löschen"** können Sie den App-Nutzer nach Bestätigung entfernen. + +--- + +## 7. Nachrichten + +Das Nachrichtensystem ermöglicht die Echtzeit-Kommunikation zwischen Ihnen (Disponent) und Ihren App-Nutzern. + +### 7.1 Nachrichtenübersicht + +Navigieren Sie zu **"Nachrichten"** (`/messages`). Die Übersicht zeigt alle Ihre App-Nutzer als Kontakte mit: + +- **Status-Anzeige** (farbiger Punkt bei ungelesenen Nachrichten) +- **Name** und **E-Mail** des App-Nutzers +- **Gesamtanzahl** der Nachrichten +- **Ungelesene Nachrichten** (fett hervorgehoben) +- **Letzte Nachricht** (Vorschau, Datum und Uhrzeit) + +> **Hinweis:** Die Übersicht aktualisiert sich automatisch. Bei eingehenden Nachrichten ertönt ein **Benachrichtigungston** und es wird eine **Browser-Benachrichtigung** angezeigt (sofern Benachrichtigungen im Browser erlaubt sind). + +### 7.2 Konversationen eines App-Nutzers + +Klicken Sie auf einen App-Nutzer, um dessen Konversationen zu sehen: + +- **Allgemeine Nachrichten**: Ein allgemeiner Chat ohne Auftragsbezug +- **Nachrichten zu Aufträgen**: Separate Konversationen für jeden Auftrag (gruppiert nach Auftragsnummer) + +Jede Konversation zeigt eine Vorschau, den Zeitpunkt der letzten Nachricht und die Anzahl ungelesener Nachrichten. + +### 7.3 Chat / Nachrichtenverlauf + +Öffnen Sie eine Konversation, um den Chatverlauf zu sehen. Die Darstellung ähnelt bekannten Messenger-Apps: + +- **Ihre Nachrichten** (vom Disponenten): rechts, grüner Hintergrund +- **Nachrichten des App-Nutzers**: links, weißer Hintergrund +- **Datumstrenner** zwischen verschiedenen Tagen +- **Zeitstempel** bei jeder Nachricht + +**Nachricht senden:** +1. Geben Sie Ihre Nachricht im Textfeld am unteren Rand ein. +2. Klicken Sie auf **"Senden"**. + +**Bild senden:** +1. Klicken Sie auf das **Büroklammer-Symbol** neben dem Textfeld. +2. Wählen Sie ein Bild aus (PNG, JPEG, GIF oder WebP, max. 32 MB). +3. Eine Vorschau wird angezeigt. Bestätigen Sie den Versand. + +> **Hinweis:** Neue Nachrichten erscheinen in Echtzeit. Die Ansicht scrollt automatisch zur neuesten Nachricht. Geöffnete Nachrichten werden automatisch als gelesen markiert. + +--- + +## 8. Rechnungen + +### 8.1 Rechnung für einen Auftrag erstellen + +1. Navigieren Sie zur **Auftragsliste** (`/jobs`). +2. Filtern Sie nach Status **"Erledigt"**. +3. Klicken Sie auf das **Dollar-Symbol** neben dem gewünschten Auftrag. +4. Das Rechnungsformular zeigt: + - **Auftragsdetails** (Abhol- und Zustelladresse, Termine) + - **Leistungsdaten**: Geben Sie Kilometer und Zeitaufwand ein + - **Leistungen**: Wählen Sie die abzurechnenden Leistungen aus Ihrem Leistungskatalog + - **Zusammenfassung**: Automatische Berechnung von Netto, MwSt. und Brutto +5. Klicken Sie auf **"Rechnung erstellen"**. + +### 8.2 Meine Rechnungen + +Unter **"Meine Rechnungen"** (`/my-invoices`) finden Sie eine Übersicht aller erstellten Rechnungen: + +- **Statusanzeige**: Offen (gelb), Bezahlt (grün), Überfällig (rot) +- **Rechnungsnummer**, **Datum**, **Betrag** +- **Suchfunktion** zum Filtern nach Rechnungsnummer, Status oder Betrag + +Klicken Sie auf eine Rechnung, um die **PDF-Version** herunterzuladen. + +--- + +## 9. KI-Statistiken + +Der KI-Statistik-Assistent (`/statistics`) bietet eine interaktive Auswertung Ihrer Geschäftsdaten per Chat-Schnittstelle. + +### Verwendung + +1. Navigieren Sie zu **"KI-Statistiken"**. +2. Verwenden Sie die **Schnellaktionen** für häufige Abfragen: + - **"Aufträge zählen"**: Zeigt die Gesamtanzahl Ihrer Aufträge + - **"Umsatz"**: Berechnet Ihren Gesamtumsatz + - **"Monatstrend"**: Zeigt die monatliche Entwicklung als Diagramm +3. Oder geben Sie eine **freie Frage** in das Textfeld ein, z.B.: + - "Wie viele Aufträge hatte ich im Januar?" + - "Zeige mir den Umsatz der letzten 6 Monate als Diagramm" + - "Welcher Kunde hat die meisten Aufträge?" + +Der Assistent analysiert Ihre Daten und antwortet mit **Text** und/oder **interaktiven Diagrammen** (Balken-, Linien-, Kreis- und weitere Diagrammtypen). + +--- + +## 10. Profil bearbeiten + +Unter **"Profil bearbeiten"** (`/edit-profile`) können Sie Ihre persönlichen Daten und Rechnungseinstellungen verwalten. Die Seite ist in drei Tabs unterteilt: + +### Tab 1: Persönliche Daten / Kontakt + +- **Firmendaten**: Firmenname (Pflicht), Firmenzusatz +- **Persönliche Daten**: Vorname, Nachname +- **Kontakt**: Telefon (Pflicht), Fax, Mobil, E-Mail (Pflicht) +- **Anschrift**: Straße, Hausnummer (Pflicht), Adresszusatz, PLZ, Ort (Pflicht) +- **Abweichende Rechnungsadresse**: Separate Adressfelder für die Rechnungsstellung +- **Digitale Abwicklung per App vorausgewählt**: Wenn aktiviert, wird bei neuen Aufträgen die digitale Abwicklung standardmäßig vorausgewählt +- **Zwei-Faktor-Authentifizierung (2FA)**: Aktivieren/deaktivieren Sie die zusätzliche Sicherheitsabfrage beim Login + +### Tab 2: Rechnungsdaten + +- **Rechnungspräfix** (z.B. "VLT" - wird der Rechnungsnummer vorangestellt) +- **USt-IdNr.** und **Steuernummer** +- **Bankverbindung**: Bankname und IBAN +- **Steuersatz** (z.B. 19%) +- **Einleitungstext**: Freitext, der auf Rechnungen als Einleitung erscheint +- **AGB/Zahlungsbedingungen**: Text für die Rechnungsfußzeile +- **Logo hochladen**: Laden Sie Ihr Firmenlogo hoch, das auf Rechnungen angezeigt wird +- **Abrechnung aktiviert**: Aktivieren Sie die Rechnungsfunktion + +### Tab 3: Leistungen / Preisliste + +Verwalten Sie Ihren **Leistungskatalog**, der bei der Auftragserstellung und Rechnungsstellung verwendet wird: + +1. Klicken Sie auf **"Leistung hinzufügen"**, um eine neue Leistung anzulegen. +2. Wählen Sie die **Leistungsbezeichnung** aus oder geben Sie eine neue ein. +3. Geben Sie den **Preis** und den **MwSt.-Satz** ein. +4. Über das Papierkorb-Symbol können Sie einzelne Leistungen entfernen. + +Rechts neben dem Formular wird eine **Live-Vorschau** Ihrer Rechnungsvorlage angezeigt, die sich bei Änderungen automatisch aktualisiert. + +--- + +## 11. Administration + +Die folgenden Bereiche stehen nur Benutzern mit Administratorrechten zur Verfügung. + +### 11.1 Admin Dashboard + +Das Admin Dashboard (`/admin-dashboard`) bietet eine Gesamtübersicht des Systems: + +- **System-Übersicht**: Anzahl Aufträge, Benutzer, App-Benutzer +- **Job-Statistiken**: Offene, in Bearbeitung befindliche und abgeschlossene Aufträge sowie Frachtgüter +- **Aufgaben-Statistiken**: Gesamtanzahl, abgeschlossene und offene Aufgaben mit Erfolgsquote +- **Benutzer-Aktivität**: Anzahl Fotos, Barcodes, Unterschriften und Kommentare +- **System-Status**: Datenbankverbindung, WebSocket-Status, Anwendungsstatus, Speicherverbrauch + +### 11.2 Preis-Tabelle + +Unter **"Preis-Tabelle"** (`/admin-price-table`) können Administratoren die Systempreise konfigurieren: + +- **Monatliche Grundpauschale** +- **App-Nutzungslizenz** +- **Umsatzbeteiligung** (in Prozent) + +### 11.3 Rechnungsgenerator + +Der Rechnungsgenerator (`/invoice-generator`) ist ein visueller Editor zum Erstellen von Rechnungsvorlagen: + +- **Drag-and-Drop** von Vorlagenblöcken (Textfeld, Überschrift, Datum, Kundeninfo, Firmeninfo, Betrag, Linie, Bild) auf eine Arbeitsfläche +- **Eigenschaften-Panel** zur Bearbeitung des ausgewählten Elements +- **Funktionen**: Speichern, Laden, PDF-Vorschau, Template-Verwaltung + +### 11.4 PDF-Test + +Unter `/pdf-test` können Administratoren die PDF-Generierung testen: + +- **System-Rechnung generieren**: Erzeugt eine PDF aus der System-Rechnungsvorlage +- **Kunden-Rechnung generieren**: Erzeugt eine PDF aus der Kunden-Rechnungsvorlage + +--- + +## Impressum + +Das Impressum ist unter `/impressum` einsehbar und enthält die gesetzlich vorgeschriebenen Angaben des Betreibers. + +--- + +*Dieses Handbuch beschreibt den Funktionsumfang von VotianLT. Bei Fragen oder Problemen wenden Sie sich bitte an den Support.* diff --git a/HANDBUCH.pdf b/HANDBUCH.pdf new file mode 100644 index 0000000..0c82ac5 Binary files /dev/null and b/HANDBUCH.pdf differ diff --git a/src/main/java/de/assecutor/votianlt/model/Job.java b/src/main/java/de/assecutor/votianlt/model/Job.java index 52d0166..f373039 100644 --- a/src/main/java/de/assecutor/votianlt/model/Job.java +++ b/src/main/java/de/assecutor/votianlt/model/Job.java @@ -12,6 +12,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.math.BigDecimal; +import java.util.List; @Data @Document(collection = "jobs") @@ -141,6 +142,14 @@ public class Job { @Field("time_in_15min_units") private Integer timeIn15MinUnits; + // Service-IDs für die Rechnung + @Field("service_ids") + private List serviceIds; + + // Streckeninformation für die Rechnung (in km) + @Field("route_distance_km") + private Double routeDistanceKm; + /** * Returns the ObjectId as string for JSON serialization. This ensures that the * job id is returned as a string when jobs are retrieved via API. diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java index 9c56a4e..db294e4 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java @@ -77,7 +77,6 @@ public final class AdminLayout extends AppLayout { // Only admin-specific menu items SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); - SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O)); SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator", new Icon(VaadinIcon.FILE_PROCESS)); SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG)); @@ -89,7 +88,6 @@ public final class AdminLayout extends AppLayout { // Icon(VaadinIcon.FILE_TEXT)); nav.addItem(dashboard); - nav.addItem(pdfTest); nav.addItem(invoiceGenerator); nav.addItem(priceTable); // nav.addItem(systemSettings); diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java index b8ff04e..db8ced8 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java @@ -123,8 +123,6 @@ public class AddressValidationService { String locationType = geometry.path("location_type").asText(); boolean isPrecise = "ROOFTOP".equals(locationType) || "RANGE_INTERPOLATED".equals(locationType); - // Adresskomponenten prüfen - boolean hasStreetNumber = false; boolean hasPostalCode = false; JsonNode addressComponents = firstResult.path("address_components"); @@ -133,7 +131,6 @@ public class AddressValidationService { for (JsonNode type : types) { String typeStr = type.asText(); if ("street_number".equals(typeStr)) { - hasStreetNumber = true; } else if ("postal_code".equals(typeStr)) { hasPostalCode = true; } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index ab00768..ef58cc5 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -675,11 +675,22 @@ public class AddJobView extends Main { return ""; }).setHeader("Berechnung").setSortable(true); servicesGrid.addColumn(service -> { - if (service.getEffectivePrice() != null) { - return service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €"; + // Get route distance for distance-based calculations + Double routeDistance = (routeCalculationResult != null && routeCalculationResult.isValid()) + ? routeCalculationResult.getDistanceKm() + : null; + BigDecimal price = calculateServicePrice(service, routeDistance); + if (price.compareTo(BigDecimal.ZERO) > 0) { + return price.setScale(2, RoundingMode.HALF_UP) + " €"; } - return ""; - }).setHeader("Preis").setSortable(true); + // Show price info if no route calculated yet + if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && routeDistance == null) { + return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km (Route fehlt)"; + } + return service.getEffectivePrice() != null + ? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €" + : ""; + }).setHeader("Preis").setSortable(false); servicesGrid.addColumn(service -> { if (service.getVatRate() != null) { return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %"; @@ -687,6 +698,10 @@ public class AddJobView extends Main { return ""; }).setHeader("MwSt").setSortable(true); servicesGrid.addComponentColumn(service -> { + // Verbindliche Leistungen können nicht gelöscht werden + if (service.isMandatory()) { + return new Span(""); // Leeres Element statt Löschen-Button + } Button removeButton = new Button(new Icon(VaadinIcon.TRASH)); removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); @@ -822,8 +837,13 @@ public class AddJobView extends Main { BigDecimal vatTotal = BigDecimal.ZERO; BigDecimal grossTotal = BigDecimal.ZERO; + // Get route distance for distance-based calculations + Double routeDistance = (routeCalculationResult != null && routeCalculationResult.isValid()) + ? routeCalculationResult.getDistanceKm() + : null; + for (Service service : selectedServices) { - BigDecimal price = service.getEffectivePrice() != null ? service.getEffectivePrice() : BigDecimal.ZERO; + BigDecimal price = calculateServicePrice(service, routeDistance); BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : BigDecimal.ZERO; netTotal = netTotal.add(price); @@ -837,6 +857,35 @@ public class AddJobView extends Main { grossTotalLabel.setText(grossTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); } + /** + * Calculates the actual price for a service based on its calculation basis and + * route distance (for distance-based services). + */ + private BigDecimal calculateServicePrice(Service service, Double routeDistance) { + if (service.getCalculationBasis() == null) { + return BigDecimal.ZERO; + } + + switch (service.getCalculationBasis()) { + case FLAT_RATE: + return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO; + + case DISTANCE: + if (service.getPricePerKilometer() != null && routeDistance != null && routeDistance > 0) { + return service.getPricePerKilometer().multiply(BigDecimal.valueOf(routeDistance)); + } + return BigDecimal.ZERO; + + case TIME: + // For time-based services, we would need time units + // For now, return the price per 15 minutes as base value + return service.getPricePer15Minutes() != null ? service.getPricePer15Minutes() : BigDecimal.ZERO; + + default: + return BigDecimal.ZERO; + } + } + private VerticalLayout createPickupSection() { VerticalLayout section = new VerticalLayout(); section.setSpacing(true); @@ -1407,8 +1456,13 @@ public class AddJobView extends Main { .reduce(BigDecimal.ZERO, BigDecimal::add); job.setPrice(totalPrice); - // Store selected service IDs in job (optional - if Job has serviceIds field) - // job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); + // Store selected service IDs in job for invoice creation + job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); + + // Store route distance in job for invoice creation + if (routeCalculationResult != null && routeCalculationResult.isValid()) { + job.setRouteDistanceKm(routeCalculationResult.getDistanceKm()); + } // Validate all required fields using the binder if (binder.writeBeanIfValid(job)) { @@ -2802,10 +2856,6 @@ public class AddJobView extends Main { return field.getValue() != null ? field.getValue().trim() : ""; } - private String getComboValueOrEmpty(ComboBox field) { - return field.getValue() != null ? field.getValue().trim() : ""; - } - /** * Zeigt den Adressvalidierungsdialog an. Die Prüfung erfolgt im Hintergrund und * der Dialog wird aktualisiert, sobald die Ergebnisse vorliegen. @@ -3034,6 +3084,12 @@ public class AddJobView extends Main { routeDistanceLabel.setText(String.format("%.1f km", routeCalculationResult.getDistanceKm())); routeDurationLabel.setText(routeCalculationResult.getFormattedDurationLong()); routeInfoBox.setVisible(true); + + // Update price summary and grid with new route distance + updatePriceSummary(); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } } else { routeInfoBox.setVisible(false); } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index f8c0060..2725883 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -43,10 +43,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter private Job currentJob; private List gridRows = new ArrayList<>(); - private List allUserServices; private Grid servicesGrid; - private IntegerField kilometersField; - private IntegerField timeField; private Div servicesSection; /** @@ -88,6 +85,20 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter setSpacing(true); } + /** + * Lädt die Services, die beim Job-Erstellen ausgewählt wurden. + */ + private void loadSelectedServicesFromJob() { + if (currentJob.getServiceIds() != null && !currentJob.getServiceIds().isEmpty()) { + gridRows.clear(); + for (String serviceId : currentJob.getServiceIds()) { + serviceRepository.findById(serviceId).ifPresent(service -> { + gridRows.add(new ServiceRow(service)); + }); + } + } + } + @Override public void setParameter(BeforeEvent event, String jobIdHex) { try { @@ -116,13 +127,18 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter H2 title = new H2("Rechnung erstellen für Auftrag " + currentJob.getJobNumber()); add(title); + // Load previously selected services from job + loadSelectedServicesFromJob(); + // Job Details Section Div jobDetailsSection = createJobDetailsSection(); add(jobDetailsSection); - // Performance Data Section - Div performanceDataSection = createPerformanceDataSection(); - add(performanceDataSection); + // Route Information Section (if available) + if (currentJob.getRouteDistanceKm() != null && currentJob.getRouteDistanceKm() > 0) { + Div routeInfoSection = createRouteInfoSection(); + add(routeInfoSection); + } // Services Selection Section Div servicesSection = createServicesSelectionSection(); @@ -164,46 +180,28 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return section; } - private Div createPerformanceDataSection() { + private Div createRouteInfoSection() { Div section = new Div(); - section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + section.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)") .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") - .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box") + .set("background-color", "var(--lumo-primary-color-10pct)"); - H3 sectionTitle = new H3("Leistungsdaten"); + H3 sectionTitle = new H3("Streckeninformation"); + sectionTitle.getStyle().set("color", "var(--lumo-primary-text-color)"); section.add(sectionTitle); - VerticalLayout performanceLayout = new VerticalLayout(); - performanceLayout.setSpacing(true); - performanceLayout.setWidthFull(); + VerticalLayout routeInfo = new VerticalLayout(); + routeInfo.setSpacing(true); + routeInfo.setWidthFull(); - // Kilometers field - HorizontalLayout kilometersLayout = new HorizontalLayout(); - kilometersLayout.setWidthFull(); - Span kilometersLabel = new Span("Gefahrene Kilometer:"); - kilometersLabel.getStyle().set("width", "200px"); - kilometersField = new IntegerField(); - kilometersField.setWidth("150px"); - kilometersField.setMin(0); - kilometersField.setValue(currentJob.getKilometersDriven() != null ? currentJob.getKilometersDriven() : 0); - kilometersField.addValueChangeListener(e -> updateSummarySection()); - kilometersLayout.add(kilometersLabel, kilometersField); - performanceLayout.add(kilometersLayout); + Double distance = currentJob.getRouteDistanceKm(); + if (distance != null) { + routeInfo.add(new HorizontalLayout(new Span("Berechnete Entfernung:"), + new Span(String.format("%.1f km", distance)))); + } - // Time field (in 15-minute units) - HorizontalLayout timeLayout = new HorizontalLayout(); - timeLayout.setWidthFull(); - Span timeLabel = new Span("Arbeitszeit (15-Minuten-Einheiten):"); - timeLabel.getStyle().set("width", "200px"); - timeField = new IntegerField(); - timeField.setWidth("150px"); - timeField.setMin(0); - timeField.setValue(currentJob.getTimeIn15MinUnits() != null ? currentJob.getTimeIn15MinUnits() : 0); - timeField.addValueChangeListener(e -> updateSummarySection()); - timeLayout.add(timeLabel, timeField); - performanceLayout.add(timeLayout); - - section.add(performanceLayout); + section.add(routeInfo); return section; } @@ -213,46 +211,23 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); - H3 sectionTitle = new H3("Leistungen auswählen"); + H3 sectionTitle = new H3("Leistungen"); servicesSection.add(sectionTitle); - // Load services for current user (only once) - if (allUserServices == null) { - String currentUserId = securityService.getCurrentUserId().toHexString(); - allUserServices = serviceRepository.findByUserId(currentUserId); - } - - // Initialize with 2 empty rows if gridRows is empty - if (gridRows.isEmpty()) { - gridRows.add(new ServiceRow()); - gridRows.add(new ServiceRow()); - } - - // Create grid with editable rows + // Create grid with read-only rows servicesGrid = new Grid<>(); servicesGrid.setWidthFull(); servicesGrid.setAllRowsVisible(true); - // Service selection column (ComboBox) - servicesGrid.addComponentColumn(row -> { - ComboBox serviceCombo = new ComboBox<>(); - serviceCombo.setItems(allUserServices); - serviceCombo.setItemLabelGenerator(Service::getName); - serviceCombo.setPlaceholder("Leistung auswählen..."); - serviceCombo.setWidthFull(); - serviceCombo.setValue(row.getService()); - - serviceCombo.addValueChangeListener(event -> { - row.setService(event.getValue()); - // Refresh the grid to show updated calculation basis and price - servicesGrid.getDataProvider().refreshItem(row); - updateSummarySection(); - }); - - return serviceCombo; + // Service name column (read-only) + servicesGrid.addColumn(row -> { + if (row.getService() != null) { + return row.getService().getName(); + } + return ""; }).setHeader("Leistung").setAutoWidth(true).setFlexGrow(2); - // Calculation basis column + // Calculation basis column (read-only) servicesGrid.addColumn(row -> { if (row.getService() != null && row.getService().getCalculationBasis() != null) { return switch (row.getService().getCalculationBasis()) { @@ -264,7 +239,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return ""; }).setHeader("Berechnungsgrundlage").setAutoWidth(true).setFlexGrow(1); - // Price column + // Price column (read-only) servicesGrid.addColumn(row -> { if (row.getService() != null) { BigDecimal price = calculateServicePrice(row.getService()); @@ -278,15 +253,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter servicesGrid.setItems(gridRows); servicesSection.add(servicesGrid); - // Add button to add new row - Button addButton = new Button("Leistung hinzufügen", e -> { - ServiceRow newRow = new ServiceRow(); - gridRows.add(newRow); - servicesGrid.getDataProvider().refreshAll(); - }); - addButton.getStyle().set("margin-top", "var(--lumo-space-m)"); - servicesSection.add(addButton); - return servicesSection; } @@ -342,13 +308,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { return service.getPrice(); } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE - && service.getPricePerKilometer() != null && kilometersField != null - && kilometersField.getValue() != null) { - BigDecimal kilometers = new BigDecimal(kilometersField.getValue()); + && service.getPricePerKilometer() != null && currentJob.getRouteDistanceKm() != null) { + BigDecimal kilometers = BigDecimal.valueOf(currentJob.getRouteDistanceKm()); return service.getPricePerKilometer().multiply(kilometers); } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME - && service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) { - BigDecimal timeUnits = new BigDecimal(timeField.getValue()); + && service.getPricePer15Minutes() != null && currentJob.getTimeIn15MinUnits() != null) { + BigDecimal timeUnits = new BigDecimal(currentJob.getTimeIn15MinUnits()); return service.getPricePer15Minutes().multiply(timeUnits); } @@ -362,14 +327,13 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { total = total.add(service.getPrice()); } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE - && service.getPricePerKilometer() != null && kilometersField != null - && kilometersField.getValue() != null) { - BigDecimal kilometers = new BigDecimal(kilometersField.getValue()); + && service.getPricePerKilometer() != null && currentJob.getRouteDistanceKm() != null) { + BigDecimal kilometers = BigDecimal.valueOf(currentJob.getRouteDistanceKm()); BigDecimal serviceTotal = service.getPricePerKilometer().multiply(kilometers); total = total.add(serviceTotal); } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME - && service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) { - BigDecimal timeUnits = new BigDecimal(timeField.getValue()); + && service.getPricePer15Minutes() != null && currentJob.getTimeIn15MinUnits() != null) { + BigDecimal timeUnits = new BigDecimal(currentJob.getTimeIn15MinUnits()); BigDecimal serviceTotal = service.getPricePer15Minutes().multiply(timeUnits); total = total.add(serviceTotal); } @@ -402,14 +366,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter } private void updateSummarySection() { - // Update the job with new values - if (kilometersField != null && kilometersField.getValue() != null) { - currentJob.setKilometersDriven(kilometersField.getValue()); - } - if (timeField != null && timeField.getValue() != null) { - currentJob.setTimeIn15MinUnits(timeField.getValue()); - } - // Refresh the services grid to update calculated prices refreshServicesGrid(); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java b/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java deleted file mode 100644 index a5ebeac..0000000 --- a/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java +++ /dev/null @@ -1,98 +0,0 @@ -package de.assecutor.votianlt.pages.view; - -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.html.H2; -import com.vaadin.flow.component.html.IFrame; -import com.vaadin.flow.component.notification.Notification; -import com.vaadin.flow.component.orderedlayout.HorizontalLayout; -import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.router.PageTitle; -import com.vaadin.flow.router.Route; -import com.vaadin.flow.server.StreamResource; -import de.assecutor.votianlt.pages.base.ui.view.AdminLayout; -import de.assecutor.votianlt.service.CustomerInvoiceService; -import de.assecutor.votianlt.service.SystemInvoiceService; -import jakarta.annotation.security.RolesAllowed; - -import java.io.ByteArrayInputStream; - -@Route(value = "pdf-test", layout = AdminLayout.class) -@PageTitle("PDF Test") -@RolesAllowed("ADMIN") -public class PdfTestView extends VerticalLayout { - private final SystemInvoiceService systemInvoiceService; - private final CustomerInvoiceService customerInvoiceService; - - public PdfTestView(SystemInvoiceService systemInvoiceService, CustomerInvoiceService customerInvoiceService) { - this.systemInvoiceService = systemInvoiceService; - this.customerInvoiceService = customerInvoiceService; - - setSpacing(false); - setPadding(false); - getStyle().set("margin", "14px"); - setWidth("90%"); - - H2 title = new H2("PDF Test"); - add(title); - - Button generateHtmlPdfButton = new Button("PDF aus system_invoice.html generieren"); - generateHtmlPdfButton.addClickListener(e -> generateHtmlPdf()); - - Button generateCustomerInvoicePdfButton = new Button("PDF aus customer_invoice.html generieren"); - generateCustomerInvoicePdfButton.addClickListener(e -> generateCustomerInvoicePdf()); - - // Create button layout - HorizontalLayout buttonLayout = new HorizontalLayout(); - buttonLayout.add(generateHtmlPdfButton, generateCustomerInvoicePdfButton); - buttonLayout.setSpacing(true); - - // Initialize PDF viewer - IFrame pdfViewer = new IFrame(); - pdfViewer.setWidth("100%"); - pdfViewer.setHeight("800px"); - pdfViewer.getStyle().set("border", "1px solid #ccc"); - pdfViewer.setVisible(false); - - add(buttonLayout); - add(pdfViewer); - } - - private void generateHtmlPdf() { - try { - byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml(); - - StreamResource resource = new StreamResource("vlt-invoice.pdf", () -> new ByteArrayInputStream(pdfBytes)); - resource.setContentType("application/pdf"); - - getUI().ifPresent(ui -> { - var registration = ui.getSession().getResourceRegistry().registerResource(resource); - ui.getPage().open(registration.getResourceUri().toString(), "_blank"); - }); - - Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); - } catch (Exception ex) { - Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), 5000, - Notification.Position.BOTTOM_CENTER); - } - } - - private void generateCustomerInvoicePdf() { - try { - byte[] pdfBytes = customerInvoiceService.generateCustomerInvoicePdf(); - - StreamResource resource = new StreamResource("customer-invoice.pdf", - () -> new ByteArrayInputStream(pdfBytes)); - resource.setContentType("application/pdf"); - - getUI().ifPresent(ui -> { - var registration = ui.getSession().getResourceRegistry().registerResource(resource); - ui.getPage().open(registration.getResourceUri().toString(), "_blank"); - }); - - Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); - } catch (Exception ex) { - Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), 5000, - Notification.Position.BOTTOM_CENTER); - } - } -}