diff --git a/src/main/java/de/assecutor/votianlt/model/InvoiceData.java b/src/main/java/de/assecutor/votianlt/model/InvoiceData.java new file mode 100644 index 0000000..be5ea5f --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/InvoiceData.java @@ -0,0 +1,77 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Data +@NoArgsConstructor +public class InvoiceData { + + // Invoice details + private String invoiceNumber; + private LocalDate invoiceDate; + private LocalDate dueDate; + + // Company information (sender) + private String companyName; + private String companyStreet; + private String companyHouseNumber; + private String companyZip; + private String companyCity; + private String companyPhone; + private String companyEmail; + private String companyWebsite; + + // Tax information + private String taxNumber; // Steuernummer + private String vatId; // USt-IdNr + private String commercialRegister; // Handelsregistereintrag + private String managingDirector; // Geschäftsführer + + // Bank details + private String bankName; + private String iban; + private String bic; + + // Customer information (recipient) + private String customerName; + private String customerStreet; + private String customerHouseNumber; + private String customerZip; + private String customerCity; + private String customerCountry; + + // Invoice items + private List items; + + // Totals + private BigDecimal subtotal; + private BigDecimal vatAmount; + private BigDecimal totalAmount; + private BigDecimal vatRate = BigDecimal.valueOf(19.0); // 19% standard VAT rate + + @Data + @NoArgsConstructor + public static class InvoiceItem { + private String description; + private BigDecimal quantity; + private String unit; // e.g., "Stück", "km", "h" + private BigDecimal unitPrice; + private BigDecimal totalPrice; + private BigDecimal vatRate; + + public InvoiceItem(String description, BigDecimal quantity, String unit, + BigDecimal unitPrice, BigDecimal vatRate) { + this.description = description; + this.quantity = quantity; + this.unit = unit; + this.unitPrice = unitPrice; + this.vatRate = vatRate; + this.totalPrice = quantity.multiply(unitPrice); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/model/PendingMqttMessage.java b/src/main/java/de/assecutor/votianlt/model/PendingMqttMessage.java new file mode 100644 index 0000000..3af6c27 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/PendingMqttMessage.java @@ -0,0 +1,56 @@ +package de.assecutor.votianlt.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "pending_mqtt_messages") +public class PendingMqttMessage { + + @Id + private ObjectId id; + + @Field("topic") + private String topic; + + @Field("payload") + private byte[] payload; + + @Field("qos") + private int qos; + + @Field("retained") + private boolean retained; + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("retry_count") + private int retryCount = 0; + + @Field("last_retry_at") + private LocalDateTime lastRetryAt; + + public PendingMqttMessage(String topic, byte[] payload, int qos, boolean retained) { + this.topic = topic; + this.payload = payload; + this.qos = qos; + this.retained = retained; + this.createdAt = LocalDateTime.now(); + this.retryCount = 0; + } + + public void incrementRetryCount() { + this.retryCount++; + this.lastRetryAt = LocalDateTime.now(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..1e15bb1 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java @@ -0,0 +1,132 @@ +package de.assecutor.votianlt.pages.base.ui.view; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.avatar.AvatarVariant; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.menubar.MenuBar; +import com.vaadin.flow.component.menubar.MenuBarVariant; +import com.vaadin.flow.component.orderedlayout.Scroller; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.sidenav.SideNav; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.Layout; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import de.assecutor.votianlt.pages.view.EditProfileView; +import de.assecutor.votianlt.security.SecurityService; + +import static com.vaadin.flow.theme.lumo.LumoUtility.*; + +@AnonymousAllowed +@Layout("admin") +public final class AdminLayout extends AppLayout { + + private final SecurityService securityService; + private Div headerRef; + private Scroller navRef; + private Component userMenuRef; + + public AdminLayout(SecurityService securityService) { + this.securityService = securityService; + setPrimarySection(Section.DRAWER); + + // Always build the drawer; keep references and toggle visibility on attach and + // after navigation + headerRef = createHeader(); + navRef = new Scroller(createSideNav()); + userMenuRef = createUserMenu(); + addToDrawer(headerRef, navRef, userMenuRef); + + updateDrawerVisibility(); + + // Re-check on attach (new UI/session) and on every navigation cycle + addAttachListener(e -> updateDrawerVisibility()); + } + + private void updateDrawerVisibility() { + boolean loggedIn = securityService.isUserLoggedIn(); + if (headerRef != null) + headerRef.setVisible(loggedIn); + if (navRef != null) + navRef.setVisible(loggedIn); + if (userMenuRef != null) + userMenuRef.setVisible(loggedIn); + setDrawerOpened(loggedIn); + } + + private Div createHeader() { + var appLogo = VaadinIcon.SHIELD.create(); + appLogo.addClassNames(TextColor.PRIMARY, IconSize.LARGE); + + var appName = new Span("VotianLT Admin"); + appName.addClassNames(FontWeight.SEMIBOLD, FontSize.LARGE); + + var header = new Div(appLogo, appName); + header.addClassNames(Display.FLEX, Padding.MEDIUM, Gap.MEDIUM, AlignItems.CENTER); + return header; + } + + private Component createSideNav() { + var nav = new SideNav(); + nav.addClassNames(Margin.Horizontal.MEDIUM); + + // Only admin-specific menu items + SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); + SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG)); + SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS)); + SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT)); + + nav.addItem(dashboard); + nav.addItem(systemSettings); + nav.addItem(userManagement); + nav.addItem(systemLogs); + + // Create a vertical layout to hold menu items + VerticalLayout navContainer = new VerticalLayout(); + navContainer.setPadding(false); + navContainer.setSpacing(false); + navContainer.add(nav); + + return navContainer; + } + + private Component createUserMenu() { + var userMenu = new MenuBar(); + userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE); + userMenu.addClassNames(Margin.MEDIUM); + + // Dynamically updatable components + var avatar = new Avatar(); + avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL); + avatar.addClassNames(Margin.Right.SMALL); + avatar.setColorIndex(1); // Different color for admin + + var userNameSpan = new Span(); + + var userMenuItem = userMenu.addItem(avatar); + userMenuItem.add(userNameSpan); + + // Profile display with navigation + userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class)); + userMenuItem.getSubMenu().addItem("Admin-Einstellungen"); + userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout()); + + // Update function for username and avatar + Runnable updateUserInfo = () -> { + String currentUser = securityService.getCurrentUsername(); + avatar.setName(currentUser + " (Admin)"); + userNameSpan.setText(currentUser); + }; + + // Update initially and on attach + updateUserInfo.run(); + addAttachListener(e -> updateUserInfo.run()); + + return userMenu; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java b/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java new file mode 100644 index 0000000..d80e32e --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java @@ -0,0 +1,357 @@ +package de.assecutor.votianlt.pages.view; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Main; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.FlexComponent; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.repository.*; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CompletableFuture; + +@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class) +@PageTitle("Admin Dashboard") +@RolesAllowed("ADMIN") +@Menu(order = 1, icon = "lumo:edit") +@Slf4j +public class AdminDashboardView extends Main { + + private final JobRepository jobRepository; + private final TaskRepository taskRepository; + private final UserRepository userRepository; + private final AppUserRepository appUserRepository; + private final CargoItemRepository cargoItemRepository; + private final PhotoRepository photoRepository; + private final BarcodeRepository barcodeRepository; + private final SignatureRepository signatureRepository; + private final CommentRepository commentRepository; + private final PendingMqttMessageRepository pendingMqttMessageRepository; + + private final Div statisticsContainer; + private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + + @Autowired + public AdminDashboardView( + JobRepository jobRepository, + TaskRepository taskRepository, + UserRepository userRepository, + AppUserRepository appUserRepository, + CargoItemRepository cargoItemRepository, + PhotoRepository photoRepository, + BarcodeRepository barcodeRepository, + SignatureRepository signatureRepository, + CommentRepository commentRepository, + PendingMqttMessageRepository pendingMqttMessageRepository) { + + this.jobRepository = jobRepository; + this.taskRepository = taskRepository; + this.userRepository = userRepository; + this.appUserRepository = appUserRepository; + this.cargoItemRepository = cargoItemRepository; + this.photoRepository = photoRepository; + this.barcodeRepository = barcodeRepository; + this.signatureRepository = signatureRepository; + this.commentRepository = commentRepository; + this.pendingMqttMessageRepository = pendingMqttMessageRepository; + + setSizeFull(); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, + LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM); + + // Header + H1 title = new H1("Administrator Dashboard"); + title.addClassNames(LumoUtility.Margin.Bottom.MEDIUM, LumoUtility.Margin.Top.NONE); + + HorizontalLayout header = new HorizontalLayout(title); + header.setAlignItems(FlexComponent.Alignment.CENTER); + header.setWidthFull(); + + // Statistics container + statisticsContainer = new Div(); + statisticsContainer.setSizeFull(); + + // Content container + VerticalLayout content = new VerticalLayout(header, statisticsContainer); + content.setSizeFull(); + content.setPadding(false); + content.setSpacing(true); + + add(content); + + // Load initial statistics + loadStatistics(); + } + + private void loadStatistics() { + log.info("Loading dashboard statistics for admin user"); + + // Show loading indicator + statisticsContainer.removeAll(); + statisticsContainer.add(new Span("Lade Statistiken...")); + + // Load statistics asynchronously + CompletableFuture.runAsync(() -> { + getUI().ifPresent(ui -> ui.access(() -> { + try { + displayStatistics(); + } catch (Exception e) { + log.error("Error loading dashboard statistics", e); + statisticsContainer.removeAll(); + statisticsContainer.add(new Span("Fehler beim Laden der Statistiken: " + e.getMessage())); + } + })); + }); + } + + private void displayStatistics() { + statisticsContainer.removeAll(); + + // Create main layout + VerticalLayout mainLayout = new VerticalLayout(); + mainLayout.setPadding(false); + mainLayout.setSpacing(true); + + // System overview section + mainLayout.add(createSystemOverviewSection()); + + // Job statistics section + mainLayout.add(createJobStatisticsSection()); + + // Task statistics section + mainLayout.add(createTaskStatisticsSection()); + + // User statistics section + mainLayout.add(createUserStatisticsSection()); + + // System health section + mainLayout.add(createSystemHealthSection()); + + statisticsContainer.add(mainLayout); + } + + private VerticalLayout createSystemOverviewSection() { + VerticalLayout section = new VerticalLayout(); + section.setPadding(false); + section.addClassName(LumoUtility.Background.CONTRAST_5); + section.getStyle().set("border-radius", "8px").set("padding", "1rem"); + + H3 title = new H3("System-Übersicht"); + title.addClassName(LumoUtility.Margin.Bottom.MEDIUM); + + HorizontalLayout cards = new HorizontalLayout(); + cards.setWidthFull(); + cards.setSpacing(true); + + // Total jobs card + long totalJobs = jobRepository.count(); + cards.add(createStatCard("Gesamt Jobs", String.valueOf(totalJobs), VaadinIcon.PACKAGE, "blue")); + + // Total users card + long totalUsers = userRepository.count(); + cards.add(createStatCard("Benutzer", String.valueOf(totalUsers), VaadinIcon.USERS, "green")); + + // Total app users card + long totalAppUsers = appUserRepository.count(); + cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple")); + + // Current time + String currentTime = LocalDateTime.now().format(dateTimeFormatter); + cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray")); + + section.add(title, cards); + return section; + } + + private VerticalLayout createJobStatisticsSection() { + VerticalLayout section = new VerticalLayout(); + section.setPadding(false); + section.addClassName(LumoUtility.Background.CONTRAST_5); + section.getStyle().set("border-radius", "8px").set("padding", "1rem"); + + H3 title = new H3("Job-Statistiken"); + title.addClassName(LumoUtility.Margin.Bottom.MEDIUM); + + HorizontalLayout cards = new HorizontalLayout(); + cards.setWidthFull(); + cards.setSpacing(true); + + // Jobs by status + try { + long openJobs = jobRepository.countByStatus(JobStatus.CREATED); + long inProgressJobs = jobRepository.countByStatus(JobStatus.IN_PROGRESS); + long completedJobs = jobRepository.countByStatus(JobStatus.COMPLETED); + + cards.add(createStatCard("Offene Jobs", String.valueOf(openJobs), VaadinIcon.HOURGLASS_START, "orange")); + cards.add(createStatCard("In Bearbeitung", String.valueOf(inProgressJobs), VaadinIcon.PLAY, "blue")); + cards.add(createStatCard("Abgeschlossen", String.valueOf(completedJobs), VaadinIcon.CHECK_CIRCLE, "green")); + + // Total cargo items + long totalCargoItems = cargoItemRepository.count(); + cards.add(createStatCard("Frachtgüter", String.valueOf(totalCargoItems), VaadinIcon.CUBE, "purple")); + + } catch (Exception e) { + log.warn("Could not load job statistics by status", e); + cards.add(createStatCard("Status-Info", "Nicht verfügbar", VaadinIcon.WARNING, "red")); + } + + section.add(title, cards); + return section; + } + + private VerticalLayout createTaskStatisticsSection() { + VerticalLayout section = new VerticalLayout(); + section.setPadding(false); + section.addClassName(LumoUtility.Background.CONTRAST_5); + section.getStyle().set("border-radius", "8px").set("padding", "1rem"); + + H3 title = new H3("Aufgaben-Statistiken"); + title.addClassName(LumoUtility.Margin.Bottom.MEDIUM); + + HorizontalLayout cards = new HorizontalLayout(); + cards.setWidthFull(); + cards.setSpacing(true); + + // Total tasks + long totalTasks = taskRepository.count(); + cards.add(createStatCard("Gesamt Aufgaben", String.valueOf(totalTasks), VaadinIcon.TASKS, "blue")); + + // Completed tasks + long completedTasks = taskRepository.countByCompleted(true); + cards.add(createStatCard("Abgeschlossen", String.valueOf(completedTasks), VaadinIcon.CHECK, "green")); + + // Pending tasks + long pendingTasks = totalTasks - completedTasks; + cards.add(createStatCard("Offen", String.valueOf(pendingTasks), VaadinIcon.CLOCK, "orange")); + + // Completion rate + double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0; + cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP, "purple")); + + section.add(title, cards); + return section; + } + + private VerticalLayout createUserStatisticsSection() { + VerticalLayout section = new VerticalLayout(); + section.setPadding(false); + section.addClassName(LumoUtility.Background.CONTRAST_5); + section.getStyle().set("border-radius", "8px").set("padding", "1rem"); + + H3 title = new H3("Benutzer-Aktivität"); + title.addClassName(LumoUtility.Margin.Bottom.MEDIUM); + + HorizontalLayout cards = new HorizontalLayout(); + cards.setWidthFull(); + cards.setSpacing(true); + + // Content statistics + long totalPhotos = photoRepository.count(); + cards.add(createStatCard("Fotos", String.valueOf(totalPhotos), VaadinIcon.CAMERA, "blue")); + + long totalBarcodes = barcodeRepository.count(); + cards.add(createStatCard("Barcodes", String.valueOf(totalBarcodes), VaadinIcon.BARCODE, "green")); + + long totalSignatures = signatureRepository.count(); + cards.add(createStatCard("Unterschriften", String.valueOf(totalSignatures), VaadinIcon.EDIT, "purple")); + + long totalComments = commentRepository.count(); + cards.add(createStatCard("Kommentare", String.valueOf(totalComments), VaadinIcon.COMMENT, "orange")); + + section.add(title, cards); + return section; + } + + private VerticalLayout createSystemHealthSection() { + VerticalLayout section = new VerticalLayout(); + section.setPadding(false); + section.addClassName(LumoUtility.Background.CONTRAST_5); + section.getStyle().set("border-radius", "8px").set("padding", "1rem"); + + H3 title = new H3("System-Status"); + title.addClassName(LumoUtility.Margin.Bottom.MEDIUM); + + HorizontalLayout cards = new HorizontalLayout(); + cards.setWidthFull(); + cards.setSpacing(true); + + // Database connection status + try { + userRepository.count(); // Test database connection + cards.add(createStatCard("Datenbank", "Verbunden", VaadinIcon.DATABASE, "green")); + } catch (Exception e) { + cards.add(createStatCard("Datenbank", "Fehler", VaadinIcon.DATABASE, "red")); + } + + // Pending MQTT messages + long pendingMqttMessages = pendingMqttMessageRepository.count(); + String mqttStatus = pendingMqttMessages == 0 ? "OK" : "Warteschlange: " + pendingMqttMessages; + String mqttColor = pendingMqttMessages == 0 ? "green" : "orange"; + cards.add(createStatCard("MQTT", mqttStatus, VaadinIcon.CONNECT, mqttColor)); + + // System uptime (placeholder) + cards.add(createStatCard("Anwendung", "Läuft", VaadinIcon.HEART, "green")); + + // Memory usage (placeholder) + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory() / 1024 / 1024; // MB + long totalMemory = runtime.totalMemory() / 1024 / 1024; // MB + long usedMemory = totalMemory - (runtime.freeMemory() / 1024 / 1024); // MB + String memoryInfo = usedMemory + "/" + maxMemory + " MB"; + cards.add(createStatCard("Speicher", memoryInfo, VaadinIcon.SERVER, "blue")); + + section.add(title, cards); + return section; + } + + private Div createStatCard(String title, String value, VaadinIcon icon, String color) { + Div card = new Div(); + card.addClassName(LumoUtility.Background.BASE); + card.getStyle() + .set("border-radius", "8px") + .set("padding", "1rem") + .set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)") + .set("min-width", "200px") + .set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)"); + + HorizontalLayout header = new HorizontalLayout(); + header.setAlignItems(FlexComponent.Alignment.CENTER); + header.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + + Icon cardIcon = icon.create(); + cardIcon.getStyle().set("color", "var(--lumo-" + color + "-color, #007bff)"); + + Span titleSpan = new Span(title); + titleSpan.addClassName(LumoUtility.FontSize.SMALL); + titleSpan.getStyle().set("color", "var(--lumo-secondary-text-color)"); + + header.add(titleSpan, cardIcon); + + Span valueSpan = new Span(value); + valueSpan.addClassName(LumoUtility.FontSize.XLARGE); + valueSpan.addClassName(LumoUtility.FontWeight.BOLD); + + VerticalLayout content = new VerticalLayout(header, valueSpan); + content.setPadding(false); + content.setSpacing(false); + + card.add(content); + return card; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/repository/PendingMqttMessageRepository.java b/src/main/java/de/assecutor/votianlt/repository/PendingMqttMessageRepository.java new file mode 100644 index 0000000..bed2c28 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/PendingMqttMessageRepository.java @@ -0,0 +1,33 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.PendingMqttMessage; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface PendingMqttMessageRepository extends MongoRepository { + + /** + * Find all pending messages ordered by creation time (oldest first) + */ + List findAllByOrderByCreatedAtAsc(); + + /** + * Find messages that haven't been retried for a while (for cleanup) + */ + List findByLastRetryAtBeforeOrLastRetryAtIsNull(LocalDateTime before); + + /** + * Count pending messages + */ + long count(); + + /** + * Delete messages older than specified date (for cleanup) + */ + void deleteByCreatedAtBefore(LocalDateTime before); +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/service/InvoicePdfGenerator.java b/src/main/java/de/assecutor/votianlt/service/InvoicePdfGenerator.java new file mode 100644 index 0000000..dbff090 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/InvoicePdfGenerator.java @@ -0,0 +1,354 @@ +package de.assecutor.votianlt.service; + +import com.itextpdf.kernel.colors.ColorConstants; +import com.itextpdf.kernel.font.PdfFont; +import com.itextpdf.kernel.font.PdfFontFactory; +import com.itextpdf.kernel.geom.PageSize; +import com.itextpdf.kernel.pdf.PdfDocument; +import com.itextpdf.kernel.pdf.PdfWriter; +import com.itextpdf.layout.Document; +import com.itextpdf.layout.borders.Border; +import com.itextpdf.layout.element.Cell; +import com.itextpdf.layout.element.Paragraph; +import com.itextpdf.layout.element.Table; +import com.itextpdf.layout.properties.TextAlignment; +import com.itextpdf.layout.properties.UnitValue; +import de.assecutor.votianlt.model.InvoiceData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +@Service +@Slf4j +public class InvoicePdfGenerator { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY); + private static final int ITEMS_PER_PAGE = 15; // Maximum items per page before page break + + public byte[] generateInvoicePdf(InvoiceData invoiceData) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (PdfWriter writer = new PdfWriter(baos); + PdfDocument pdfDoc = new PdfDocument(writer); + Document document = new Document(pdfDoc, PageSize.A4)) { + + // Set document margins + document.setMargins(50, 50, 80, 50); // top, right, bottom, left + + PdfFont font = PdfFontFactory.createFont("Helvetica"); + PdfFont boldFont = PdfFontFactory.createFont("Helvetica-Bold"); + + document.setFont(font); + + // Calculate totals + calculateTotals(invoiceData); + + // Add header with company and customer info + addHeader(document, invoiceData, boldFont, font); + + // Add invoice details + addInvoiceDetails(document, invoiceData, boldFont, font); + + // Add invoice items (with page breaks if necessary) + addInvoiceItems(document, invoiceData, boldFont, font); + + // Add totals + addTotals(document, invoiceData, boldFont, font); + + // Add footer on all pages + addFooter(pdfDoc, invoiceData, font); + + document.close(); + } + + log.info("Generated invoice PDF for invoice number: {}", invoiceData.getInvoiceNumber()); + return baos.toByteArray(); + } + + private void calculateTotals(InvoiceData invoiceData) { + BigDecimal subtotal = BigDecimal.ZERO; + BigDecimal vatAmount = BigDecimal.ZERO; + + for (InvoiceData.InvoiceItem item : invoiceData.getItems()) { + BigDecimal itemTotal = item.getQuantity().multiply(item.getUnitPrice()); + item.setTotalPrice(itemTotal); + subtotal = subtotal.add(itemTotal); + + // Calculate VAT for this item + BigDecimal itemVat = itemTotal.multiply(item.getVatRate()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + vatAmount = vatAmount.add(itemVat); + } + + invoiceData.setSubtotal(subtotal); + invoiceData.setVatAmount(vatAmount); + invoiceData.setTotalAmount(subtotal.add(vatAmount)); + } + + private void addHeader(Document document, InvoiceData invoiceData, PdfFont boldFont, PdfFont font) { + // Create header table with company info (left) and customer info (right) + Table headerTable = new Table(UnitValue.createPercentArray(new float[]{50, 50})) + .setWidth(UnitValue.createPercentValue(100)) + .setMarginBottom(20); + + // Company information (left side) + Cell companyCell = new Cell() + .setBorder(Border.NO_BORDER) + .setVerticalAlignment(com.itextpdf.layout.properties.VerticalAlignment.TOP); + + companyCell.add(new Paragraph(invoiceData.getCompanyName()) + .setFont(boldFont).setFontSize(14)); + companyCell.add(new Paragraph(invoiceData.getCompanyStreet() + " " + + invoiceData.getCompanyHouseNumber()).setFont(font).setFontSize(10)); + companyCell.add(new Paragraph(invoiceData.getCompanyZip() + " " + + invoiceData.getCompanyCity()).setFont(font).setFontSize(10)); + + if (invoiceData.getCompanyPhone() != null) { + companyCell.add(new Paragraph("Tel: " + invoiceData.getCompanyPhone()) + .setFont(font).setFontSize(10)); + } + if (invoiceData.getCompanyEmail() != null) { + companyCell.add(new Paragraph("E-Mail: " + invoiceData.getCompanyEmail()) + .setFont(font).setFontSize(10)); + } + + // Customer information (right side) + Cell customerCell = new Cell() + .setBorder(Border.NO_BORDER) + .setVerticalAlignment(com.itextpdf.layout.properties.VerticalAlignment.TOP); + + customerCell.add(new Paragraph("Rechnungsempfänger:") + .setFont(boldFont).setFontSize(10)); + customerCell.add(new Paragraph(invoiceData.getCustomerName()) + .setFont(boldFont).setFontSize(12)); + customerCell.add(new Paragraph(invoiceData.getCustomerStreet() + " " + + invoiceData.getCustomerHouseNumber()).setFont(font).setFontSize(10)); + customerCell.add(new Paragraph(invoiceData.getCustomerZip() + " " + + invoiceData.getCustomerCity()).setFont(font).setFontSize(10)); + + if (invoiceData.getCustomerCountry() != null && + !invoiceData.getCustomerCountry().equalsIgnoreCase("Deutschland")) { + customerCell.add(new Paragraph(invoiceData.getCustomerCountry()) + .setFont(font).setFontSize(10)); + } + + headerTable.addCell(companyCell); + headerTable.addCell(customerCell); + document.add(headerTable); + } + + private void addInvoiceDetails(Document document, InvoiceData invoiceData, PdfFont boldFont, PdfFont font) { + // Invoice title + document.add(new Paragraph("RECHNUNG") + .setFont(boldFont) + .setFontSize(20) + .setTextAlignment(TextAlignment.CENTER) + .setMarginTop(20) + .setMarginBottom(20)); + + // Invoice details table + Table detailsTable = new Table(UnitValue.createPercentArray(new float[]{30, 20, 30, 20})) + .setWidth(UnitValue.createPercentValue(100)) + .setMarginBottom(20); + + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("Rechnungsnummer:").setFont(boldFont).setFontSize(10))); + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(invoiceData.getInvoiceNumber()).setFont(font).setFontSize(10))); + + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("Rechnungsdatum:").setFont(boldFont).setFontSize(10))); + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(invoiceData.getInvoiceDate().format(DATE_FORMATTER)).setFont(font).setFontSize(10))); + + if (invoiceData.getDueDate() != null) { + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("Fälligkeitsdatum:").setFont(boldFont).setFontSize(10))); + detailsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(invoiceData.getDueDate().format(DATE_FORMATTER)).setFont(font).setFontSize(10))); + } + + document.add(detailsTable); + } + + private void addInvoiceItems(Document document, InvoiceData invoiceData, PdfFont boldFont, PdfFont font) { + // Items table header + Table itemsTable = new Table(UnitValue.createPercentArray(new float[]{40, 10, 10, 15, 10, 15})) + .setWidth(UnitValue.createPercentValue(100)) + .setMarginBottom(10); + + // Header row + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Beschreibung").setFont(boldFont).setFontSize(10))); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Menge").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Einheit").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.CENTER)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Einzelpreis").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("MwSt%").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Gesamtpreis").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + + // Add items + int itemCount = 0; + for (InvoiceData.InvoiceItem item : invoiceData.getItems()) { + if (itemCount > 0 && itemCount % ITEMS_PER_PAGE == 0) { + // Add current table and start new page + document.add(itemsTable); + document.add(new com.itextpdf.layout.element.AreaBreak()); + + // Create new table with header + itemsTable = new Table(UnitValue.createPercentArray(new float[]{40, 10, 10, 15, 10, 15})) + .setWidth(UnitValue.createPercentValue(100)) + .setMarginBottom(10); + + // Re-add header + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Beschreibung").setFont(boldFont).setFontSize(10))); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Menge").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Einheit").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.CENTER)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Einzelpreis").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("MwSt%").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addHeaderCell(new Cell().setBackgroundColor(ColorConstants.LIGHT_GRAY) + .add(new Paragraph("Gesamtpreis").setFont(boldFont).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + } + + itemsTable.addCell(new Cell().add(new Paragraph(item.getDescription()).setFont(font).setFontSize(9))); + itemsTable.addCell(new Cell().add(new Paragraph(formatDecimal(item.getQuantity())).setFont(font).setFontSize(9)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addCell(new Cell().add(new Paragraph(item.getUnit() != null ? item.getUnit() : "").setFont(font).setFontSize(9)) + .setTextAlignment(TextAlignment.CENTER)); + itemsTable.addCell(new Cell().add(new Paragraph(formatCurrency(item.getUnitPrice())).setFont(font).setFontSize(9)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addCell(new Cell().add(new Paragraph(formatDecimal(item.getVatRate()) + "%").setFont(font).setFontSize(9)) + .setTextAlignment(TextAlignment.RIGHT)); + itemsTable.addCell(new Cell().add(new Paragraph(formatCurrency(item.getTotalPrice())).setFont(font).setFontSize(9)) + .setTextAlignment(TextAlignment.RIGHT)); + + itemCount++; + } + + document.add(itemsTable); + } + + private void addTotals(Document document, InvoiceData invoiceData, PdfFont boldFont, PdfFont font) { + // Totals table (right-aligned) + Table totalsTable = new Table(UnitValue.createPercentArray(new float[]{70, 30})) + .setWidth(UnitValue.createPercentValue(100)) + .setMarginTop(20); + + // Empty cell for spacing + totalsTable.addCell(new Cell().setBorder(Border.NO_BORDER)); + + // Totals cell + Cell totalsCell = new Cell().setBorder(Border.NO_BORDER); + + Table innerTotalsTable = new Table(UnitValue.createPercentArray(new float[]{70, 30})) + .setWidth(UnitValue.createPercentValue(100)); + + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("Nettobetrag:").setFont(font).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(formatCurrency(invoiceData.getSubtotal())).setFont(font).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("MwSt (" + formatDecimal(invoiceData.getVatRate()) + "%):").setFont(font).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(formatCurrency(invoiceData.getVatAmount())).setFont(font).setFontSize(10)) + .setTextAlignment(TextAlignment.RIGHT)); + + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph("Rechnungsbetrag:").setFont(boldFont).setFontSize(12)) + .setTextAlignment(TextAlignment.RIGHT)); + innerTotalsTable.addCell(new Cell().setBorder(Border.NO_BORDER) + .add(new Paragraph(formatCurrency(invoiceData.getTotalAmount())).setFont(boldFont).setFontSize(12)) + .setTextAlignment(TextAlignment.RIGHT)); + + totalsCell.add(innerTotalsTable); + totalsTable.addCell(totalsCell); + + document.add(totalsTable); + } + + private void addFooter(PdfDocument pdfDoc, InvoiceData invoiceData, PdfFont font) { + int numberOfPages = pdfDoc.getNumberOfPages(); + + for (int i = 1; i <= numberOfPages; i++) { + com.itextpdf.kernel.pdf.PdfPage page = pdfDoc.getPage(i); + + Document footerDoc = new Document(pdfDoc, PageSize.A4); + footerDoc.setFont(font); + + // Footer content + Table footerTable = new Table(UnitValue.createPercentArray(new float[]{33, 33, 34})) + .setWidth(UnitValue.createPercentValue(100)) + .setFixedPosition(50, 20, 495); // x, y, width + + // Bank details + Cell bankCell = new Cell().setBorder(Border.NO_BORDER); + bankCell.add(new Paragraph("Bankverbindung:").setFont(font).setFontSize(8).setBold()); + bankCell.add(new Paragraph(invoiceData.getBankName()).setFont(font).setFontSize(7)); + bankCell.add(new Paragraph("IBAN: " + invoiceData.getIban()).setFont(font).setFontSize(7)); + bankCell.add(new Paragraph("BIC: " + invoiceData.getBic()).setFont(font).setFontSize(7)); + + // Tax information + Cell taxCell = new Cell().setBorder(Border.NO_BORDER); + taxCell.add(new Paragraph("Steuerliche Angaben:").setFont(font).setFontSize(8).setBold()); + if (invoiceData.getTaxNumber() != null) { + taxCell.add(new Paragraph("Steuernr.: " + invoiceData.getTaxNumber()).setFont(font).setFontSize(7)); + } + if (invoiceData.getVatId() != null) { + taxCell.add(new Paragraph("USt-IdNr.: " + invoiceData.getVatId()).setFont(font).setFontSize(7)); + } + + // Company details + Cell companyCell = new Cell().setBorder(Border.NO_BORDER); + if (invoiceData.getCommercialRegister() != null) { + companyCell.add(new Paragraph(invoiceData.getCommercialRegister()).setFont(font).setFontSize(7)); + } + if (invoiceData.getManagingDirector() != null) { + companyCell.add(new Paragraph("Geschäftsführer: " + invoiceData.getManagingDirector()).setFont(font).setFontSize(7)); + } + + footerTable.addCell(bankCell); + footerTable.addCell(taxCell); + footerTable.addCell(companyCell); + + footerDoc.add(footerTable); + footerDoc.close(); + } + } + + private String formatCurrency(BigDecimal amount) { + return String.format(Locale.GERMANY, "%.2f €", amount); + } + + private String formatDecimal(BigDecimal amount) { + return String.format(Locale.GERMANY, "%.2f", amount); + } +} \ No newline at end of file