Erweiterungen

This commit is contained in:
2026-02-18 13:30:11 +01:00
parent b4b1685ea6
commit 19ac94e0b8
8 changed files with 572 additions and 213 deletions

441
HANDBUCH.md Normal file
View File

@@ -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.*

BIN
HANDBUCH.pdf Normal file

Binary file not shown.

View File

@@ -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<String> 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.

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<String> 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);
}

View File

@@ -43,10 +43,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
private List<Service> allUserServices;
private Grid<ServiceRow> 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<Service> 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();

View File

@@ -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);
}
}
}