From f4c2fa6ba3ab389fd4703c3d9951c79671138f38 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 7 Jul 2026 17:41:21 +0200 Subject: [PATCH] feat: Rechnungsfilter (offen/gesendet), Rabatt-Dialog, Adress-Labels mit voller Adresse und Duplikatsschutz im Adressbuch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rechnungen erstellen: Filter Alle/Offene/Gesendete pro Abrechnungsmonat; Versand wird in system_invoice_dispatch protokolliert - Rabatt pro Kunde jetzt über Icon-Dialog mit Prozentwert und Grund; Grund erscheint auf der Rechnungsposition - MessageController: stille catch-Blöcke durch Logging ersetzt, offene Pflicht-Tasks werden bei station_completed geloggt - Auftragserstellung: Abhol- und Lieferadresslisten zeigen die komplette Adresse, Auftraggeber nur den Firmennamen; (2)-Zähler entfernt - Adressbuch: komplett identische Einträge werden beim Anlegen übersprungen bzw. mit Hinweis abgelehnt - App: Versionsanzeige liest zur Laufzeit aus dem Build (package_info_plus), pubspec auf 0.9.20+1; Versionspflege dokumentiert (pom.xml fürs Web, pubspec.yaml für die App) Co-Authored-By: Claude Fable 5 --- app/CLAUDE.md | 2 + app/lib/settings_view.dart | 15 +++- app/pubspec.yaml | 2 +- .../controller/MessageController.java | 23 ++++-- .../model/invoices/SystemInvoiceDispatch.java | 42 +++++++++++ .../component/CustomerAddressLabelHelper.java | 47 ++++++++---- .../ui/component/DeliveryStationDialog.java | 2 +- .../ui/component/DeliveryStationTile.java | 2 +- .../ui/component/PickupStationDialog.java | 4 +- .../pages/domain/AddCustomerRepository.java | 2 + .../pages/service/AddCustomerService.java | 35 ++++++++- .../votianlt/pages/view/AddCustomerView.java | 8 ++- .../votianlt/pages/view/AddJobView.java | 4 +- .../pages/view/AdminCreateInvoicesView.java | 72 +++++++++++++++++-- .../SystemInvoiceDispatchRepository.java | 15 ++++ .../src/main/resources/messages_de.properties | 5 ++ .../src/main/resources/messages_ee.properties | 5 ++ .../src/main/resources/messages_en.properties | 5 ++ .../src/main/resources/messages_es.properties | 5 ++ .../src/main/resources/messages_fr.properties | 5 ++ .../src/main/resources/messages_lt.properties | 5 ++ .../src/main/resources/messages_lv.properties | 5 ++ .../src/main/resources/messages_pl.properties | 5 ++ .../src/main/resources/messages_ru.properties | 5 ++ .../src/main/resources/messages_tr.properties | 5 ++ 25 files changed, 290 insertions(+), 35 deletions(-) create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceDispatch.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/SystemInvoiceDispatchRepository.java diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 7724b2a..22038d0 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -13,6 +13,8 @@ flutter run -d # Run app on device dart run build_runner build # Generate ObjectBox code after entity changes ``` +**Versioning:** The Flutter app version is maintained in `pubspec.yaml` (`version:`); the settings view shows it at runtime via `package_info_plus`. The web app/backend version is maintained separately in `backend/pom.xml` (``) and surfaced through the `app.version` property. + **Important:** Run `flutter analyze` after every task and fix any reported issues before committing. ## Architecture Overview diff --git a/app/lib/settings_view.dart b/app/lib/settings_view.dart index 49721da..ff9adf0 100644 --- a/app/lib/settings_view.dart +++ b/app/lib/settings_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'l10n/app_localizations.dart'; import 'app_state.dart'; import 'app_theme.dart'; @@ -26,11 +27,23 @@ class SettingsView extends StatefulWidget { class _SettingsViewState extends State { late String _selectedLanguageCode; final AppState _appState = AppState(); + String _appVersion = ''; @override void initState() { super.initState(); _selectedLanguageCode = _appState.languageCode; + _loadAppVersion(); + } + + Future _loadAppVersion() async { + final info = await PackageInfo.fromPlatform(); + if (!mounted) { + return; + } + setState(() { + _appVersion = info.version; + }); } void _onLanguageSelected(String languageCode) async { @@ -213,7 +226,7 @@ class _SettingsViewState extends State { ListTile( leading: Icon(Icons.info_outline, color: AppColors.textMuted), title: Text(l10n.version), - subtitle: const Text('0.9.2'), + subtitle: Text(_appVersion), ), const Divider(height: 1, indent: 72), ], diff --git a/app/pubspec.yaml b/app/pubspec.yaml index cd3fe1d..a3837af 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.16+1 +version: 0.9.20+1 environment: sdk: ^3.7.0 diff --git a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 2c2b599..2a902b6 100644 --- a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -383,7 +383,8 @@ public class MessageController { jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName, extraDataSummary); } catch (Exception e) { - // Ignore history logging errors + log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId, + e.getMessage(), e); } // Send email notification for task completion @@ -395,7 +396,8 @@ public class MessageController { // It is now driven by explicit station_completed messages from the app // (see handleStationCompleted). } catch (Exception e) { - // Ignore email notification errors + log.error("[TASK] Could not send completion notification for task {} of job {}: {}", taskIdStr, jobId, + e.getMessage(), e); } jobUpdateBroadcaster.broadcast(jobId); @@ -422,11 +424,17 @@ public class MessageController { try { emailService.sendJobCompletionNotification(jobId, completedBy); } catch (Exception e) { - // Ignore email notification errors + log.error("[JOB] Could not send job completion notification for job {}: {}", jobId, e.getMessage(), + e); } + } else { + List openTaskIds = mandatoryTasks.stream().filter(task -> !task.isCompleted()) + .map(task -> task.getIdAsString()).toList(); + log.info("[JOB] Job {} not completed yet, {} of {} mandatory tasks still open: {}", jobId, + openTaskIds.size(), mandatoryTasks.size(), openTaskIds); } } catch (Exception e) { - // Ignore job completion check errors + log.error("[JOB] Completion check failed for job {}: {}", jobId, e.getMessage(), e); } } @@ -434,6 +442,7 @@ public class MessageController { try { Optional jobOpt = jobRepository.findById(jobId); if (jobOpt.isEmpty()) { + log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId); return; } @@ -442,9 +451,10 @@ public class MessageController { job.setStatus(JobStatus.COMPLETED); job.setUpdatedAt(LocalDateTime.now()); jobRepository.save(job); + log.info("[JOB] Job {} marked as COMPLETED", jobId); } } catch (Exception e) { - // Ignore job status update errors + log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e); } } @@ -511,7 +521,8 @@ public class MessageController { ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload); messageService.receiveMessageFromClient(inboundPayload); } catch (Exception e) { - // Ignore message handling errors + log.error("[MESSAGE] Could not process incoming message from app user {}: {}", appUserId, e.getMessage(), + e); } } diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceDispatch.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceDispatch.java new file mode 100644 index 0000000..e31b2da --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceDispatch.java @@ -0,0 +1,42 @@ +package de.assecutor.votianlt.model.invoices; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * Records that a system invoice has been sent to a customer by email. Used to + * filter the monthly invoice list into open and already sent invoices. + */ +@Data +@NoArgsConstructor +@Document(collection = "system_invoice_dispatch") +public class SystemInvoiceDispatch { + + @Id + private ObjectId id; + + /** Invoice number, e.g. SYS-202607-42; one dispatch record per invoice. */ + @Indexed(unique = true) + @Field("invoice_number") + private String invoiceNumber; + + @Field("user_id") + private ObjectId userId; + + /** Billing month in ISO format, e.g. 2026-07. */ + @Field("billing_month") + private String billingMonth; + + @Field("sent_at") + private LocalDateTime sentAt; + + @Field("sent_to") + private String sentTo; +} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/CustomerAddressLabelHelper.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/CustomerAddressLabelHelper.java index 741de42..5967752 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/CustomerAddressLabelHelper.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/CustomerAddressLabelHelper.java @@ -8,28 +8,49 @@ public final class CustomerAddressLabelHelper { private CustomerAddressLabelHelper() { } - public static void putUnique(Map target, Customer customer, String fallbackLabel) { - String label = build(customer, fallbackLabel); - String uniqueLabel = label; - int counter = 2; - while (target.containsKey(uniqueLabel)) { - uniqueLabel = label + " (" + counter++ + ")"; - } - target.put(uniqueLabel, customer); + /** Adds the customer under its full address label ("Name, Street No, Zip City"). */ + public static void put(Map target, Customer customer, String fallbackLabel) { + target.put(build(customer, fallbackLabel), customer); } + /** Adds the customer under its plain name label (company or person name). */ + public static void putName(Map target, Customer customer, String fallbackLabel) { + target.put(buildName(customer, fallbackLabel), customer); + } + + /** + * Builds the selection label as full address ("Name, Street No, Zip City") + * so that entries with the same name (e.g. branches of one company) remain + * distinguishable in the combo boxes. + */ public static String build(Customer customer, String fallbackLabel) { if (customer == null) { return fallbackLabel; } - String companyName = trimToNull(customer.getCompanyName()); - if (companyName != null) { - return companyName; + StringBuilder label = new StringBuilder(buildName(customer, fallbackLabel)); + String street = trimToNull(join(" ", customer.getStreet(), customer.getHouseNumber())); + if (street != null) { + label.append(", ").append(street); + } + String city = trimToNull(join(" ", customer.getZip(), customer.getCity())); + if (city != null) { + label.append(", ").append(city); + } + return label.toString(); + } + + /** Plain name label: company name, otherwise person name, otherwise fallback. */ + public static String buildName(Customer customer, String fallbackLabel) { + if (customer == null) { + return fallbackLabel; } - String fullName = trimToNull(join(" ", customer.getFirstname(), customer.getLastName())); - return fullName != null ? fullName : fallbackLabel; + String name = trimToNull(customer.getCompanyName()); + if (name == null) { + name = trimToNull(join(" ", customer.getFirstname(), customer.getLastName())); + } + return name != null ? name : fallbackLabel; } public static String resolveCompanyValue(Map addressOptions, String comboValue) { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java index 40e66e0..cd8a59d 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java @@ -687,7 +687,7 @@ public class DeliveryStationDialog extends Dialog { private void setupCompanyAutocomplete(ComboBox companyField, List customers) { companyAddressOptions.clear(); for (Customer customer : customers) { - CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer, + CustomerAddressLabelHelper.put(companyAddressOptions, customer, translationHelper.getTranslation("addjob.customer.unnamed")); } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java index 52fe7d3..4640c2a 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java @@ -229,7 +229,7 @@ public class DeliveryStationTile extends VerticalLayout { TranslationHelper translationHelper) { companyAddressOptions.clear(); for (Customer customer : customers) { - CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer, + CustomerAddressLabelHelper.put(companyAddressOptions, customer, translationHelper.getTranslation("addjob.customer.unnamed")); } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java index e15565c..f28c158 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java @@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog { customerLabelMap.clear(); for (Customer c : customers) { - CustomerAddressLabelHelper.putUnique(customerLabelMap, c, + CustomerAddressLabelHelper.putName(customerLabelMap, c, translationHelper.getTranslation("addjob.customer.unnamed")); } customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); @@ -832,7 +832,7 @@ public class PickupStationDialog extends Dialog { private void setupCompanyAutocomplete(ComboBox companyField, List customers) { companyCustomerMap.clear(); for (Customer customer : customers) { - CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer, + CustomerAddressLabelHelper.put(companyCustomerMap, customer, translationHelper.getTranslation("addjob.customer.unnamed")); } companyField.setItems(new ArrayList<>(companyCustomerMap.keySet())); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/domain/AddCustomerRepository.java b/backend/src/main/java/de/assecutor/votianlt/pages/domain/AddCustomerRepository.java index 65ba975..227f633 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/domain/AddCustomerRepository.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/domain/AddCustomerRepository.java @@ -1,9 +1,11 @@ package de.assecutor.votianlt.pages.domain; import de.assecutor.votianlt.model.Customer; +import java.util.List; import org.bson.types.ObjectId; import org.springframework.data.mongodb.repository.MongoRepository; public interface AddCustomerRepository extends MongoRepository { + List findByOwnerAndInternal(ObjectId owner, boolean internal); } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/service/AddCustomerService.java b/backend/src/main/java/de/assecutor/votianlt/pages/service/AddCustomerService.java index 8e308d6..c389e8f 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/service/AddCustomerService.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/service/AddCustomerService.java @@ -21,18 +21,29 @@ public class AddCustomerService { this.sequenceGeneratorService = sequenceGeneratorService; } - public void addCustomer(Customer customer) { + /** + * Creates the customer unless a completely identical entry already exists + * for the current owner. Returns false if the duplicate was skipped. + */ + public boolean addCustomer(Customer customer) { validateCustomer(customer); // Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser(); customer.setCreatedBy(currentUser.getId()); customer.setOwner(currentUser.getId()); + + // Keine komplett identischen Einträge anlegen + if (identicalCustomerExists(customer)) { + return false; + } + if (customer.getUsrId() == null) { customer.setUsrId(sequenceGeneratorService.nextCustomerNumber()); } addCustomerRepository.save(customer); + return true; } public void addInternalCustomer(Customer customer) { @@ -44,6 +55,28 @@ public class AddCustomerService { addCustomer(customer); } + private boolean identicalCustomerExists(Customer customer) { + return addCustomerRepository.findByOwnerAndInternal(customer.getOwner(), customer.isInternal()).stream() + .anyMatch(existing -> isIdentical(existing, customer)); + } + + private boolean isIdentical(Customer a, Customer b) { + return equalsNormalized(a.getCompanyName(), b.getCompanyName()) && equalsNormalized(a.getTitle(), b.getTitle()) + && equalsNormalized(a.getFirstname(), b.getFirstname()) + && equalsNormalized(a.getLastName(), b.getLastName()) + && equalsNormalized(a.getTelephone(), b.getTelephone()) && equalsNormalized(a.getMail(), b.getMail()) + && equalsNormalized(a.getStreet(), b.getStreet()) + && equalsNormalized(a.getHouseNumber(), b.getHouseNumber()) + && equalsNormalized(a.getAddressAddition(), b.getAddressAddition()) + && equalsNormalized(a.getZip(), b.getZip()) && equalsNormalized(a.getCity(), b.getCity()); + } + + private boolean equalsNormalized(String first, String second) { + String left = first != null ? first.trim() : ""; + String right = second != null ? second.trim() : ""; + return left.equalsIgnoreCase(right); + } + public void updateCustomer(Customer customer) { if (customer == null || customer.getId() == null) { throw new IllegalArgumentException("Kunden-ID fehlt"); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java index 570e27c..c1abe2c 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java @@ -214,7 +214,13 @@ public class AddCustomerView extends Main implements HasDynamicTitle { Customer customer = new Customer(); binder.writeBean(customer); - addCustomerService.addCustomer(customer); + boolean created = addCustomerService.addCustomer(customer); + if (!created) { + com.vaadin.flow.component.notification.Notification.show( + getTranslation("addcustomer.notification.duplicate"), 5000, + com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER); + return; + } com.vaadin.flow.component.notification.Notification.show(getTranslation("addcustomer.notification.success"), 3000, com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index b6fc0e2..9545a57 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle { List ownerCustomers = customerService.findAllForCurrentOwner(); customerLabelToEntity.clear(); for (Customer c : ownerCustomers) { - CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); + CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); } customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); @@ -1415,7 +1415,7 @@ public class AddJobView extends Main implements HasDynamicTitle { Map addressOptions = new LinkedHashMap<>(); for (Customer customer : allCustomers) { - CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed")); + CustomerAddressLabelHelper.put(addressOptions, customer, getTranslation("addjob.customer.unnamed")); } // Set items for autocomplete diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java index 22b905d..43117f6 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java @@ -23,11 +23,13 @@ import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.votianlt.model.PriceTable; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.invoices.SystemInvoiceData; +import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.view.AdminLayout; import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.PriceTableRepository; +import de.assecutor.votianlt.repository.SystemInvoiceDispatchRepository; import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.EmailService; @@ -40,6 +42,7 @@ import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -72,10 +75,20 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { private final CustomerInvoiceService customerInvoiceService; private final SystemCompanyProfileService systemCompanyProfileService; private final EmailService emailService; + private final SystemInvoiceDispatchRepository dispatchRepository; + + private final Grid grid = new Grid<>(); /** Billing month, selectable by the user; defaults to the current month. */ private YearMonth billingMonth = YearMonth.now(); + /** Filter for the invoice list: all, only open or only sent invoices. */ + private enum InvoiceFilter { + ALL, OPEN, SENT + } + + private InvoiceFilter invoiceFilter = InvoiceFilter.ALL; + /** Discount with reason per customer (user id), entered via the dialog. */ private final Map discounts = new HashMap<>(); @@ -86,7 +99,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository, AppUserRepository appUserRepository, InvoiceTemplateService invoiceTemplateService, CustomerInvoiceService customerInvoiceService, SystemCompanyProfileService systemCompanyProfileService, - EmailService emailService) { + EmailService emailService, SystemInvoiceDispatchRepository dispatchRepository) { this.userRepository = userRepository; this.priceTableRepository = priceTableRepository; this.appUserRepository = appUserRepository; @@ -94,6 +107,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { this.customerInvoiceService = customerInvoiceService; this.systemCompanyProfileService = systemCompanyProfileService; this.emailService = emailService; + this.dispatchRepository = dispatchRepository; setSizeFull(); addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, @@ -116,17 +130,33 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { month -> month.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()))); monthSelect.setValue(billingMonth); + // Filter: all invoices of the month, only open or only sent ones + Select filterSelect = new Select<>(); + filterSelect.setLabel(getTranslation("admincreateinvoices.filter.label")); + filterSelect.setItems(InvoiceFilter.values()); + filterSelect.setItemLabelGenerator( + filter -> getTranslation("admincreateinvoices.filter." + filter.name().toLowerCase())); + filterSelect.setValue(invoiceFilter); + filterSelect.addValueChangeListener(event -> { + if (event.getValue() != null) { + invoiceFilter = event.getValue(); + grid.deselectAll(); + grid.setItems(loadBillingInfos()); + } + }); + add(new ViewToolbar(getTranslation("admincreateinvoices.title"))); - // Month selection left-aligned below the heading - monthSelect.getStyle().set("align-self", "flex-start"); - add(monthSelect); + // Month and filter selection left-aligned below the heading + com.vaadin.flow.component.orderedlayout.HorizontalLayout selectionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout( + monthSelect, filterSelect); + selectionLayout.getStyle().set("align-self", "flex-start"); + add(selectionLayout); Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint")); hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE); add(hint); - Grid grid = new Grid<>(); grid.setItems(loadBillingInfos()); grid.addColumn(info -> customerDisplayName(info.user())) .setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true); @@ -211,8 +241,16 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { private List loadBillingInfos() { PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null); - return userRepository.findAll().stream().filter(this::isCurrentCustomer) + List infos = userRepository.findAll().stream().filter(this::isCurrentCustomer) .filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).toList(); + if (invoiceFilter == InvoiceFilter.ALL) { + return infos; + } + java.util.Set sentInvoiceNumbers = dispatchRepository.findByBillingMonth(billingMonth.toString()) + .stream().map(SystemInvoiceDispatch::getInvoiceNumber).collect(java.util.stream.Collectors.toSet()); + boolean wantSent = invoiceFilter == InvoiceFilter.SENT; + return infos.stream() + .filter(info -> sentInvoiceNumbers.contains(buildInvoiceNumber(info.user())) == wantSent).toList(); } /** @@ -535,6 +573,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel, issuerName), preview.pdf(), preview.invoiceNumber() + ".pdf"); + recordDispatch(preview, email); sent++; } catch (Exception ex) { log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex); @@ -542,6 +581,8 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { } } if (sent > 0) { + grid.deselectAll(); + grid.setItems(loadBillingInfos()); Notification .show(getTranslation("admincreateinvoices.notification.sent", sent), 5000, Notification.Position.BOTTOM_CENTER) @@ -555,6 +596,25 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { } } + /** + * Persists (or refreshes) the dispatch record of a sent invoice so the + * list can be filtered into open and sent invoices. + */ + private void recordDispatch(InvoicePreview preview, String email) { + try { + SystemInvoiceDispatch dispatch = dispatchRepository.findByInvoiceNumber(preview.invoiceNumber()) + .orElseGet(SystemInvoiceDispatch::new); + dispatch.setInvoiceNumber(preview.invoiceNumber()); + dispatch.setUserId(preview.info().user().getId()); + dispatch.setBillingMonth(billingMonth.toString()); + dispatch.setSentAt(LocalDateTime.now()); + dispatch.setSentTo(email); + dispatchRepository.save(dispatch); + } catch (Exception ex) { + log.error("Error recording dispatch of invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex); + } + } + /** * Returns the stored system template data, or null (with a notification) * if no template has been saved yet. diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/SystemInvoiceDispatchRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/SystemInvoiceDispatchRepository.java new file mode 100644 index 0000000..a91b207 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/repository/SystemInvoiceDispatchRepository.java @@ -0,0 +1,15 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; +import java.util.Optional; + +public interface SystemInvoiceDispatchRepository extends MongoRepository { + + List findByBillingMonth(String billingMonth); + + Optional findByInvoiceNumber(String invoiceNumber); +} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 8a98427..b9da999 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -353,6 +353,7 @@ addcustomer.title=Neue Adresse anlegen addcustomer.button.submit=Adresse anlegen addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus addcustomer.notification.success=Kunde erfolgreich angelegt +addcustomer.notification.duplicate=Ein identischer Adressbucheintrag existiert bereits addcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben addcustomer.notification.error=Fehler: {0} addcustomer.validation.required=Dieses Feld ist erforderlich @@ -1131,6 +1132,10 @@ admincreateinvoices.notification.notemplate=Kein Rechnungstemplate vorhanden. Bi admincreateinvoices.notification.error=Fehler beim Erstellen der Rechnung: {0} admincreateinvoices.button.createselected=Rechnungen erstellen ({0}) admincreateinvoices.field.month=Abrechnungsmonat +admincreateinvoices.filter.label=Anzeigen +admincreateinvoices.filter.all=Alle Rechnungen +admincreateinvoices.filter.open=Nur offene +admincreateinvoices.filter.sent=Nur gesendete admincreateinvoices.dialog.title=Rechnungen prüfen admincreateinvoices.dialog.position=Rechnung {0} von {1} admincreateinvoices.dialog.button.previous=Zurück diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 57dc1c0..04a8fd1 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -313,6 +313,7 @@ addcustomer.title=Uue aadressi loomine addcustomer.button.submit=Loo aadress addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad addcustomer.notification.success=Klient edukalt loodud +addcustomer.notification.duplicate=Identne aadressiraamatu kirje on juba olemas addcustomer.notification.check=Palun kontrollige oma sisestusi addcustomer.notification.error=Viga: {0} addcustomer.validation.required=See v\u00e4li on kohustuslik @@ -961,6 +962,10 @@ admincreateinvoices.notification.notemplate=Arve mall puudub. Palun salvestage k admincreateinvoices.notification.error=Viga arve koostamisel: {0} admincreateinvoices.button.createselected=Koosta arved ({0}) admincreateinvoices.field.month=Arveldusperiood +admincreateinvoices.filter.label=Kuva +admincreateinvoices.filter.all=Kõik arved +admincreateinvoices.filter.open=Ainult avatud +admincreateinvoices.filter.sent=Ainult saadetud admincreateinvoices.dialog.title=Arvete ülevaatus admincreateinvoices.dialog.position=Arve {0} / {1} admincreateinvoices.dialog.button.previous=Tagasi diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 74323c4..66b06ea 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -353,6 +353,7 @@ addcustomer.title=Create New Address addcustomer.button.submit=Create Address addcustomer.notification.validation=Please fill in all required fields addcustomer.notification.success=Customer created successfully +addcustomer.notification.duplicate=An identical address book entry already exists addcustomer.notification.check=Please check your input addcustomer.notification.error=Error: {0} addcustomer.validation.required=This field is required @@ -1131,6 +1132,10 @@ admincreateinvoices.notification.notemplate=No invoice template available. Pleas admincreateinvoices.notification.error=Error creating the invoice: {0} admincreateinvoices.button.createselected=Create invoices ({0}) admincreateinvoices.field.month=Billing month +admincreateinvoices.filter.label=Show +admincreateinvoices.filter.all=All invoices +admincreateinvoices.filter.open=Only open +admincreateinvoices.filter.sent=Only sent admincreateinvoices.dialog.title=Review invoices admincreateinvoices.dialog.position=Invoice {0} of {1} admincreateinvoices.dialog.button.previous=Back diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index e12af38..866e08f 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -352,6 +352,7 @@ addcustomer.title=Crear nueva dirección addcustomer.button.submit=Crear dirección addcustomer.notification.validation=Por favor, complete todos los campos obligatorios addcustomer.notification.success=Cliente creado correctamente +addcustomer.notification.duplicate=Ya existe una entrada idéntica en la libreta de direcciones addcustomer.notification.check=Por favor, revise sus datos addcustomer.notification.error=Error: {0} addcustomer.validation.required=Este campo es obligatorio @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=No hay plantilla de factura. Guarde admincreateinvoices.notification.error=Error al crear la factura: {0} admincreateinvoices.button.createselected=Crear facturas ({0}) admincreateinvoices.field.month=Mes de facturación +admincreateinvoices.filter.label=Mostrar +admincreateinvoices.filter.all=Todas las facturas +admincreateinvoices.filter.open=Solo abiertas +admincreateinvoices.filter.sent=Solo enviadas admincreateinvoices.dialog.title=Revisar facturas admincreateinvoices.dialog.position=Factura {0} de {1} admincreateinvoices.dialog.button.previous=Atrás diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index 6648801..2d32c29 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -352,6 +352,7 @@ addcustomer.title=Cr\u00e9er une nouvelle adresse addcustomer.button.submit=Cr\u00e9er l'adresse addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires addcustomer.notification.success=Client cr\u00e9\u00e9 avec succ\u00e8s +addcustomer.notification.duplicate=Une entr\u00e9e identique existe d\u00e9j\u00e0 dans le carnet d'adresses addcustomer.notification.check=Veuillez v\u00e9rifier vos saisies addcustomer.notification.error=Erreur : {0} addcustomer.validation.required=Ce champ est obligatoire @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=Aucun modèle de facture disponible. admincreateinvoices.notification.error=Erreur lors de la création de la facture : {0} admincreateinvoices.button.createselected=Créer les factures ({0}) admincreateinvoices.field.month=Mois de facturation +admincreateinvoices.filter.label=Afficher +admincreateinvoices.filter.all=Toutes les factures +admincreateinvoices.filter.open=Uniquement ouvertes +admincreateinvoices.filter.sent=Uniquement envoyées admincreateinvoices.dialog.title=Vérifier les factures admincreateinvoices.dialog.position=Facture {0} sur {1} admincreateinvoices.dialog.button.previous=Retour diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index 3c28e61..79e4e28 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -352,6 +352,7 @@ addcustomer.title=Sukurti naują adresą addcustomer.button.submit=Sukurti adresą addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus addcustomer.notification.success=Klientas sėkmingai sukurtas +addcustomer.notification.duplicate=Identiškas adresų knygos įrašas jau egzistuoja addcustomer.notification.check=Prašome patikrinti savo įvestį addcustomer.notification.error=Klaida: {0} addcustomer.validation.required=Šis laukas yra privalomas @@ -1064,6 +1065,10 @@ admincreateinvoices.notification.notemplate=Nėra sąskaitos šablono. Pirmiausi admincreateinvoices.notification.error=Klaida kuriant sąskaitą: {0} admincreateinvoices.button.createselected=Sukurti sąskaitas ({0}) admincreateinvoices.field.month=Atsiskaitymo mėnuo +admincreateinvoices.filter.label=Rodyti +admincreateinvoices.filter.all=Visos sąskaitos +admincreateinvoices.filter.open=Tik atviros +admincreateinvoices.filter.sent=Tik išsiųstos admincreateinvoices.dialog.title=Peržiūrėti sąskaitas admincreateinvoices.dialog.position=Sąskaita {0} iš {1} admincreateinvoices.dialog.button.previous=Atgal diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 02ff4bd..019a771 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -352,6 +352,7 @@ addcustomer.title=Izveidot jaunu adresi addcustomer.button.submit=Izveidot adresi addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus addcustomer.notification.success=Klients veiksmīgi izveidots +addcustomer.notification.duplicate=Identisks adrešu grāmatas ieraksts jau pastāv addcustomer.notification.check=Lūdzu, pārbaudiet savus ievadītos datus addcustomer.notification.error=Kļūda: {0} addcustomer.validation.required=Šis lauks ir obligāts @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=Nav rēķina veidnes. Lūdzu, vispir admincreateinvoices.notification.error=Kļūda, veidojot rēķinu: {0} admincreateinvoices.button.createselected=Izveidot rēķinus ({0}) admincreateinvoices.field.month=Norēķinu mēnesis +admincreateinvoices.filter.label=Rādīt +admincreateinvoices.filter.all=Visi rēķini +admincreateinvoices.filter.open=Tikai atvērtie +admincreateinvoices.filter.sent=Tikai nosūtītie admincreateinvoices.dialog.title=Pārskatīt rēķinus admincreateinvoices.dialog.position=Rēķins {0} no {1} admincreateinvoices.dialog.button.previous=Atpakaļ diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index 0afb78f..77f61d6 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -352,6 +352,7 @@ addcustomer.title=Dodaj nowy adres addcustomer.button.submit=Dodaj adres addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola addcustomer.notification.success=Klient zosta\u0142 pomy\u015blnie dodany +addcustomer.notification.duplicate=Identyczny wpis w ksi\u0105\u017cce adresowej ju\u017c istnieje addcustomer.notification.check=Prosz\u0119 sprawdzi\u0107 wprowadzone dane addcustomer.notification.error=B\u0142\u0105d: {0} addcustomer.validation.required=To pole jest wymagane @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=Brak szablonu faktury. Najpierw zapi admincreateinvoices.notification.error=Błąd podczas tworzenia faktury: {0} admincreateinvoices.button.createselected=Utwórz faktury ({0}) admincreateinvoices.field.month=Miesiąc rozliczeniowy +admincreateinvoices.filter.label=Pokaż +admincreateinvoices.filter.all=Wszystkie faktury +admincreateinvoices.filter.open=Tylko otwarte +admincreateinvoices.filter.sent=Tylko wysłane admincreateinvoices.dialog.title=Przegląd faktur admincreateinvoices.dialog.position=Faktura {0} z {1} admincreateinvoices.dialog.button.previous=Wstecz diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 5217644..daf5281 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -352,6 +352,7 @@ addcustomer.title=Создать новый адрес addcustomer.button.submit=Создать адрес addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля addcustomer.notification.success=Клиент успешно создан +addcustomer.notification.duplicate=Идентичная запись в адресной книге уже существует addcustomer.notification.check=Пожалуйста, проверьте ваши данные addcustomer.notification.error=Ошибка: {0} addcustomer.validation.required=Это поле обязательно для заполнения @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=Шаблон счета отсутс admincreateinvoices.notification.error=Ошибка при создании счета: {0} admincreateinvoices.button.createselected=Создать счета ({0}) admincreateinvoices.field.month=Расчетный месяц +admincreateinvoices.filter.label=Показать +admincreateinvoices.filter.all=Все счета +admincreateinvoices.filter.open=Только открытые +admincreateinvoices.filter.sent=Только отправленные admincreateinvoices.dialog.title=Проверка счетов admincreateinvoices.dialog.position=Счет {0} из {1} admincreateinvoices.dialog.button.previous=Назад diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index a500bf1..8cc615f 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -352,6 +352,7 @@ addcustomer.title=Yeni Adres Olu\u015ftur addcustomer.button.submit=Adres Olu\u015ftur addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun addcustomer.notification.success=M\u00fc\u015fteri ba\u015far\u0131yla olu\u015fturuldu +addcustomer.notification.duplicate=Adres defterinde ayn\u0131 kay\u0131t zaten mevcut addcustomer.notification.check=L\u00fctfen giri\u015flerinizi kontrol edin addcustomer.notification.error=Hata: {0} addcustomer.validation.required=Bu alan gereklidir @@ -1062,6 +1063,10 @@ admincreateinvoices.notification.notemplate=Fatura şablonu yok. Lütfen önce f admincreateinvoices.notification.error=Fatura oluşturulurken hata: {0} admincreateinvoices.button.createselected=Fatura oluştur ({0}) admincreateinvoices.field.month=Fatura ayı +admincreateinvoices.filter.label=Göster +admincreateinvoices.filter.all=Tüm faturalar +admincreateinvoices.filter.open=Yalnızca açık +admincreateinvoices.filter.sent=Yalnızca gönderilen admincreateinvoices.dialog.title=Faturaları incele admincreateinvoices.dialog.position=Fatura {0} / {1} admincreateinvoices.dialog.button.previous=Geri