Compare commits

...

7 Commits

Author SHA1 Message Date
31b18e1f52 refactor: E-Rechnungs-Modul, Freigabe-Workflow und DATEV-Export entfernt
Entfernt ZUGFeRD/Factur-X-Anreicherung (Mustangproject), PAdES-Signatur
(BouncyCastle/DSS) inkl. nutzerseitiger Keystore-Verwaltung, den
Approval-Workflow für Storno-/Berichtigungsbelege sowie den DATEV-CSV-Export.
Navigation kehrt zur klassischen Rechnungsansicht zurück; Version auf 0.9.17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 08:43:13 +02:00
d699609aa1 feat: E-Rechnungs-Backend, Pflichtangaben-Validator, Nummern-Audit und DATEV-Export
Größerer Wurf rund um Rechnungen: das vollständige E-Rechnungs- und
Lifecycle-Backend wird eingecheckt, die UI-seitige Rechnungserstellung wird
zugunsten eines DATEV-Exports zurückgebaut, und die Test-Infrastruktur wird
auf JDK 25 angehoben.

E-Rechnung & Signatur
- ZUGFeRD/Factur-X/XRechnung-Anreicherung via Mustang (EInvoiceService).
- PAdES-Detached-Signatur via PDFBox + BouncyCastle, System- und Nutzer-
  Keystore-Pfad, Nutzer-Keystores AES-GCM-verschlüsselt (AesGcmCipher,
  SigningCredentialsService, UserSigningCredentials).
- Konfiguration via EInvoiceProperties + EInvoiceSecurityInitializer.
- Approval-Workflow für kritische Rechnungsvorgänge (Storno, Berichtigung):
  InvoiceApprovalService, InvoiceApprovalRequest, ApprovalsView,
  InvoicePermissionService, InvoiceRoles.

Lifecycle & Audit
- InvoiceStatus DRAFT/ISSUED/SENT/CANCELLED/CORRECTED, InvoiceType
  INVOICE/CORRECTION/CANCELLATION, PaymentStatus, lückenloser Audit-Trail
  via InvoiceAuditEntry/Action.
- InvoiceLifecycleService verwaltet alle Übergänge inklusive Storno- und
  Berichtigungsbelegen mit Querverweis zur Originalrechnung.
- InvoiceLifecycleMigration zieht Bestandsdaten in das neue Lifecycle-Modell.
- InvoiceExportService bündelt Original + Folgebelege als ZIP für die
  Auslieferung an den Steuerberater.

Pflichtangaben-Validator (§ 14 UStG)
- InvoiceComplianceValidator + Exception sammeln alle Verstöße in einem
  Lauf (Pflichtfelder, Adressen, Steuernummer/USt-IdNr, Items, Betrags-
  konsistenz, Hinweispflicht bei 0 % USt).
- Wird vor jedem DRAFT-→-ISSUED-Übergang im Lifecycle aufgerufen, sodass
  festgeschriebene Rechnungen keine Pflichtfeld-Lücken mehr haben können.

Rechnungsnummer-Audit (§ 14 Abs. 4 Nr. 4 UStG / GoBD)
- InvoiceNumberReservation + Status RESERVED/USED/VOIDED protokollieren
  jede aus dem Counter gezogene Nummer.
- UserInvoiceDataService schreibt bei Vergabe ein RESERVED-Audit, der
  Lifecycle setzt nach Festschreiben USED bzw. nach Löschen eines Entwurfs
  VOIDED — Lücken im Nummernkreis sind dadurch erklärbar.
- InvoiceNumberAuditService liefert markUsed/markVoided/findUnused für
  Folge-UI und Betriebsprüfungs-Reports.

UI-Rückbau und DATEV-Export
- Routen für CreateInvoiceView, InvoicesView, MyInvoicesView auskommentiert
  (Code bleibt erhalten, Reaktivierung dokumentiert).
- Rechnungs-Buttons aus ShowJobsView entfernt, Nav-Eintrag „Rechnungen"
  durch „DATEV-Export" ersetzt.
- DatevExportService erzeugt einen DATEV-EXTF-Buchungsstapel (Version 7,
  Windows-1252, CRLF) mit SKR03-Erlöskonten (8400/8300/8125), Sammel-
  debitor 10000 und korrektem S/H-Verhalten für Stornorechnungen.
- DatevExportView mit Zeitraum-Picker und Auto-Download.
- i18n-Keys (de/en) für nav.datev.export und datev.export.*.

Tests & Build
- EInvoiceServiceTest (Signatur-Pfade), EInvoiceServiceDssValidationTest
  (PAdES-Profil via EU DSS 6.2 — dokumentiert PKCS7-B als Ist-Stand),
  InvoiceComplianceValidatorTest (26 Cases als Spezifikation der
  Pflichtangaben), InvoiceNumberAuditServiceTest, DatevExportServiceTest.
- Mockito 5.18 + ByteBuddy 1.17.5 in <dependencyManagement> gepinnt;
  die Spring-Boot-3.4.3-Defaults (Mockito 5.14.2 / ByteBuddy 1.15.11)
  konnten den Inline-Mock-Maker auf JDK 25 nicht laden, weshalb die
  beiden DemoModeServiceTests vorher rot waren.
- DSS-Test-Dependencies (dss-pades-pdfbox, dss-validation,
  dss-utils-apache-commons, dss-crl-parser-x509crl 6.2) im Test-Scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:34:04 +02:00
5ac629c23d feat: Kundenauswahl vereinheitlicht und Job manuell beenden mit Leistungs-/Routenerfassung
- Kunden-Repository liefert auch Legacy-Dokumente ohne internal-Flag ($ne: true)
- Auftraggeber- und Abholadress-Labels über neuen CustomerAddressLabelHelper, zeigen nur Firmenname bzw. Vor-/Nachname ohne Adresszusatz
- Pickup-Dialog: E-Mail ist kein Pflichtfeld mehr
- JobManualCompleteView erhält Route-/Leistungen-/Zusammenfassung-/Bemerkung-Block mit Vorbelegung aus dem Auftrag; bei fehlenden Routendaten manuelle Eingabe von Entfernung und Dauer, die in die Preisermittlung einfliessen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:11:08 +02:00
069b829294 feat: konfigurierbarer USt-Satz im Profil, Rechnungserstellung und Vorschau
- Neues Feld vatRate im User-Profil (Default 19 %), bearbeitbar im
  Rechnungs-Tab neben Rechnungslegung-Checkbox und Rechnungsprefix
- Canvas-Vorschau und PDF-Vorschau reagieren live auf den eingegebenen
  Steuersatz (JS-Setter updateProfileVatRate, dynamische Sample-Zeilen
  und Summary)
- Neue USt-Kachel auf create_invoice mit Eingabefeld; Summary-Kachel,
  PDF-Preview und gespeicherte Rechnung übernehmen den Feldwert
- Rechnungsvorschau für reale Aufträge auf dreispaltiges Layout (Name,
  Steuersatz, Nettobetrag) inkl. "zzgl. X% USt"-Zeile vereinheitlicht
- Kachel-Overflow auf create_invoice durch box-sizing: border-box
  korrigiert
2026-04-21 10:01:11 +02:00
704d1e7378 feat: Adressbuch mit Kundennummer, Update-Flow und interne Einträge
- Menüpunkt "Kunden" in "Adressbuch" umbenannt und App-Label
  "Verfügbare Jobs" zu "Auftragsliste" geändert (alle 10 Sprachen)
- Fortlaufende Kundennummer (usrId) ab 10000 über neuen
  SequenceGeneratorService und Counter-Dokument in misc-Collection
- Abholung/Lieferstation-Dialog: Änderungen an verknüpften
  Stammdaten aktualisieren den bestehenden Adressbuch-Eintrag
  statt einen neuen zu erzeugen; Checkbox-Label wechselt zu
  "Adresse im Adressbuch aktualisieren"
- Geänderte Adressen ohne Checkbox werden als interner Customer
  (internal=true) gesichert und im Adressbuch ausgeblendet
- E-Mail in AddCustomer und in Stations-Dialogen kein Pflichtfeld
  mehr; "(Login)" aus profile.email entfernt
- Manuelles Beenden eines Auftrags öffnet neue Seite
  JobManualCompleteView statt eines Dialogs
2026-04-20 12:42:56 +02:00
6e8bedd9b4 feat: Drag-and-Drop-Reihenfolge, Station-Abschluss-Flow und UI-Verbesserungen
Lieferstationen-Dialog (Backend/Vaadin):
- Aufgaben per Drag & Drop neu anordnen, inkl. Drag-Handle, komprimierter
  Kachelansicht während des Drags und horizontaler Einfügelinie als Drop-Target
- Drop-Indikator wird unterdrückt, wenn der Drop keine Positionsänderung bewirken
  würde, und nach dem Abschluss clientseitig zuverlässig aufgeräumt
- Drag-Handle, Aufgabentyp-Label und Close-Button auf einheitlicher Position
  ausgerichtet; Abstände in der Kachel komprimiert

Station-Abschluss-Flow (Flutter-App + Backend):
- Neuer Button "Station abschließen" unter den Aufgaben; deaktiviert, solange
  Pflichtaufgaben offen sind, ansonsten aktiv (auch wenn nur optionale Aufgaben
  existieren)
- Hinweisdialog nach Erledigung der letzten Pflichtaufgabe sowie Warnung bei
  offenen optionalen Aufgaben vor dem Senden
- Neue station_completed-Nachricht (jobId, jobNumber, stationOrder,
  completedAt, hasIncompleteOptionalTasks) wird an den Server gesendet
- Backend: Auftrag wird nicht mehr automatisch beim Erledigen der letzten
  Pflichtaufgabe abgeschlossen, sondern erst beim Empfang der
  station_completed-Nachricht (neuer Handler in MessageController und
  MessagingConfig)

Aufgabenliste in der App:
- Farbcodierung optionaler Aufgaben entfernt; stattdessen vertikal zentrierter
  "Optional"-Chip am rechten Kartenrand

Weitere UI-Überarbeitungen über Login, Jobs, Chats, Settings, Aufgaben-Capture-
Screens, Offline-Banner und zugehörige Widgets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:26:30 +02:00
1ac755bcbd Version 0.9.16: Skip-Button entfernt, manuelle Auftragsbeendigung und E-Mail-Verbesserungen
App:
- Skip-Button für optionale Aufgaben entfernt — optionale Aufgaben blockieren
  nicht mehr den Fortschritt und können jederzeit nachträglich bearbeitet werden

Backend:
- Manuelle Auftragsbeendigung mit Begründung in der Job-Zusammenfassung hinzugefügt
- Leere Lieferstationen werden beim Übernehmen automatisch entfernt
- E-Mail-Benachrichtigungen zeigen jetzt den tatsächlichen App-Benutzernamen an
- WebSocket: konfigurierbare Max-Nachrichtengröße und Session-Idle-Timeout
- docker_push.sh Pfadkorrektur
- Lokalisierungen für 10 Sprachen aktualisiert
- EmailService-Test hinzugefügt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:43:38 +02:00
100 changed files with 6954 additions and 989 deletions

175
app/lib/app_theme.dart Normal file
View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
class AppColors {
static const Color primary = Color(0xFF2563EB);
static const Color primaryStrong = Color(0xFF1D4ED8);
static const Color primarySoft = Color(0xFFE8F0FF);
static const Color secondary = Color(0xFF0F4C5C);
static const Color secondarySoft = Color(0xFFDDEEF2);
static const Color success = Color(0xFF059669);
static const Color successSoft = Color(0xFFE7F6F1);
static const Color warning = Color(0xFFD97706);
static const Color warningSoft = Color(0xFFFFF4E5);
static const Color danger = Color(0xFFDC2626);
static const Color dangerSoft = Color(0xFFFDECEC);
static const Color surface = Color(0xFFFFFFFF);
static const Color surfaceMuted = Color(0xFFF7FAFF);
static const Color scaffold = Color(0xFFF5F7FB);
static const Color scaffoldAccent = Color(0xFFEEF4FF);
static const Color border = Color(0xFFD6DDE7);
static const Color borderStrong = Color(0xFFC6D0DD);
static const Color text = Color(0xFF1E293B);
static const Color textStrong = Color(0xFF0F172A);
static const Color textMuted = Color(0xFF64748B);
}
class AppGradients {
static const LinearGradient shellBackground = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.scaffoldAccent,
AppColors.surfaceMuted,
AppColors.scaffold,
],
stops: [0, 0.45, 1],
);
}
ThemeData buildAppTheme() {
final colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light,
).copyWith(
primary: AppColors.primary,
onPrimary: Colors.white,
primaryContainer: AppColors.primarySoft,
onPrimaryContainer: AppColors.primaryStrong,
secondary: AppColors.secondary,
onSecondary: Colors.white,
secondaryContainer: AppColors.secondarySoft,
onSecondaryContainer: AppColors.secondary,
tertiary: AppColors.success,
onTertiary: Colors.white,
tertiaryContainer: AppColors.successSoft,
onTertiaryContainer: AppColors.success,
surface: AppColors.surface,
onSurface: AppColors.textStrong,
onSurfaceVariant: AppColors.textMuted,
outline: AppColors.border,
surfaceTint: Colors.transparent,
error: AppColors.danger,
onError: Colors.white,
);
final baseTheme = ThemeData(useMaterial3: true, colorScheme: colorScheme);
const radius = Radius.circular(14);
final border = OutlineInputBorder(
borderRadius: const BorderRadius.all(radius),
borderSide: const BorderSide(color: AppColors.border),
);
return baseTheme.copyWith(
scaffoldBackgroundColor: AppColors.scaffold,
canvasColor: AppColors.scaffold,
textTheme: baseTheme.textTheme
.apply(bodyColor: AppColors.text, displayColor: AppColors.textStrong)
.copyWith(
headlineMedium: baseTheme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textStrong,
),
titleLarge: baseTheme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textStrong,
),
bodyLarge: baseTheme.textTheme.bodyLarge?.copyWith(
color: AppColors.text,
),
bodyMedium: baseTheme.textTheme.bodyMedium?.copyWith(
color: AppColors.text,
),
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.primarySoft,
foregroundColor: AppColors.primaryStrong,
surfaceTintColor: Colors.transparent,
elevation: 0,
),
cardTheme: const CardThemeData(
color: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(18)),
side: BorderSide(color: AppColors.border),
),
),
dividerTheme: const DividerThemeData(
color: AppColors.border,
space: 1,
thickness: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
labelStyle: const TextStyle(color: AppColors.textMuted),
hintStyle: const TextStyle(color: AppColors.textMuted),
prefixIconColor: AppColors.textMuted,
suffixIconColor: AppColors.textMuted,
border: border,
enabledBorder: border,
focusedBorder: border.copyWith(
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
errorBorder: border.copyWith(
borderSide: const BorderSide(color: AppColors.danger),
),
focusedErrorBorder: border.copyWith(
borderSide: const BorderSide(color: AppColors.danger, width: 1.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.border,
disabledForegroundColor: AppColors.textMuted,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryStrong,
textStyle: const TextStyle(fontWeight: FontWeight.w600),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.textStrong,
contentTextStyle: baseTheme.textTheme.bodyMedium?.copyWith(
color: Colors.white,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
badgeTheme: const BadgeThemeData(
backgroundColor: AppColors.primary,
textColor: Colors.white,
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primary,
),
listTileTheme: const ListTileThemeData(iconColor: AppColors.textMuted),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/delivery_station.dart';
@@ -20,7 +21,7 @@ Color? deliveryStationCardBackgroundColor(
final isCompleted = station.tasks.every(
(task) => taskStatuses[task.id] ?? task.completed,
);
return isCompleted ? Colors.green[50] : null;
return isCompleted ? AppColors.successSoft : null;
}
class CargoItemsView extends StatefulWidget {
@@ -57,7 +58,6 @@ class _CargoItemsViewState extends State<CargoItemsView> {
return Scaffold(
appBar: AppBar(
title: Text(widget.job.jobNumber),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -139,7 +139,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icon(
Icons.arrow_downward,
size: 16,
color: Colors.blue[600],
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
@@ -163,7 +163,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icon(
Icons.local_shipping_outlined,
size: 24,
color: Colors.deepPurple[600],
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
@@ -313,7 +313,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.deepPurple[100],
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -321,7 +321,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.deepPurple[700],
color: AppColors.primaryStrong,
),
),
),
@@ -359,7 +359,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icons.location_on_outlined,
AppLocalizations.of(context).location,
addressLines.join('\n'),
Colors.blue,
AppColors.primary,
),
if (station.phone.trim().isNotEmpty) ...[
const SizedBox(height: 12),
@@ -367,7 +367,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icons.phone_outlined,
l10n.phone,
station.phone,
Colors.green,
AppColors.success,
),
],
if (station.deliveryDate.trim().isNotEmpty ||
@@ -380,7 +380,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
station.deliveryDate,
station.deliveryTime,
].where((part) => part.trim().isNotEmpty).join(' '),
Colors.orange,
AppColors.warning,
),
],
const SizedBox(height: 12),
@@ -388,7 +388,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icons.task_alt,
AppLocalizations.of(context).tasks,
'${station.tasks.length}',
Colors.deepPurple,
AppColors.primaryStrong,
),
],
),

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'app_state.dart';
@@ -257,13 +258,12 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
'Job-Nr: ${_activeChat.jobNumber}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: AppColors.textMuted,
fontWeight: FontWeight.normal,
),
),
],
),
backgroundColor: Colors.deepPurple[100],
actions: [
IconButton(
icon: Icon(isJobChat ? Icons.work : Icons.support_agent),
@@ -281,7 +281,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
// Messages list
Expanded(
child: Container(
decoration: BoxDecoration(color: Colors.grey[50]),
decoration: const BoxDecoration(color: AppColors.surfaceMuted),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
@@ -325,7 +325,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
vertical: isImage ? 6 : 8,
),
decoration: BoxDecoration(
color: isOwn ? Colors.deepPurple[100] : Colors.white,
color: isOwn ? AppColors.primarySoft : AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
@@ -351,7 +351,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
children: [
Text(
_formatMessageTime(message.createdAt),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
if (isOwn) ...[
const SizedBox(width: 4),
@@ -362,10 +365,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
size: 14,
color:
message.pendingSync
? Colors.orange[700]
? AppColors.warning
: (message.read
? Colors.deepPurple[400]
: Colors.grey[600]),
? AppColors.primary
: AppColors.textMuted),
),
],
],
@@ -384,7 +387,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (!isImage) {
return Text(
message.content,
style: TextStyle(fontSize: 15, color: Colors.grey[800]),
style: const TextStyle(fontSize: 15, color: AppColors.textStrong),
);
}
@@ -455,8 +458,8 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey[300]!)),
color: AppColors.surface,
border: const Border(top: BorderSide(color: AppColors.border)),
),
child: SafeArea(
child: Row(
@@ -466,12 +469,12 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.attach_file,
color: Colors.black87,
color: AppColors.text,
size: 20,
),
),
@@ -480,7 +483,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
),
child: TextField(
@@ -508,7 +511,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.deepPurple,
color: AppColors.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.send, color: Colors.white, size: 20),

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/chat.dart';
@@ -52,10 +53,7 @@ class _ChatsViewState extends State<ChatsView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).chats),
backgroundColor: Colors.deepPurple[100],
),
appBar: AppBar(title: Text(AppLocalizations.of(context).chats)),
body: Column(
children: [const OfflineBanner(), Expanded(child: _buildBody())],
),
@@ -72,11 +70,15 @@ class _ChatsViewState extends State<ChatsView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.chat_outlined, size: 64, color: Colors.grey),
const Icon(
Icons.chat_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context).noChatsAvailable,
style: const TextStyle(fontSize: 16, color: Colors.grey),
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
),
],
),
@@ -108,10 +110,11 @@ class _ChatsViewState extends State<ChatsView> {
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isJobChat ? Colors.blue[100] : Colors.green[100],
backgroundColor:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
child: Icon(
isJobChat ? Icons.work : Icons.support_agent,
color: isJobChat ? Colors.blue[700] : Colors.green[700],
color: isJobChat ? AppColors.primaryStrong : AppColors.secondary,
),
),
title: Text(() {
@@ -129,7 +132,7 @@ class _ChatsViewState extends State<ChatsView> {
previewText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -137,16 +140,17 @@ class _ChatsViewState extends State<ChatsView> {
children: [
Text(
timeLabel,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isJobChat ? Colors.blue[50] : Colors.green[50],
color:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isJobChat ? Colors.blue[200]! : Colors.green[200]!,
color: isJobChat ? AppColors.primary : AppColors.secondary,
),
),
child: Text(
@@ -154,7 +158,8 @@ class _ChatsViewState extends State<ChatsView> {
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isJobChat ? Colors.blue[700] : Colors.green[700],
color:
isJobChat ? AppColors.primaryStrong : AppColors.secondary,
),
),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'services/websocket_service.dart';
@@ -98,7 +99,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (isConnected && !_wasConnected) {
_showSnack(
AppLocalizations.of(context).connectionRestored,
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
);
if (_appState.isLoggedIn) {
_loadJobs();
@@ -115,7 +116,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (_appState.isLoggedIn && !_isLoggingOut) {
_showSnack(
AppLocalizations.of(context).connectionLost,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
}
@@ -146,7 +147,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
jobNumber != null
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
: AppLocalizations.of(context).jobRemoved;
_showSnack(message, backgroundColor: Colors.orange);
_showSnack(message, backgroundColor: AppColors.warning);
}
});
@@ -177,7 +178,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
jobNumber.isNotEmpty
? '${AppLocalizations.of(context).newJobReceived}: $jobNumber'
: AppLocalizations.of(context).newJobReceived;
_showSnack(message, backgroundColor: Colors.green);
_showSnack(message, backgroundColor: AppColors.success);
}
} catch (e) {
developer.log('Error handling job_created event: $e', name: 'JobsView');
@@ -204,7 +205,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
});
_showSnack(
AppLocalizations.of(context).jobsUpdated,
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
);
}
} finally {
@@ -560,7 +561,6 @@ class _JobsViewState extends State<JobsView> with RouteAware {
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(AppLocalizations.of(context).availableJobs),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
@@ -694,7 +694,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
foregroundColor: Colors.white,
),
child: Text(AppLocalizations.of(context).logout),
@@ -766,8 +766,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).refresh),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple[100],
foregroundColor: Colors.deepPurple[700],
backgroundColor: AppColors.primarySoft,
foregroundColor: AppColors.primaryStrong,
),
),
],
@@ -792,7 +792,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
} else {
_showSnack(
AppLocalizations.of(context).offline,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
}
@@ -908,7 +908,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) {
_showSnack(
AppLocalizations.of(context).jobDeleted,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
} catch (e, st) {
@@ -917,7 +917,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) {
_showSnack(
AppLocalizations.of(context).jobDeleteError,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
} finally {
@@ -935,19 +935,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Color statusColor;
switch (job.statusColor) {
case 'green':
statusColor = Colors.green;
statusColor = AppColors.success;
break;
case 'blue':
statusColor = Colors.blue;
statusColor = AppColors.primary;
break;
case 'orange':
statusColor = Colors.orange;
statusColor = AppColors.warning;
break;
case 'red':
statusColor = Colors.red;
statusColor = AppColors.danger;
break;
default:
statusColor = Colors.grey;
statusColor = AppColors.textMuted;
}
// Determine card background color based on task completion
@@ -965,9 +965,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (totalTasks == 0 || completedTasks == 0) {
cardBg = null; // unchanged (default)
} else if (completedTasks > 0 && completedTasks < totalTasks) {
cardBg = Colors.yellow[50];
cardBg = AppColors.warningSoft;
} else if (completedTasks == totalTasks) {
cardBg = Colors.green[50];
cardBg = AppColors.successSoft;
}
// Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
@@ -1033,7 +1033,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
iconSize: 28,
padding: const EdgeInsets.all(10),
splashRadius: 24,
icon: const Icon(Icons.delete, color: Colors.red),
icon: const Icon(Icons.delete, color: AppColors.danger),
tooltip: AppLocalizations.of(context).deleteJob,
onPressed: () {
if (isDeleting) {
@@ -1233,13 +1233,13 @@ class _JobsViewState extends State<JobsView> with RouteAware {
? 0
: completedTasks / totalTasks,
minHeight: 8,
backgroundColor: Colors.grey[200],
backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>(
completedTasks >= totalTasks
? Colors.green
? AppColors.success
: (completedTasks > 0
? Colors.amber
: Colors.deepPurpleAccent),
? AppColors.warning
: AppColors.primary),
),
),
),
@@ -1336,7 +1336,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Icon(
Icons.arrow_downward,
size: 16,
color: Colors.blue[600],
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
@@ -1375,7 +1375,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
tooltip: 'Route planen',
icon: const Icon(
Icons.route,
color: Colors.blueAccent,
color: AppColors.primary,
),
onPressed: () {
if (_routeActionInProgress) return;

View File

@@ -55,7 +55,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get jobs => 'Jobs';
@override
String get availableJobs => 'Verfügbare Jobs';
String get availableJobs => 'Auftragsliste';
@override
String get chats => 'Chats';

View File

@@ -55,7 +55,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get jobs => 'Jobs';
@override
String get availableJobs => 'Available Jobs';
String get availableJobs => 'Order List';
@override
String get chats => 'Chats';

View File

@@ -42,7 +42,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get jobs => 'Trabajos';
@override
String get availableJobs => 'Trabajos Disponibles';
String get availableJobs => 'Lista de pedidos';
@override
String get chats => 'Chats';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get jobs => 'Tööd';
@override
String get availableJobs => 'Saadaolevad tööd';
String get availableJobs => 'Tellimuste loend';
@override
String get chats => 'Vestlused';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get jobs => 'Emplois';
@override
String get availableJobs => 'Emplois Disponibles';
String get availableJobs => 'Liste des commandes';
@override
String get chats => 'Discussions';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get jobs => 'Darbai';
@override
String get availableJobs => 'Galimi darbai';
String get availableJobs => 'Užsakymų sąrašas';
@override
String get chats => 'Pokalbiai';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get jobs => 'Darbi';
@override
String get availableJobs => 'Pieejamie darbi';
String get availableJobs => 'Pasūtījumu saraksts';
@override
String get chats => 'Tērzēšanas';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get jobs => 'Zadania';
@override
String get availableJobs => 'Dostępne Zadania';
String get availableJobs => 'Lista zleceń';
@override
String get chats => 'Czaty';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get jobs => 'Задания';
@override
String get availableJobs => 'Доступные задания';
String get availableJobs => 'Список заказов';
@override
String get chats => 'Чаты';
@override

View File

@@ -42,7 +42,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get jobs => 'İşler';
@override
String get availableJobs => 'Mevcut İşler';
String get availableJobs => 'Sipariş Listesi';
@override
String get chats => 'Sohbetler';
@override

View File

@@ -7,6 +7,7 @@ import 'services/websocket_service.dart';
import 'services/dart_mq.dart';
import 'services/database_service.dart';
import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
class LoginView extends StatefulWidget {
@@ -57,7 +58,7 @@ class _LoginViewState extends State<LoginView> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).loginSuccess),
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
duration: const Duration(seconds: 1),
),
);
@@ -228,7 +229,7 @@ class _LoginViewState extends State<LoginView> {
content: Text(
'${AppLocalizations.of(context).loginFailed}: $errorMessage',
),
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
@@ -292,7 +293,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(localizations.connecting),
backgroundColor: Colors.blue,
backgroundColor: AppColors.primary,
duration: const Duration(seconds: 1),
),
);
@@ -345,7 +346,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(localizations.connectionTimeout),
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 2),
),
);
@@ -364,7 +365,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('${localizations.connectionError}: $e'),
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
@@ -420,7 +421,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('${localizations.loginError}: $e'),
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
@@ -440,203 +441,207 @@ class _LoginViewState extends State<LoginView> {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Colors.grey[50],
body: Column(
children: [
Expanded(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo oder App-Name
Icon(
Icons.account_circle,
size: 100,
color: Colors.deepPurple,
),
const SizedBox(height: 32),
Text(
l10n.welcomeBack,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey[800],
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppGradients.shellBackground),
child: Column(
children: [
Expanded(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.account_circle,
size: 100,
color: AppColors.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const SizedBox(height: 32),
Text(
l10n.loginSubtitle,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// E-Mail-Feld
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailAddress,
hintText: l10n.emailAddressHint,
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
Text(
l10n.welcomeBack,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textStrong,
),
filled: true,
fillColor: Colors.white,
textAlign: TextAlign.center,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.emailAddressRequired;
}
if (!RegExp(
r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$',
).hasMatch(value)) {
return l10n.emailAddressInvalid;
}
return null;
},
),
const SizedBox(height: 16),
const SizedBox(height: 8),
// Passwort-Feld
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: l10n.password,
hintText: l10n.passwordHint,
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
Text(
l10n.loginSubtitle,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppColors.textMuted),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// E-Mail-Feld
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailAddress,
hintText: l10n.emailAddressHint,
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
filled: true,
fillColor: AppColors.surface,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.white,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.passwordRequired;
}
if (value.length < 6) {
return l10n.passwordMinLength;
}
return null;
},
),
const SizedBox(height: 24),
// Passwort vergessen Link
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.forgotPasswordMessage),
duration: const Duration(seconds: 1),
),
);
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.emailAddressRequired;
}
if (!RegExp(
r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$',
).hasMatch(value)) {
return l10n.emailAddressInvalid;
}
return null;
},
child: Text(
l10n.forgotPassword,
style: const TextStyle(
color: Colors.deepPurple,
fontWeight: FontWeight.w500,
),
const SizedBox(height: 16),
// Passwort-Feld
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: l10n.password,
hintText: l10n.passwordHint,
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: AppColors.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.passwordRequired;
}
if (value.length < 6) {
return l10n.passwordMinLength;
}
return null;
},
),
const SizedBox(height: 24),
// Passwort vergessen Link
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.forgotPasswordMessage),
duration: const Duration(seconds: 1),
),
);
},
child: Text(
l10n.forgotPassword,
style: const TextStyle(
color: AppColors.primaryStrong,
fontWeight: FontWeight.w500,
),
),
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 24),
// Verbindungsstatus
// Anmelden Button
ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
// Verbindungsstatus
// Anmelden Button
ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
elevation: 2,
),
child:
_isLoggingIn
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
child:
_isLoggingIn
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
),
const SizedBox(width: 12),
Text(
l10n.loggingIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
const SizedBox(width: 12),
Text(
l10n.loggingIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
)
: Text(
l10n.login,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
],
)
: Text(
l10n.login,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 24),
],
),
const SizedBox(height: 24),
],
),
),
),
),
),
),
),
// Version number at the bottom
if (_appVersion.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Version $_appVersion',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
textAlign: TextAlign.center,
if (_appVersion.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Version $_appVersion',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
textAlign: TextAlign.center,
),
),
),
],
],
),
),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'app_theme.dart';
import 'login_view.dart';
import 'jobs_view.dart';
import 'cargo_items_view.dart';
@@ -104,10 +105,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return MaterialApp(
title: 'VotianLT App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
theme: buildAppTheme(),
// Localization configuration
locale: locale,
localizationsDelegates: const [
@@ -178,10 +176,7 @@ class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
appBar: AppBar(title: Text(widget.title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -2,6 +2,8 @@ import '../task.dart';
// Signature Task
class SignatureTask extends Task {
final String? note;
SignatureTask({
required super.id,
required super.jobId,
@@ -14,11 +16,19 @@ class SignatureTask extends Task {
super.title,
super.description,
super.displayName,
this.note,
});
factory SignatureTask.fromJson(Map<String, dynamic> json) {
final commonProps = Task.parseCommonProperties(json);
String? note;
final taskSpecificData = json['taskSpecificData'];
if (taskSpecificData is Map<String, dynamic>) {
final n = taskSpecificData['note'];
if (n is String) note = n;
}
return SignatureTask(
id: commonProps['id'],
jobId: commonProps['jobId'],
@@ -31,6 +41,7 @@ class SignatureTask extends Task {
title: commonProps['title'],
description: commonProps['description'],
displayName: commonProps['displayName'],
note: note,
);
}
@@ -47,7 +58,11 @@ class SignatureTask extends Task {
'taskOrder': taskOrder,
'description': description,
'displayName': displayName,
'taskSpecificData': {'taskType': 'SIGNATURE', 'title': title},
'taskSpecificData': {
'taskType': 'SIGNATURE',
'title': title,
'note': note,
},
};
}
@@ -64,6 +79,7 @@ class SignatureTask extends Task {
String? title,
String? description,
String? displayName,
String? note,
}) {
return SignatureTask(
id: id ?? this.id,
@@ -77,6 +93,7 @@ class SignatureTask extends Task {
title: title ?? this.title,
description: description ?? this.description,
displayName: displayName ?? this.displayName,
note: note ?? this.note,
);
}
}

View File

@@ -191,7 +191,7 @@
},
{
"id": "5:2194624907249454848",
"lastPropertyId": "6:5035828038544573244",
"lastPropertyId": "7:5673785903451668117",
"name": "TaskStatusEntity",
"properties": [
{
@@ -275,7 +275,9 @@
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredPropertyUids": [
5673785903451668117
],
"retiredRelationUids": [],
"version": 1
}

View File

@@ -240,7 +240,7 @@ final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity(
id: const obx_int.IdUid(5, 2194624907249454848),
name: 'TaskStatusEntity',
lastPropertyId: const obx_int.IdUid(6, 5035828038544573244),
lastPropertyId: const obx_int.IdUid(7, 5673785903451668117),
flags: 0,
properties: <obx_int.ModelProperty>[
obx_int.ModelProperty(
@@ -371,7 +371,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
lastSequenceId: const obx_int.IdUid(0, 0),
retiredEntityUids: const [],
retiredIndexUids: const [],
retiredPropertyUids: const [],
retiredPropertyUids: const [5673785903451668117],
retiredRelationUids: const [],
modelVersion: 5,
modelVersionParserMinimum: 5,
@@ -632,7 +632,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
},
objectToFB: (TaskStatusEntity object, fb.Builder fbb) {
final taskIdOffset = fbb.writeString(object.taskId);
fbb.startTable(7);
fbb.startTable(8);
fbb.addInt64(0, object.id);
fbb.addOffset(1, taskIdOffset);
fbb.addBool(2, object.completed);

View File

@@ -831,6 +831,42 @@ class DatabaseService {
}
}
/// Save signature note (Bemerkung) for a task into user_data table
Future<void> saveTaskSignatureNote(String taskId, String note) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final key = 'task_signature_note:$taskId';
await saveKeyValue(key, note);
} catch (e, st) {
developer.log(
'Error saving task signature note: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Load signature note (Bemerkung) for a task from user_data table
Future<String?> loadTaskSignatureNote(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
return await loadKeyValue('task_signature_note:$taskId');
} catch (e, st) {
developer.log(
'Error loading task signature note: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
/// Load signature SVG for a task from user_data table
Future<String?> loadTaskSignature(String taskId) async {
try {

View File

@@ -1189,6 +1189,36 @@ class WebSocketService {
}
}
/// Send station completion event to server.
/// Messages are buffered if offline and sent automatically when reconnected.
Future<void> sendStationCompleted({
required String jobId,
required String jobNumber,
required int stationOrder,
bool hasIncompleteOptionalTasks = false,
}) async {
const String destination = '/server/station_completed';
final payload = <String, dynamic>{
'jobId': jobId,
'jobNumber': jobNumber,
'stationOrder': stationOrder,
'completedAt': DateTime.now().toUtc().toIso8601String(),
'hasIncompleteOptionalTasks': hasIncompleteOptionalTasks,
};
try {
final jsonPayload = jsonEncode(payload);
sendMessage(destination, jsonPayload);
} catch (e, st) {
developer.log(
'Error sending station completion: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
}
/// Dispose resources
void dispose() {
_stopReconnectTimer();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'l10n/app_localizations.dart';
import 'app_state.dart';
import 'app_theme.dart';
/// Supported languages with their display names and flag emojis
class LanguageOption {
@@ -98,11 +99,9 @@ class _SettingsViewState extends State<SettingsView> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.languageChanged}: $flagEmoji $languageName',
),
content: Text('${l10n.languageChanged}: $flagEmoji $languageName'),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
),
);
}
@@ -129,10 +128,7 @@ class _SettingsViewState extends State<SettingsView> {
final languageOptions = _getLanguageOptions();
return Scaffold(
appBar: AppBar(
title: Text(l10n.settings),
backgroundColor: Colors.deepPurple[100],
),
appBar: AppBar(title: Text(l10n.settings)),
body: ListView(
children: [
// Language Selection Section
@@ -143,7 +139,7 @@ class _SettingsViewState extends State<SettingsView> {
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey,
color: AppColors.textMuted,
letterSpacing: 1.2,
),
),
@@ -160,7 +156,7 @@ class _SettingsViewState extends State<SettingsView> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey[100],
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
),
child: Center(
@@ -173,22 +169,27 @@ class _SettingsViewState extends State<SettingsView> {
title: Text(
language.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? Colors.deepPurple : Colors.black87,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
color:
isSelected
? AppColors.primaryStrong
: AppColors.textStrong,
),
),
trailing: isSelected
? const Icon(
Icons.check_circle,
color: Colors.deepPurple,
)
: const Icon(
Icons.circle_outlined,
color: Colors.grey,
),
trailing:
isSelected
? const Icon(
Icons.check_circle,
color: AppColors.primary,
)
: const Icon(
Icons.circle_outlined,
color: AppColors.textMuted,
),
onTap: () => _onLanguageSelected(language.code),
selected: isSelected,
selectedTileColor: Colors.deepPurple.withValues(alpha: 0.05),
selectedTileColor: AppColors.primarySoft,
),
const Divider(height: 1, indent: 72),
],
@@ -203,17 +204,14 @@ class _SettingsViewState extends State<SettingsView> {
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey,
color: AppColors.textMuted,
letterSpacing: 1.2,
),
),
),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.info_outline,
color: Colors.grey[600],
),
leading: Icon(Icons.info_outline, color: AppColors.textMuted),
title: Text(l10n.version),
subtitle: const Text('0.9.2'),
),

View File

@@ -5,6 +5,7 @@ import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/job.dart';
@@ -40,7 +41,6 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final Set<String> _skippedTasks = {};
final DatabaseService _databaseService = DatabaseService();
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@@ -61,7 +61,7 @@ class _TaskViewState extends State<TaskView> {
.toList();
}
/// Load task completion statuses from database and merge with JSON task states
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses();
setState(() {
@@ -90,7 +90,6 @@ class _TaskViewState extends State<TaskView> {
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -117,14 +116,14 @@ class _TaskViewState extends State<TaskView> {
constraints: const BoxConstraints(maxHeight: 150),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
border: Border.all(color: Colors.grey[300]!, width: 1),
color: AppColors.surfaceMuted,
border: Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Text(
_getRemark(),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: const TextStyle(fontSize: 14, color: AppColors.text),
),
),
),
@@ -133,7 +132,13 @@ class _TaskViewState extends State<TaskView> {
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Expanded(child: _buildTasksStepper())],
children: [
Expanded(child: _buildTasksStepper()),
if (_visibleTasks.isNotEmpty) ...[
const SizedBox(height: 12),
_buildCompleteStationButton(),
],
],
),
),
),
@@ -142,22 +147,111 @@ class _TaskViewState extends State<TaskView> {
);
}
bool get _canCompleteStation {
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
// immer aktiv.
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
}
}
return true;
}
bool get _hasIncompleteOptionalTasks {
for (final t in _visibleTasks) {
if (t.optional && !_completedTasks.contains(t.id)) {
return true;
}
}
return false;
}
Widget _buildCompleteStationButton() {
final bool enabled = _canCompleteStation;
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: enabled ? _onCompleteStationPressed : null,
icon: const Icon(Icons.flag),
label: const Text('Station abschließen'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
);
}
void _onCompleteStationPressed() {
if (_hasIncompleteOptionalTasks) {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Offene optionale Aufgaben'),
content: const Text(
'Es gibt nicht erledigte optionale Aufgaben. Möchten Sie die Station trotzdem abschließen?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_sendStationCompleted();
},
child: const Text('Trotzdem abschließen'),
),
],
);
},
);
} else {
_sendStationCompleted();
}
}
void _sendStationCompleted() {
final stationOrder = widget.stationOrder ?? 0;
try {
StompService().sendStationCompleted(
jobId: widget.job.id,
jobNumber: widget.job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Station abgeschlossen')),
);
Navigator.of(context).pop();
} catch (e) {
developer.log('Error sending station completion: $e', name: 'TaskView');
}
}
Widget _buildTasksStepper() {
if (_visibleTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_outlined, size: 64, color: Colors.grey[400]),
const Icon(
Icons.task_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context).noTasks,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).noTasksMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
textAlign: TextAlign.center,
),
],
@@ -170,36 +264,30 @@ class _TaskViewState extends State<TaskView> {
itemBuilder: (context, index) {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final isSkipped = _skippedTasks.contains(task.id);
final canBeCompletedNow =
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index);
!isCompleted && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
// (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.)
final Color cardColor =
isCompleted
? const Color(0xFFE8F5E9) // hellgrün
: isSkipped
? const Color(0xFFFFF8E1) // hellgelb
? AppColors.successSoft
: canBeCompletedNow
? Colors.white
: const Color(0xFFF5F5F5); // hellgrau
? AppColors.surface
: AppColors.surfaceMuted;
final Color borderColor =
isCompleted
? Colors.green[300]!
: isSkipped
? Colors.amber[300]!
? AppColors.success.withValues(alpha: 0.35)
: canBeCompletedNow
? Colors.grey[300]!
: Colors.grey[200]!;
? AppColors.border
: AppColors.border.withValues(alpha: 0.7);
final Color circleColor =
isCompleted
? Colors.green[600]!
: isSkipped
? Colors.amber[600]!
? AppColors.success
: canBeCompletedNow
? Colors.deepPurple[400]!
: Colors.grey[400]!;
? AppColors.primary
: AppColors.textMuted;
return Card(
margin: const EdgeInsets.only(bottom: 12),
@@ -220,61 +308,93 @@ class _TaskViewState extends State<TaskView> {
borderRadius: BorderRadius.circular(12),
color: cardColor,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: Stack(
children: [
// Task number circle
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Task number circle
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Padding(
padding: EdgeInsets.only(
right: task.optional ? 72 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(task, isCompleted, index),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
const Icon(
Icons.check_circle,
color: AppColors.success,
),
],
],
),
if (task.optional)
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
),
),
child: const Text(
'Optional',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
),
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(
task,
isCompleted || isSkipped,
index,
),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
Icon(Icons.check_circle, color: Colors.green[600]),
],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
],
],
),
),
@@ -502,10 +622,11 @@ class _TaskViewState extends State<TaskView> {
builder:
(context) => SignatureCaptureScreen(
task: task,
onSignatureCompleted: (String svg) async {
onSignatureCompleted: (String svg, String note) async {
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
await _databaseService.saveTaskSignatureNote(task.id, note);
} catch (e, stackTrace) {
developer.log(
'Error saving task signature: $e',
@@ -529,6 +650,7 @@ class _TaskViewState extends State<TaskView> {
'signatureSvg': svg,
'svgLength': svg.length,
'hasSignature': true,
'signatureNote': note,
},
);
},
@@ -612,6 +734,7 @@ class _TaskViewState extends State<TaskView> {
String? taskType,
Map<String, dynamic>? extraData,
}) {
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() {
_completedTasks.add(taskId);
});
@@ -628,15 +751,60 @@ class _TaskViewState extends State<TaskView> {
} catch (e) {
developer.log('Error sending task completion: $e', name: 'TaskView');
}
// Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde,
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showLastMandatoryCompletedDialog();
}
});
}
}
bool get _hasOpenMandatoryTasks {
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return true;
}
}
return false;
}
void _showLastMandatoryCompletedDialog() {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Alle Pflichtaufgaben erledigt'),
content: const Text(
'Alle nicht optionalen Aufgaben dieser Station sind erledigt. '
'Möchten Sie die Station jetzt abschließen?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Später'),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_onCompleteStationPressed();
},
child: const Text('Station abschließen'),
),
],
);
},
);
}
bool _arePreviousTasksCompleted(int index) {
if (index <= 0) return true;
for (int i = 0; i < index; i++) {
final t = _visibleTasks[i];
if (!t.optional &&
!_completedTasks.contains(t.id) &&
!_skippedTasks.contains(t.id)) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
}
}
@@ -730,6 +898,10 @@ class _TaskViewState extends State<TaskView> {
task.description != null
? localizeKnownText(context, task.description!)
: null;
final String? signatureNote =
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
? task.note!.trim()
: null;
if (displayName?.isNotEmpty == true) {
return Column(
@@ -740,14 +912,39 @@ class _TaskViewState extends State<TaskView> {
const SizedBox(height: 2),
Text(description!, style: subtitleStyle),
],
if (signatureNote != null) ...[
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
],
);
}
if (description?.isNotEmpty == true) {
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description!, style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
return Text(description!, style: titleStyle);
}
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_getStandardTaskDisplayText(task), style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
// Fall back to standard text based on task type
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
}

View File

@@ -9,7 +9,11 @@ class BarcodeCaptureScreen extends StatefulWidget {
final BarcodeTask task;
final Function(List<String>) onBarcodesCompleted;
const BarcodeCaptureScreen({super.key, required this.task, required this.onBarcodesCompleted});
const BarcodeCaptureScreen({
super.key,
required this.task,
required this.onBarcodesCompleted,
});
@override
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
@@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${AppLocalizations.of(context).cameraError}: $e'),
),
);
}
}
}
@@ -142,7 +150,28 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(appBar: AppBar(title: Text(AppLocalizations.of(context).barcodeScan), backgroundColor: Colors.deepPurple[100], leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop())), body: Column(children: [OfflineBanner(), Expanded(child: _isScannerInitialized ? (_isMobilePlatform ? _buildMobileView() : _buildDesktopView()) : const Center(child: CircularProgressIndicator()))]));
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).barcodeScan),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
body: Column(
children: [
const OfflineBanner(),
Expanded(
child:
_isScannerInitialized
? (_isMobilePlatform
? _buildMobileView()
: _buildDesktopView())
: const Center(child: CircularProgressIndicator()),
),
],
),
);
}
Widget _buildMobileView() {
@@ -153,9 +182,33 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
flex: 3,
child: Stack(
children: [
MobileScanner(controller: _scannerController, onDetect: _onBarcodeDetected),
MobileScanner(
controller: _scannerController,
onDetect: _onBarcodeDetected,
),
// Overlay with scanning frame
Container(decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: Center(child: Container(width: 250, height: 250, decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2), borderRadius: BorderRadius.circular(12)), child: Container(margin: const EdgeInsets.all(20), decoration: BoxDecoration(border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8)))))),
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
),
child: Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: Colors.green, width: 2),
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
],
),
),
@@ -167,20 +220,47 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
'${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
Text(
'${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: _scannedBarcodes.length,
itemBuilder: (context, index) {
return Card(child: ListTile(leading: const Icon(Icons.qr_code), title: Text(_scannedBarcodes[index]), trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeBarcode(index))));
return Card(
child: ListTile(
leading: const Icon(Icons.qr_code),
title: Text(_scannedBarcodes[index]),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeBarcode(index),
),
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canFinish() ? _finishTask : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
),
@@ -195,9 +275,15 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).enterBarcode, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(
AppLocalizations.of(context).enterBarcode,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
Text(
'${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 24),
Expanded(
child: ListView.builder(
@@ -207,7 +293,18 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: _textControllers[index],
decoration: InputDecoration(labelText: index < widget.task.minBarcodeCount ? AppLocalizations.of(context).barcodeNumberRequired(index + 1) : AppLocalizations.of(context).barcodeNumberOptional(index + 1), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.qr_code)),
decoration: InputDecoration(
labelText:
index < widget.task.minBarcodeCount
? AppLocalizations.of(
context,
).barcodeNumberRequired(index + 1)
: AppLocalizations.of(
context,
).barcodeNumberOptional(index + 1),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.qr_code),
),
onChanged: (value) {
setState(() {
// Trigger rebuild to update button state
@@ -219,7 +316,16 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
),
),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canFinish() ? _finishTask : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
);

View File

@@ -6,6 +6,7 @@ import 'package:camera/camera.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart' as fsel;
import 'package:votianlt_app/services/developer.dart' as developer;
import '../app_theme.dart';
import '../l10n/app_localizations.dart';
import '../models/tasks/photo_task.dart';
import '../widgets/offline_banner.dart';
@@ -91,11 +92,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
}
}
} catch (e, stackTrace) {
developer.log('Error initializing camera: $e', name: 'PhotoCaptureScreen');
developer.log(
'Error initializing camera: $e',
name: 'PhotoCaptureScreen',
);
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')),
SnackBar(
content: Text('${AppLocalizations.of(context).cameraError}: $e'),
),
);
}
}
@@ -118,7 +124,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).cameraNotReady)),
SnackBar(
content: Text(AppLocalizations.of(context).cameraNotReady),
),
);
}
}
@@ -127,7 +135,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
SnackBar(
content: Text('${AppLocalizations.of(context).photoError}: $e'),
),
);
}
}
@@ -136,7 +146,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
Future<void> _pickPhotoFromFile() async {
try {
// Use file_selector for desktop and web for robust platform support
final bool useFileSelector = kIsWeb ||
final bool useFileSelector =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux;
@@ -146,7 +157,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
label: 'images',
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
);
final fsel.XFile? picked = await fsel.openFile(acceptedTypeGroups: [typeGroup]);
final fsel.XFile? picked = await fsel.openFile(
acceptedTypeGroups: [typeGroup],
);
if (picked != null) {
final data = await picked.readAsBytes();
setState(() {
@@ -187,11 +200,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
}
}
} catch (e, stackTrace) {
developer.log('Error picking photo from file: $e', name: 'PhotoCaptureScreen');
developer.log(
'Error picking photo from file: $e',
name: 'PhotoCaptureScreen',
);
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
SnackBar(
content: Text('${AppLocalizations.of(context).photoError}: $e'),
),
);
}
}
@@ -230,7 +248,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: Text(AppLocalizations.of(context).delete, style: const TextStyle(color: Colors.white)),
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(color: Colors.white),
),
),
],
);
@@ -240,7 +261,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bool get _canComplete {
return _capturedPhotos.length >= widget.task.minPhotoCount &&
_capturedPhotos.length <= widget.task.maxPhotoCount;
_capturedPhotos.length <= widget.task.maxPhotoCount;
}
bool get _canTakeMore {
@@ -276,7 +297,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
curve: Curves.easeInOut,
);
} catch (e, stackTrace) {
developer.log('Error animating to page: $e', name: 'PhotoCaptureScreen');
developer.log(
'Error animating to page: $e',
name: 'PhotoCaptureScreen',
);
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
_pageController.jumpToPage(clamped);
}
@@ -304,7 +328,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).photoCapture),
backgroundColor: Colors.blue,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
actions: [
if (_canComplete)
@@ -315,7 +339,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
},
child: Text(
AppLocalizations.of(context).finish,
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
@@ -327,7 +354,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
color: Colors.grey[100],
color: AppColors.surfaceMuted,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -337,7 +364,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
),
Text(
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
),
],
),
@@ -345,19 +375,20 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
// Camera preview, photo gallery or empty state
Expanded(
child: _capturedPhotos.isEmpty
? _buildCameraOrEmptyState()
: _buildPhotoGallery(),
child:
_capturedPhotos.isEmpty
? _buildCameraOrEmptyState()
: _buildPhotoGallery(),
),
// Bottom controls
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
color: AppColors.textStrong.withValues(alpha: 0.12),
spreadRadius: 1,
blurRadius: 5,
offset: Offset(0, -3),
@@ -372,23 +403,50 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
// Camera or file select button
Expanded(
child: ElevatedButton.icon(
onPressed: _canTakeMore && _isCameraSupportedOnThisPlatform
? (_useFilePickerMode
? _pickPhotoFromFile
: (_isCameraInitialized ? _capturePhoto : null))
: null,
icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt),
onPressed:
_canTakeMore && _isCameraSupportedOnThisPlatform
? (_useFilePickerMode
? _pickPhotoFromFile
: (_isCameraInitialized
? _capturePhoto
: null))
: null,
icon: Icon(
_useFilePickerMode
? Icons.photo_library
: Icons.camera_alt,
),
label: Text(
!_isCameraSupportedOnThisPlatform
? AppLocalizations.of(context).cameraNotSupportedOnPlatform
? AppLocalizations.of(
context,
).cameraNotSupportedOnPlatform
: (!_canTakeMore
? AppLocalizations.of(context).maxPhotosReached
? AppLocalizations.of(
context,
).maxPhotosReached
: (_useFilePickerMode
? AppLocalizations.of(context).selectPhoto
: (_isCameraInitialized ? AppLocalizations.of(context).takePhoto : (defaultTargetPlatform == TargetPlatform.macOS ? AppLocalizations.of(context).cameraReadyNoPreview : AppLocalizations.of(context).cameraLoading)))),
: (_isCameraInitialized
? AppLocalizations.of(
context,
).takePhoto
: (defaultTargetPlatform ==
TargetPlatform.macOS
? AppLocalizations.of(
context,
).cameraReadyNoPreview
: AppLocalizations.of(
context,
).cameraLoading)))),
),
style: ElevatedButton.styleFrom(
backgroundColor: _canTakeMore && (_useFilePickerMode || _isCameraInitialized) ? Colors.blue : Colors.grey,
backgroundColor:
_canTakeMore &&
(_useFilePickerMode ||
_isCameraInitialized)
? AppColors.primary
: AppColors.borderStrong,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12),
),
@@ -405,7 +463,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
padding: EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
),
),
],
@@ -416,18 +477,23 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canComplete
? () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
}
: null,
onPressed:
_canComplete
? () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: _canComplete ? Colors.green : Colors.grey,
backgroundColor:
_canComplete ? Colors.green : Colors.grey,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish, style: const TextStyle(fontWeight: FontWeight.bold)),
child: Text(
AppLocalizations.of(context).finish,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
],
@@ -451,7 +517,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraNotAvailable,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
SizedBox(height: 8),
Text(
@@ -477,7 +547,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(height: 16),
Text(
AppLocalizations.of(context).addPhotos,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
SizedBox(height: 8),
Text(
@@ -518,11 +592,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 80,
color: Colors.grey[400],
),
Icon(Icons.camera_alt, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraInitializing,
@@ -535,10 +605,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(height: 8),
Text(
AppLocalizations.of(context).cameraLoadingMessage,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
@@ -601,7 +668,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 50, color: Colors.grey[600]),
Icon(
Icons.error,
size: 50,
color: Colors.grey[600],
),
SizedBox(height: 8),
Text(AppLocalizations.of(context).photoError),
],
@@ -621,9 +692,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bottom: 0,
child: Center(
child: IconButton(
onPressed: _currentPhotoIndex > 0
? _goToPreviousPhoto
: null,
onPressed:
_currentPhotoIndex > 0 ? _goToPreviousPhoto : null,
icon: Icon(Icons.chevron_left, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
@@ -638,9 +708,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bottom: 0,
child: Center(
child: IconButton(
onPressed: _currentPhotoIndex < _capturedPhotos.length - 1
? _goToNextPhoto
: null,
onPressed:
_currentPhotoIndex < _capturedPhotos.length - 1
? _goToNextPhoto
: null,
icon: Icon(Icons.chevron_right, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
@@ -658,19 +729,21 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _capturedPhotos.asMap().entries.map((entry) {
return Container(
width: 8,
height: 8,
margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPhotoIndex == entry.key
? Colors.blue
: Colors.grey[400],
),
);
}).toList(),
children:
_capturedPhotos.asMap().entries.map((entry) {
return Container(
width: 8,
height: 8,
margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
_currentPhotoIndex == entry.key
? AppColors.primary
: Colors.grey[400],
),
);
}).toList(),
),
),
],

View File

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:signature/signature.dart';
import '../app_theme.dart';
import '../l10n/app_localizations.dart';
import '../models/tasks/signature_task.dart';
import '../widgets/offline_banner.dart';
class SignatureCaptureScreen extends StatefulWidget {
final SignatureTask task;
final void Function(String svg) onSignatureCompleted;
final void Function(String svg, String note) onSignatureCompleted;
const SignatureCaptureScreen({
super.key,
@@ -23,6 +23,7 @@ class SignatureCaptureScreen extends StatefulWidget {
class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
late final SignatureController _controller;
final TextEditingController _noteController = TextEditingController();
bool _hasSignature = false;
bool _isMobilePlatform = false;
@@ -84,11 +85,16 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
void dispose() {
_controller.removeListener(_onSignatureChanged);
_controller.dispose();
_noteController.dispose();
_restoreOrientation();
super.dispose();
}
String _buildSvgFromPoints(List<Point?> points, {double strokeWidth = 3.0, String strokeColor = '#000000'}) {
String _buildSvgFromPoints(
List<Point?> points, {
double strokeWidth = 3.0,
String strokeColor = '#000000',
}) {
// Convert collected signature points (with null separators for stroke breaks) into an SVG string
// Determine bounds
double? minX, minY, maxX, maxY;
@@ -130,7 +136,8 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
}
}
final String svg = '<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
final String svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return svg;
}
@@ -141,25 +148,30 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
if (!hasAnyPoint) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).signatureRequired)),
SnackBar(
content: Text(AppLocalizations.of(context).signatureRequired),
),
);
return;
}
// Build SVG from the captured signature points
final String svg = _buildSvgFromPoints(_controller.points);
final String note = _noteController.text.trim();
// Close this screen first to show the updated TaskView quickly
if (!mounted) return;
_restoreOrientation();
Navigator.of(context).pop();
// Then notify the caller (SVG only)
widget.onSignatureCompleted(svg);
// Then notify the caller (SVG + Bemerkung)
widget.onSignatureCompleted(svg, note);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).signatureError}: $e')),
SnackBar(
content: Text('${AppLocalizations.of(context).signatureError}: $e'),
),
);
}
}
@@ -169,7 +181,6 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).signatureCapture),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -197,61 +208,71 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).signatureInstruction,
style: TextStyle(color: Colors.grey[700]),
),
const SizedBox(height: 12),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[400]!),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
backgroundColor: Colors.white,
Text(
AppLocalizations.of(context).signatureInstruction,
style: const TextStyle(color: AppColors.textMuted),
),
),
const SizedBox(height: 12),
Expanded(
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderStrong),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
backgroundColor: AppColors.surface,
),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _noteController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).completeTaskNote,
border: const OutlineInputBorder(),
),
maxLines: 2,
minLines: 1,
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).clear),
),
const Spacer(),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: _hasSignature ? _finish : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).clear),
),
const Spacer(),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: _hasSignature ? _finish : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
],
),
),
),
],
),

View File

@@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:votianlt_app/services/developer.dart' as developer;
import '../app_theme.dart';
import '../l10n/app_localizations.dart';
class ChatPhotoDialog extends StatefulWidget {
@@ -278,7 +279,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning, color: Colors.orange[700], size: 40),
const Icon(Icons.warning, color: AppColors.warning, size: 40),
const SizedBox(height: 12),
Text(_errorMessage!, textAlign: TextAlign.center),
],
@@ -330,11 +331,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.photo_camera_back,
color: Colors.deepPurple[400],
size: 48,
),
Icon(Icons.photo_camera_back, color: AppColors.primary, size: 48),
const SizedBox(height: 12),
const Text(
'Wähle ein Foto von deinem Gerät aus.',

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:votianlt_app/services/developer.dart' as developer;
import 'package:votianlt_app/services/websocket_service.dart';
import 'package:votianlt_app/services/dart_mq.dart';
import '../app_theme.dart';
class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key});
@@ -24,8 +25,13 @@ class _OfflineBannerState extends State<OfflineBanner> {
// Check if we're already connected (e.g., coming back to this screen)
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
// Initialize countdown based on current connection state
_onConnectionChange(_stompService.isConnected && _stompService.isAuthenticated);
_connSub = DartMQ().subscribe<bool>(MQTopics.connectionStatus, _onConnectionChange);
_onConnectionChange(
_stompService.isConnected && _stompService.isAuthenticated,
);
_connSub = DartMQ().subscribe<bool>(
MQTopics.connectionStatus,
_onConnectionChange,
);
}
void _onConnectionChange(bool isConnected) {
@@ -68,7 +74,10 @@ class _OfflineBannerState extends State<OfflineBanner> {
// Only auto-reconnect if we already know the target; discovery remains user-initiated
await _stompService.connect();
} catch (e, stackTrace) {
developer.log('Auto-reconnect attempt failed: $e', name: 'OfflineBanner');
developer.log(
'Auto-reconnect attempt failed: $e',
name: 'OfflineBanner',
);
developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
}
@@ -114,19 +123,19 @@ class _OfflineBannerState extends State<OfflineBanner> {
title = 'Offline Verbindung verloren';
subtitle = 'Verbindung wird wiederhergestellt.';
icon = Icons.wifi_off;
bgColor = Colors.red[50];
iconColor = Colors.red[700];
titleColor = Colors.red[900];
subtitleColor = Colors.red[800];
bgColor = AppColors.dangerSoft;
iconColor = AppColors.danger;
titleColor = AppColors.danger;
subtitleColor = AppColors.danger.withValues(alpha: 0.85);
} else {
// Initial connection attempt
title = 'Verbinde mit Server...';
subtitle = 'Bitte warten.';
icon = Icons.sync;
bgColor = Colors.orange[50];
iconColor = Colors.orange[700];
titleColor = Colors.orange[900];
subtitleColor = Colors.orange[800];
bgColor = AppColors.warningSoft;
iconColor = AppColors.warning;
titleColor = AppColors.warning;
subtitleColor = AppColors.warning.withValues(alpha: 0.85);
}
return Container(
@@ -152,10 +161,7 @@ class _OfflineBannerState extends State<OfflineBanner> {
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
color: subtitleColor,
fontSize: 12,
),
style: TextStyle(color: subtitleColor, fontSize: 12),
),
],
),
@@ -165,4 +171,3 @@ class _OfflineBannerState extends State<OfflineBanner> {
);
}
}

View File

@@ -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.15+1
version: 0.9.16+1
environment:
sdk: ^3.7.0

View File

@@ -4,7 +4,7 @@ set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
readonly BACKEND_DIR="${SCRIPT_DIR}/backend"
readonly BACKEND_DIR="${SCRIPT_DIR}"
usage() {
cat <<'EOF'

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.15</revision>
<revision>0.9.17</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -44,6 +44,31 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Mockito + ByteBuddy hochziehen, weil die Spring-Boot-3.4.3-Defaults
(Mockito 5.14.2 / ByteBuddy 1.15.11) den Inline-Mock-Maker auf
JDK 25 nicht laden können — die JVM lehnt die Class-Modifikation
von java.lang.Object ab. Mockito 5.18 + ByteBuddy 1.17.5 sind die
erste stabile Kombination, die JDK 25 offiziell trägt. -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.17.5</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.17.5</version>
</dependency>
</dependencies>
</dependencyManagement>

Binary file not shown.

View File

@@ -346,11 +346,14 @@ window.initProfileInvoiceGenerator = function() {
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
// Sample data rows (placeholder)
var sampleData = [
{ name: 'Umzugsleistung inkl. Verpackung', vat: '19%', net: '450,00 €' },
{ name: 'Entsorgung Möbel', vat: '19%', net: '85,00 €' },
{ name: 'Montage/De-Montage', vat: '19%', net: '120,00 €' }
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
];
var currentY = y + rowHeight;
@@ -415,9 +418,8 @@ window.initProfileInvoiceGenerator = function() {
// Calculate totals from sample data
var netTotal = 655.00; // 450 + 85 + 120
var vatRate = 0.19;
var vatTotal = 124.45; // 655 * 0.19
var grossTotal = 779.45; // 655 + 124.45
var vatTotal = netTotal * vatRate;
var grossTotal = netTotal + vatTotal;
// Draw summary lines
ctx.textBaseline = 'middle';
@@ -435,7 +437,7 @@ window.initProfileInvoiceGenerator = function() {
// Umsatzsteuer - label left, value right
ctx.font = fontSize + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('zzgl. 19% USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
@@ -1135,6 +1137,12 @@ window.initProfileInvoiceGenerator = function() {
};
};
window.updateProfileVatRate = function(rate) {
if (rate == null || isNaN(rate)) return;
window.profileInvoiceVatRate = rate;
draw();
};
window.updateProfileMasterdataValue = function(key, value) {
if (!window.masterdataValues) window.masterdataValues = {};
window.masterdataValues[key] = value;

View File

@@ -1068,6 +1068,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-radius: 24px;
background: rgba(255, 255, 255, 0.84);
box-shadow: var(--app-shadow-sm);
box-sizing: border-box;
}
.route-card,
@@ -1095,6 +1096,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--app-shadow-sm);
box-sizing: border-box;
}
.detail-card,
@@ -1143,6 +1145,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
.dialog-task-card {
position: relative;
padding-top: calc(var(--lumo-space-m) + 5px) !important;
gap: calc(var(--lumo-space-m) / 16) !important;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
@@ -1152,14 +1156,112 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-color: rgba(37, 99, 235, 0.24);
}
.dialog-floating-delete {
.dialog-task-card.drag-over-top::before,
.dialog-task-card.drag-over-bottom::after {
content: "";
position: absolute;
top: 0.65rem;
right: 0.65rem;
z-index: 10;
left: 0;
right: 0;
height: 3px;
background: var(--lumo-primary-color);
border-radius: 2px;
z-index: 20;
pointer-events: none;
box-shadow: 0 0 6px rgba(37, 99, 235, 0.45);
}
.dialog-task-card.drag-over-top::before {
top: calc(-0.5 * var(--lumo-space-m) - 1.5px);
}
.dialog-task-card.drag-over-bottom::after {
bottom: calc(-0.5 * var(--lumo-space-m) - 1.5px);
}
.dialog-task-card.dragging {
opacity: 0.35;
transform: scale(0.96);
box-shadow: none;
}
/* Compressed cards during reorder drag */
.tasks-reordering .dialog-task-card {
padding: 0.4rem 0.8rem !important;
transition: padding 0.2s ease, max-height 0.2s ease;
}
.tasks-reordering .dialog-task-card:hover {
transform: none;
}
.tasks-reordering .dialog-task-config,
.tasks-reordering .dialog-floating-delete,
.tasks-reordering .dialog-task-drag-handle {
display: none;
}
.tasks-reordering .dialog-task-card vaadin-combo-box {
display: none;
}
.dialog-task-summary {
display: none;
align-items: center;
gap: 0.5rem;
font-size: var(--lumo-font-size-s);
color: var(--lumo-body-text-color);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dialog-task-summary .task-type-label {
color: var(--lumo-primary-color);
font-weight: 600;
}
.dialog-task-summary .task-desc-label {
color: var(--lumo-secondary-text-color);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
.tasks-reordering .dialog-task-summary {
display: flex;
}
.dialog-task-card[draggable="true"] {
cursor: grab;
}
.dialog-task-drag-handle {
cursor: grab;
color: var(--lumo-secondary-text-color);
padding: 0.2rem;
min-width: 1.7rem;
min-height: 1.7rem;
flex-shrink: 0;
position: absolute;
top: -5px;
left: 15px;
z-index: 10;
}
.dialog-task-drag-handle:hover {
color: var(--lumo-primary-color);
}
.dialog-floating-delete {
padding: 0.2rem;
min-width: 1.7rem;
min-height: 1.7rem;
flex-shrink: 0;
position: absolute;
top: -5px;
right: 15px;
z-index: 10;
}
.inline-caption,

View File

@@ -0,0 +1,59 @@
package de.assecutor.votianlt.config;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell.
*
* Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf
* <code>type=INVOICE, status=ISSUED</code> gesetzt sie sind in der Praxis bereits
* ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF.
*
* Ein Audit-Eintrag dokumentiert die Migration (R-37).
*/
@Component
@Order(50)
public class InvoiceLifecycleMigration implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Override
public void run(String... args) {
List<CustomerInvoice> legacyInvoices = invoiceRepository.findByStatusIsNull();
if (legacyInvoices.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (CustomerInvoice invoice : legacyInvoices) {
invoice.setType(InvoiceType.INVOICE);
invoice.setStatus(InvoiceStatus.ISSUED);
if (invoice.getIssuedAt() == null) {
invoice.setIssuedAt(now);
}
InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system",
"Migration: Bestandsrechnung auf Status ISSUED gesetzt.");
invoice.addAuditEntry(entry);
}
invoiceRepository.saveAll(legacyInvoices);
log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size());
}
}

View File

@@ -8,6 +8,7 @@ import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.SignatureTask;
import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.CargoItemRepository;
@@ -133,6 +134,14 @@ public class MessageController {
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
for (BaseTask task : tasks) {
if (task instanceof SignatureTask signatureTask && task.getId() != null) {
List<Signature> signatures = signatureRepository.findByTaskIdOrderByCreatedAtDesc(task.getId());
if (!signatures.isEmpty()) {
signatureTask.setNote(signatures.get(0).getNote());
}
}
}
return new JobWithRelatedDataDTO(job, cargoItems, tasks);
}).toList();
@@ -246,13 +255,18 @@ public class MessageController {
Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) {
Object signatureSvgObj = extraData.get("signatureSvg");
Object signatureNoteObj = extraData.get("signatureNote");
String signatureNote = signatureNoteObj instanceof String s ? s : null;
if (signatureSvgObj instanceof String signatureSvg) {
if (!signatureSvg.isBlank()) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
completedBy);
signatureNote, completedBy);
signatureRepository.save(signatureEntry);
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
if (signatureNote != null && !signatureNote.isBlank()) {
extraDataSummary += ", Bemerkung: " + signatureNote;
}
} else {
extraDataSummary = "Leere Unterschrift";
}
@@ -375,7 +389,9 @@ public class MessageController {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
checkAndHandleJobCompletion(jobId, completedBy);
// Job completion is no longer auto-triggered by task completion.
// It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted).
} catch (Exception e) {
// Ignore email notification errors
}
@@ -430,6 +446,47 @@ public class MessageController {
}
}
/**
* Handle station completion message from app. Client sends to
* /server/station_completed with payload:
* {
* "jobId": "jobnum:ABC123",
* "jobNumber": "ABC123",
* "stationOrder": 0,
* "completedAt": "2026-04-13T12:34:56.789Z",
* "hasIncompleteOptionalTasks": false
* }
*
* The job is marked as completed once this message is received and all
* mandatory tasks across all stations are completed.
*/
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
try {
String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null;
if (jobNumber == null || jobNumber.isBlank()) {
log.warn("[STATION] station_completed without jobNumber");
return;
}
Optional<Job> jobOpt = jobRepository.findByJobNumber(jobNumber);
if (jobOpt.isEmpty()) {
log.warn("[STATION] Job with jobNumber {} not found", jobNumber);
return;
}
ObjectId jobId = jobOpt.get().getId();
String completedBy = appUserId != null ? appUserId : "Unknown";
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber,
payload.get("stationOrder"));
checkAndHandleJobCompletion(jobId, completedBy);
jobUpdateBroadcaster.broadcast(jobId);
} catch (Exception e) {
log.error("[STATION] Error handling station_completed: {}", e.getMessage());
}
}
/**
* Handle incoming message from a client via WebSocket. Client sends to
* /server/message with payload: { "content": "message payload", "contentType":

View File

@@ -70,6 +70,14 @@ public class MessagingConfig {
});
});
// Station completion handler — marks a job as completed once all mandatory
// tasks have been finished and the app confirms the station is done.
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {
messageController.handleStationCompleted(appUserId, payloadMap);
});
});
// Chat message handler
webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {

View File

@@ -1,10 +1,12 @@
package de.assecutor.votianlt.messaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/**
@@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Value("${app.messaging.websocket.allowed-origins:*}")
private String allowedOrigins;
@Value("${app.messaging.websocket.max-text-message-size:10485760}")
private int maxTextMessageSize;
@Value("${app.messaging.websocket.max-session-idle-timeout:300000}")
private long maxSessionIdleTimeout;
public WebSocketConfig(WebSocketService webSocketService) {
this.webSocketService = webSocketService;
}
@@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(maxTextMessageSize);
container.setMaxBinaryMessageBufferSize(maxTextMessageSize);
container.setMaxSessionIdleTimeout(maxSessionIdleTimeout);
return container;
}
}

View File

@@ -0,0 +1,13 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Data
@Document(collection = "misc")
public class Counter {
@Id
private String id;
private long sequence;
}

View File

@@ -53,4 +53,10 @@ public class Customer {
@Field("owner")
private ObjectId owner;
@Field("internal")
private boolean internal;
@Field("usrId")
private Integer usrId;
}

View File

@@ -20,6 +20,7 @@ public class Signature {
private ObjectId taskId;
private String signatureSvg;
private String note;
private LocalDateTime createdAt;
private String completedBy;
@@ -35,4 +36,9 @@ public class Signature {
this.signatureSvg = signatureSvg;
this.completedBy = completedBy;
}
public Signature(ObjectId taskId, String signatureSvg, String note, String completedBy) {
this(taskId, signatureSvg, completedBy);
this.note = note;
}
}

View File

@@ -7,6 +7,7 @@ import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.index.Indexed;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Set;
@@ -68,4 +69,7 @@ public class User {
// Spracheinstellung (standardmäßig Deutsch)
@Field("language")
private Language language = Language.DE;
// Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %)
private BigDecimal vatRate = new BigDecimal("0.19");
}

View File

@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Document(collection = "customerInvoices")
@@ -12,6 +14,33 @@ public class CustomerInvoice {
@Id
private String id;
// Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22
private InvoiceStatus status = InvoiceStatus.DRAFT;
private InvoiceType type = InvoiceType.INVOICE;
// Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30)
private String originalInvoiceId;
private String originalInvoiceNumber;
private LocalDate originalInvoiceDate;
// Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege.
private String cancellationInvoiceId;
private String correctionInvoiceId;
private String replacementInvoiceId;
// Zeitstempel für Statusübergänge
private LocalDateTime issuedAt;
private LocalDateTime sentAt;
private LocalDateTime cancelledAt;
// Änderungsprotokoll (R-36 bis R-39)
private List<InvoiceAuditEntry> auditLog = new ArrayList<>();
// Zahlungsstatus gemäß R-23 bis R-26
private PaymentStatus paymentStatus = PaymentStatus.UNPAID;
private BigDecimal paidAmount;
private LocalDateTime lastPaymentAt;
// Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum
@@ -372,4 +401,134 @@ public class CustomerInvoice {
public void setPdfData(byte[] pdfData) {
this.pdfData = pdfData;
}
public InvoiceStatus getStatus() {
return status;
}
public void setStatus(InvoiceStatus status) {
this.status = status;
}
public InvoiceType getType() {
return type;
}
public void setType(InvoiceType type) {
this.type = type;
}
public String getOriginalInvoiceId() {
return originalInvoiceId;
}
public void setOriginalInvoiceId(String originalInvoiceId) {
this.originalInvoiceId = originalInvoiceId;
}
public String getOriginalInvoiceNumber() {
return originalInvoiceNumber;
}
public void setOriginalInvoiceNumber(String originalInvoiceNumber) {
this.originalInvoiceNumber = originalInvoiceNumber;
}
public LocalDate getOriginalInvoiceDate() {
return originalInvoiceDate;
}
public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) {
this.originalInvoiceDate = originalInvoiceDate;
}
public String getCancellationInvoiceId() {
return cancellationInvoiceId;
}
public void setCancellationInvoiceId(String cancellationInvoiceId) {
this.cancellationInvoiceId = cancellationInvoiceId;
}
public String getCorrectionInvoiceId() {
return correctionInvoiceId;
}
public void setCorrectionInvoiceId(String correctionInvoiceId) {
this.correctionInvoiceId = correctionInvoiceId;
}
public String getReplacementInvoiceId() {
return replacementInvoiceId;
}
public void setReplacementInvoiceId(String replacementInvoiceId) {
this.replacementInvoiceId = replacementInvoiceId;
}
public LocalDateTime getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getSentAt() {
return sentAt;
}
public void setSentAt(LocalDateTime sentAt) {
this.sentAt = sentAt;
}
public LocalDateTime getCancelledAt() {
return cancelledAt;
}
public void setCancelledAt(LocalDateTime cancelledAt) {
this.cancelledAt = cancelledAt;
}
public List<InvoiceAuditEntry> getAuditLog() {
if (auditLog == null) {
auditLog = new ArrayList<>();
}
return auditLog;
}
public void setAuditLog(List<InvoiceAuditEntry> auditLog) {
this.auditLog = auditLog != null ? auditLog : new ArrayList<>();
}
public void addAuditEntry(InvoiceAuditEntry entry) {
if (entry == null) {
return;
}
getAuditLog().add(entry);
}
public PaymentStatus getPaymentStatus() {
return paymentStatus;
}
public void setPaymentStatus(PaymentStatus paymentStatus) {
this.paymentStatus = paymentStatus;
}
public BigDecimal getPaidAmount() {
return paidAmount;
}
public void setPaidAmount(BigDecimal paidAmount) {
this.paidAmount = paidAmount;
}
public LocalDateTime getLastPaymentAt() {
return lastPaymentAt;
}
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
this.lastPaymentAt = lastPaymentAt;
}
}

View File

@@ -0,0 +1,8 @@
package de.assecutor.votianlt.model.invoices;
/**
* Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden.
*/
public enum InvoiceAuditAction {
CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED
}

View File

@@ -0,0 +1,89 @@
package de.assecutor.votianlt.model.invoices;
import java.time.LocalDateTime;
/**
* Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39.
*
* Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals
* geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest.
*/
public class InvoiceAuditEntry {
private LocalDateTime timestamp;
private String userId;
private String userDisplayName;
private InvoiceAuditAction action;
private String reason;
/** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */
private String resultingInvoiceId;
private String resultingInvoiceNumber;
public InvoiceAuditEntry() {
}
public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) {
this.timestamp = LocalDateTime.now();
this.action = action;
this.userId = userId;
this.userDisplayName = userDisplayName;
this.reason = reason;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserDisplayName() {
return userDisplayName;
}
public void setUserDisplayName(String userDisplayName) {
this.userDisplayName = userDisplayName;
}
public InvoiceAuditAction getAction() {
return action;
}
public void setAction(InvoiceAuditAction action) {
this.action = action;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getResultingInvoiceId() {
return resultingInvoiceId;
}
public void setResultingInvoiceId(String resultingInvoiceId) {
this.resultingInvoiceId = resultingInvoiceId;
}
public String getResultingInvoiceNumber() {
return resultingInvoiceNumber;
}
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
this.resultingInvoiceNumber = resultingInvoiceNumber;
}
}

View File

@@ -0,0 +1,61 @@
package de.assecutor.votianlt.model.invoices;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.CompoundIndexes;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
/**
* Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer.
* Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt
* werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet
* verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD.
*
* Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits-
* Index erzwingt das auch bei nebenläufigen Aufrufen.
*/
@Data
@Document(collection = "invoice_number_reservations")
@CompoundIndexes({
@CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true),
@CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}")
})
public class InvoiceNumberReservation {
@Id
private ObjectId id;
@Indexed
private ObjectId userId;
/** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */
private String number;
/** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */
private long sequence;
/** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */
private String prefix;
private Instant reservedAt;
/** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */
private String reservedBy;
private InvoiceNumberReservationStatus status;
/** Bei status=USED: ID der ausgestellten Rechnung. */
private String invoiceId;
private Instant usedAt;
/** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */
private String voidReason;
private Instant voidedAt;
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.votianlt.model.invoices;
/**
* Status einer aus dem Nummernkreis gezogenen Rechnungsnummer.
*
* RESERVED Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben.
* Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen
* abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige
* Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG).
* USED Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch.
* VOIDED Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender
* hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist
* dokumentiert und betriebsprüfungstauglich.
*/
public enum InvoiceNumberReservationStatus {
RESERVED, USED, VOIDED
}

View File

@@ -0,0 +1,22 @@
package de.assecutor.votianlt.model.invoices;
/**
* Lebenszyklus einer Rechnung gemäß R-01 bis R-04.
*
* DRAFT noch in Bearbeitung, darf editiert oder gelöscht werden.
* ISSUED formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden.
* SENT an den Empfänger versendet.
* CANCELLED durch eine Stornorechnung aufgehoben.
* CORRECTED durch eine Berichtigung formal korrigiert (Original bleibt sichtbar).
*/
public enum InvoiceStatus {
DRAFT, ISSUED, SENT, CANCELLED, CORRECTED;
public boolean isFinalized() {
return this != DRAFT;
}
public boolean isMutable() {
return this == DRAFT;
}
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff.
*
* INVOICE reguläre Ausgangsrechnung.
* CORRECTION Rechnungsberichtigung für formale Fehler (R-12 bis R-16).
* Verweist eindeutig auf die zu korrigierende Originalrechnung.
* CANCELLATION Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22).
* Verweist eindeutig auf die zu stornierende Originalrechnung.
*/
public enum InvoiceType {
INVOICE, CORRECTION, CANCELLATION
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Zahlungsstatus einer Rechnung gemäß R-23 bis R-26.
*
* UNPAID noch nicht bezahlt.
* PARTIALLY_PAID Teilzahlung erhalten, Restbetrag offen.
* PAID vollständig bezahlt.
* OVERPAID Zahlbetrag übersteigt den Rechnungsbetrag.
* REFUND_DUE Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung).
*/
public enum PaymentStatus {
UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE
}

View File

@@ -1,14 +1,20 @@
package de.assecutor.votianlt.model.task;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Transient;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class SignatureTask extends BaseTask {
@Transient
@JsonIgnore
private String note;
@Override
public String getTaskType() {
return "SIGNATURE";
@@ -21,11 +27,17 @@ public class SignatureTask extends BaseTask {
@Override
public Object getTaskSpecificData() {
return new TaskSpecificData();
return new TaskSpecificData(note);
}
@Data
@NoArgsConstructor
public class TaskSpecificData {
public String taskType = getTaskType();
// No specific data for signature task
public String note;
public TaskSpecificData(String note) {
this.note = note;
}
}
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.votianlt.pages.base.ui.component;
import de.assecutor.votianlt.model.Customer;
import java.util.Map;
public final class CustomerAddressLabelHelper {
private CustomerAddressLabelHelper() {
}
public static void putUnique(Map<String, Customer> 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);
}
public static String build(Customer customer, String fallbackLabel) {
if (customer == null) {
return fallbackLabel;
}
String companyName = trimToNull(customer.getCompanyName());
if (companyName != null) {
return companyName;
}
String fullName = trimToNull(join(" ", customer.getFirstname(), customer.getLastName()));
return fullName != null ? fullName : fallbackLabel;
}
public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) {
if (addressOptions.containsKey(comboValue)) {
Customer customer = addressOptions.get(comboValue);
return customer != null ? customer.getCompanyName() : null;
}
return comboValue;
}
private static String join(String separator, String first, String second) {
String left = first != null ? first.trim() : "";
String right = second != null ? second.trim() : "";
return (left + separator + right).trim();
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -5,6 +5,10 @@ import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.dnd.DragSource;
import com.vaadin.flow.component.dnd.DropEffect;
import com.vaadin.flow.component.dnd.DropTarget;
import com.vaadin.flow.component.dnd.EffectAllowed;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
@@ -52,10 +56,28 @@ public class DeliveryStationDialog extends Dialog {
private String zip;
private String city;
private boolean saveAddress;
private boolean addressDiffersFromCustomer;
private org.bson.types.ObjectId customerId;
public boolean isAddressDiffersFromCustomer() {
return addressDiffersFromCustomer;
}
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
}
private List<BaseTask> tasks = new ArrayList<>();
private boolean addressValidatedByGoogle;
private AddressValidationResult addressValidationResult;
public org.bson.types.ObjectId getCustomerId() {
return customerId;
}
public void setCustomerId(org.bson.types.ObjectId customerId) {
this.customerId = customerId;
}
public boolean isAddressValidatedByGoogle() {
return addressValidatedByGoogle;
}
@@ -203,12 +225,14 @@ public class DeliveryStationDialog extends Dialog {
private final List<BaseTask> tasksState = new ArrayList<>();
private VerticalLayout tasksList;
private VerticalLayout draggedTaskContainer;
private Span addressTabError;
private Span tasksTabError;
private final DeliveryStationTile.TranslationHelper translationHelper;
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
private org.bson.types.ObjectId selectedCustomerId;
public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
@@ -230,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
formLayout.setSpacing(true);
formLayout.setWidthFull();
// Company with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
// Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true);
company.setWidthFull();
setupCompanyAutocomplete(company, customers);
@@ -366,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
addressTabError = createTabErrorIndicator();
tasksTabError = createTabErrorIndicator();
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.delivery.address"), formLayout);
addressTab.add(addressTabError);
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
createTasksTab(templates, templateSaveCallback));
@@ -507,6 +531,13 @@ public class DeliveryStationDialog extends Dialog {
zip.setValue(data.getZip());
if (data.getCity() != null)
city.setValue(data.getCity());
selectedCustomerId = data.getCustomerId();
if (selectedCustomerId == null && customerSelectedFromOptions) {
Customer matched = companyAddressOptions.get(companyOption);
if (matched != null) {
selectedCustomerId = matched.getId();
}
}
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState();
@@ -543,10 +574,43 @@ public class DeliveryStationDialog extends Dialog {
data.setZip(zip.getValue());
data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setTasks(new ArrayList<>(tasksState));
return data;
}
private boolean computeAddressDiffers() {
boolean hasAnyValue = !isBlank(resolveCompanyValue(company.getValue())) || !isBlank(firstName.getValue())
|| !isBlank(lastName.getValue()) || !isBlank(phone.getValue()) || !isBlank(mail.getValue())
|| !isBlank(street.getValue()) || !isBlank(houseNumber.getValue())
|| !isBlank(addressAddition.getValue()) || !isBlank(zip.getValue()) || !isBlank(city.getValue());
if (!hasAnyValue) {
return false;
}
if (selectedCustomerId == null) {
return true;
}
Customer linked = findCustomerById(selectedCustomerId);
return linked == null || !matchesCurrentCustomer(linked);
}
private Customer findCustomerById(org.bson.types.ObjectId id) {
if (id == null) {
return null;
}
for (Customer c : companyAddressOptions.values()) {
if (c != null && id.equals(c.getId())) {
return c;
}
}
return null;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private boolean validateRequiredFields() {
// Address tab validation
boolean addressValid = true;
@@ -596,11 +660,9 @@ public class DeliveryStationDialog extends Dialog {
String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
applyErrorStyling(mail, invalid);
return !invalid;
}
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) {
@@ -625,17 +687,8 @@ public class DeliveryStationDialog extends Dialog {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
companyAddressOptions.clear();
for (Customer customer : customers) {
String label = buildCompanyAddressLabel(customer);
if (label == null) {
continue;
}
String uniqueLabel = label;
int counter = 2;
while (companyAddressOptions.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
companyAddressOptions.put(uniqueLabel, customer);
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
@@ -643,10 +696,12 @@ public class DeliveryStationDialog extends Dialog {
companyField.addValueChangeListener(event -> {
Customer customer = companyAddressOptions.get(event.getValue());
if (customer == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -675,74 +730,38 @@ public class DeliveryStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState();
});
}
private void updateSaveAddressState() {
Customer selectedCustomer = companyAddressOptions.get(company.getValue());
boolean customerSelectedFromOptions = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
boolean customerDataMatches = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
if (customerSelectedFromOptions) {
if (customerDataMatches) {
saveAddress.setValue(false);
saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement();
return;
}
saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement();
}
private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
}
private String buildCompanyAddressLabel(Customer customer) {
if (customer == null) {
return null;
}
List<String> leftParts = new ArrayList<>();
if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
leftParts.add(customer.getCompanyName().trim());
}
String fullName = ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim();
if (!fullName.isBlank()) {
leftParts.add(fullName);
}
List<String> rightParts = new ArrayList<>();
String streetLine = ((customer.getStreet() != null ? customer.getStreet() : "") + " "
+ (customer.getHouseNumber() != null ? customer.getHouseNumber() : "")).trim();
if (!streetLine.isBlank()) {
rightParts.add(streetLine);
}
String cityLine = ((customer.getZip() != null ? customer.getZip() : "") + " "
+ (customer.getCity() != null ? customer.getCity() : "")).trim();
if (!cityLine.isBlank()) {
rightParts.add(cityLine);
}
String left = String.join(" | ", leftParts);
String right = String.join(", ", rightParts);
String label = left;
if (!right.isBlank()) {
label = label.isBlank() ? right : left + " | " + right;
}
return label.isBlank() ? null : label;
mail.setRequiredIndicatorVisible(false);
}
private String resolveCompanyValue(String comboValue) {
Customer customer = companyAddressOptions.get(comboValue);
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
return customer.getCompanyName();
}
return comboValue;
return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
}
private String findCompanyOptionLabel(DeliveryData data) {
@@ -866,6 +885,15 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card");
// Drag handle
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dragHandle.addClassName("dialog-task-drag-handle");
// Compact summary shown during drag
HorizontalLayout summaryRow = createDragSummary("", "");
summaryRow.addClassName("dialog-task-summary");
// Task type selection
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values());
@@ -877,6 +905,7 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false);
configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
// Red X button positioned in top-right corner
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
@@ -884,8 +913,14 @@ public class DeliveryStationDialog extends Dialog {
deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton);
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
headerRow.setAlignItems(FlexComponent.Alignment.START);
headerRow.setWidthFull();
headerRow.setFlexGrow(1, taskTypeCombo);
taskContainer.add(headerRow, configContainer);
setupDragAndDrop(taskContainer);
// Create Task and add to state with correct order
BaseTask task = new ConfirmationTask("");
@@ -896,6 +931,7 @@ public class DeliveryStationDialog extends Dialog {
taskTypeCombo.setValue(TaskType.CONFIRMATION);
updateTaskConfiguration(configContainer, currentTask[0]);
updateDragSummary(summaryRow, TaskType.CONFIRMATION, task);
taskTypeCombo.addValueChangeListener(ev -> {
TaskType selectedType = ev.getValue();
@@ -940,6 +976,7 @@ public class DeliveryStationDialog extends Dialog {
}
updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
}
});
@@ -953,6 +990,18 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card");
// Drag handle
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dragHandle.addClassName("dialog-task-drag-handle");
// Compact summary shown during drag
TaskType initialTaskType = getTaskTypeFromTask(task);
HorizontalLayout summaryRow = createDragSummary(
initialTaskType != null ? initialTaskType.getDisplayName() : "",
task.getDescription() != null ? task.getDescription() : "");
summaryRow.addClassName("dialog-task-summary");
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values());
taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName);
@@ -962,21 +1011,27 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false);
configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton);
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
headerRow.setAlignItems(FlexComponent.Alignment.START);
headerRow.setWidthFull();
headerRow.setFlexGrow(1, taskTypeCombo);
taskContainer.add(headerRow, configContainer);
setupDragAndDrop(taskContainer);
final BaseTask[] currentTask = { task };
// Set the combo value BEFORE registering the listener
TaskType taskType = getTaskTypeFromTask(task);
if (taskType != null) {
taskTypeCombo.setValue(taskType);
if (initialTaskType != null) {
taskTypeCombo.setValue(initialTaskType);
}
// Register the listener for user-initiated type changes only
@@ -1021,11 +1076,13 @@ public class DeliveryStationDialog extends Dialog {
}
updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
}
});
// Render the UI with the loaded task
updateTaskConfiguration(configContainer, task);
updateDragSummary(summaryRow, initialTaskType, task);
tasksList.add(taskContainer);
updateTaskDeleteAvailability();
@@ -1042,6 +1099,160 @@ public class DeliveryStationDialog extends Dialog {
};
}
private HorizontalLayout createDragSummary(String typeName, String description) {
Span typeLabel = new Span(typeName);
typeLabel.addClassName("task-type-label");
Span descLabel = new Span(description != null && !description.isBlank() ? "" + description : "");
descLabel.addClassName("task-desc-label");
HorizontalLayout layout = new HorizontalLayout(typeLabel, descLabel);
layout.setSpacing(false);
layout.setPadding(false);
layout.setAlignItems(FlexComponent.Alignment.CENTER);
return layout;
}
private void updateDragSummary(HorizontalLayout summaryRow, TaskType taskType, BaseTask task) {
summaryRow.getChildren()
.filter(Span.class::isInstance)
.map(Span.class::cast)
.forEach(span -> {
if (span.getClassNames().contains("task-type-label")) {
span.setText(taskType != null ? taskType.getDisplayName() : "");
} else if (span.getClassNames().contains("task-desc-label")) {
String desc = task.getDescription();
span.setText(desc != null && !desc.isBlank() ? "" + desc : "");
}
});
}
private void clearAllDropIndicators() {
if (tasksList == null) {
return;
}
tasksList.removeClassName("tasks-reordering");
tasksList.getChildren()
.filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast)
.forEach(c -> {
c.removeClassName("drag-over-top");
c.removeClassName("drag-over-bottom");
c.removeClassName("dragging");
});
}
private void setupDragAndDrop(VerticalLayout taskContainer) {
DragSource<VerticalLayout> dragSource = DragSource.create(taskContainer);
dragSource.setEffectAllowed(EffectAllowed.MOVE);
dragSource.addDragStartListener(e -> {
draggedTaskContainer = taskContainer;
taskContainer.addClassName("dragging");
if (tasksList != null) {
// Update all summaries with latest description values before compressing
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
for (int i = 0; i < rows.size() && i < tasksState.size(); i++) {
if (rows.get(i) instanceof VerticalLayout row) {
row.getChildren()
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.findFirst()
.ifPresent(headerRow -> headerRow.getChildren()
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.filter(c -> c.getClassNames().contains("dialog-task-summary"))
.findFirst()
.ifPresent(summary -> {
int idx = rows.indexOf(row);
if (idx >= 0 && idx < tasksState.size()) {
BaseTask t = tasksState.get(idx);
TaskType tt = getTaskTypeFromTask(t);
updateDragSummary(summary, tt, t);
}
}));
}
}
tasksList.addClassName("tasks-reordering");
}
});
dragSource.addDragEndListener(e -> {
draggedTaskContainer = null;
clearAllDropIndicators();
});
DropTarget<VerticalLayout> dropTarget = DropTarget.create(taskContainer);
dropTarget.setDropEffect(DropEffect.MOVE);
dropTarget.addDropListener(e -> {
if (draggedTaskContainer != null && draggedTaskContainer != taskContainer) {
moveTaskRow(draggedTaskContainer, taskContainer);
}
clearAllDropIndicators();
});
// Client-side drag events with position detection and no-op suppression
taskContainer.getElement().executeJs(
"const el = this;"
+ "function clearIndicators() {"
+ " var p = el.parentElement;"
+ " if (p) p.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach("
+ " function(c) { c.classList.remove('drag-over-top', 'drag-over-bottom'); });"
+ "}"
+ "function getSiblings() {"
+ " return Array.from(el.parentElement.children).filter("
+ " function(c) { return c.classList.contains('dialog-task-card'); });"
+ "}"
+ "el.addEventListener('dragstart', function() {"
+ " el.parentElement.__draggedEl = el;"
+ "});"
+ "el.addEventListener('dragend', function() {"
+ " el.parentElement.__draggedEl = null;"
+ "});"
+ "el.addEventListener('dragover', function(e) {"
+ " e.preventDefault();"
+ " var dragged = el.parentElement.__draggedEl;"
+ " if (!dragged || dragged === el) { clearIndicators(); return; }"
+ " var cards = getSiblings();"
+ " var dragIdx = cards.indexOf(dragged);"
+ " var myIdx = cards.indexOf(el);"
+ " clearIndicators();"
+ " var rect = el.getBoundingClientRect();"
+ " var midY = rect.top + rect.height / 2;"
+ " if (e.clientY < midY) {"
+ " if (myIdx !== dragIdx + 1) el.classList.add('drag-over-top');"
+ " } else {"
+ " if (myIdx !== dragIdx - 1) el.classList.add('drag-over-bottom');"
+ " }"
+ "});"
+ "el.addEventListener('dragleave', function() {"
+ " el.classList.remove('drag-over-top', 'drag-over-bottom');"
+ "});"
+ "el.addEventListener('drop', function() {"
+ " clearIndicators();"
+ "});");
}
private void moveTaskRow(VerticalLayout source, VerticalLayout target) {
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
int fromIndex = rows.indexOf(source);
int toIndex = rows.indexOf(target);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) {
return;
}
// Reorder tasksState
BaseTask movedTask = tasksState.remove(fromIndex);
tasksState.add(toIndex, movedTask);
// Reorder UI: remove all, re-add in new order
List<com.vaadin.flow.component.Component> rowList = new ArrayList<>(rows);
com.vaadin.flow.component.Component movedRow = rowList.remove(fromIndex);
rowList.add(toIndex, movedRow);
tasksList.removeAll();
rowList.forEach(tasksList::add);
// Update taskOrder values
reorderTasksAfterDeletion();
}
private void reorderTasksAfterDeletion() {
for (int i = 0; i < tasksState.size(); i++) {
BaseTask task = tasksState.get(i);
@@ -1094,11 +1305,15 @@ public class DeliveryStationDialog extends Dialog {
.filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast)
.forEach(taskContainer -> taskContainer.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.findFirst()
.ifPresent(button -> button.setEnabled(deletable)));
.ifPresent(headerRow -> headerRow.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst()
.ifPresent(button -> button.setEnabled(deletable))));
}
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
@@ -1142,10 +1357,14 @@ public class DeliveryStationDialog extends Dialog {
break;
case SIGNATURE:
Span info = new Span(translationHelper.getTranslation("addjob.tasks.signature.noconfig"));
info.getStyle().set("color", "var(--lumo-secondary-text-color)");
info.getStyle().set("font-style", "italic");
configContainer.add(info);
TextField signatureNoteField = new TextField(
translationHelper.getTranslation("addjob.tasks.signature.notelabel"));
signatureNoteField.setPlaceholder(
translationHelper.getTranslation("addjob.tasks.signature.notelabel.placeholder"));
signatureNoteField.setWidthFull();
signatureNoteField.setValue(task.getDescription() != null ? task.getDescription() : "");
signatureNoteField.addValueChangeListener(ev -> task.setDescription(ev.getValue()));
configContainer.add(signatureNoteField);
break;
case TODOLIST:

View File

@@ -16,8 +16,10 @@ import com.vaadin.flow.component.textfield.TextField;
import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;
import java.util.Map;
/**
* A self-contained tile for one delivery station in the AddJob form. Contains
@@ -51,6 +53,7 @@ public class DeliveryStationTile extends VerticalLayout {
private final TextField city;
private final Checkbox saveAddress;
private final H3 title;
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
private ChangeListener changeListener;
private DeleteListener deleteListener;
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
add(titleLayout);
// Company with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
// Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true);
company.setWidthFull();
setupCompanyAutocomplete(company, customers, translationHelper);
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
TranslationHelper translationHelper) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
companyAddressOptions.clear();
for (Customer customer : customers) {
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
companyField.setItems(companyNames);
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
String selectedAddress = event.getValue();
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return;
}
Optional<Customer> matchingCustomer = customers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
Customer customer = companyAddressOptions.get(selectedAddress);
if (customer != null) {
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
*/
public DeliveryStation getDeliveryStation() {
DeliveryStation station = new DeliveryStation();
station.setCompany(company.getValue());
station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
station.setSalutation(salutation.getValue());
station.setFirstName(firstName.getValue());
station.setLastName(lastName.getValue());

View File

@@ -35,7 +35,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/**
@@ -228,6 +227,25 @@ public class PickupStationDialog extends Dialog {
public void setCargoItems(List<CargoItem> cargoItems) {
this.cargoItems = cargoItems != null ? cargoItems : new ArrayList<>();
}
private org.bson.types.ObjectId customerId;
private boolean addressDiffersFromCustomer;
public org.bson.types.ObjectId getCustomerId() {
return customerId;
}
public void setCustomerId(org.bson.types.ObjectId customerId) {
this.customerId = customerId;
}
public boolean isAddressDiffersFromCustomer() {
return addressDiffersFromCustomer;
}
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
}
}
public interface SaveListener {
@@ -250,6 +268,7 @@ public class PickupStationDialog extends Dialog {
private final ComboBox<String> customerComboBox;
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
private org.bson.types.ObjectId selectedCustomerId;
private DatePicker appointmentDatePicker;
private TimePicker appointmentTimePicker;
private Checkbox digitalProcessingCheckbox;
@@ -283,7 +302,7 @@ public class PickupStationDialog extends Dialog {
formLayout.setSpacing(true);
formLayout.setWidthFull();
// Customer selection
// Principal selection
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
customerComboBox.setRequiredIndicatorVisible(true);
@@ -291,27 +310,14 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear();
for (Customer c : customers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
? c.getCompanyName() + " | "
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
if (label.isBlank()) {
label = translationHelper.getTranslation("addjob.customer.unnamed");
}
String uniqueLabel = label;
int counter = 2;
while (customerLabelMap.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
customerLabelMap.put(uniqueLabel, c);
CustomerAddressLabelHelper.putUnique(customerLabelMap, c,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
// Company with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
// Pickup address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
company.setAllowCustomValue(true);
company.setWidthFull();
setupCompanyAutocomplete(company, customers);
@@ -431,18 +437,18 @@ public class PickupStationDialog extends Dialog {
customerComboBox.addValueChangeListener(ev -> {
String selected = ev.getValue();
if (selected == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
Customer c = customerLabelMap.get(selected);
if (c == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
if (c.getCompanyName() != null)
company.setValue(c.getCompanyName());
else
company.clear();
selectedCustomerId = c.getId();
setCompanySelection(c);
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|| "Divers".equalsIgnoreCase(c.getTitle())))
salutation.setValue(c.getTitle());
@@ -488,7 +494,12 @@ public class PickupStationDialog extends Dialog {
updateSaveAddressState();
});
formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, mail, streetLayout,
Div addressDivider = new Div();
addressDivider.setWidthFull();
addressDivider.getStyle().set("border-top", "1px solid var(--lumo-contrast-20pct)");
addressDivider.getStyle().set("margin", "var(--lumo-space-m) 0 var(--lumo-space-s)");
formLayout.add(customerComboBox, addressDivider, company, salutation, firstName, lastName, phone, mail, streetLayout,
addressAddition, zipCityLayout, saveAddress);
// TabSheet with address, appointments, and cargo tabs
@@ -500,7 +511,7 @@ public class PickupStationDialog extends Dialog {
appointmentsTabError = createTabErrorIndicator();
cargoTabError = createTabErrorIndicator();
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.pickup.address"), formLayout);
addressTab.add(addressTabError);
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
createAppointmentsTab(availableAppUsers));
@@ -614,13 +625,23 @@ public class PickupStationDialog extends Dialog {
public void setData(PickupData data) {
if (data == null)
return;
if (data.getCustomerSelection() != null) {
customerComboBox.setValue(data.getCustomerSelection());
String customerSelection = normalizeValue(data.getCustomerSelection());
if (!customerSelection.isEmpty()) {
if (!customerLabelMap.containsKey(customerSelection)) {
customerLabelMap.put(customerSelection, null);
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
}
customerComboBox.setValue(customerSelection);
} else {
customerComboBox.clear();
}
if (data.getCompany() != null)
String companyOption = findCompanyOptionLabel(data);
boolean customerSelectedFromOptions = companyOption != null;
if (companyOption != null) {
company.setValue(companyOption);
} else if (data.getCompany() != null) {
company.setValue(data.getCompany());
}
if (data.getSalutation() != null)
salutation.setValue(data.getSalutation());
if (data.getFirstName() != null)
@@ -663,13 +684,19 @@ public class PickupStationDialog extends Dialog {
}
}
saveAddress.setValue(data.isSaveAddress());
if (data.getCustomerId() != null) {
selectedCustomerId = data.getCustomerId();
} else {
Customer matched = companyOption != null ? companyCustomerMap.get(companyOption) : null;
selectedCustomerId = matched != null ? matched.getId() : null;
}
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState();
}
private PickupData collectData() {
PickupData data = new PickupData();
data.setCompany(company.getValue());
data.setCompany(resolveCompanyValue(company.getValue()));
data.setSalutation(salutation.getValue());
data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue());
@@ -681,6 +708,8 @@ public class PickupStationDialog extends Dialog {
data.setZip(zip.getValue());
data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setCustomerSelection(customerComboBox.getValue());
if (appointmentDatePicker != null) {
data.setAppointmentDate(appointmentDatePicker.getValue());
@@ -747,12 +776,9 @@ public class PickupStationDialog extends Dialog {
private boolean validateMailField() {
String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
applyErrorStyling(mail, invalid);
return !invalid;
}
private boolean validateCargoItems() {
@@ -804,54 +830,24 @@ public class PickupStationDialog extends Dialog {
}
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
companyCustomerMap.clear();
for (Customer customer : customers) {
String companyName = normalizeValue(customer.getCompanyName());
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) {
continue;
}
companyCustomerMap.put(companyName, customer);
CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
companyField.setItems(companyNames);
companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
String selectedAddress = event.getValue();
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
Optional<Customer> matchingCustomer = customers.stream()
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
salutation.setValue(customer.getTitle());
}
if (customer.getFirstname() != null)
firstName.setValue(customer.getFirstname());
if (customer.getLastName() != null)
lastName.setValue(customer.getLastName());
if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone());
if (customer.getMail() != null)
mail.setValue(customer.getMail());
if (customer.getStreet() != null)
street.setValue(customer.getStreet());
if (customer.getHouseNumber() != null)
houseNumber.setValue(customer.getHouseNumber());
if (customer.getAddressAddition() != null)
addressAddition.setValue(customer.getAddressAddition());
if (customer.getZip() != null)
zip.setValue(customer.getZip());
if (customer.getCity() != null)
city.setValue(customer.getCity());
Customer customer = companyCustomerMap.get(selectedAddress);
if (customer != null) {
applyCustomerAddress(customer);
}
updateSaveAddressState();
@@ -859,33 +855,40 @@ public class PickupStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState();
});
}
private void updateSaveAddressState() {
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue()));
boolean existingCustomerSelected = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean existingCompanySelected = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
Customer selectedCompanyCustomer = companyCustomerMap.get(company.getValue());
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
if (existingCustomerSelected || existingCompanySelected) {
if (customerDataMatches || companyDataMatches) {
saveAddress.setValue(false);
saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement();
return;
}
saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement();
}
private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
mail.setRequiredIndicatorVisible(false);
}
private boolean matchesCustomer(Customer customer) {
return sameValue(company.getValue(), customer.getCompanyName())
return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
&& sameValue(salutation.getValue(), customer.getTitle())
&& sameValue(firstName.getValue(), customer.getFirstname())
&& sameValue(lastName.getValue(), customer.getLastName())
@@ -906,6 +909,142 @@ public class PickupStationDialog extends Dialog {
return value == null ? "" : value.trim();
}
private boolean computeAddressDiffers() {
boolean hasAnyValue = !normalizeValue(resolveCompanyValue(company.getValue())).isEmpty()
|| !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty()
|| !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
|| !normalizeValue(addressAddition.getValue()).isEmpty() || !normalizeValue(zip.getValue()).isEmpty()
|| !normalizeValue(city.getValue()).isEmpty();
if (!hasAnyValue) {
return false;
}
if (selectedCustomerId == null) {
return true;
}
Customer linked = findCustomerById(selectedCustomerId);
return linked == null || !matchesCustomer(linked);
}
private Customer findCustomerById(org.bson.types.ObjectId id) {
if (id == null) {
return null;
}
for (Customer c : customerLabelMap.values()) {
if (c != null && id.equals(c.getId())) {
return c;
}
}
for (Customer c : companyCustomerMap.values()) {
if (c != null && id.equals(c.getId())) {
return c;
}
}
return null;
}
private void applyCustomerAddress(Customer customer) {
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
salutation.setValue(customer.getTitle());
} else {
salutation.clear();
}
if (customer.getFirstname() != null)
firstName.setValue(customer.getFirstname());
else
firstName.clear();
if (customer.getLastName() != null)
lastName.setValue(customer.getLastName());
else
lastName.clear();
if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone());
else
phone.clear();
if (customer.getMail() != null)
mail.setValue(customer.getMail());
else
mail.clear();
if (customer.getStreet() != null)
street.setValue(customer.getStreet());
else
street.clear();
if (customer.getHouseNumber() != null)
houseNumber.setValue(customer.getHouseNumber());
else
houseNumber.clear();
if (customer.getAddressAddition() != null)
addressAddition.setValue(customer.getAddressAddition());
else
addressAddition.clear();
if (customer.getZip() != null)
zip.setValue(customer.getZip());
else
zip.clear();
if (customer.getCity() != null)
city.setValue(customer.getCity());
else
city.clear();
}
private void setCompanySelection(Customer customer) {
String label = findCompanyOptionLabel(customer);
if (label != null) {
company.setValue(label);
} else if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
company.setValue(customer.getCompanyName());
} else {
company.clear();
}
}
private String resolveCompanyValue(String comboValue) {
return CustomerAddressLabelHelper.resolveCompanyValue(companyCustomerMap, comboValue);
}
private String findCompanyOptionLabel(Customer customer) {
if (customer == null || customer.getId() == null) {
return null;
}
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
Customer option = entry.getValue();
if (option != null && customer.getId().equals(option.getId())) {
return entry.getKey();
}
}
return null;
}
private String findCompanyOptionLabel(PickupData data) {
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
Customer customer = entry.getValue();
if (data.getCustomerId() != null && customer.getId() != null && data.getCustomerId().equals(customer.getId())) {
return entry.getKey();
}
if (matchesCustomer(customer, data)) {
return entry.getKey();
}
}
return null;
}
private boolean matchesCustomer(Customer customer, PickupData data) {
return sameValue(customer.getCompanyName(), data.getCompany())
&& sameValue(customer.getTitle(), data.getSalutation())
&& sameValue(customer.getFirstname(), data.getFirstName())
&& sameValue(customer.getLastName(), data.getLastName())
&& sameValue(customer.getTelephone(), data.getPhone())
&& sameValue(customer.getMail(), data.getMail())
&& sameValue(customer.getStreet(), data.getStreet())
&& sameValue(customer.getHouseNumber(), data.getHouseNumber())
&& sameValue(customer.getAddressAddition(), data.getAddressAddition())
&& sameValue(customer.getZip(), data.getZip())
&& sameValue(customer.getCity(), data.getCity());
}
// ============================================
// Appointments & Processing Tab
// ============================================

View File

@@ -5,6 +5,7 @@ import org.bson.types.ObjectId;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List;
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
@@ -13,4 +14,9 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
Slice<Customer> findAllBy(Pageable pageable);
List<Customer> findByOwner(ObjectId owner);
// $ne: true matches documents where internal is false, null, or the field is missing
// (legacy data without the internal field still shows up in customer dropdowns).
@Query("{ 'owner' : ?0, 'internal' : { $ne: true } }")
List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
}

View File

@@ -12,10 +12,13 @@ import org.springframework.transaction.annotation.Transactional;
public class AddCustomerService {
private final AddCustomerRepository addCustomerRepository;
private final SecurityService securityService;
private final SequenceGeneratorService sequenceGeneratorService;
AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService) {
AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService,
SequenceGeneratorService sequenceGeneratorService) {
this.addCustomerRepository = addCustomerRepository;
this.securityService = securityService;
this.sequenceGeneratorService = sequenceGeneratorService;
}
public void addCustomer(Customer customer) {
@@ -25,6 +28,35 @@ public class AddCustomerService {
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
customer.setCreatedBy(currentUser.getId());
customer.setOwner(currentUser.getId());
if (customer.getUsrId() == null) {
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
}
addCustomerRepository.save(customer);
}
public void addInternalCustomer(Customer customer) {
if (customer == null) {
return;
}
customer.setId(null);
customer.setInternal(true);
addCustomer(customer);
}
public void updateCustomer(Customer customer) {
if (customer == null || customer.getId() == null) {
throw new IllegalArgumentException("Kunden-ID fehlt");
}
validateCustomer(customer);
Customer existing = addCustomerRepository.findById(customer.getId())
.orElseThrow(() -> new IllegalArgumentException("Kunde nicht gefunden"));
customer.setCreatedBy(existing.getCreatedBy());
customer.setOwner(existing.getOwner());
if (customer.getUsrId() == null) {
customer.setUsrId(existing.getUsrId());
}
addCustomerRepository.save(customer);
}
@@ -35,13 +67,10 @@ public class AddCustomerService {
}
String mail = customer.getMail() != null ? customer.getMail().trim() : "";
if (mail.isEmpty()) {
throw new IllegalArgumentException("E-Mail-Adresse ist ein Pflichtfeld");
}
if (!mail.contains("@")) {
if (!mail.isEmpty() && !mail.contains("@")) {
throw new IllegalArgumentException("Bitte geben Sie eine gültige E-Mail-Adresse ein");
}
customer.setMail(mail);
customer.setMail(mail.isEmpty() ? null : mail);
}
}

View File

@@ -32,7 +32,7 @@ public class CustomerService {
public List<Customer> findAllForCurrentOwner() {
ObjectId ownerId = securityService.getCurrentUserId();
return todoRepository.findByOwner(ownerId);
return todoRepository.findByOwnerAndInternalFalse(ownerId);
}
public Customer save(Customer customer) {
@@ -43,4 +43,8 @@ public class CustomerService {
return todoRepository.findById(id).orElse(null);
}
public void deleteById(ObjectId id) {
todoRepository.deleteById(id);
}
}

View File

@@ -0,0 +1,54 @@
package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.Counter;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
@Service
public class SequenceGeneratorService {
public static final String CUSTOMER_NUMBER_SEQ = "customerNumber";
public static final int CUSTOMER_NUMBER_START = 10000;
private final MongoTemplate mongoTemplate;
public SequenceGeneratorService(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
public int nextCustomerNumber() {
return (int) nextSequence(CUSTOMER_NUMBER_SEQ, CUSTOMER_NUMBER_START);
}
private long nextSequence(String sequenceId, long startValue) {
ensureInitialized(sequenceId, startValue - 1);
Counter updated = mongoTemplate.findAndModify(
Query.query(Criteria.where("_id").is(sequenceId)),
new Update().inc("sequence", 1),
FindAndModifyOptions.options().returnNew(true),
Counter.class);
return updated != null ? updated.getSequence() : startValue;
}
private void ensureInitialized(String sequenceId, long initialValue) {
boolean exists = mongoTemplate.exists(
Query.query(Criteria.where("_id").is(sequenceId)),
Counter.class);
if (exists) {
return;
}
Counter counter = new Counter();
counter.setId(sequenceId);
counter.setSequence(initialValue);
try {
mongoTemplate.insert(counter);
} catch (DuplicateKeyException ignored) {
// Ein anderer Thread hat den Counter gleichzeitig angelegt — passt.
}
}
}

View File

@@ -1,8 +1,14 @@
package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.UserInvoiceData;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
@@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional;
@Service
public class UserInvoiceDataService {
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
private final UserInvoiceDataRepository userInvoiceDataRepository;
private final MongoTemplate mongoTemplate;
private final InvoiceNumberReservationRepository reservationRepository;
private final SecurityService securityService;
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) {
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate,
InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) {
this.userInvoiceDataRepository = userInvoiceDataRepository;
this.mongoTemplate = mongoTemplate;
this.reservationRepository = reservationRepository;
this.securityService = securityService;
}
public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
@@ -64,6 +78,12 @@ public class UserInvoiceDataService {
/**
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
*
* Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED
* persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem
* Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener
* Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später
* vom Lifecycle-Service auf USED bzw. VOIDED gesetzt.
*/
public String generateNextInvoiceNumber(ObjectId userId) {
Query query = Query.query(Criteria.where("userId").is(userId));
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
return findByUserId(userId).map(d -> {
String prefix = d.getPrefix() != null ? d.getPrefix() : "";
return prefix + String.format("%06d", d.getNextInvoiceNumber());
long sequence = d.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}).orElse("000000");
}
String prefix = before.getPrefix() != null ? before.getPrefix() : "";
return prefix + String.format("%06d", before.getNextInvoiceNumber());
long sequence = before.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}
/**
* Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags
* ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository
* vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir
* loggen den Fehler und vertrauen darauf, dass die anschließende Lücken-
* Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung
* sichtbar macht.
*/
private void recordReservation(ObjectId userId, String number, long sequence, String prefix) {
try {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setPrefix(prefix);
reservation.setReservedAt(Instant.now());
reservation.setReservedBy(currentUserDisplayName());
reservation.setStatus(InvoiceNumberReservationStatus.RESERVED);
reservationRepository.save(reservation);
} catch (Exception ex) {
log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}",
number, userId, ex.getMessage(), ex);
}
}
private String currentUserDisplayName() {
try {
var user = securityService.getCurrentDatabaseUser();
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
return composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
return "system";
}
}
private String safe(String value) {
return value != null ? value : "";
}
}

View File

@@ -46,11 +46,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
public AddCustomerView(AddCustomerService todoService, Clock clock) {
this.addCustomerService = todoService;
// Firma (Pflichtfeld)
// Firma (optional; auch Privatpersonen können im Adressbuch stehen)
companyName = new TextField(getTranslation("profile.company"));
companyName.setRequiredIndicatorVisible(true);
companyName.setWidthFull();
companyName.addBlurListener(e -> validateField(companyName));
// Anrede (Dropdown)
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -81,9 +79,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
fax = new TextField(getTranslation("profile.fax"));
fax.setWidthFull();
// E-Mail (Pflichtfeld)
// E-Mail (optional)
mail = new TextField(getTranslation("profile.email"));
mail.setRequiredIndicatorVisible(true);
mail.setWidthFull();
mail.addBlurListener(e -> validateEmail());
@@ -163,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
private void configureBinder() {
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required"))
.bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
@@ -179,8 +175,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
binder.forField(fax).bind(Customer::getFax, Customer::setFax);
binder.forField(mail).asRequired(getTranslation("profile.validation.email.required"))
.withValidator(email -> email.contains("@"), getTranslation("profile.validation.email.invalid"))
binder.forField(mail)
.withValidator(email -> email == null || email.isBlank() || email.contains("@"),
getTranslation("profile.validation.email.invalid"))
.bind(Customer::getMail, Customer::setMail);
binder.forField(street).asRequired(getTranslation("profile.validation.street.required"))
@@ -247,10 +244,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void validateEmail() {
String value = mail.getValue();
if (value == null || value.trim().isEmpty()) {
mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.validation.email.required"));
} else if (!value.contains("@")) {
if (value != null && !value.trim().isEmpty() && !value.contains("@")) {
mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.validation.email.invalid"));
} else {
@@ -260,7 +254,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
private boolean validateAllFields() {
validateField(companyName);
validateField(firstName);
validateField(lastName);
validateField(telephone);
@@ -270,9 +263,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city);
validateEmail();
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
&& !city.isInvalid();
return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
&& !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
}
@Override

View File

@@ -63,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
import de.assecutor.votianlt.model.RouteCalculationResult;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
import de.assecutor.votianlt.pages.base.ui.component.CustomerAddressLabelHelper;
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
@@ -137,12 +138,16 @@ public class AddJobView extends Main implements HasDynamicTitle {
private TextField pickupZip;
private TextField pickupCity;
private Checkbox savePickupAddress;
private org.bson.types.ObjectId pickupCustomerId;
private boolean pickupAddressDiffers;
// Delivery stations as tiles in a 3x3 grid (max 7 delivery + 1 pickup + 1 plus
// = 9)
private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
private final List<org.bson.types.ObjectId> deliveryStationsCustomerId = new ArrayList<>();
private final List<Boolean> deliveryStationsAddressDiffers = new ArrayList<>();
private final List<String> deliveryStationsMailState = new ArrayList<>();
private final List<Div> deliveryStationSlotList = new ArrayList<>();
private final List<Span> deliveryStationDistanceChips = new ArrayList<>();
@@ -232,32 +237,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
customerSelection.setWidthFull();
customerSelection.setRequiredIndicatorVisible(true);
customerSelection.setAllowCustomValue(true);
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear();
for (Customer c : ownerCustomers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
? c.getCompanyName() + " | "
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
if (label.isBlank()) {
label = getTranslation("addjob.customer.unnamed");
}
// Bei Duplikaten Label einzigartig machen
String uniqueLabel = label;
int counter = 2;
while (customerLabelToEntity.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
customerLabelToEntity.put(uniqueLabel, c);
CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
}
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
// Pickup address
pickupCompany = new ComboBox<>(getTranslation("profile.company"));
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder"));
pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
pickupCompany.setAllowCustomValue(true);
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -721,6 +713,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Add empty state for this station
deliveryStationsState.add(new DeliveryStation());
deliveryStationsSaveAddress.add(true);
deliveryStationsCustomerId.add(null);
deliveryStationsAddressDiffers.add(false);
deliveryStationsMailState.add(null);
deliveryStationsValidatedByGoogle.add(false);
@@ -769,6 +763,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsCustomerId.remove(removeIdx);
deliveryStationsAddressDiffers.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx);
@@ -849,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
translationHelper, data -> {
// Update customer selection from dialog
if (data.getCustomerSelection() != null) {
customerSelection.setValue(data.getCustomerSelection());
setCustomerSelectionValue(data.getCustomerSelection());
} else {
customerSelection.clear();
}
@@ -867,6 +863,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupZip.setValue(data.getZip() != null ? data.getZip() : "");
pickupCity.setValue(data.getCity() != null ? data.getCity() : "");
savePickupAddress.setValue(data.isSaveAddress());
pickupCustomerId = data.getCustomerId();
pickupAddressDiffers = data.isAddressDiffersFromCustomer();
// Sync appointment fields for binder/submit
pickupDate.setValue(data.getAppointmentDate());
@@ -913,6 +911,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(pickupZip.getValue());
currentData.setCity(pickupCity.getValue());
currentData.setSaveAddress(savePickupAddress.getValue());
currentData.setCustomerId(pickupCustomerId);
currentData.setCustomerSelection(customerSelection.getValue());
// Pre-fill pickup-specific fields
currentData.setAppointmentDate(pickupDate.getValue());
@@ -1105,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
return trimmed.isEmpty() ? null : trimmed;
}
private void setCustomerSelectionValue(String value) {
String normalizedValue = trimToNull(value);
if (normalizedValue == null) {
customerSelection.clear();
return;
}
if (!customerLabelToEntity.containsKey(normalizedValue)) {
customerLabelToEntity.put(normalizedValue, null);
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
}
customerSelection.setValue(normalizedValue);
}
private void openDeliveryDialog(StationTile tile, int stationIndex) {
// Ensure index is valid (could have changed due to deletions)
int actualIndex = deliveryStationTilesList.indexOf(tile);
@@ -1137,6 +1149,14 @@ public class AddJobView extends Main implements HasDynamicTitle {
station.setCity(data.getCity());
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
while (deliveryStationsCustomerId.size() <= idx) {
deliveryStationsCustomerId.add(null);
}
deliveryStationsCustomerId.set(idx, data.getCustomerId());
while (deliveryStationsAddressDiffers.size() <= idx) {
deliveryStationsAddressDiffers.add(false);
}
deliveryStationsAddressDiffers.set(idx, data.isAddressDiffersFromCustomer());
deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
// Store tasks for this delivery station
@@ -1182,6 +1202,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(station.getZip());
currentData.setCity(station.getCity());
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
if (actualIndex < deliveryStationsCustomerId.size()) {
currentData.setCustomerId(deliveryStationsCustomerId.get(actualIndex));
}
if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
}
@@ -1390,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Get all customers for the current owner
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
// Extract unique company names (filter out null/empty values)
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName)
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
Map<String, Customer> addressOptions = new LinkedHashMap<>();
for (Customer customer : allCustomers) {
CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
}
// Set items for autocomplete
companyField.setItems(companyNames);
companyField.setItems(new ArrayList<>(addressOptions.keySet()));
// Add selection listener to auto-fill pickup address fields when company is
// selected
companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
String selectedAddress = event.getValue();
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return;
}
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
resetRouteInformation();
// Find the first customer with this company name
Optional<Customer> matchingCustomer = allCustomers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
Customer customer = addressOptions.get(selectedAddress);
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
if (customer != null) {
pickupCustomerId = customer.getId();
// Fill pickup address fields
if (customer.getTitle() != null
@@ -1454,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Reactivate save checkbox for custom values
savePickupAddress.setValue(true);
pickupCustomerId = null;
pickupMail = null;
});
}
@@ -1817,39 +1840,60 @@ public class AddJobView extends Main implements HasDynamicTitle {
return;
}
// NEU: Kunden anlegen, wenn Checkboxen aktiviert
// Kunden anlegen/aktualisieren bzw. intern sichern
Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue());
pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
if (savePickupAddress.getValue()) {
Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue());
pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
addCustomerService.addCustomer(pickupCustomer);
if (pickupCustomerId != null) {
pickupCustomer.setId(pickupCustomerId);
addCustomerService.updateCustomer(pickupCustomer);
} else {
addCustomerService.addCustomer(pickupCustomer);
}
} else if (pickupAddressDiffers) {
addCustomerService.addInternalCustomer(pickupCustomer);
}
// Save delivery station addresses as customers if checkbox is checked
// Delivery-Stationen: anlegen, aktualisieren oder als intern sichern
for (int i = 0; i < deliveryStationsState.size(); i++) {
if (i < deliveryStationsSaveAddress.size() && deliveryStationsSaveAddress.get(i)) {
DeliveryStation ds = deliveryStationsState.get(i);
Customer deliveryCustomer = new Customer();
deliveryCustomer.setCompanyName(ds.getCompany());
deliveryCustomer.setTitle(ds.getSalutation());
deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
deliveryCustomer.setZip(ds.getZip());
deliveryCustomer.setCity(ds.getCity());
addCustomerService.addCustomer(deliveryCustomer);
DeliveryStation ds = deliveryStationsState.get(i);
Customer deliveryCustomer = new Customer();
deliveryCustomer.setCompanyName(ds.getCompany());
deliveryCustomer.setTitle(ds.getSalutation());
deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
deliveryCustomer.setZip(ds.getZip());
deliveryCustomer.setCity(ds.getCity());
boolean saveRequested = i < deliveryStationsSaveAddress.size()
&& deliveryStationsSaveAddress.get(i);
org.bson.types.ObjectId existingId = i < deliveryStationsCustomerId.size()
? deliveryStationsCustomerId.get(i)
: null;
boolean addressDiffers = i < deliveryStationsAddressDiffers.size()
&& deliveryStationsAddressDiffers.get(i);
if (saveRequested) {
if (existingId != null) {
deliveryCustomer.setId(existingId);
addCustomerService.updateCustomer(deliveryCustomer);
} else {
addCustomerService.addCustomer(deliveryCustomer);
}
} else if (addressDiffers) {
addCustomerService.addInternalCustomer(deliveryCustomer);
}
}
@@ -1944,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
*/
private void loadJobIntoForm(Job job) {
if (job.getCustomerSelection() != null) {
customerSelection.setValue(job.getCustomerSelection());
setCustomerSelectionValue(job.getCustomerSelection());
}
}
@@ -2093,7 +2137,105 @@ public class AddJobView extends Main implements HasDynamicTitle {
return null;
}
/**
* Entfernt alle leeren (nicht editierten) Lieferstationen, sofern mindestens
* eine valide Lieferstation übrig bleibt. Wird aufgerufen bevor die Stationen
* übernommen werden.
*/
private void removeEmptyDeliveryStations() {
// Indizes der leeren Stationen sammeln (absteigend, damit beim Entfernen die Indizes stabil bleiben)
List<Integer> emptyIndices = new ArrayList<>();
for (int i = 0; i < deliveryStationsState.size(); i++) {
if (hasDeliveryStationValidationErrors(deliveryStationsState.get(i))) {
emptyIndices.add(i);
}
}
// Mindestens eine valide Station muss übrig bleiben
if (emptyIndices.size() >= deliveryStationsState.size()) {
return;
}
// Von hinten nach vorne entfernen, damit Indizes stabil bleiben
for (int k = emptyIndices.size() - 1; k >= 0; k--) {
int idx = emptyIndices.get(k);
deliveryStationTilesList.remove(idx);
deliveryStationsState.remove(idx);
deliveryStationsSaveAddress.remove(idx);
deliveryStationsCustomerId.remove(idx);
deliveryStationsAddressDiffers.remove(idx);
deliveryStationsMailState.remove(idx);
deliveryStationsValidatedByGoogle.remove(idx);
deliveryStationTasksState.remove(idx);
Div removedSlot = deliveryStationSlotList.remove(idx);
deliveryStationDistanceChips.remove(idx);
pickupToDeliveryRouteResults.remove(idx);
stationsGridContainer.remove(removedSlot);
}
// Tasks und Routen-Maps re-indizieren
Map<Integer, List<BaseTask>> reindexedTasks = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedTasks.put(newIdx, entry.getValue());
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexedTasks);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedRoutes.put(newIdx, entry.getValue());
}
pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
// Service-Zuordnungen anpassen
for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) {
continue;
}
if (emptyIndices.contains(stationOrder)) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else {
int newOrder = stationOrder - (int) emptyIndices.stream().filter(ei -> ei < stationOrder).count();
selectedService.setDeliveryStationOrder(newOrder);
}
}
// Tiles neu nummerieren und Click-Listener aktualisieren
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
StationTile t = deliveryStationTilesList.get(i);
int newNumber = i + 1;
t.updateStationNumber(newNumber);
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
if (i == 0) {
t.setDeleteListener(null);
}
}
// "+" Button wieder anzeigen falls unter Maximum
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
&& addStationButtonSlot.getParent().isEmpty()) {
stationsGridContainer.add(addStationButtonSlot);
}
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
}
private void handleApplyStations() {
removeEmptyDeliveryStations();
revealPriceAndDetailsSection();
if (!areAllStationsValidatedByGoogle()) {

View File

@@ -11,6 +11,7 @@ import com.vaadin.flow.component.html.Span;
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.component.textfield.NumberField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
@@ -26,12 +27,13 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
@@ -61,13 +63,15 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final InvoiceTemplateService invoiceTemplateService;
private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private User currentUser;
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
private Grid<ServiceRow> servicesGrid;
private Div servicesSection;
private Div summarySection;
private NumberField vatField;
/**
* Helper class to represent a row in the services grid
@@ -114,8 +118,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository,
CustomerService customerService) {
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
InvoiceLifecycleService invoiceLifecycleService) {
this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository;
this.userRepository = userRepository;
@@ -123,8 +127,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.invoiceTemplateService = invoiceTemplateService;
this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceRepository = customerInvoiceRepository;
this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
setSizeFull();
setPadding(true);
setSpacing(true);
@@ -176,6 +180,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return;
}
currentUser = securityService.getAuthenticatedUser()
.flatMap(auth -> userRepository.findByEmail(auth.getUsername())).orElse(null);
createInvoiceView();
}
@@ -203,8 +210,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Div servicesSection = createServicesSelectionSection();
add(servicesSection);
// VAT Section (must exist before summary so effectiveVatRate() can read the field)
Div vatSection = createVatSection();
add(vatSection);
// Summary Section
Div summarySection = createSummarySection();
summarySection = createSummarySection();
add(summarySection);
// Create Invoice Button
@@ -336,13 +347,16 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
section.setWidthFull();
section.addClassName("invoice-section-card");
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
populateSummarySection(section);
return section;
}
private void populateSummarySection(Div section) {
H3 sectionTitle = new H3(getTranslation("createinvoice.section.summary"));
section.add(sectionTitle);
// Calculate totals
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -355,9 +369,40 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
totalAmount.setScale(2, RoundingMode.HALF_UP) + "", true));
section.add(priceTable);
}
private Div createVatSection() {
Div section = new Div();
section.setWidthFull();
section.addClassName("invoice-section-card");
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
H3 sectionTitle = new H3(getTranslation("createinvoice.section.vat"));
section.add(sectionTitle);
vatField = new NumberField();
vatField.setLabel(getTranslation("createinvoice.field.vatrate"));
vatField.setSuffixComponent(new Span("%"));
vatField.setStep(0.01);
vatField.setMin(0);
BigDecimal initialRate = currentUser != null && currentUser.getVatRate() != null
? currentUser.getVatRate()
: Service.FIXED_VAT_RATE;
vatField.setValue(initialRate.multiply(new BigDecimal("100")).doubleValue());
vatField.addValueChangeListener(e -> refreshSummarySection());
section.add(vatField);
return section;
}
private void refreshSummarySection() {
if (summarySection == null) {
return;
}
summarySection.removeAll();
populateSummarySection(summarySection);
}
private Div createPriceRow(String label, String value, boolean bold) {
Div row = new Div();
row.addClassName("price-row");
@@ -427,6 +472,17 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return units;
}
private BigDecimal effectiveVatRate() {
if (vatField != null && vatField.getValue() != null) {
return new BigDecimal(Double.toString(vatField.getValue()))
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
}
if (currentUser != null && currentUser.getVatRate() != null) {
return currentUser.getVatRate();
}
return Service.FIXED_VAT_RATE;
}
private BigDecimal calculateNetAmount() {
BigDecimal total = BigDecimal.ZERO;
@@ -514,7 +570,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -529,8 +585,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount);
invoice.setPdfData(pdfBytes);
CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
"Rechnung erstellt aus Auftrag " + currentJob.getJobNumber());
currentJob.setInvoiceId(savedInvoice.getId());
jobRepository.save(currentJob);
@@ -539,6 +599,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
Notification.Position.BOTTOM_END);
} catch (InvoiceLifecycleException lifecycleEx) {
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
} catch (Exception ex) {
log.error("Fehler beim Speichern der Rechnung", ex);
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
@@ -553,7 +616,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
throws Exception {
// Calculate totals
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);

View File

@@ -36,7 +36,8 @@ public class CustomersView extends Main implements HasDynamicTitle {
createBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
todoGrid = new Grid<>();
todoGrid.setItems(query -> todoService.list(toSpringPageRequest(query)).stream());
todoGrid.setItems(query -> todoService.list(toSpringPageRequest(query)).stream()
.filter(c -> !c.isInternal()));
todoGrid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"));
todoGrid.setSizeFull();
todoGrid.addClassName("data-grid");

View File

@@ -191,6 +191,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
if (customer != null && customer.getId() != null) {
customerService.deleteById(customer.getId());
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
Notification.Position.MIDDLE);
confirmDialog.close();

View File

@@ -76,6 +76,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private final InvoiceTemplateService invoiceTemplateService;
private UserInvoiceData currentInvoiceData;
private Checkbox billingEnabled;
private NumberField vatRateField;
private VerticalLayout propertiesPanelProfile;
private final ServiceRepository serviceRepository;
@@ -330,10 +331,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
termsTextArea = new TextArea();
pdfFrame = new IFrame();
// Nur die Checkbox "Rechnungslegung über votianLT"
// Checkbox "Rechnungslegung über votianLT"
billingEnabled = new Checkbox(getTranslation("profile.billing.enabled"));
billingEnabled.setValue(true); // Standardmäßig aktiviert
billingTab.add(billingEnabled);
prefixField.setLabel(getTranslation("profile.billing.prefix"));
prefixField.setPlaceholder("z.B. RE-2024-");
@@ -347,7 +347,30 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
"if (window.updateProfileMasterdataValue) { window.updateProfileMasterdataValue('masterdata.invoice_number', '"
+ invNr.replace("'", "\\'") + "'); }");
});
billingTab.add(prefixField);
vatRateField = new NumberField();
vatRateField.setLabel(getTranslation("profile.settings.vatrate"));
vatRateField.setSuffixComponent(new Span("%"));
vatRateField.setStep(0.01);
vatRateField.setMin(0);
vatRateField.setMaxWidth("200px");
if (currentUser.getVatRate() != null) {
vatRateField.setValue(currentUser.getVatRate().multiply(new java.math.BigDecimal("100")).doubleValue());
}
vatRateField.addValueChangeListener(e -> {
Double v = e.getValue();
if (v != null) {
currentUser.setVatRate(new java.math.BigDecimal(Double.toString(v))
.divide(new java.math.BigDecimal("100"), 4, java.math.RoundingMode.HALF_UP));
getElement().executeJs("if (window.updateProfileVatRate) { window.updateProfileVatRate($0); }",
v / 100.0);
}
});
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField);
billingHeaderLayout.setSpacing(true);
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout);
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout();
@@ -451,6 +474,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingEnabled.addValueChangeListener(e -> {
boolean visible = e.getValue();
prefixField.setVisible(visible);
vatRateField.setVisible(visible);
mainLayout.setVisible(visible);
actionLayout.setVisible(visible);
});
@@ -843,6 +867,17 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
&& !houseNumberField.isInvalid() && !zipField.isInvalid() && !cityField.isInvalid();
}
private BigDecimal getPreviewVatRate() {
if (vatRateField != null && vatRateField.getValue() != null) {
return new BigDecimal(Double.toString(vatRateField.getValue())).divide(new BigDecimal("100"), 4,
java.math.RoundingMode.HALF_UP);
}
if (currentUser != null && currentUser.getVatRate() != null) {
return currentUser.getVatRate();
}
return Service.FIXED_VAT_RATE;
}
// Methoden für den Rechnungsgenerator im Profil
private void generatePreviewPdfFromProfile() {
try {
@@ -862,8 +897,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} else {
templateData = result.toString();
}
BigDecimal previewVatRate = getPreviewVatRate();
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
currentUser, prefixField.getValue());
currentUser, prefixField.getValue(), previewVatRate);
showPdfInDialog(pdfBytes);
} catch (Exception ex) {
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
@@ -1424,9 +1460,14 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
+ "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}";
BigDecimal rate = currentUser.getVatRate() != null ? currentUser.getVatRate()
: Service.FIXED_VAT_RATE;
double vatRateJs = rate.doubleValue();
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " console.log('Loading template into canvas...'); " + " window.masterdataValues = "
+ " console.log('Loading template into canvas...'); "
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
+ " window.masterdataValues = "
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
+ " window.loadProfileTemplate(templateData); " + " } else { "
+ " console.error('loadProfileTemplate or canvas not available'); " + " } "

View File

@@ -1,39 +1,82 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
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.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.StreamRegistration;
import com.vaadin.flow.server.StreamResource;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
Locale.GERMANY);
private final Grid<CustomerInvoice> invoiceGrid;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final SecurityService securityService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceExportService invoiceExportService;
private final InvoicePermissionService invoicePermissionService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService) {
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
setSizeFull();
setPadding(true);
@@ -43,60 +86,502 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
addClassName("data-view");
add(new ViewToolbar(getTranslation("invoices.title")));
add(buildLegalDisclaimer());
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull();
invoiceGrid.addClassName("data-grid");
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderTypeBadge)
.setHeader(getTranslation("invoices.column.type")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderStatusBadge)
.setHeader(getTranslation("invoices.column.status")).setAutoWidth(true);
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
.setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true);
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
invoiceGrid.getStyle().set("cursor", "pointer");
invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
invoiceGrid.addItemClickListener(event -> {
CustomerInvoice invoice = event.getItem();
if (invoice != null) {
downloadInvoicePdf(invoice);
}
});
invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
loadInvoices();
Div gridPanel = new Div(invoiceGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull();
add(gridPanel);
loadInvoices();
}
private Component buildLegalDisclaimer() {
Div banner = new Div();
banner.addClassName("surface-panel");
banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)")
.set("background", "var(--lumo-contrast-5pct)");
Span text = new Span(getTranslation("invoices.disclaimer"));
text.getStyle().set("font-size", "var(--lumo-font-size-s)");
banner.add(text);
return banner;
}
private void loadInvoices() {
String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData).sorted((left, right) -> {
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
return 0;
}
if (left.getInvoiceDate() == null) {
return 1;
}
if (right.getInvoiceDate() == null) {
return -1;
}
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}).toList();
.sorted(Comparator
.comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
: i.getInvoiceDate())
.reversed())
.toList();
invoiceGrid.setItems(invoices);
}
if (invoices.isEmpty()) {
Span emptyState = new Span(getTranslation("invoices.empty"));
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)");
add(emptyState);
private Component renderStatusBadge(CustomerInvoice invoice) {
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
switch (status) {
case DRAFT -> badge.getElement().getThemeList().add("contrast");
case SENT -> badge.getElement().getThemeList().add("success");
case CANCELLED -> badge.getElement().getThemeList().add("error");
case CORRECTED -> badge.getElement().getThemeList().add("warning");
default -> {
}
}
return badge;
}
private Component renderTypeBadge(CustomerInvoice invoice) {
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
HorizontalLayout layout = new HorizontalLayout();
layout.setSpacing(true);
layout.setPadding(false);
Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
if (type == InvoiceType.CANCELLATION) {
badge.getElement().getThemeList().add("error");
} else if (type == InvoiceType.CORRECTION) {
badge.getElement().getThemeList().add("warning");
}
layout.add(badge);
return layout;
}
private Component renderActions(CustomerInvoice invoice) {
HorizontalLayout actions = new HorizontalLayout();
actions.setSpacing(true);
actions.setPadding(false);
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
actions.add(viewBtn);
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(historyBtn);
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
User currentUser = invoicePermissionService.currentUser();
// Aktionen nur für reguläre, noch aktive Rechnungen anbieten
boolean isLiveInvoice = type == InvoiceType.INVOICE
&& (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT);
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
&& invoicePermissionService.canMarkAsSent(currentUser)) {
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
e -> markAsSent(invoice));
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(sentBtn);
}
if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(correctBtn);
}
if (invoicePermissionService.canCancel(currentUser)) {
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
actions.add(cancelBtn);
}
}
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
&& invoicePermissionService.canRecordPayment(currentUser)) {
Button payBtn = new Button(getTranslation("invoices.action.payment"),
e -> openPaymentDialog(invoice));
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(payBtn);
}
// Belegpaket exportieren (R-33/R-34)
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(exportBtn);
return actions;
}
private void openPaymentDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
Span hint = new Span(getTranslation("invoices.payment.hint",
java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding)));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
NumberField amountField = new NumberField(getTranslation("invoices.payment.amount"));
amountField.setStep(0.01);
amountField.setValue(outstanding.doubleValue());
amountField.setRequiredIndicatorVisible(true);
amountField.setWidthFull();
content.add(amountField);
TextField referenceField = new TextField(getTranslation("invoices.payment.reference"));
referenceField.setWidthFull();
content.add(referenceField);
TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.payment.confirm"), e -> {
Double amount = amountField.getValue();
if (amount == null || amount == 0d) {
amountField.setInvalid(true);
amountField.setErrorMessage(getTranslation("invoices.payment.amount.required"));
return;
}
performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(),
reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason,
Dialog dialog) {
try {
invoicePermissionService.requirePayment(invoicePermissionService.currentUser());
invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void exportPackage(CustomerInvoice invoice) {
try {
byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice);
String fileName = invoiceExportService.suggestFilename(invoice);
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void markAsSent(CustomerInvoice invoice) {
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert");
Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE);
}
}
private void openCancellationDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.cancel.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("100px");
reasonField.setRequired(true);
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.cancel.confirm"), e -> {
String reason = reasonField.getValue();
if (reason == null || reason.isBlank()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required"));
return;
}
performCancellation(invoice, reason, dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCancel(currentUser);
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason);
invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.cancelled", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void openCorrectionDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.correct.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields"));
fieldsField.setWidthFull();
fieldsField.setMinHeight("100px");
fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper"));
fieldsField.setRequired(true);
content.add(fieldsField);
TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.correct.confirm"), e -> {
String fields = fieldsField.getValue();
if (fields == null || fields.isBlank()) {
fieldsField.setInvalid(true);
fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required"));
return;
}
performCorrection(invoice, fields, reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCorrect(currentUser);
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields);
invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.corrected", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private User resolveIssuer(CustomerInvoice invoice) {
if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) {
try {
return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId()))
.orElseGet(securityService::getCurrentDatabaseUser);
} catch (IllegalArgumentException ex) {
// userId ist kein gültiger ObjectId Fallback auf eingeloggten Nutzer
}
}
return securityService.getCurrentDatabaseUser();
}
private void openHistoryDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
// Verkettung anzeigen, falls vorhanden
Div linksBlock = renderRelatedInvoiceLinks(invoice);
if (linksBlock != null) {
content.add(linksBlock);
}
H3 logTitle = new H3(getTranslation("invoices.history.log"));
content.add(logTitle);
List<InvoiceAuditEntry> log = invoice.getAuditLog();
if (log == null || log.isEmpty()) {
content.add(new Span(getTranslation("invoices.history.empty")));
} else {
log.stream()
.sorted(Comparator
.comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null
? java.time.LocalDateTime.MIN
: e.getTimestamp())
.reversed())
.forEach(entry -> content.add(renderAuditEntry(entry)));
}
dialog.add(DialogStylingHelper.wrapContent(content, true));
Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close());
closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(closeBtn);
dialog.open();
}
private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) {
Div block = new Div();
boolean hasContent = false;
if (invoice.getOriginalInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.original"),
invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId()));
hasContent = true;
}
if (invoice.getCancellationInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.cancellation"),
null, invoice.getCancellationInvoiceId()));
hasContent = true;
}
if (invoice.getCorrectionInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.correction"),
null, invoice.getCorrectionInvoiceId()));
hasContent = true;
}
if (invoice.getReplacementInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.replacement"),
null, invoice.getReplacementInvoiceId()));
hasContent = true;
}
return hasContent ? block : null;
}
private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) {
HorizontalLayout row = new HorizontalLayout();
row.setSpacing(true);
row.setPadding(false);
Span lbl = new Span(label);
lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)");
row.add(lbl);
CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null);
String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber()
: fallbackNumber != null ? fallbackNumber : invoiceId;
if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) {
Anchor link = new Anchor("javascript:void(0)", number);
link.getElement().addEventListener("click", e -> downloadInvoicePdf(related));
row.add(link);
} else {
row.add(new Span(number));
}
return row;
}
private Div renderAuditEntry(InvoiceAuditEntry entry) {
Div container = new Div();
container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px")
.set("border-left", "3px solid var(--lumo-contrast-30pct)")
.set("background", "var(--lumo-contrast-5pct)");
String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "";
String actionLabel = entry.getAction() != null
? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT))
: "?";
String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system";
Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel);
header.getStyle().set("font-weight", "600");
container.add(header);
if (entry.getReason() != null && !entry.getReason().isBlank()) {
Div reason = new Div();
reason.setText(entry.getReason());
reason.getStyle().set("margin-top", "4px");
container.add(reason);
}
if (entry.getResultingInvoiceNumber() != null) {
Div link = new Div();
link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber()));
link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
container.add(link);
}
return container;
}
private void downloadInvoicePdf(CustomerInvoice invoice) {
@@ -123,10 +608,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
}
}
private boolean hasPdfData(CustomerInvoice invoice) {
return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0;
}
private String getRecipientLabel(CustomerInvoice invoice) {
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
}

View File

@@ -0,0 +1,698 @@
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.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
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.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
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.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.JobHistoryService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed("USER")
@Slf4j
public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle {
private static final class ServiceRow {
private final Service service;
private final JobServiceSelection selection;
private ServiceRow(Service service, JobServiceSelection selection) {
this.service = service;
this.selection = selection;
}
}
private final JobRepository jobRepository;
private final JobHistoryService jobHistoryService;
private final SecurityService securityService;
private final ServiceRepository serviceRepository;
private final VerticalLayout content;
private Job job;
private final List<ServiceRow> serviceRows = new ArrayList<>();
private Grid<ServiceRow> servicesGrid;
private Span netTotalLabel;
private Span grossTotalLabel;
private TextArea remarkArea;
private BigDecimal vatRate = Service.FIXED_VAT_RATE;
private Double manualDistanceKm;
private Integer manualDurationSeconds;
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService, ServiceRepository serviceRepository) {
this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService;
this.securityService = securityService;
this.serviceRepository = serviceRepository;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL);
addClassName("data-view");
add(new ViewToolbar(getTranslation("jobsummary.dialog.manualcomplete.title")));
content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(true);
content.setWidthFull();
content.addClassNames("form-shell", "form-card");
add(content);
}
@Override
public String getPageTitle() {
return getTranslation("jobsummary.dialog.manualcomplete.title");
}
@Override
public void setParameter(BeforeEvent event, String parameter) {
content.removeAll();
serviceRows.clear();
job = null;
if (parameter == null || parameter.isBlank()) {
content.add(new Span(getTranslation("jobhistory.error.no.id")));
return;
}
ObjectId jobId;
try {
jobId = new ObjectId(parameter);
} catch (Exception e) {
content.add(new Span(getTranslation("jobhistory.error.invalid.id", parameter)));
return;
}
Job loaded = jobRepository.findById(jobId).orElse(null);
if (loaded == null) {
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
return;
}
job = loaded;
loadVatRate();
render();
}
private void loadVatRate() {
try {
User user = securityService.getCurrentDatabaseUser();
if (user != null && user.getVatRate() != null) {
vatRate = user.getVatRate();
}
} catch (Exception e) {
log.warn("Could not load user VAT rate, falling back to default: {}", e.getMessage());
}
}
private void render() {
manualDistanceKm = job.getRouteDistanceKm();
manualDurationSeconds = job.getRouteDurationSeconds();
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
warningText.getStyle().set("color", "var(--lumo-error-text-color)");
TextArea reasonField = new TextArea(getTranslation("jobsummary.dialog.manualcomplete.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("100px");
reasonField.setRequired(true);
content.add(warningText, reasonField);
boolean hasRouteData = manualDistanceKm != null && manualDistanceKm > 0;
content.add(hasRouteData ? createRouteSection() : createManualRouteSection());
content.add(createServicesSection());
content.add(createSummarySection());
content.add(createRemarkSection());
HorizontalLayout buttonBar = new HorizontalLayout();
buttonBar.setWidthFull();
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonBar.setSpacing(true);
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
e -> getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())));
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
confirmButton.addClickListener(e -> confirm(reasonField));
buttonBar.add(cancelButton, confirmButton);
content.add(buttonBar);
loadSelectedServicesFromJob();
}
private VerticalLayout createRouteSection() {
VerticalLayout routeBox = new VerticalLayout();
routeBox.setPadding(true);
routeBox.setSpacing(true);
routeBox.setWidthFull();
routeBox.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
routeBox.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
routeBox.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
routeBox.addClassName("route-card");
H3 routeTitle = new H3(getTranslation("addjob.route.title"));
routeTitle.getStyle().set("margin", "0");
routeTitle.getStyle().set("color", "var(--lumo-primary-text-color)");
HorizontalLayout distanceRow = new HorizontalLayout();
distanceRow.setWidthFull();
distanceRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
distanceRow.setAlignItems(FlexComponent.Alignment.CENTER);
Span distanceLabel = new Span(getTranslation("addjob.route.distance") + ":");
Span distanceValue = new Span(formatDistance(job.getRouteDistanceKm()));
distanceValue.getStyle().set("font-weight", "bold");
distanceValue.getStyle().set("font-size", "var(--lumo-font-size-l)");
distanceValue.getStyle().set("color", "var(--lumo-primary-text-color)");
distanceRow.add(distanceLabel, distanceValue);
HorizontalLayout durationRow = new HorizontalLayout();
durationRow.setWidthFull();
durationRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
durationRow.setAlignItems(FlexComponent.Alignment.CENTER);
Span durationLabel = new Span(getTranslation("addjob.route.duration") + ":");
Span durationValue = new Span(formatDuration(job.getRouteDurationSeconds()));
durationValue.getStyle().set("font-weight", "bold");
durationValue.getStyle().set("color", "var(--lumo-secondary-text-color)");
durationRow.add(durationLabel, durationValue);
routeBox.add(routeTitle, distanceRow, durationRow);
return routeBox;
}
private VerticalLayout createManualRouteSection() {
VerticalLayout box = new VerticalLayout();
box.setPadding(true);
box.setSpacing(true);
box.setWidthFull();
box.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
box.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
box.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
box.addClassName("route-card");
H3 title = new H3(getTranslation("addjob.route.title"));
title.getStyle().set("margin", "0");
title.getStyle().set("color", "var(--lumo-primary-text-color)");
NumberField distanceField = new NumberField(getTranslation("addjob.route.distance.km"));
distanceField.setMin(0);
distanceField.setStep(0.1);
distanceField.setPlaceholder(getTranslation("addjob.route.distance.placeholder"));
distanceField.setWidthFull();
IntegerField hoursField = new IntegerField(getTranslation("jobmanualcomplete.route.hours"));
hoursField.setMin(0);
hoursField.setStepButtonsVisible(true);
hoursField.setWidthFull();
IntegerField minutesField = new IntegerField(getTranslation("jobmanualcomplete.route.minutes"));
minutesField.setMin(0);
minutesField.setMax(59);
minutesField.setStepButtonsVisible(true);
minutesField.setWidthFull();
if (manualDurationSeconds != null && manualDurationSeconds > 0) {
hoursField.setValue(manualDurationSeconds / 3600);
minutesField.setValue((manualDurationSeconds % 3600) / 60);
}
distanceField.addValueChangeListener(e -> {
manualDistanceKm = e.getValue();
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
});
Runnable recalcDuration = () -> {
Integer h = hoursField.getValue();
Integer m = minutesField.getValue();
int hours = h != null ? Math.max(0, h) : 0;
int minutes = m != null ? Math.max(0, Math.min(59, m)) : 0;
int total = hours * 3600 + minutes * 60;
manualDurationSeconds = total > 0 ? total : null;
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
};
hoursField.addValueChangeListener(e -> recalcDuration.run());
minutesField.addValueChangeListener(e -> recalcDuration.run());
HorizontalLayout durationRow = new HorizontalLayout(hoursField, minutesField);
durationRow.setWidthFull();
durationRow.setSpacing(true);
hoursField.getStyle().set("flex", "1");
minutesField.getStyle().set("flex", "1");
Span hint = new Span(getTranslation("jobmanualcomplete.route.manual.hint"));
hint.getStyle().set("font-size", "var(--lumo-font-size-s)");
hint.getStyle().set("color", "var(--lumo-secondary-text-color)");
hint.getStyle().set("font-style", "italic");
box.add(title, distanceField, durationRow, hint);
return box;
}
private VerticalLayout createServicesSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(true);
section.setWidthFull();
H3 title = new H3(getTranslation("addjob.services.title"));
title.getStyle().set("margin", "0");
servicesGrid = new Grid<>();
servicesGrid.setWidthFull();
servicesGrid.setHeight("250px");
servicesGrid.setItems(serviceRows);
servicesGrid.addClassName("data-grid");
servicesGrid.addColumn(row -> row.service != null ? row.service.getName() : "")
.setHeader(getTranslation("common.service")).setSortable(true);
servicesGrid.addColumn(row -> formatDeliveryStationLabel(
row.selection != null ? row.selection.getDeliveryStationOrder() : null))
.setHeader(getTranslation("addjob.services.deliverystation")).setSortable(false);
servicesGrid.addColumn(row -> formatCalculationBasis(row.service))
.setHeader(getTranslation("addjob.services.calculation")).setSortable(true);
servicesGrid.addColumn(this::formatPrice).setHeader(getTranslation("common.price")).setSortable(false);
servicesGrid.addComponentColumn(row -> {
if (row.service != null && row.service.isMandatory()) {
return new Span("");
}
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
ButtonVariant.LUMO_SMALL);
removeButton.addClickListener(e -> {
serviceRows.remove(row);
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
});
return removeButton;
}).setHeader(getTranslation("common.actions")).setAutoWidth(true).setFlexGrow(0);
Div gridPanel = new Div(servicesGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull();
Button addButton = new Button(getTranslation("addjob.services.add"), new Icon(VaadinIcon.PLUS));
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addButton.addClickListener(e -> openAddServiceDialog());
section.add(title, gridPanel, addButton);
return section;
}
private VerticalLayout createSummarySection() {
VerticalLayout summary = new VerticalLayout();
summary.setPadding(true);
summary.setSpacing(true);
summary.setWidthFull();
summary.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
summary.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
summary.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
summary.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
summary.addClassName("summary-card");
H3 title = new H3(getTranslation("addjob.summary.title"));
title.getStyle().set("margin", "0");
summary.add(title);
Div priceTable = new Div();
priceTable.getStyle().set("width", "100%");
Div netRow = new Div();
netRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
Span netLabel = new Span(getTranslation("addjob.summary.net") + ":");
netLabel.getStyle().set("padding-right", "8px");
netTotalLabel = new Span("0,00 €");
netTotalLabel.getStyle().set("font-weight", "bold").set("white-space", "nowrap");
netRow.add(netLabel, netTotalLabel);
Div grossRow = new Div();
grossRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
Span grossLabel = new Span(getTranslation("addjob.summary.gross") + ":");
grossLabel.getStyle().set("padding-right", "8px").set("font-weight", "bold");
grossTotalLabel = new Span("0,00 €");
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
grossTotalLabel.getStyle().set("font-weight", "bold");
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)").set("white-space", "nowrap");
grossRow.add(grossLabel, grossTotalLabel);
priceTable.add(netRow, grossRow);
summary.add(priceTable);
return summary;
}
private VerticalLayout createRemarkSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(true);
section.setWidthFull();
H3 title = new H3(getTranslation("addjob.tasks.remark"));
title.getStyle().set("margin", "0");
remarkArea = new TextArea();
remarkArea.setPlaceholder(getTranslation("addjob.tasks.remark.placeholder"));
remarkArea.setWidthFull();
remarkArea.setMinHeight("120px");
if (job.getRemark() != null) {
remarkArea.setValue(job.getRemark());
}
section.add(title, remarkArea);
return section;
}
private void loadSelectedServicesFromJob() {
serviceRows.clear();
if (job.getSelectedServices() != null && !job.getSelectedServices().isEmpty()) {
for (JobServiceSelection selection : job.getSelectedServices()) {
if (selection.getServiceId() == null) {
continue;
}
serviceRepository.findById(selection.getServiceId())
.ifPresent(service -> serviceRows.add(new ServiceRow(service, selection)));
}
} else if (job.getServiceIds() != null && !job.getServiceIds().isEmpty()) {
for (String serviceId : job.getServiceIds()) {
serviceRepository.findById(serviceId)
.ifPresent(service -> serviceRows.add(new ServiceRow(service, null)));
}
}
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
}
private void openAddServiceDialog() {
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.services.dialog.title"), "720px");
dialog.setCloseOnOutsideClick(false);
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
User currentUser = securityService.getCurrentDatabaseUser();
List<Service> availableServices = currentUser != null
? serviceRepository.findByUserId(currentUser.getId().toString())
: List.of();
ComboBox<Service> serviceCombo = new ComboBox<>(getTranslation("common.service"));
serviceCombo.setWidthFull();
serviceCombo.setItems(availableServices);
serviceCombo.setItemLabelGenerator(service -> {
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
&& service.getEffectivePrice() != null) {
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
}
return service.getName();
});
serviceCombo.setPlaceholder(getTranslation("addjob.services.dialog.placeholder"));
serviceCombo.setRequired(true);
List<Integer> stationOrders = availableDeliveryStationOrders();
ComboBox<Integer> stationCombo = new ComboBox<>(getTranslation("addjob.services.deliverystation"));
stationCombo.setWidthFull();
stationCombo.setRequired(true);
stationCombo.setRequiredIndicatorVisible(true);
stationCombo.setItems(stationOrders);
stationCombo.setItemLabelGenerator(this::buildDeliveryStationSelectionLabel);
stationCombo.setPlaceholder(getTranslation("addjob.services.dialog.station.placeholder"));
if (!stationOrders.isEmpty()) {
stationCombo.setValue(0);
}
dialogContent.add(serviceCombo, stationCombo);
Button cancel = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button add = new Button(getTranslation("addjob.services.dialog.add"), e -> {
Service service = serviceCombo.getValue();
Integer stationOrder = stationCombo.getValue();
if (service == null || stationOrder == null) {
return;
}
JobServiceSelection selection = new JobServiceSelection();
selection.setServiceId(service.getId());
selection.setDeliveryStationOrder(stationOrder);
selection.setRouteDistanceKm(manualDistanceKm);
selection.setRouteDurationSeconds(manualDurationSeconds);
serviceRows.add(new ServiceRow(service, selection));
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
dialog.close();
});
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.getFooter().add(cancel, add);
dialog.open();
}
private List<Integer> availableDeliveryStationOrders() {
List<Integer> orders = new ArrayList<>();
if (job.getDeliveryStations() != null) {
for (int i = 0; i < job.getDeliveryStations().size(); i++) {
orders.add(i);
}
}
return orders;
}
private String buildDeliveryStationSelectionLabel(Integer order) {
if (order == null || job.getDeliveryStations() == null || order < 0
|| order >= job.getDeliveryStations().size()) {
return "-";
}
DeliveryStation station = job.getDeliveryStations().get(order);
StringBuilder label = new StringBuilder(getTranslation("addjob.station.delivery", order + 1));
if (station.getCity() != null && !station.getCity().isBlank()) {
label.append(" - ").append(station.getCity());
} else if (station.getCompany() != null && !station.getCompany().isBlank()) {
label.append(" - ").append(station.getCompany());
}
return label.toString();
}
private String formatDeliveryStationLabel(Integer order) {
if (order == null || order < 0) {
return "-";
}
return getTranslation("addjob.station.delivery", order + 1);
}
private String formatCalculationBasis(Service service) {
if (service == null || service.getCalculationBasis() == null) {
return "";
}
return switch (service.getCalculationBasis()) {
case DISTANCE -> getTranslation("addjob.services.basis.distance");
case TIME -> getTranslation("addjob.services.basis.time");
case FLAT_RATE -> getTranslation("addjob.services.basis.flatrate");
};
}
private String formatPrice(ServiceRow row) {
Service service = row.service;
if (service == null || service.getCalculationBasis() == null) {
return "";
}
BigDecimal price = calculateServicePrice(row);
if (price != null && price.compareTo(BigDecimal.ZERO) > 0) {
return price.setScale(2, RoundingMode.HALF_UP) + "";
}
if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && service.getPricePerKilometer() != null
&& routeDistanceFor(row.selection) == null) {
return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km ("
+ getTranslation("addjob.services.route.missing") + ")";
}
if (service.getCalculationBasis() == Service.CalculationBasis.TIME && service.getPricePer15Minutes() != null
&& routeDurationFor(row.selection) == null) {
return service.getPricePer15Minutes().setScale(2, RoundingMode.HALF_UP) + " €/15 Min. ("
+ getTranslation("addjob.services.route.missing") + ")";
}
return service.getEffectivePrice() != null
? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + ""
: "";
}
private BigDecimal calculateServicePrice(ServiceRow row) {
Service service = row.service;
if (service == null || service.getCalculationBasis() == null) {
return BigDecimal.ZERO;
}
switch (service.getCalculationBasis()) {
case FLAT_RATE:
return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO;
case DISTANCE: {
Double km = routeDistanceFor(row.selection);
if (service.getPricePerKilometer() != null && km != null && km > 0) {
return service.getPricePerKilometer().multiply(BigDecimal.valueOf(km));
}
return BigDecimal.ZERO;
}
case TIME: {
Integer seconds = routeDurationFor(row.selection);
if (service.getPricePer15Minutes() != null && seconds != null && seconds > 0) {
int units = seconds / 900;
if (seconds % 900 > 0) {
units++;
}
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
}
return BigDecimal.ZERO;
}
default:
return BigDecimal.ZERO;
}
}
private Double routeDistanceFor(JobServiceSelection selection) {
if (selection != null && selection.getRouteDistanceKm() != null) {
return selection.getRouteDistanceKm();
}
return manualDistanceKm;
}
private Integer routeDurationFor(JobServiceSelection selection) {
if (selection != null && selection.getRouteDurationSeconds() != null) {
return selection.getRouteDurationSeconds();
}
return manualDurationSeconds;
}
private void updatePriceSummary() {
BigDecimal net = BigDecimal.ZERO;
for (ServiceRow row : serviceRows) {
net = net.add(calculateServicePrice(row));
}
BigDecimal gross = net.add(net.multiply(vatRate));
netTotalLabel.setText(formatAmount(net));
grossTotalLabel.setText(formatAmount(gross));
}
private String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + "";
}
private String formatDistance(Double km) {
if (km == null) {
return "-";
}
return String.format(Locale.GERMANY, "%.1f km", km);
}
private String formatDuration(Integer seconds) {
if (seconds == null || seconds <= 0) {
return "-";
}
int hours = seconds / 3600;
int minutes = (seconds % 3600) / 60;
if (hours > 0) {
return String.format("%d Std. %d Min.", hours, minutes);
}
return String.format("%d Min.", minutes);
}
private void confirm(TextArea reasonField) {
String reason = reasonField.getValue();
if (reason == null || reason.trim().isEmpty()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
return;
}
try {
JobStatus oldStatus = job.getStatus();
List<JobServiceSelection> selections = new ArrayList<>();
for (ServiceRow row : serviceRows) {
if (row.service == null) {
continue;
}
JobServiceSelection selection = row.selection != null ? row.selection : new JobServiceSelection();
selection.setServiceId(row.service.getId());
if (selection.getDeliveryStationOrder() == null && row.selection != null) {
selection.setDeliveryStationOrder(row.selection.getDeliveryStationOrder());
}
selections.add(selection);
}
job.setSelectedServices(selections);
String remark = remarkArea.getValue();
job.setRemark(remark != null && !remark.isBlank() ? remark.trim() : null);
if (job.getRouteDistanceKm() == null || job.getRouteDistanceKm() <= 0) {
job.setRouteDistanceKm(manualDistanceKm);
job.setRouteDurationSeconds(manualDurationSeconds);
}
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
String currentUser = securityService.getCurrentUsername();
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s", currentUser,
reason.trim());
jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"),
description, currentUser, JobHistoryType.STATUS_CHANGE);
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
} catch (Exception ex) {
Notification
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
}

View File

@@ -212,6 +212,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId));
});
// Create Manual Completion Button for app jobs (digital processing)
Button manualCompleteButton = null;
if (job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED) {
manualCompleteButton = new Button(getTranslation("jobsummary.button.manualcomplete"));
manualCompleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
manualCompleteButton.addClickListener(e -> getUI()
.ifPresent(ui -> ui.navigate("job_manual_complete/" + job.getId().toHexString())));
}
// Create Job History Button for toolbar
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -219,8 +229,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString()));
});
// Add toolbar with both buttons in top right (Send Message button on the left)
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
// Add toolbar with buttons
if (manualCompleteButton != null) {
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
jobHistoryButton));
} else {
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
}
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);

View File

@@ -86,7 +86,8 @@ public class ShowCustomersView extends VerticalLayout implements HasDynamicTitle
var customers = customerService.findAll();
var currentUserId = securityService.getCurrentUserId();
var ownCustomers = customers.stream()
.filter(c -> c.getCreatedBy() != null && c.getCreatedBy().equals(currentUserId)).toList();
.filter(c -> c.getCreatedBy() != null && c.getCreatedBy().equals(currentUserId))
.filter(c -> !c.isInternal()).toList();
grid.setItems(ownCustomers);
}

View File

@@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
private final SecurityService securityService;
private final ClientConnectionService clientConnectionService;
private final MessagingPublisher messagingPublisher;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final Grid<Job> grid = new Grid<>(Job.class, false);
@Autowired
@@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
this.securityService = securityService;
this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher;
this.customerInvoiceRepository = customerInvoiceRepository;
setSizeFull();
setPadding(true);
setSpacing(true);

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
Optional<CustomerInvoice> findByJobId(String jobId);
List<CustomerInvoice> findByUserId(String userId);
/** Liefert die höchstens eine aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */
Optional<CustomerInvoice> findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status);
/** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */
List<CustomerInvoice> findByOriginalInvoiceId(String originalInvoiceId);
/** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */
List<CustomerInvoice> findByStatusIsNull();
}

View File

@@ -0,0 +1,23 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface InvoiceNumberReservationRepository extends MongoRepository<InvoiceNumberReservation, ObjectId> {
Optional<InvoiceNumberReservation> findByUserIdAndNumber(ObjectId userId, String number);
Optional<InvoiceNumberReservation> findByUserIdAndSequence(ObjectId userId, long sequence);
List<InvoiceNumberReservation> findByUserIdOrderBySequenceAsc(ObjectId userId);
List<InvoiceNumberReservation> findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId,
InvoiceNumberReservationStatus status);
}

View File

@@ -10,4 +10,6 @@ import java.util.List;
@Repository
public interface SignatureRepository extends MongoRepository<Signature, ObjectId> {
List<Signature> findByTaskId(ObjectId taskId);
List<Signature> findByTaskIdOrderByCreatedAtDesc(ObjectId taskId);
}

View File

@@ -0,0 +1,26 @@
package de.assecutor.votianlt.security;
/**
* Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42.
*
* Die Rollen sind als Konstanten definiert und werden in der bestehenden
* {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt.
*
* Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die
* {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen
* Rechnungsrollen explizit zugewiesen sind.
*/
public final class InvoiceRoles {
/** Erstellt Entwürfe und stellt Rechnungen aus. */
public static final String CREATOR = "INVOICE_CREATOR";
/** Prüft Entwürfe und Folgebelege vor Freigabe. */
public static final String REVIEWER = "INVOICE_REVIEWER";
/** Gibt Storno- und Berichtigungsbelege frei (R-42). */
public static final String APPROVER = "INVOICE_APPROVER";
/** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */
public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT";
private InvoiceRoles() {
}
}

View File

@@ -0,0 +1,71 @@
package de.assecutor.votianlt.service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.SecureRandom;
/**
* Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256.
*
* Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}.
* Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert
* deterministisch denselben AES-Schlüssel.
*
* Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten.
*/
final class AesGcmCipher {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
AesGcmCipher(String masterKey) {
if (masterKey == null || masterKey.length() < 16) {
throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein.");
}
try {
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8"));
this.key = new SecretKeySpec(derived, "AES");
} catch (Exception ex) {
throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex);
}
}
byte[] encrypt(byte[] plaintext) {
try {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] ciphertext = cipher.doFinal(plaintext);
return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array();
} catch (Exception ex) {
throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
byte[] decrypt(byte[] ivAndCiphertext) {
if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) {
throw new IllegalArgumentException("Ciphertext zu kurz.");
}
try {
ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext);
byte[] iv = new byte[IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return cipher.doFinal(ciphertext);
} catch (Exception ex) {
throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
}

View File

@@ -258,6 +258,13 @@ public class CustomerInvoiceService {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, null);
}
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate) throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
@@ -458,7 +465,7 @@ public class CustomerInvoiceService {
}
} else if ("services.list".equals(variable)) {
// Render services list as a table
htmlBuilder.append(generateServicesTableHtml(mmWidth));
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
} else if (text.contains("<br>")) {
// Multi-line text: render without nowrap so <br> tags work
htmlBuilder.append("<span>").append(text).append("</span>");
@@ -484,16 +491,23 @@ public class CustomerInvoiceService {
/**
* Generate HTML table for services list with summary section below.
*/
private String generateServicesTableHtml(double widthMm) {
private String generateServicesTableHtml(double widthMm, BigDecimal vatRate) {
StringBuilder html = new StringBuilder();
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, java.math.RoundingMode.HALF_UP)
.stripTrailingZeros();
if (pct.scale() < 0) {
pct = pct.setScale(0);
}
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
// Sample data for preview (will be replaced with actual job data later)
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", "19%", "450,00 €" },
{ "Entsorgung Möbel", "19%", "85,00 €" }, { "Montage/De-Montage", "19%", "120,00 €" } };
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", vatLabel, "450,00 €" },
{ "Entsorgung Möbel", vatLabel, "85,00 €" }, { "Montage/De-Montage", vatLabel, "120,00 €" } };
// Calculate totals
double netTotal = 655.00;
double grossTotal = 779.45;
double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
// Wrapper div
html.append("<div style='width:100%;box-sizing:border-box;'>");
@@ -797,7 +811,9 @@ public class CustomerInvoiceService {
// Get invoice data from variables
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
// Parse services JSON from variables
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
@@ -822,7 +838,9 @@ public class CustomerInvoiceService {
// Header row
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
html.append(
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:75%;white-space:nowrap;'>Name</th>");
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
html.append("</tr>");
@@ -832,7 +850,7 @@ public class CustomerInvoiceService {
// Fallback: show a single row with no data
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append(
"<td colspan='2' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
html.append("</tr>");
} else {
for (int i = 0; i < servicesData.size(); i++) {
@@ -843,8 +861,10 @@ public class CustomerInvoiceService {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>")
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(vatRateLabel)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(netAmount).append(" €</td>");
html.append("</tr>");
@@ -865,6 +885,15 @@ public class CustomerInvoiceService {
.append(netTotal).append("</td>");
html.append("</tr>");
// Umsatzsteuer
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
.append(escapeHtml(vatRateLabel)).append(" USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(vatTotal).append("</td>");
html.append("</tr>");
// Gesamtsumme
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -892,4 +921,181 @@ public class CustomerInvoiceService {
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;");
}
/**
* Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19.
* Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die
* Beträge als negative Werte dar.
*/
public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception {
return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason,
null, true);
}
/**
* Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14.
* Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben.
*/
public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields)
throws Exception {
return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate,
reason, correctedFields, false);
}
private byte[] generateCorrectionDocumentPdf(String documentLabel,
de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number,
java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts)
throws Exception {
java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now();
java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy",
Locale.GERMANY);
BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount());
BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount());
BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount())
: safeAmount(original.getTotalAmount());
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
html.append("@page { size: A4; margin: 20mm 18mm 20mm 18mm; }");
html.append("body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }");
html.append("h1 { font-size: 18pt; letter-spacing: 0.05em; margin: 0 0 8pt 0; }");
html.append("h2 { font-size: 13pt; margin: 18pt 0 6pt 0; }");
html.append(".doc-number { font-size: 11pt; color: #555; }");
html.append(".section { margin-top: 14pt; }");
html.append(".reference { background: #f6f6f6; border-left: 3px solid #888; padding: 8pt 12pt; }");
html.append(".reason { background: #fff8e6; border-left: 3px solid #d9a300; padding: 8pt 12pt; }");
html.append("table.amounts { width: 60%; margin-left: 40%; border-collapse: collapse; margin-top: 10pt; }");
html.append("table.amounts td { padding: 3pt 6pt; }");
html.append("table.amounts td.value { text-align: right; }");
html.append("table.amounts tr.total td { font-weight: bold; border-top: 1px solid #333; }");
html.append(".addresses { width: 100%; margin-top: 14pt; }");
html.append(".addresses td { width: 50%; vertical-align: top; }");
html.append(".muted { color: #666; font-size: 9pt; }");
html.append("</style></head><body>");
html.append("<h1>").append(escapeHtml(documentLabel)).append("</h1>");
html.append("<div class='doc-number'>Beleg-Nr.: ")
.append(escapeHtml(safe(number)))
.append(" &nbsp;·&nbsp; Datum: ").append(escapeHtml(effectiveDate.format(dateFmt)))
.append("</div>");
// Sender / Empfänger
html.append("<table class='addresses'><tr><td>");
html.append("<strong>Aussteller</strong><br/>");
html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(),
original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry()));
if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) {
html.append("<div class='muted'>Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber()))
.append("</div>");
}
if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) {
html.append("<div class='muted'>USt-IdNr.: ").append(escapeHtml(original.getSenderVatId()))
.append("</div>");
}
html.append("</td><td>");
html.append("<strong>Empfänger</strong><br/>");
html.append(formatAddressBlock(
firstNonBlank(original.getRecipientCompany(), original.getRecipientName()),
original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(),
original.getRecipientCountry()));
html.append("</td></tr></table>");
// Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28)
html.append("<div class='section reference'>");
html.append("<strong>Bezug:</strong> ");
html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich ");
html.append("eindeutig auf die Rechnung <strong>")
.append(escapeHtml(safe(original.getInvoiceNumber()))).append("</strong>");
if (original.getInvoiceDate() != null) {
html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt)));
}
html.append(".");
html.append("</div>");
if (correctedFields != null && !correctedFields.isBlank()) {
html.append("<div class='section'><h2>Berichtigte Angaben</h2>");
html.append("<div>").append(escapeHtml(correctedFields).replace("\n", "<br/>")).append("</div></div>");
}
if (reason != null && !reason.isBlank()) {
html.append("<div class='section reason'><strong>Grund:</strong> ")
.append(escapeHtml(reason).replace("\n", "<br/>")).append("</div>");
}
html.append("<h2>Beträge</h2>");
html.append("<table class='amounts'>");
html.append("<tr><td>Nettobetrag</td><td class='value'>").append(formatCurrency(net)).append("</td></tr>");
if (original.getVatRate() != null) {
BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100"))
.setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
if (vatPct.scale() < 0) {
vatPct = vatPct.setScale(0);
}
html.append("<tr><td>zzgl. ").append(vatPct.toPlainString().replace('.', ','))
.append("% USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
} else {
html.append("<tr><td>zzgl. USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
}
html.append("<tr class='total'><td>Gesamtbetrag</td><td class='value'>").append(formatCurrency(total))
.append("</td></tr>");
html.append("</table>");
html.append("<div class='section muted'>");
html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und ");
html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY)));
html.append(" sind gemeinsam aufzubewahren.");
html.append("</div>");
html.append("</body></html>");
return generatePdfFromHtmlString(html.toString());
}
private BigDecimal safeAmount(BigDecimal value) {
return value != null ? value : BigDecimal.ZERO;
}
private BigDecimal negateOrZero(BigDecimal value) {
return value != null ? value.negate() : BigDecimal.ZERO;
}
private String formatAddressBlock(String name, String street, String postcode, String city, String country) {
StringBuilder sb = new StringBuilder();
if (name != null && !name.isBlank()) {
sb.append(escapeHtml(name)).append("<br/>");
}
if (street != null && !street.isBlank()) {
sb.append(escapeHtml(street)).append("<br/>");
}
String line = String.join(" ", filterBlanks(postcode, city)).trim();
if (!line.isEmpty()) {
sb.append(escapeHtml(line)).append("<br/>");
}
if (country != null && !country.isBlank()) {
sb.append(escapeHtml(country));
}
return sb.toString();
}
private java.util.List<String> filterBlanks(String... values) {
java.util.List<String> out = new java.util.ArrayList<>();
for (String v : values) {
if (v != null && !v.isBlank()) {
out.add(v);
}
}
return out;
}
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) {
return v;
}
}
return "";
}
}

View File

@@ -1,7 +1,9 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@@ -22,6 +24,7 @@ public class EmailService {
private final UserRepository userRepository;
private final JobRepository jobRepository;
private final TaskAssignmentService taskAssignmentService;
private final AppUserRepository appUserRepository;
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
@@ -52,8 +55,10 @@ public class EmailService {
return;
}
String completedByName = resolveCompletedByName(job, completedBy);
// Send email
sendEmail(user, job, taskType);
sendEmail(user, job, taskType, completedByName);
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId);
@@ -63,7 +68,7 @@ public class EmailService {
}
}
private void sendEmail(User user, Job job, String taskType) {
private void sendEmail(User user, Job job, String taskType, String completedByName) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
@@ -71,18 +76,17 @@ public class EmailService {
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
String taskTypeName = getTaskTypeDisplayName(taskType);
StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n");
body.append("eine Aufgabe wurde von ").append(appUserName).append(" abgeschlossen:\n\n");
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
if (job.getDeliveryCompany() != null) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
}
body.append("Aufgabe: ").append(taskTypeName).append("\n");
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
String deliveryCities = job.getDeliveryCitiesDisplay();
if (job.getPickupCity() != null || deliveryCities != null) {
@@ -121,16 +125,55 @@ public class EmailService {
return fullName.isEmpty() ? "Benutzer" : fullName;
}
private String buildAppUserName(User user) {
private String buildAppUserName(AppUser appUser) {
StringBuilder name = new StringBuilder();
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
name.append(user.getFirstname()).append(" ");
if (appUser.getVorname() != null && !appUser.getVorname().isBlank()) {
name.append(appUser.getVorname()).append(" ");
}
if (user.getName() != null && !user.getName().isBlank()) {
name.append(user.getName());
if (appUser.getNachname() != null && !appUser.getNachname().isBlank()) {
name.append(appUser.getNachname());
}
String fullName = name.toString().trim();
return fullName.isEmpty() ? "App-Benutzer" : fullName;
if (!fullName.isEmpty()) {
return fullName;
}
if (appUser.getBezeichnung() != null && !appUser.getBezeichnung().isBlank()) {
return appUser.getBezeichnung().trim();
}
if (appUser.getEmail() != null && !appUser.getEmail().isBlank()) {
return appUser.getEmail().trim();
}
return "App-Benutzer";
}
private String resolveCompletedByName(Job job, String completedBy) {
Optional<AppUser> assignedAppUser = findAppUserById(job != null ? job.getAppUser() : null);
if (assignedAppUser.isPresent()) {
return buildAppUserName(assignedAppUser.get());
}
if (completedBy != null && !completedBy.isBlank() && !"Unknown".equalsIgnoreCase(completedBy)) {
Optional<AppUser> completingAppUser = findAppUserById(completedBy);
if (completingAppUser.isPresent()) {
return buildAppUserName(completingAppUser.get());
}
return completedBy;
}
return "App-Benutzer";
}
private Optional<AppUser> findAppUserById(String appUserId) {
if (appUserId == null || appUserId.isBlank()) {
return Optional.empty();
}
try {
return appUserRepository.findById(new ObjectId(appUserId));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
private String getTaskTypeDisplayName(String taskType) {
@@ -173,8 +216,10 @@ public class EmailService {
return;
}
String completedByName = resolveCompletedByName(job, completedBy);
// Send job completion email
sendJobCompletionEmail(user, job);
sendJobCompletionEmail(user, job, completedByName);
log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber());
} catch (Exception e) {
@@ -182,7 +227,7 @@ public class EmailService {
}
}
private void sendJobCompletionEmail(User user, Job job) {
private void sendJobCompletionEmail(User user, Job job, String completedByName) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
@@ -190,7 +235,6 @@ public class EmailService {
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
// Count completed tasks
var allTasks = taskAssignmentService.findTasksForJob(job);
@@ -220,7 +264,7 @@ public class EmailService {
}
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
body.append("Mit freundlichen Grüßen,\n");

View File

@@ -0,0 +1,35 @@
package de.assecutor.votianlt.service;
import java.util.Collections;
import java.util.List;
/**
* Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben
* nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden
* gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt
* durchführen kann statt jeden Fehler einzeln zu beheben.
*/
public class InvoiceComplianceException extends InvoiceLifecycleException {
private final List<String> violations;
public InvoiceComplianceException(List<String> violations) {
super(buildMessage(violations));
this.violations = List.copyOf(violations);
}
public List<String> getViolations() {
return Collections.unmodifiableList(violations);
}
private static String buildMessage(List<String> violations) {
if (violations == null || violations.isEmpty()) {
return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht.";
}
StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:");
for (String violation : violations) {
sb.append("\n • ").append(violation);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,189 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG
* sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung
* (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf
* keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt
* änderbar ist.
*
* Nicht abgedeckt (bewusst):
* <ul>
* <li>Lückenlose Rechnungsnummer-Vergabe (separater Block).</li>
* <li>Online-Validierung der USt-IdNr beim Bzst (separater Block).</li>
* <li>Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge,
* Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.</li>
* </ul>
*
* Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs-
* differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken.
*/
@Service
public class InvoiceComplianceValidator {
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
/**
* Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen,
* wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts.
*/
public void validateForIssuance(CustomerInvoice invoice) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) {
// Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft.
return;
}
List<String> violations = collectViolations(invoice);
if (!violations.isEmpty()) {
throw new InvoiceComplianceException(violations);
}
}
private List<String> collectViolations(CustomerInvoice invoice) {
List<String> violations = new ArrayList<>();
checkInvoiceNumber(invoice, violations);
checkDates(invoice, violations);
checkSender(invoice, violations);
checkRecipient(invoice, violations);
checkItems(invoice, violations);
checkAmounts(invoice, violations);
checkVatNotices(invoice, violations);
return violations;
}
private void checkInvoiceNumber(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getInvoiceNumber())) {
violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG).");
}
}
private void checkDates(CustomerInvoice invoice, List<String> violations) {
if (invoice.getInvoiceDate() == null) {
violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG).");
}
if (invoice.getDeliveryDate() == null) {
violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). "
+ "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein.");
}
}
private void checkSender(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getSenderName())) {
violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode())
|| isBlank(invoice.getSenderCity())) {
violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) {
violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG).");
}
}
private void checkRecipient(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getRecipientName())) {
violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode())
|| isBlank(invoice.getRecipientCity())) {
violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt "
+ "(§ 14 Abs. 4 Nr. 1 UStG).");
}
}
private void checkItems(CustomerInvoice invoice, List<String> violations) {
List<CustomerInvoiceItem> items = invoice.getItems();
if (items == null || items.isEmpty()) {
violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich "
+ "(§ 14 Abs. 4 Nr. 5 UStG).");
return;
}
for (int i = 0; i < items.size(); i++) {
CustomerInvoiceItem item = items.get(i);
int rowNumber = i + 1;
if (item == null) {
violations.add("Position " + rowNumber + ": leere Position.");
continue;
}
if (isBlank(item.getDescription())) {
violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt.");
}
if (item.getQuantity() == null || item.getQuantity().signum() <= 0) {
violations.add("Position " + rowNumber + ": Menge muss größer 0 sein.");
}
if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) {
violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ.");
}
}
}
private void checkAmounts(CustomerInvoice invoice, List<String> violations) {
BigDecimal net = invoice.getNetAmount();
BigDecimal vat = invoice.getVatAmount();
BigDecimal total = invoice.getTotalAmount();
if (net == null || vat == null || total == null) {
violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein "
+ "(§ 14 Abs. 4 Nr. 7 + 8 UStG).");
return;
}
if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent).");
}
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
BigDecimal sumItems = invoice.getItems().stream()
.map(CustomerInvoiceItem::getNetTotal)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net
+ " ab (Differenz > 1 Cent).");
}
}
}
private void checkVatNotices(CustomerInvoice invoice, List<String> violations) {
BigDecimal rate = invoice.getVatRate();
if (rate == null) {
violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG).");
return;
}
if (rate.signum() == 0) {
// Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder
// Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine
// innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung.
// Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft.
boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes());
if (!hasNotice) {
violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich "
+ "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, "
+ "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). "
+ "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen.");
}
} else {
BigDecimal expectedVat = invoice.getNetAmount() != null
? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP)
: null;
if (expectedVat != null && invoice.getVatAmount() != null
&& expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount()
+ " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ").");
}
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -0,0 +1,182 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Export-Funktion für Rechnungen gemäß R-33 und R-34.
*
* Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen
* (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei
* mit Audit-Log und Verkettungsangaben in einer ZIP-Datei.
*/
@Service
public class InvoiceExportService {
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
Locale.GERMANY);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
/**
* Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen
* sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg,
* wird automatisch das Bündel um die zugehörige Originalrechnung erweitert.
*/
public byte[] exportInvoicePackage(CustomerInvoice anchor) {
if (anchor == null) {
throw new IllegalArgumentException("Rechnung erforderlich.");
}
CustomerInvoice root = resolveRoot(anchor);
List<CustomerInvoice> bundle = collectBundle(root);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (CustomerInvoice invoice : bundle) {
writePdfEntry(zip, invoice);
}
String manifest = buildManifest(root, bundle);
ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt");
zip.putNextEntry(manifestEntry);
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return baos.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex);
}
}
/**
* Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor.
*/
public String suggestFilename(CustomerInvoice anchor) {
CustomerInvoice root = resolveRoot(anchor);
String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId();
return "Rechnung_" + sanitize(number) + ".zip";
}
private CustomerInvoice resolveRoot(CustomerInvoice anchor) {
if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) {
return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor);
}
return anchor;
}
private List<CustomerInvoice> collectBundle(CustomerInvoice root) {
List<CustomerInvoice> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
result.add(root);
seen.add(root.getId());
for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) {
if (related.getId() != null && seen.add(related.getId())) {
result.add(related);
}
}
return result;
}
private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
return;
}
String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) {
case INVOICE -> "Rechnung";
case CANCELLATION -> "Storno";
case CORRECTION -> "Berichtigung";
};
String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId();
String name = sanitize(label + "_" + number) + ".pdf";
ZipEntry entry = new ZipEntry(name);
zip.putNextEntry(entry);
zip.write(invoice.getPdfData());
zip.closeEntry();
}
private String buildManifest(CustomerInvoice root, List<CustomerInvoice> bundle) {
StringBuilder sb = new StringBuilder();
sb.append("Rechnungspaket\n");
sb.append("===============\n\n");
sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber()));
if (root.getInvoiceDate() != null) {
sb.append(" vom ").append(root.getInvoiceDate());
}
sb.append("\n");
sb.append("Status: ").append(root.getStatus()).append("\n");
sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n");
if (root.getTotalAmount() != null) {
sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n");
}
if (root.getPaidAmount() != null) {
sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n");
}
sb.append("\nEnthaltene Belege:\n");
for (CustomerInvoice invoice : bundle) {
sb.append("- [").append(invoice.getType()).append("] ")
.append(safe(invoice.getInvoiceNumber()));
if (invoice.getInvoiceDate() != null) {
sb.append(" vom ").append(invoice.getInvoiceDate());
}
sb.append(" — Status ").append(invoice.getStatus()).append("\n");
}
sb.append("\nÄnderungsprotokoll der Originalrechnung:\n");
List<InvoiceAuditEntry> log = root.getAuditLog();
if (log == null || log.isEmpty()) {
sb.append("(keine Einträge)\n");
} else {
for (InvoiceAuditEntry entry : log) {
sb.append("- ");
sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-");
sb.append(" · ").append(entry.getAction());
sb.append(" · ").append(safe(entry.getUserDisplayName()));
if (entry.getReason() != null && !entry.getReason().isBlank()) {
sb.append("").append(entry.getReason());
}
if (entry.getResultingInvoiceNumber() != null) {
sb.append("").append(entry.getResultingInvoiceNumber());
}
sb.append("\n");
}
}
sb.append(
"\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n");
sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n");
return sb.toString();
}
private String safe(String value) {
return value != null ? value : "";
}
private String sanitize(String input) {
if (input == null) {
return "Beleg";
}
return input.replaceAll("[^A-Za-z0-9._-]", "_");
}
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.votianlt.service;
/**
* Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung
* gegen die Regeln aus <code>invoices_rules.md</code> verstößt (z.B. R-03, R-08, R-11, R-35).
*
* Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden.
*/
public class InvoiceLifecycleException extends RuntimeException {
public InvoiceLifecycleException(String message) {
super(message);
}
public InvoiceLifecycleException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,536 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus
* <code>invoices_rules.md</code>.
*
* Phase 1 stellt die Status- und Audit-Mechanik bereit:
* <ul>
* <li>R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.</li>
* <li>R-07: Finalisierung markiert eine Rechnung als verbindlich.</li>
* <li>R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.</li>
* <li>R-35: Direktes Löschen finalisierter Belege wird abgelehnt.</li>
* <li>R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.</li>
* </ul>
*
* Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet.
*/
@Service
public class InvoiceLifecycleService {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class);
private final CustomerInvoiceRepository invoiceRepository;
private final SecurityService securityService;
private final InvoiceComplianceValidator complianceValidator;
private final InvoiceNumberAuditService numberAuditService;
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
InvoiceComplianceValidator complianceValidator,
InvoiceNumberAuditService numberAuditService) {
this.invoiceRepository = invoiceRepository;
this.securityService = securityService;
this.complianceValidator = complianceValidator;
this.numberAuditService = numberAuditService;
}
/**
* Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT).
*/
public CustomerInvoice createDraft(CustomerInvoice draft, String reason) {
if (draft == null) {
throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein.");
}
draft.setStatus(InvoiceStatus.DRAFT);
if (draft.getType() == null) {
draft.setType(InvoiceType.INVOICE);
}
draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED).
* Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in
* einem Schritt vereint (R-06/R-07).
*/
public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() == null) {
invoice.setType(InvoiceType.INVOICE);
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05).
* Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08).
*/
public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) {
if (draft == null || draft.getId() == null) {
throw new IllegalArgumentException("Bestehender Entwurf erwartet.");
}
CustomerInvoice persisted = invoiceRepository.findById(draft.getId())
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId()));
if (!persisted.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. "
+ "Bitte erstellen Sie eine Berichtigung oder ein Storno.");
}
draft.setStatus(InvoiceStatus.DRAFT);
draft.setAuditLog(persisted.getAuditLog());
draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer
* eindeutig ist (R-11) und protokolliert den Wechsel.
*/
public CustomerInvoice issue(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) {
return invoice;
}
if (invoice.getStatus() != InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus());
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Markiert eine ausgestellte Rechnung als versendet (R-08).
*/
public CustomerInvoice markAsSent(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Eine Rechnung muss vor dem Versand zunächst ausgestellt werden.");
}
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden.");
}
invoice.setStatus(InvoiceStatus.SENT);
invoice.setSentAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason));
return invoiceRepository.save(invoice);
}
/**
* Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22).
*
* Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION}
* mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf
* {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über
* {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt.
*
* @param originalId ID der zu stornierenden Rechnung
* @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg
* @param cancellationDate Belegdatum des Stornos
* @param pdfData generiertes PDF des Stornobelegs
* @param reason nachvollziehbarer Grund (R-36)
*/
public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate,
byte[] pdfData, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert.");
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet.");
}
if (cancellationNumber == null || cancellationNumber.isBlank()) {
throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice cancellation = new CustomerInvoice();
cancellation.setType(InvoiceType.CANCELLATION);
cancellation.setStatus(InvoiceStatus.ISSUED);
cancellation.setInvoiceNumber(cancellationNumber);
cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now());
cancellation.setIssuedAt(LocalDateTime.now());
cancellation.setUserId(original.getUserId());
cancellation.setJobId(original.getJobId());
cancellation.setOriginalInvoiceId(original.getId());
cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber());
cancellation.setOriginalInvoiceDate(original.getInvoiceDate());
// Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben
copyParties(original, cancellation);
cancellation.setItems(original.getItems());
// Beträge negieren Storno bucht den Originalbetrag aus
cancellation.setNetAmount(negate(original.getNetAmount()));
cancellation.setVatRate(original.getVatRate());
cancellation.setVatAmount(negate(original.getVatAmount()));
cancellation.setTotalAmount(negate(original.getTotalAmount()));
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
cancellation.setPdfData(pdfData);
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
cancellation.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(cancellation);
CustomerInvoice savedCancellation = invoiceRepository.save(cancellation);
numberAuditService.markUsed(savedCancellation);
// Original markieren und verlinken
original.setStatus(InvoiceStatus.CANCELLED);
original.setCancelledAt(LocalDateTime.now());
original.setCancellationInvoiceId(savedCancellation.getId());
// Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26)
BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO;
original.setPaymentStatus(computePaymentStatus(original, paid));
InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason);
cancelEntry.setResultingInvoiceId(savedCancellation.getId());
cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber());
original.addAuditEntry(cancelEntry);
invoiceRepository.save(original);
log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(),
savedCancellation.getInvoiceNumber());
return savedCancellation;
}
/**
* Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16).
*
* Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe).
* Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung
* wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den
* Berichtigungsbeleg.
*
* @param originalId ID der zu berichtigenden Rechnung
* @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg
* @param correctionDate Belegdatum
* @param pdfData generiertes PDF des Berichtigungsbelegs
* @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14)
* @param reason Grund der Berichtigung (R-36)
*/
public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate,
byte[] pdfData, String correctedFields, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet.");
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung.");
}
if (correctionNumber == null || correctionNumber.isBlank()) {
throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice correction = new CustomerInvoice();
correction.setType(InvoiceType.CORRECTION);
correction.setStatus(InvoiceStatus.ISSUED);
correction.setInvoiceNumber(correctionNumber);
correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now());
correction.setIssuedAt(LocalDateTime.now());
correction.setUserId(original.getUserId());
correction.setJobId(original.getJobId());
correction.setOriginalInvoiceId(original.getId());
correction.setOriginalInvoiceNumber(original.getInvoiceNumber());
correction.setOriginalInvoiceDate(original.getInvoiceDate());
copyParties(original, correction);
correction.setItems(original.getItems());
correction.setNetAmount(original.getNetAmount());
correction.setVatRate(original.getVatRate());
correction.setVatAmount(original.getVatAmount());
correction.setTotalAmount(original.getTotalAmount());
String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber();
correction.setDescription(
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
: descriptionPrefix + "" + correctedFields);
correction.setPdfData(pdfData);
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(correctionNumber);
correction.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(correction);
CustomerInvoice savedCorrection = invoiceRepository.save(correction);
numberAuditService.markUsed(savedCorrection);
original.setStatus(InvoiceStatus.CORRECTED);
original.setCorrectionInvoiceId(savedCorrection.getId());
InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason);
correctEntry.setResultingInvoiceId(savedCorrection.getId());
correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber());
original.addAuditEntry(correctEntry);
invoiceRepository.save(original);
log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(),
savedCorrection.getInvoiceNumber());
return savedCorrection;
}
/**
* Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22).
* Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte
* Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt.
*/
public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement,
String reason) {
CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId);
if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden.");
}
if (replacement == null) {
throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein.");
}
replacement.setType(InvoiceType.INVOICE);
replacement.setOriginalInvoiceId(cancelledOriginal.getId());
replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber());
replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate());
CustomerInvoice savedReplacement = createAndIssue(replacement,
reason != null ? reason : "Ersatzrechnung nach Storno");
cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId());
InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason);
replaceEntry.setResultingInvoiceId(savedReplacement.getId());
replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber());
cancelledOriginal.addAuditEntry(replaceEntry);
invoiceRepository.save(cancelledOriginal);
return savedReplacement;
}
/**
* Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung.
*/
public List<CustomerInvoice> findRelatedDocuments(String originalInvoiceId) {
if (originalInvoiceId == null) {
return List.of();
}
return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId);
}
/**
* Erfasst eine Zahlung zur Rechnung (R-23 bis R-26).
*
* Eine Zahlung wird ausschließlich erfasst die Rechnungsdaten selbst werden nicht
* verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu
* Bruttobetrag der Rechnung.
*
* @param invoiceId Rechnung, auf die gebucht wird
* @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen)
* @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg)
* @param reason erläuternder Grund für das Audit-Log
*/
public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference,
String reason) {
if (amount == null) {
throw new IllegalArgumentException("Zahlbetrag erforderlich.");
}
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen.");
}
if (invoice.getType() == InvoiceType.CORRECTION) {
throw new InvoiceLifecycleException(
"Auf Berichtigungsbelegen werden keine Zahlungen erfasst buchen Sie auf der Originalrechnung.");
}
BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
BigDecimal newPaid = previous.add(amount);
invoice.setPaidAmount(newPaid);
invoice.setLastPaymentAt(LocalDateTime.now());
invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid));
StringBuilder logReason = new StringBuilder();
logReason.append("Zahlung erfasst: ").append(amount.toPlainString());
if (paymentReference != null && !paymentReference.isBlank()) {
logReason.append(" (Referenz: ").append(paymentReference).append(")");
}
if (reason != null && !reason.isBlank()) {
logReason.append(" ").append(reason);
}
invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString()));
return invoiceRepository.save(invoice);
}
/**
* Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine
* Überzahlung bzw. einen Erstattungsanspruch (R-26).
*/
public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
return total.subtract(paid);
}
private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE;
}
int cmp = paid.compareTo(total);
if (paid.signum() == 0) {
return PaymentStatus.UNPAID;
}
if (cmp == 0) {
return PaymentStatus.PAID;
}
if (cmp < 0) {
return PaymentStatus.PARTIALLY_PAID;
}
return PaymentStatus.OVERPAID;
}
/**
* Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35).
*/
public void deleteDraft(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (!invoice.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Finalisierte Rechnungen können nicht gelöscht werden. "
+ "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch.");
}
invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason));
log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason);
invoiceRepository.delete(invoice);
// Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das
// Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung
// als unerklärter „RESERVED"-Eintrag im Audit hängen.
if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank()
&& invoice.getUserId() != null) {
try {
ObjectId userId = new ObjectId(invoice.getUserId());
String voidReason = (reason != null && !reason.isBlank())
? reason
: "Rechnungsentwurf gelöscht";
numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason);
} catch (IllegalArgumentException | InvoiceLifecycleException ex) {
// Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor
// erfasst das später; das Löschen selbst soll nicht blockiert werden.
log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage());
}
}
}
public Optional<CustomerInvoice> findById(String invoiceId) {
return invoiceRepository.findById(invoiceId);
}
private CustomerInvoice requireInvoice(String invoiceId) {
if (invoiceId == null || invoiceId.isBlank()) {
throw new IllegalArgumentException("Rechnungs-ID erforderlich.");
}
return invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
}
private void ensureInvoiceNumberUnique(CustomerInvoice invoice) {
String number = invoice.getInvoiceNumber();
if (number == null || number.isBlank()) {
throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer.");
}
invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> {
if (!existing.getId().equals(invoice.getId())) {
throw new InvoiceLifecycleException(
"Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet.");
}
});
}
private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) {
String userId = null;
String displayName = "system";
try {
User user = securityService.getCurrentDatabaseUser();
userId = user.getId() != null ? user.getId().toHexString() : null;
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
displayName = composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
// Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration).
}
return new InvoiceAuditEntry(action, userId, displayName, reason);
}
private String safe(String value) {
return value != null ? value : "";
}
private void copyParties(CustomerInvoice source, CustomerInvoice target) {
target.setSenderName(source.getSenderName());
target.setSenderAddress(source.getSenderAddress());
target.setSenderPostcode(source.getSenderPostcode());
target.setSenderCity(source.getSenderCity());
target.setSenderCountry(source.getSenderCountry());
target.setSenderTaxNumber(source.getSenderTaxNumber());
target.setSenderVatId(source.getSenderVatId());
target.setSenderPhone(source.getSenderPhone());
target.setSenderEmail(source.getSenderEmail());
target.setSenderWebsite(source.getSenderWebsite());
target.setRecipientName(source.getRecipientName());
target.setRecipientCompany(source.getRecipientCompany());
target.setRecipientAddress(source.getRecipientAddress());
target.setRecipientPostcode(source.getRecipientPostcode());
target.setRecipientCity(source.getRecipientCity());
target.setRecipientCountry(source.getRecipientCountry());
target.setRecipientVatId(source.getRecipientVatId());
}
private BigDecimal negate(BigDecimal value) {
return value != null ? value.negate() : null;
}
}

View File

@@ -0,0 +1,166 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem
* Counter wird als RESERVED protokolliert; dieser Service vollzieht die
* Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen)
* und liefert Lücken-Reports für die Betriebsprüfung.
*
* Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende
* Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede
* fehlende Nummer dokumentiert erklärt werden kann (GoBD).
*
* Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die
* fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene
* USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar.
*/
@Service
public class InvoiceNumberAuditService {
private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class);
private final InvoiceNumberReservationRepository repository;
public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) {
this.repository = repository;
}
/**
* Markiert die Reservierung der übergebenen Rechnung als USED.
* Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen.
*/
public void markUsed(CustomerInvoice invoice) {
if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) {
return;
}
ObjectId userId = parseUserId(invoice.getUserId());
if (userId == null) {
return;
}
try {
Optional<InvoiceNumberReservation> existing = repository.findByUserIdAndNumber(userId,
invoice.getInvoiceNumber());
InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice));
reservation.setStatus(InvoiceNumberReservationStatus.USED);
reservation.setInvoiceId(invoice.getId());
reservation.setUsedAt(Instant.now());
repository.save(reservation);
} catch (Exception ex) {
log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}",
invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex);
}
}
/**
* Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED.
* Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke.
*/
public void markVoided(ObjectId userId, String number, String reason) {
if (userId == null || number == null || number.isBlank()) {
throw new IllegalArgumentException("userId und number sind Pflichtparameter.");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung.");
}
InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number)
.orElseThrow(() -> new InvoiceLifecycleException(
"Keine Reservierung für Nummer " + number + " gefunden."));
if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) {
throw new InvoiceLifecycleException(
"Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet "
+ "und kann nicht verworfen werden.");
}
reservation.setStatus(InvoiceNumberReservationStatus.VOIDED);
reservation.setVoidReason(reason);
reservation.setVoidedAt(Instant.now());
repository.save(reservation);
}
/**
* Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge.
* Basis für vollständige Audit-Reports.
*/
public List<InvoiceNumberReservation> findAll(ObjectId userId) {
return repository.findByUserIdOrderBySequenceAsc(userId);
}
/**
* Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers
* (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder
* enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund.
* Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind
* unerklärte Lücken und sollten in der UI hervorgehoben werden.
*/
public List<InvoiceNumberReservation> findUnused(ObjectId userId) {
List<InvoiceNumberReservation> all = repository.findByUserIdOrderBySequenceAsc(userId);
return all.stream()
.filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED)
.toList();
}
/**
* Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene
* Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit
* vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege,
* deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt.
*/
private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(invoice.getInvoiceNumber());
reservation.setSequence(extractSequence(invoice.getInvoiceNumber()));
reservation.setReservedAt(Instant.now());
reservation.setReservedBy("system (bootstrap)");
return reservation;
}
/**
* Best-effort-Extraktion der numerischen Sequenz aus einer formatierten
* Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine
* trailing-Ziffern vorhanden sind — dann ist die Nummer für die
* Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten.
*/
private long extractSequence(String number) {
if (number == null) {
return -1L;
}
int end = number.length();
int start = end;
while (start > 0 && Character.isDigit(number.charAt(start - 1))) {
start--;
}
if (start == end) {
return -1L;
}
try {
return Long.parseLong(number.substring(start, end));
} catch (NumberFormatException ex) {
return -1L;
}
}
private ObjectId parseUserId(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return new ObjectId(value);
} catch (IllegalArgumentException ex) {
log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value);
return null;
}
}
}

View File

@@ -0,0 +1,140 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.security.InvoiceRoles;
import de.assecutor.votianlt.security.SecurityService;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42.
*
* Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle,
* der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung
* — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig.
*
* Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen
* Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt.
*/
@Service
public class InvoicePermissionService {
private static final String ROLE_ADMIN = "ADMIN";
private final SecurityService securityService;
public InvoicePermissionService(SecurityService securityService) {
this.securityService = securityService;
}
public boolean canCreateOrIssue(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user);
}
public boolean canMarkAsSent(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER)
|| isUnscoped(user);
}
public boolean canCancel(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public boolean canCorrect(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user);
}
public boolean canRecordPayment(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public void requireCreate(User user) {
if (!canCreateOrIssue(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen.");
}
}
public void requireSend(User user) {
if (!canMarkAsSent(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen als versendet zu markieren.");
}
}
public void requireCancel(User user) {
if (!canCancel(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu stornieren.");
}
}
public void requireCorrect(User user) {
if (!canCorrect(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen.");
}
}
public void requirePayment(User user) {
if (!canRecordPayment(user)) {
throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen.");
}
}
/**
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
*/
public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) {
if (user == null) {
return false;
}
if (isAdmin(user)) {
return true;
}
if (invoice == null || invoice.getUserId() == null || user.getId() == null) {
return false;
}
return invoice.getUserId().equals(user.getId().toHexString());
}
private boolean hasAnyInvoiceRole(User user, String... roles) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> userRoles = user.getRoles();
if (userRoles.contains(ROLE_ADMIN)) {
return true;
}
for (String role : roles) {
if (userRoles.contains(role)) {
return true;
}
}
return false;
}
/**
* {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist —
* dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat).
*/
private boolean isUnscoped(User user) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> roles = user.getRoles();
return roles.stream().noneMatch(r -> r.startsWith("INVOICE_"));
}
private boolean isAdmin(User user) {
return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN);
}
/** Bequemer Lookup für die UI. */
public User currentUser() {
return securityService.getCurrentDatabaseUser();
}
}

View File

@@ -62,7 +62,7 @@ app.security.two-factor.enabled=true
# WebSocket Configuration
app.messaging.websocket.path=/ws/messaging
app.messaging.websocket.max-text-message-size=65536
app.messaging.websocket.max-text-message-size=10485760
app.messaging.websocket.max-session-idle-timeout=300000
app.messaging.websocket.allowed-origins=*

View File

@@ -5,7 +5,7 @@ dialog.confirm=Bestätigen
# Navigation and Main Layout
nav.jobs=Aufträge
nav.job.create=Auftragserstellung
nav.customers=Kunden
nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
@@ -31,7 +31,7 @@ profile.lastname=Nachname
profile.phone=Telefonnummer
profile.fax=Telefon (Fax)
profile.mobile=Telefon (Mobil)
profile.email=E-Mail-Adresse (Login)*
profile.email=E-Mail-Adresse
profile.street=Straße
profile.housenr=Hausnr
profile.addressadd=Adresszusatz
@@ -46,6 +46,7 @@ profile.settings.digitalprocess=Digitale Abwicklung
profile.settings.digitalprocess.info=Aufträge werden digital über die App abgewickelt
profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -241,7 +242,7 @@ page.title.appuser.create=Neuen App-Nutzer anlegen
page.title.messages=Nachrichten
page.title.register=Bei VotianLT registrieren
page.title.customers=Kunden
page.title.customer.edit=Kunde bearbeiten
page.title.customer.edit=Adresse bearbeiten
page.title.verwaltung=Verwaltung
page.title.company.create=Neue Firma anlegen
page.title.imprint=Impressum
@@ -338,13 +339,13 @@ customers.column.street=Straße
customers.column.city=Ort
# Edit Customer
editcustomer.title=Kunde bearbeiten
editcustomer.notification.notfound=Kunde nicht gefunden
editcustomer.notification.invalid.id=Ungültige Kunden-ID
editcustomer.notification.saved=Kunde erfolgreich gespeichert
editcustomer.title=Adresse bearbeiten
editcustomer.notification.notfound=Adresse nicht gefunden
editcustomer.notification.invalid.id=Ungültige Adress-ID
editcustomer.notification.saved=Adresse erfolgreich gespeichert
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
editcustomer.notification.deleted=Kunde erfolgreich gelöscht
editcustomer.dialog.delete.text=Möchten Sie diesen Kunden wirklich löschen?
editcustomer.notification.deleted=Adresse erfolgreich gelöscht
editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
editcustomer.dialog.delete.confirm=Löschen
# Add Customer
@@ -428,9 +429,9 @@ messages.sender.unknown=Unbekannter Absender
# Add Job
addjob.title=Neuen Auftrag anlegen
addjob.customer.label=Kunde
addjob.customer.placeholder=Kunde auswählen
addjob.customer.unnamed=Unbenannter Kunde
addjob.customer.label=Auftraggeber
addjob.customer.placeholder=Auftraggeber auswählen
addjob.customer.unnamed=Unbenannter Auftraggeber
addjob.button.clearfields=Felder leeren
addjob.button.submit=Auftrag anlegen
addjob.address.salutation=Anrede
@@ -439,6 +440,10 @@ addjob.salutation.mr=Herr
addjob.salutation.ms=Frau
addjob.salutation.other=Divers
addjob.address.company.placeholder=Firma eingeben
addjob.address.pickup.label=Abholadresse
addjob.address.pickup.placeholder=Abholadresse auswählen oder eingeben
addjob.address.delivery.label=Lieferadresse
addjob.address.delivery.placeholder=Lieferadresse auswählen oder eingeben
addjob.address.street.placeholder=Straße eingeben
addjob.address.housenumber=Hausnummer
addjob.address.addition.placeholder=Adresszusatz
@@ -447,7 +452,8 @@ addjob.address.city.placeholder.pickup=Ort (Abholung)
addjob.address.city.placeholder.delivery=Ort (Lieferung)
addjob.address.delivery.street.placeholder=Straße (Lieferung)
addjob.address.delivery.addition.placeholder=Adresszusatz (Lieferung)
addjob.address.save=Adresse speichern
addjob.address.save=Adresse in Adressbuch übernehmen
addjob.address.update=Adresse im Adressbuch aktualisieren
addjob.section.pickup=Abholung
addjob.section.delivery=Lieferung
addjob.stations.apply=Stationen \u00fcbernehmen
@@ -458,6 +464,8 @@ addjob.station.max.reached=Maximale Anzahl von 25 Lieferstationen erreicht
addjob.station.unused=Nicht genutzt
addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
addjob.tab.addresses=Auftraggeber & Adressen
addjob.tab.pickup.address=Auftraggeber & Abholadresse
addjob.tab.delivery.address=Lieferadresse
addjob.tab.appointments=Termine & Verarbeitung
addjob.tab.cargo=Fracht
addjob.tab.tasks=Aufgaben
@@ -513,7 +521,8 @@ addjob.tasks.photo.min=Min. Fotos
addjob.tasks.photo.max=Max. Fotos
addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. Barcodes
addjob.tasks.signature.noconfig=Keine Konfiguration erforderlich
addjob.tasks.signature.notelabel=Bemerkung (optional)
addjob.tasks.signature.notelabel.placeholder=Hinweistext für die Bemerkung eingeben
addjob.tasks.todolist.title=To-Do Liste
addjob.tasks.todolist.item.placeholder=To-Do eingeben
addjob.tasks.todolist.add=To-Do hinzufügen
@@ -610,6 +619,17 @@ jobsummary.task.photo.taken=Aufgenommene Fotos ({0})
jobsummary.task.button.text=Button-Text
jobsummary.button.schliessen=Schließen
jobsummary.route.planned=Geplante Route
jobsummary.button.manualcomplete=Manuell beenden
jobsummary.dialog.manualcomplete.title=Auftrag manuell beenden
jobsummary.dialog.manualcomplete.text=Der Auftrag {0} wird jetzt manuell abgeschlossen. Danach kann er nicht mehr per App weiter bearbeitet werden.
jobsummary.dialog.manualcomplete.reason=Begründung
jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründung ein
jobsummary.dialog.manualcomplete.cancel=Abbrechen
jobsummary.dialog.manualcomplete.confirm=Akzeptiert
jobsummary.history.manualcomplete.reason=Manuell beendet
jobmanualcomplete.route.hours=Stunden
jobmanualcomplete.route.minutes=Minuten
jobmanualcomplete.route.manual.hint=Keine Routendaten vorhanden bitte Entfernung und Dauer manuell erfassen.
# Jobs
jobs.title=Aufträge
@@ -652,6 +672,8 @@ createinvoice.section.job=Auftragsdetails
createinvoice.section.route=Streckeninfo
createinvoice.section.services=Leistungen
createinvoice.section.summary=Zusammenfassung
createinvoice.section.vat=Umsatzsteuer
createinvoice.field.vatrate=USt-Satz
createinvoice.field.jobnumber=Auftragsnummer
createinvoice.field.customer=Kunde
createinvoice.field.status=Status
@@ -684,6 +706,72 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt.
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0}
invoices.column.status=Status
invoices.column.type=Typ
invoices.column.actions=Aktionen
invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug.
invoices.status.draft=Entwurf
invoices.status.issued=Ausgestellt
invoices.status.sent=Versendet
invoices.status.cancelled=Storniert
invoices.status.corrected=Berichtigt
invoices.type.invoice=Rechnung
invoices.type.cancellation=Stornorechnung
invoices.type.correction=Berichtigung
invoices.action.view=PDF anzeigen
invoices.action.history=Historie
invoices.action.marksent=Versendet markieren
invoices.action.correct=Berichtigen
invoices.action.cancel=Stornieren
invoices.notification.sent=Rechnung als versendet markiert.
invoices.notification.cancelled=Stornobeleg {0} erstellt.
invoices.notification.corrected=Berichtigungsbeleg {0} erstellt.
invoices.notification.error=Aktion fehlgeschlagen: {0}
invoices.cancel.title=Rechnung {0} stornieren
invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert.
invoices.cancel.reason=Grund der Stornierung
invoices.cancel.reason.required=Bitte einen Grund angeben.
invoices.cancel.confirm=Stornobeleg erstellen
invoices.correct.title=Rechnung {0} berichtigen
invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie.
invoices.correct.fields=Berichtigte Angaben
invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden.
invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben.
invoices.correct.reason=Grund der Berichtigung
invoices.correct.confirm=Berichtigung erstellen
invoices.history.title=Historie zu Rechnung {0}
invoices.history.log=Änderungsprotokoll
invoices.history.empty=Keine Einträge vorhanden.
invoices.history.original=Original­rechnung
invoices.history.cancellation=Stornobeleg
invoices.history.correction=Berichtigungsbeleg
invoices.history.replacement=Ersatzrechnung
invoices.audit.action.created_draft=Entwurf erstellt
invoices.audit.action.updated_draft=Entwurf geändert
invoices.audit.action.issued=Ausgestellt
invoices.audit.action.sent=Versendet
invoices.audit.action.cancelled=Storniert
invoices.audit.action.corrected=Berichtigt
invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt
invoices.payment.overpaid=Überzahlung
invoices.payment.refund_due=Erstattung offen
invoices.action.payment=Zahlung erfassen
invoices.action.export=Exportieren
invoices.payment.title=Zahlung zu Rechnung {0}
invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden.
invoices.payment.amount=Zahlbetrag
invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben.
invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst.
# My Invoices
myinvoices.title=Rechnungen

View File

@@ -3,7 +3,7 @@ dialog.cancel=T\u00fchista
dialog.confirm=Kinnita
nav.jobs=Tellimused
nav.job.create=Tellimuse loomine
nav.customers=Kliendid
nav.customers=Aadressiraamat
nav.appusers=\u00c4pikasutajad
nav.statistics=Statistika
nav.invoices=Arved
@@ -27,7 +27,7 @@ profile.lastname=Perekonnanimi
profile.phone=Telefoninumber
profile.fax=Telefon (faks)
profile.mobile=Telefon (mobiil)
profile.email=E-posti aadress (sisselogimine)*
profile.email=E-posti aadress
profile.street=T\u00e4nav
profile.housenr=Majanumber
profile.addressadd=Aadressi t\u00e4iend
@@ -377,9 +377,9 @@ messages.preview.image=Pilt
messages.preview.empty=Eelvaade puudub
messages.sender.unknown=Tundmatu saatja
addjob.title=Uue tellimuse loomine
addjob.customer.label=Klient
addjob.customer.placeholder=Valige klient
addjob.customer.unnamed=Nimetu klient
addjob.customer.label=Tellija
addjob.customer.placeholder=Vali tellija
addjob.customer.unnamed=Nimetu tellija
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
addjob.button.submit=Loo tellimus
addjob.address.salutation=P\u00f6\u00f6rdumine
@@ -388,6 +388,10 @@ addjob.salutation.mr=Hr
addjob.salutation.ms=Pr
addjob.salutation.other=Muu
addjob.address.company.placeholder=Sisestage ettev\u00f5te
addjob.address.pickup.label=Pealekorje aadress
addjob.address.pickup.placeholder=Vali v\u00f5i sisesta pealekorje aadress
addjob.address.delivery.label=Kohaletoimetamise aadress
addjob.address.delivery.placeholder=Vali v\u00f5i sisesta kohaletoimetamise aadress
addjob.address.street.placeholder=Sisestage t\u00e4nav
addjob.address.housenumber=Majanumber
addjob.address.addition.placeholder=Aadressi t\u00e4iend
@@ -396,7 +400,8 @@ addjob.address.city.placeholder.pickup=Asukoht (pealekorje)
addjob.address.city.placeholder.delivery=Asukoht (kohaletoimetamine)
addjob.address.delivery.street.placeholder=T\u00e4nav (kohaletoimetamine)
addjob.address.delivery.addition.placeholder=Aadressi t\u00e4iend (kohaletoimetamine)
addjob.address.save=Salvesta aadress
addjob.address.save=Lisa aadress aadressiraamatusse
addjob.address.update=Uuenda aadressi aadressiraamatus
addjob.section.pickup=Pealekorje
addjob.section.delivery=Kohaletoimetamine
addjob.stations.apply=Rakenda jaamad
@@ -407,6 +412,8 @@ addjob.station.max.reached=Maksimaalne arv 25 kohaletoimetamise jaama on saavuta
addjob.station.unused=Kasutamata
addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
addjob.tab.addresses=Tellija ja aadressid
addjob.tab.pickup.address=Tellija ja pealekorje aadress
addjob.tab.delivery.address=Kohaletoimetamise aadress
addjob.tab.appointments=Ajad ja t\u00f6\u00f6tlemine
addjob.tab.cargo=Veosed
addjob.tab.tasks=\u00dclesanded
@@ -462,7 +469,8 @@ addjob.tasks.photo.min=Min. fotosid
addjob.tasks.photo.max=Max. fotosid
addjob.tasks.barcode.min=Min. v\u00f6\u00f6tkoode
addjob.tasks.barcode.max=Max. v\u00f6\u00f6tkoode
addjob.tasks.signature.noconfig=Seadistamine pole vajalik
addjob.tasks.signature.notelabel=Märkus (valikuline)
addjob.tasks.signature.notelabel.placeholder=Sisestage vihje tekst märkusele
addjob.tasks.todolist.title=\u00dclesannete nimekiri
addjob.tasks.todolist.item.placeholder=Sisestage \u00fclesanne
addjob.tasks.todolist.add=Lisa \u00fclesanne
@@ -557,6 +565,17 @@ jobsummary.task.photo.taken=Tehtud fotod ({0})
jobsummary.task.button.text=Nupu tekst
jobsummary.button.schliessen=Sulge
jobsummary.route.planned=Planeeritud marsruut
jobsummary.button.manualcomplete=Lõpeta käsitsi
jobsummary.dialog.manualcomplete.title=Lõpeta tellimus käsitsi
jobsummary.dialog.manualcomplete.text=Tellimus {0} lõpetatakse nüüd käsitsi. Pärast seda ei saa seda enam rakenduse kaudu töödelda.
jobsummary.dialog.manualcomplete.reason=Põhjendus
jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus
jobsummary.dialog.manualcomplete.cancel=Tühista
jobsummary.dialog.manualcomplete.confirm=Nõustu
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
jobmanualcomplete.route.hours=Tunnid
jobmanualcomplete.route.minutes=Minutid
jobmanualcomplete.route.manual.hint=Marsruudiandmed puuduvad palun sisestage vahemaa ja kestus käsitsi.
jobs.title=Tellimused
jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirm
# Navigation and Main Layout
nav.jobs=Jobs
nav.job.create=Create Job
nav.customers=Customers
nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
@@ -31,7 +31,7 @@ profile.lastname=Last Name
profile.phone=Phone Number
profile.fax=Phone (Fax)
profile.mobile=Phone (Mobile)
profile.email=Email Address (Login)*
profile.email=Email Address
profile.street=Street
profile.housenr=House No.
profile.addressadd=Address Suffix
@@ -46,6 +46,7 @@ profile.settings.digitalprocess=Digital Processing
profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -428,9 +429,9 @@ messages.sender.unknown=Unknown Sender
# Add Job
addjob.title=Create New Job
addjob.customer.label=Customer
addjob.customer.placeholder=Select Customer
addjob.customer.unnamed=Unnamed Customer
addjob.customer.label=Principal
addjob.customer.placeholder=Select principal
addjob.customer.unnamed=Unnamed principal
addjob.button.clearfields=Clear Fields
addjob.button.submit=Create Job
addjob.address.salutation=Salutation
@@ -439,6 +440,10 @@ addjob.salutation.mr=Mr
addjob.salutation.ms=Ms
addjob.salutation.other=Other
addjob.address.company.placeholder=Enter company
addjob.address.pickup.label=Pickup address
addjob.address.pickup.placeholder=Select or enter pickup address
addjob.address.delivery.label=Delivery address
addjob.address.delivery.placeholder=Select or enter delivery address
addjob.address.street.placeholder=Enter street
addjob.address.housenumber=House Number
addjob.address.addition.placeholder=Address suffix
@@ -447,7 +452,8 @@ addjob.address.city.placeholder.pickup=City (Pickup)
addjob.address.city.placeholder.delivery=City (Delivery)
addjob.address.delivery.street.placeholder=Street (Delivery)
addjob.address.delivery.addition.placeholder=Address suffix (Delivery)
addjob.address.save=Save Address
addjob.address.save=Add address to address book
addjob.address.update=Update address in address book
addjob.section.pickup=Pickup
addjob.section.delivery=Delivery
addjob.stations.apply=Apply Stations
@@ -458,6 +464,8 @@ addjob.station.max.reached=Maximum number of 25 delivery stations reached
addjob.station.unused=Not used
addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
addjob.tab.addresses=Client & Addresses
addjob.tab.pickup.address=Principal & Pickup Address
addjob.tab.delivery.address=Delivery Address
addjob.tab.appointments=Appointments & Processing
addjob.tab.cargo=Cargo
addjob.tab.tasks=Tasks
@@ -513,7 +521,8 @@ addjob.tasks.photo.min=Min. Photos
addjob.tasks.photo.max=Max. Photos
addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. Barcodes
addjob.tasks.signature.noconfig=No configuration required
addjob.tasks.signature.notelabel=Note (optional)
addjob.tasks.signature.notelabel.placeholder=Enter hint text for the note
addjob.tasks.todolist.title=To-Do List
addjob.tasks.todolist.item.placeholder=Enter to-do
addjob.tasks.todolist.add=Add To-Do
@@ -610,6 +619,17 @@ jobsummary.task.photo.taken=Photos taken ({0})
jobsummary.task.button.text=Button Text
jobsummary.button.schliessen=Close
jobsummary.route.planned=Planned Route
jobsummary.button.manualcomplete=Complete manually
jobsummary.dialog.manualcomplete.title=Complete job manually
jobsummary.dialog.manualcomplete.text=Job {0} will now be completed manually. It can no longer be processed via the app afterwards.
jobsummary.dialog.manualcomplete.reason=Reason
jobsummary.dialog.manualcomplete.reason.required=Please enter a reason
jobsummary.dialog.manualcomplete.cancel=Cancel
jobsummary.dialog.manualcomplete.confirm=Accept
jobsummary.history.manualcomplete.reason=Manually completed
jobmanualcomplete.route.hours=Hours
jobmanualcomplete.route.minutes=Minutes
jobmanualcomplete.route.manual.hint=No route data available please enter distance and duration manually.
# Jobs
jobs.title=Jobs
@@ -652,6 +672,8 @@ createinvoice.section.job=Job Details
createinvoice.section.route=Route Info
createinvoice.section.services=Services
createinvoice.section.summary=Summary
createinvoice.section.vat=VAT
createinvoice.field.vatrate=VAT rate
createinvoice.field.jobnumber=Job Number
createinvoice.field.customer=Customer
createinvoice.field.status=Status
@@ -684,6 +706,72 @@ invoices.column.amount=Amount
invoices.column.description=Description
invoices.empty=No invoices have been created yet.
invoices.notification.pdf.missing=No PDF is stored for this invoice.
invoices.notification.pdf.error=Failed to display PDF: {0}
invoices.column.status=Status
invoices.column.type=Type
invoices.column.actions=Actions
invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original.
invoices.status.draft=Draft
invoices.status.issued=Issued
invoices.status.sent=Sent
invoices.status.cancelled=Cancelled
invoices.status.corrected=Corrected
invoices.type.invoice=Invoice
invoices.type.cancellation=Cancellation invoice
invoices.type.correction=Correction document
invoices.action.view=View PDF
invoices.action.history=History
invoices.action.marksent=Mark as sent
invoices.action.correct=Correct
invoices.action.cancel=Cancel
invoices.notification.sent=Invoice marked as sent.
invoices.notification.cancelled=Cancellation document {0} created.
invoices.notification.corrected=Correction document {0} created.
invoices.notification.error=Action failed: {0}
invoices.cancel.title=Cancel invoice {0}
invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice.
invoices.cancel.reason=Reason for cancellation
invoices.cancel.reason.required=Please provide a reason.
invoices.cancel.confirm=Create cancellation document
invoices.correct.title=Correct invoice {0}
invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly.
invoices.correct.fields=Corrected information
invoices.correct.fields.helper=Describe which fields are added or replaced.
invoices.correct.fields.required=Please describe the corrected information.
invoices.correct.reason=Reason for correction
invoices.correct.confirm=Create correction document
invoices.history.title=History for invoice {0}
invoices.history.log=Audit log
invoices.history.empty=No entries available.
invoices.history.original=Original invoice
invoices.history.cancellation=Cancellation document
invoices.history.correction=Correction document
invoices.history.replacement=Replacement invoice
invoices.audit.action.created_draft=Draft created
invoices.audit.action.updated_draft=Draft updated
invoices.audit.action.issued=Issued
invoices.audit.action.sent=Sent
invoices.audit.action.cancelled=Cancelled
invoices.audit.action.corrected=Corrected
invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0}
invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid
invoices.payment.overpaid=Overpaid
invoices.payment.refund_due=Refund due
invoices.action.payment=Record payment
invoices.action.export=Export
invoices.payment.title=Payment for invoice {0}
invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections.
invoices.payment.amount=Amount
invoices.payment.amount.required=Please enter a non-zero amount.
invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note
invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded.
# My Invoices
myinvoices.title=Invoices

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmar
# Navigation and Main Layout
nav.jobs=Pedidos
nav.job.create=Crear pedido
nav.customers=Clientes
nav.customers=Libreta de direcciones
nav.appusers=Usuarios de la app
nav.statistics=Estad\u00edsticas
nav.invoices=Facturas
@@ -31,7 +31,7 @@ profile.lastname=Apellido
profile.phone=N\u00famero de tel\u00e9fono
profile.fax=Tel\u00e9fono (Fax)
profile.mobile=Tel\u00e9fono (M\u00f3vil)
profile.email=Direcci\u00f3n de correo electr\u00f3nico (Login)*
profile.email=Direcci\u00f3n de correo electr\u00f3nico
profile.street=Calle
profile.housenr=N\u00famero
profile.addressadd=Complemento de direcci\u00f3n
@@ -428,9 +428,9 @@ messages.sender.unknown=Remitente desconocido
# Add Job
addjob.title=Crear nuevo pedido
addjob.customer.label=Cliente
addjob.customer.placeholder=Seleccionar cliente
addjob.customer.unnamed=Cliente sin nombre
addjob.customer.label=Ordenante
addjob.customer.placeholder=Seleccionar ordenante
addjob.customer.unnamed=Ordenante sin nombre
addjob.button.clearfields=Vaciar campos
addjob.button.submit=Crear pedido
addjob.address.salutation=Tratamiento
@@ -439,6 +439,10 @@ addjob.salutation.mr=Sr.
addjob.salutation.ms=Sra.
addjob.salutation.other=Otro
addjob.address.company.placeholder=Introducir empresa
addjob.address.pickup.label=Direcci\u00f3n de recogida
addjob.address.pickup.placeholder=Seleccionar o introducir direcci\u00f3n de recogida
addjob.address.delivery.label=Direcci\u00f3n de entrega
addjob.address.delivery.placeholder=Seleccionar o introducir direcci\u00f3n de entrega
addjob.address.street.placeholder=Introducir calle
addjob.address.housenumber=N\u00famero de casa
addjob.address.addition.placeholder=Complemento de direcci\u00f3n
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Localidad (Recogida)
addjob.address.city.placeholder.delivery=Localidad (Entrega)
addjob.address.delivery.street.placeholder=Calle (Entrega)
addjob.address.delivery.addition.placeholder=Complemento de direcci\u00f3n (Entrega)
addjob.address.save=Guardar direcci\u00f3n
addjob.address.save=A\u00f1adir direcci\u00f3n a la libreta de direcciones
addjob.address.update=Actualizar direcci\u00f3n en la libreta de direcciones
addjob.section.pickup=Recogida
addjob.section.delivery=Entrega
addjob.stations.apply=Aplicar estaciones
@@ -458,6 +463,8 @@ addjob.station.max.reached=Se ha alcanzado el n\u00famero m\u00e1ximo de 25 esta
addjob.station.unused=No utilizada
addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
addjob.tab.addresses=Cliente y direcciones
addjob.tab.pickup.address=Ordenante y direcci\u00f3n de recogida
addjob.tab.delivery.address=Direcci\u00f3n de entrega
addjob.tab.appointments=Citas y procesamiento
addjob.tab.cargo=Carga
addjob.tab.tasks=Tareas
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=M\u00edn. fotos
addjob.tasks.photo.max=M\u00e1x. fotos
addjob.tasks.barcode.min=M\u00edn. c\u00f3digos de barras
addjob.tasks.barcode.max=M\u00e1x. c\u00f3digos de barras
addjob.tasks.signature.noconfig=No se requiere configuraci\u00f3n
addjob.tasks.signature.notelabel=Nota (opcional)
addjob.tasks.signature.notelabel.placeholder=Introducir texto de sugerencia para la nota
addjob.tasks.todolist.title=Lista de tareas pendientes
addjob.tasks.todolist.item.placeholder=Introducir tarea pendiente
addjob.tasks.todolist.add=A\u00f1adir tarea pendiente
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Fotos tomadas ({0})
jobsummary.task.button.text=Texto del bot\u00f3n
jobsummary.button.schliessen=Cerrar
jobsummary.route.planned=Ruta planificada
jobsummary.button.manualcomplete=Finalizar manualmente
jobsummary.dialog.manualcomplete.title=Finalizar pedido manualmente
jobsummary.dialog.manualcomplete.text=El pedido {0} se completar\u00e1 manualmente. Despu\u00e9s ya no podr\u00e1 ser procesado a trav\u00e9s de la aplicaci\u00f3n.
jobsummary.dialog.manualcomplete.reason=Motivo
jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo
jobsummary.dialog.manualcomplete.cancel=Cancelar
jobsummary.dialog.manualcomplete.confirm=Aceptar
jobsummary.history.manualcomplete.reason=Finalizado manualmente
jobmanualcomplete.route.hours=Horas
jobmanualcomplete.route.minutes=Minutos
jobmanualcomplete.route.manual.hint=No hay datos de ruta disponibles introduzca la distancia y la duración manualmente.
# Jobs
jobs.title=Pedidos

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmer
# Navigation and Main Layout
nav.jobs=Missions
nav.job.create=Cr\u00e9ation de mission
nav.customers=Clients
nav.customers=Carnet d'adresses
nav.appusers=Utilisateurs d'app
nav.statistics=Statistiques
nav.invoices=Factures
@@ -31,7 +31,7 @@ profile.lastname=Nom
profile.phone=Num\u00e9ro de t\u00e9l\u00e9phone
profile.fax=T\u00e9l\u00e9phone (fax)
profile.mobile=T\u00e9l\u00e9phone (mobile)
profile.email=Adresse e-mail (connexion)*
profile.email=Adresse e-mail
profile.street=Rue
profile.housenr=N\u00b0
profile.addressadd=Compl\u00e9ment d'adresse
@@ -428,9 +428,9 @@ messages.sender.unknown=Exp\u00e9diteur inconnu
# Add Job
addjob.title=Cr\u00e9er une nouvelle mission
addjob.customer.label=Client
addjob.customer.placeholder=S\u00e9lectionner un client
addjob.customer.unnamed=Client sans nom
addjob.customer.label=Donneur d'ordre
addjob.customer.placeholder=S\u00e9lectionner le donneur d'ordre
addjob.customer.unnamed=Donneur d'ordre sans nom
addjob.button.clearfields=Vider les champs
addjob.button.submit=Cr\u00e9er la mission
addjob.address.salutation=Civilit\u00e9
@@ -439,6 +439,10 @@ addjob.salutation.mr=Monsieur
addjob.salutation.ms=Madame
addjob.salutation.other=Autre
addjob.address.company.placeholder=Saisir l'entreprise
addjob.address.pickup.label=Adresse d'enl\u00e8vement
addjob.address.pickup.placeholder=S\u00e9lectionner ou saisir l'adresse d'enl\u00e8vement
addjob.address.delivery.label=Adresse de livraison
addjob.address.delivery.placeholder=S\u00e9lectionner ou saisir l'adresse de livraison
addjob.address.street.placeholder=Saisir la rue
addjob.address.housenumber=Num\u00e9ro
addjob.address.addition.placeholder=Compl\u00e9ment d'adresse
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Ville (enl\u00e8vement)
addjob.address.city.placeholder.delivery=Ville (livraison)
addjob.address.delivery.street.placeholder=Rue (livraison)
addjob.address.delivery.addition.placeholder=Compl\u00e9ment d'adresse (livraison)
addjob.address.save=Enregistrer l'adresse
addjob.address.save=Ajouter l'adresse au carnet d'adresses
addjob.address.update=Mettre \u00e0 jour l'adresse dans le carnet d'adresses
addjob.section.pickup=Enl\u00e8vement
addjob.section.delivery=Livraison
addjob.stations.apply=Appliquer les stations
@@ -458,6 +463,8 @@ addjob.station.max.reached=Nombre maximum de 25 stations de livraison atteint
addjob.station.unused=Non utilis\u00e9e
addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison.
addjob.tab.addresses=Donneur d'ordre & adresses
addjob.tab.pickup.address=Donneur d'ordre & adresse d'enl\u00e8vement
addjob.tab.delivery.address=Adresse de livraison
addjob.tab.appointments=Rendez-vous & traitement
addjob.tab.cargo=Fret
addjob.tab.tasks=T\u00e2ches
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. photos
addjob.tasks.photo.max=Max. photos
addjob.tasks.barcode.min=Min. codes-barres
addjob.tasks.barcode.max=Max. codes-barres
addjob.tasks.signature.noconfig=Aucune configuration n\u00e9cessaire
addjob.tasks.signature.notelabel=Note (optionnelle)
addjob.tasks.signature.notelabel.placeholder=Saisir le texte d'indication pour la note
addjob.tasks.todolist.title=Liste de t\u00e2ches
addjob.tasks.todolist.item.placeholder=Saisir la t\u00e2che
addjob.tasks.todolist.add=Ajouter une t\u00e2che
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Photos prises ({0})
jobsummary.task.button.text=Texte du bouton
jobsummary.button.schliessen=Fermer
jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu
jobsummary.button.manualcomplete=Terminer manuellement
jobsummary.dialog.manualcomplete.title=Terminer la commande manuellement
jobsummary.dialog.manualcomplete.text=La commande {0} va maintenant \u00eatre termin\u00e9e manuellement. Elle ne pourra plus \u00eatre trait\u00e9e via l\u2019application par la suite.
jobsummary.dialog.manualcomplete.reason=Motif
jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif
jobsummary.dialog.manualcomplete.cancel=Annuler
jobsummary.dialog.manualcomplete.confirm=Accepter
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
jobmanualcomplete.route.hours=Heures
jobmanualcomplete.route.minutes=Minutes
jobmanualcomplete.route.manual.hint=Aucune donn\u00e9e d'itin\u00e9raire disponible \u2013 veuillez saisir la distance et la dur\u00e9e manuellement.
# Jobs
jobs.title=Missions

View File

@@ -5,7 +5,7 @@ dialog.confirm=Patvirtinti
# Navigation and Main Layout
nav.jobs=Užsakymai
nav.job.create=Užsakymo kūrimas
nav.customers=Klientai
nav.customers=Adres\u0173 knyga
nav.appusers=Programėlės naudotojai
nav.statistics=Statistika
nav.invoices=Sąskaitos faktūros
@@ -31,7 +31,7 @@ profile.lastname=Pavardė
profile.phone=Telefono numeris
profile.fax=Telefonas (faksas)
profile.mobile=Telefonas (mob.)
profile.email=El. pašto adresas (prisijungimas)*
profile.email=El. pašto adresas*
profile.street=Gatvė
profile.housenr=Namo nr.
profile.addressadd=Adreso priedas
@@ -428,9 +428,9 @@ messages.sender.unknown=Nežinomas siuntėjas
# Add Job
addjob.title=Sukurti naują užsakymą
addjob.customer.label=Klientas
addjob.customer.placeholder=Pasirinkite klientą
addjob.customer.unnamed=Klientas be pavadinimo
addjob.customer.label=Užsakovas
addjob.customer.placeholder=Pasirinkite užsakovą
addjob.customer.unnamed=Neįvardytas užsakovas
addjob.button.clearfields=Išvalyti laukus
addjob.button.submit=Sukurti užsakymą
addjob.address.salutation=Kreipinys
@@ -439,6 +439,10 @@ addjob.salutation.mr=Ponas
addjob.salutation.ms=Ponia
addjob.salutation.other=Kita
addjob.address.company.placeholder=Įveskite įmonę
addjob.address.pickup.label=Atsiėmimo adresas
addjob.address.pickup.placeholder=Pasirinkti arba įvesti atsiėmimo adresą
addjob.address.delivery.label=Pristatymo adresas
addjob.address.delivery.placeholder=Pasirinkti arba įvesti pristatymo adresą
addjob.address.street.placeholder=Įveskite gatvę
addjob.address.housenumber=Namo numeris
addjob.address.addition.placeholder=Adreso priedas
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Vietovė (atsiėmimas)
addjob.address.city.placeholder.delivery=Vietovė (pristatymas)
addjob.address.delivery.street.placeholder=Gatvė (pristatymas)
addjob.address.delivery.addition.placeholder=Adreso priedas (pristatymas)
addjob.address.save=Išsaugoti adresą
addjob.address.save=Pridėti adresą į adresų knygą
addjob.address.update=Atnaujinti adresą adresų knygoje
addjob.section.pickup=Atsiėmimas
addjob.section.delivery=Pristatymas
addjob.stations.apply=Pritaikyti stotis
@@ -458,6 +463,8 @@ addjob.station.max.reached=Pasiektas maksimalus 25 pristatymo stočių skaičius
addjob.station.unused=Nenaudojama
addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
addjob.tab.addresses=Užsakovas ir adresai
addjob.tab.pickup.address=Užsakovas ir atsiėmimo adresas
addjob.tab.delivery.address=Pristatymo adresas
addjob.tab.appointments=Terminai ir apdorojimas
addjob.tab.cargo=Krovinys
addjob.tab.tasks=Užduotys
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. nuotraukų
addjob.tasks.photo.max=Maks. nuotraukų
addjob.tasks.barcode.min=Min. brūkšninių kodų
addjob.tasks.barcode.max=Maks. brūkšninių kodų
addjob.tasks.signature.noconfig=Konfigūracija nereikalinga
addjob.tasks.signature.notelabel=Pastaba (neprivaloma)
addjob.tasks.signature.notelabel.placeholder=Įveskite patarimo tekstą pastabai
addjob.tasks.todolist.title=Užduočių sąrašas
addjob.tasks.todolist.item.placeholder=Įveskite užduotį
addjob.tasks.todolist.add=Pridėti užduotį
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Padarytos nuotraukos ({0})
jobsummary.task.button.text=Mygtuko tekstas
jobsummary.button.schliessen=Uždaryti
jobsummary.route.planned=Planuotas maršrutas
jobsummary.button.manualcomplete=Užbaigti rankiniu būdu
jobsummary.dialog.manualcomplete.title=Užbaigti užsakymą rankiniu būdu
jobsummary.dialog.manualcomplete.text=Užsakymas {0} dabar bus užbaigtas rankiniu būdu. Po to jo nebebus galima apdoroti per programėlę.
jobsummary.dialog.manualcomplete.reason=Priežastis
jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį
jobsummary.dialog.manualcomplete.cancel=Atšaukti
jobsummary.dialog.manualcomplete.confirm=Priimti
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
jobmanualcomplete.route.hours=Valandos
jobmanualcomplete.route.minutes=Minutės
jobmanualcomplete.route.manual.hint=Maršruto duomenų nėra prašome įvesti atstumą ir trukmę rankiniu būdu.
# Jobs
jobs.title=Užsakymai
@@ -652,6 +671,8 @@ createinvoice.section.job=Užsakymo informacija
createinvoice.section.route=Maršruto informacija
createinvoice.section.services=Paslaugos
createinvoice.section.summary=Santrauka
createinvoice.section.vat=PVM
createinvoice.field.vatrate=PVM tarifas
createinvoice.field.jobnumber=Užsakymo numeris
createinvoice.field.customer=Klientas
createinvoice.field.status=Būsena

View File

@@ -5,7 +5,7 @@ dialog.confirm=Apstiprināt
# Navigation and Main Layout
nav.jobs=Uzdevumi
nav.job.create=Izveidot uzdevumu
nav.customers=Klienti
nav.customers=Adrešu gr\u0101mata
nav.appusers=Lietotnes lietotāji
nav.statistics=Statistika
nav.invoices=Rēķini
@@ -31,7 +31,7 @@ profile.lastname=Uzvārds
profile.phone=Tālruņa numurs
profile.fax=Tālrunis (fakss)
profile.mobile=Tālrunis (mobilais)
profile.email=E-pasta adrese (pieteikšanās)*
profile.email=E-pasta adrese
profile.street=Iela
profile.housenr=Mājas nr.
profile.addressadd=Adreses papildinājums
@@ -428,9 +428,9 @@ messages.sender.unknown=Nezināms sūtītājs
# Add Job
addjob.title=Izveidot jaunu uzdevumu
addjob.customer.label=Klients
addjob.customer.placeholder=Izvēlēties klientu
addjob.customer.unnamed=Nenosaukts klients
addjob.customer.label=Pasūtītājs
addjob.customer.placeholder=Izvēlēties pasūtītāju
addjob.customer.unnamed=Nenosaukts pasūtītājs
addjob.button.clearfields=Notīrīt laukus
addjob.button.submit=Izveidot uzdevumu
addjob.address.salutation=Uzruna
@@ -439,6 +439,10 @@ addjob.salutation.mr=Kungs
addjob.salutation.ms=Kundze
addjob.salutation.other=Cits
addjob.address.company.placeholder=Ievadiet uzņēmumu
addjob.address.pickup.label=Saņemšanas adrese
addjob.address.pickup.placeholder=Izvēlēties vai ievadīt saņemšanas adresi
addjob.address.delivery.label=Piegādes adrese
addjob.address.delivery.placeholder=Izvēlēties vai ievadīt piegādes adresi
addjob.address.street.placeholder=Ievadiet ielu
addjob.address.housenumber=Mājas numurs
addjob.address.addition.placeholder=Adreses papildinājums
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Vieta (saņemšana)
addjob.address.city.placeholder.delivery=Vieta (piegāde)
addjob.address.delivery.street.placeholder=Iela (piegāde)
addjob.address.delivery.addition.placeholder=Adreses papildinājums (piegāde)
addjob.address.save=Saglabāt adresi
addjob.address.save=Pievienot adresi adrešu grāmatai
addjob.address.update=Atjaunin\u0101t adresi adrešu gr\u0101mat\u0101
addjob.section.pickup=Saņemšana
addjob.section.delivery=Piegāde
addjob.stations.apply=Pārņemt stacijas
@@ -458,6 +463,8 @@ addjob.station.max.reached=Sasniegts maksimālais piegādes staciju skaits - 25
addjob.station.unused=Netiek izmantots
addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās.
addjob.tab.addresses=Pasūtītājs un adreses
addjob.tab.pickup.address=Pasūtītājs un saņemšanas adrese
addjob.tab.delivery.address=Piegādes adrese
addjob.tab.appointments=Termiņi un apstrāde
addjob.tab.cargo=Krava
addjob.tab.tasks=Uzdevuma darbības
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. fotogrāfijas
addjob.tasks.photo.max=Maks. fotogrāfijas
addjob.tasks.barcode.min=Min. svītrkodi
addjob.tasks.barcode.max=Maks. svītrkodi
addjob.tasks.signature.noconfig=Konfigurācija nav nepieciešama
addjob.tasks.signature.notelabel=Piezīme (neobligāta)
addjob.tasks.signature.notelabel.placeholder=Ievadiet padoma tekstu piezīmei
addjob.tasks.todolist.title=Uzdevumu saraksts
addjob.tasks.todolist.item.placeholder=Ievadiet uzdevumu
addjob.tasks.todolist.add=Pievienot uzdevumu
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Uzņemtās fotogrāfijas ({0})
jobsummary.task.button.text=Pogas teksts
jobsummary.button.schliessen=Aizvērt
jobsummary.route.planned=Plānotais maršruts
jobsummary.button.manualcomplete=Pabeigt manuāli
jobsummary.dialog.manualcomplete.title=Pabeigt pasūtījumu manuāli
jobsummary.dialog.manualcomplete.text=Pasūtījums {0} tagad tiks pabeigts manuāli. Pēc tam to vairs nevarēs apstrādāt, izmantojot lietotni.
jobsummary.dialog.manualcomplete.reason=Pamatojums
jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu
jobsummary.dialog.manualcomplete.cancel=Atcelt
jobsummary.dialog.manualcomplete.confirm=Apstiprināt
jobsummary.history.manualcomplete.reason=Pabeigts manuāli
jobmanualcomplete.route.hours=Stundas
jobmanualcomplete.route.minutes=Minūtes
jobmanualcomplete.route.manual.hint=Maršruta dati nav pieejami lūdzu, manuāli ievadiet attālumu un ilgumu.
# Jobs
jobs.title=Uzdevumi

View File

@@ -5,7 +5,7 @@ dialog.confirm=Potwierd\u017a
# Navigation and Main Layout
nav.jobs=Zlecenia
nav.job.create=Tworzenie zlecenia
nav.customers=Klienci
nav.customers=Ksi\u0105\u017cka adresowa
nav.appusers=U\u017cytkownicy aplikacji
nav.statistics=Statystyki
nav.invoices=Faktury
@@ -31,7 +31,7 @@ profile.lastname=Nazwisko
profile.phone=Numer telefonu
profile.fax=Telefon (faks)
profile.mobile=Telefon (kom\u00f3rkowy)
profile.email=Adres e-mail (login)*
profile.email=Adres e-mail
profile.street=Ulica
profile.housenr=Nr domu
profile.addressadd=Dodatek do adresu
@@ -428,9 +428,9 @@ messages.sender.unknown=Nieznany nadawca
# Add Job
addjob.title=Dodaj nowe zlecenie
addjob.customer.label=Klient
addjob.customer.placeholder=Wybierz klienta
addjob.customer.unnamed=Klient bez nazwy
addjob.customer.label=Zleceniodawca
addjob.customer.placeholder=Wybierz zleceniodawcę
addjob.customer.unnamed=Nienazwany zleceniodawca
addjob.button.clearfields=Wyczy\u015b\u0107 pola
addjob.button.submit=Utw\u00f3rz zlecenie
addjob.address.salutation=Zwrot grzeczno\u015bciowy
@@ -439,6 +439,10 @@ addjob.salutation.mr=Pan
addjob.salutation.ms=Pani
addjob.salutation.other=Inna
addjob.address.company.placeholder=Wprowad\u017a firm\u0119
addjob.address.pickup.label=Adres odbioru
addjob.address.pickup.placeholder=Wybierz lub wprowad\u017a adres odbioru
addjob.address.delivery.label=Adres dostawy
addjob.address.delivery.placeholder=Wybierz lub wprowad\u017a adres dostawy
addjob.address.street.placeholder=Wprowad\u017a ulic\u0119
addjob.address.housenumber=Numer domu
addjob.address.addition.placeholder=Dodatek do adresu
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Miejscowo\u015b\u0107 (odbi\u00f3r)
addjob.address.city.placeholder.delivery=Miejscowo\u015b\u0107 (dostawa)
addjob.address.delivery.street.placeholder=Ulica (dostawa)
addjob.address.delivery.addition.placeholder=Dodatek do adresu (dostawa)
addjob.address.save=Zapisz adres
addjob.address.save=Dodaj adres do ksi\u0105\u017cki adresowej
addjob.address.update=Zaktualizuj adres w ksi\u0105\u017cce adresowej
addjob.section.pickup=Odbi\u00f3r
addjob.section.delivery=Dostawa
addjob.stations.apply=Zastosuj stacje
@@ -458,6 +463,8 @@ addjob.station.max.reached=Osi\u0105gni\u0119to maksymaln\u0105 liczb\u0119 25 s
addjob.station.unused=Nieu\u017cywana
addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
addjob.tab.addresses=Zleceniodawca i adresy
addjob.tab.pickup.address=Zleceniodawca i adres odbioru
addjob.tab.delivery.address=Adres dostawy
addjob.tab.appointments=Terminy i przetwarzanie
addjob.tab.cargo=\u0141adunek
addjob.tab.tasks=Zadania
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. zdj\u0119\u0107
addjob.tasks.photo.max=Maks. zdj\u0119\u0107
addjob.tasks.barcode.min=Min. kod\u00f3w kreskowych
addjob.tasks.barcode.max=Maks. kod\u00f3w kreskowych
addjob.tasks.signature.noconfig=Konfiguracja nie jest wymagana
addjob.tasks.signature.notelabel=Notatka (opcjonalnie)
addjob.tasks.signature.notelabel.placeholder=Wprowadź tekst podpowiedzi dla notatki
addjob.tasks.todolist.title=Lista zada\u0144
addjob.tasks.todolist.item.placeholder=Wprowad\u017a zadanie
addjob.tasks.todolist.add=Dodaj zadanie
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Wykonane zdj\u0119cia ({0})
jobsummary.task.button.text=Tekst przycisku
jobsummary.button.schliessen=Zamknij
jobsummary.route.planned=Planowana trasa
jobsummary.button.manualcomplete=Zako\u0144cz r\u0119cznie
jobsummary.dialog.manualcomplete.title=Zako\u0144cz zlecenie r\u0119cznie
jobsummary.dialog.manualcomplete.text=Zlecenie {0} zostanie teraz zako\u0144czone r\u0119cznie. Po tym nie b\u0119dzie mo\u017cna go dalej obs\u0142ugiwa\u0107 przez aplikacj\u0119.
jobsummary.dialog.manualcomplete.reason=Uzasadnienie
jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadnienie
jobsummary.dialog.manualcomplete.cancel=Anuluj
jobsummary.dialog.manualcomplete.confirm=Akceptuj
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
jobmanualcomplete.route.hours=Godziny
jobmanualcomplete.route.minutes=Minuty
jobmanualcomplete.route.manual.hint=Brak danych trasy \u2013 prosz\u0119 r\u0119cznie poda\u0107 odleg\u0142o\u015b\u0107 i czas trwania.
# Jobs
jobs.title=Zlecenia

View File

@@ -5,7 +5,7 @@ dialog.confirm=Подтвердить
# Navigation and Main Layout
nav.jobs=Заказы
nav.job.create=Создание заказа
nav.customers=Клиенты
nav.customers=Адресная книга
nav.appusers=Пользователи приложения
nav.statistics=Статистика
nav.invoices=Счета
@@ -31,7 +31,7 @@ profile.lastname=Фамилия
profile.phone=Номер телефона
profile.fax=Телефон (факс)
profile.mobile=Телефон (мобильный)
profile.email=Адрес электронной почты (логин)*
profile.email=Адрес электронной почты
profile.street=Улица
profile.housenr=Дом
profile.addressadd=Дополнение к адресу
@@ -428,9 +428,9 @@ messages.sender.unknown=Неизвестный отправитель
# Add Job
addjob.title=Создать новый заказ
addjob.customer.label=Клиент
addjob.customer.placeholder=Выберите клиента
addjob.customer.unnamed=Безымянный клиент
addjob.customer.label=Заказчик
addjob.customer.placeholder=Выберите заказчика
addjob.customer.unnamed=Безымянный заказчик
addjob.button.clearfields=Очистить поля
addjob.button.submit=Создать заказ
addjob.address.salutation=Обращение
@@ -439,6 +439,10 @@ addjob.salutation.mr=Господин
addjob.salutation.ms=Госпожа
addjob.salutation.other=Другое
addjob.address.company.placeholder=Введите компанию
addjob.address.pickup.label=Адрес забора
addjob.address.pickup.placeholder=Выберите или введите адрес забора
addjob.address.delivery.label=Адрес доставки
addjob.address.delivery.placeholder=Выберите или введите адрес доставки
addjob.address.street.placeholder=Введите улицу
addjob.address.housenumber=Номер дома
addjob.address.addition.placeholder=Дополнение к адресу
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=Город (забор)
addjob.address.city.placeholder.delivery=Город (доставка)
addjob.address.delivery.street.placeholder=Улица (доставка)
addjob.address.delivery.addition.placeholder=Дополнение к адресу (доставка)
addjob.address.save=Сохранить адрес
addjob.address.save=Добавить адрес в адресную книгу
addjob.address.update=Обновить адрес в адресной книге
addjob.section.pickup=Забор
addjob.section.delivery=Доставка
addjob.stations.apply=Применить станции
@@ -458,6 +463,8 @@ addjob.station.max.reached=Достигнуто максимальное кол
addjob.station.unused=Не используется
addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки.
addjob.tab.addresses=Заказчик и адреса
addjob.tab.pickup.address=Заказчик и адрес забора
addjob.tab.delivery.address=Адрес доставки
addjob.tab.appointments=Сроки и обработка
addjob.tab.cargo=Груз
addjob.tab.tasks=Задачи
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Мин. фото
addjob.tasks.photo.max=Макс. фото
addjob.tasks.barcode.min=Мин. штрих-кодов
addjob.tasks.barcode.max=Макс. штрих-кодов
addjob.tasks.signature.noconfig=Настройка не требуется
addjob.tasks.signature.notelabel=Примечание (необязательно)
addjob.tasks.signature.notelabel.placeholder=Введите текст подсказки для примечания
addjob.tasks.todolist.title=Список дел
addjob.tasks.todolist.item.placeholder=Введите задачу
addjob.tasks.todolist.add=Добавить задачу
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=Сделанные фотографии ({0})
jobsummary.task.button.text=Текст кнопки
jobsummary.button.schliessen=Закрыть
jobsummary.route.planned=Запланированный маршрут
jobsummary.button.manualcomplete=Завершить вручную
jobsummary.dialog.manualcomplete.title=Завершить заказ вручную
jobsummary.dialog.manualcomplete.text=Заказ {0} будет завершён вручную. После этого его больше нельзя будет обрабатывать через приложение.
jobsummary.dialog.manualcomplete.reason=Обоснование
jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укажите обоснование
jobsummary.dialog.manualcomplete.cancel=Отмена
jobsummary.dialog.manualcomplete.confirm=Принять
jobsummary.history.manualcomplete.reason=Завершено вручную
jobmanualcomplete.route.hours=Часы
jobmanualcomplete.route.minutes=Минуты
jobmanualcomplete.route.manual.hint=Данные маршрута отсутствуют — пожалуйста, введите расстояние и продолжительность вручную.
# Jobs
jobs.title=Заказы

View File

@@ -5,7 +5,7 @@ dialog.confirm=Onayla
# Navigation and Main Layout
nav.jobs=\u0130\u015fler
nav.job.create=\u0130\u015f Olu\u015ftur
nav.customers=M\u00fc\u015fteriler
nav.customers=Adres Defteri
nav.appusers=Uygulama Kullan\u0131c\u0131lar\u0131
nav.statistics=\u0130statistikler
nav.invoices=Faturalar
@@ -31,7 +31,7 @@ profile.lastname=Soyad
profile.phone=Telefon Numaras\u0131
profile.fax=Telefon (Faks)
profile.mobile=Telefon (Mobil)
profile.email=E-Posta Adresi (Giri\u015f)*
profile.email=E-Posta Adresi*
profile.street=Sokak
profile.housenr=Kap\u0131 No
profile.addressadd=Adres Eki
@@ -428,9 +428,9 @@ messages.sender.unknown=Bilinmeyen G\u00f6nderici
# Add Job
addjob.title=Yeni \u0130\u015f Olu\u015ftur
addjob.customer.label=M\u00fc\u015fteri
addjob.customer.placeholder=M\u00fc\u015fteri Se\u00e7in
addjob.customer.unnamed=\u0130simsiz M\u00fc\u015fteri
addjob.customer.label=Sipari\u015f veren
addjob.customer.placeholder=Sipari\u015f vereni se\u00e7
addjob.customer.unnamed=\u0130simsiz sipari\u015f veren
addjob.button.clearfields=Alanlar\u0131 Temizle
addjob.button.submit=\u0130\u015f Olu\u015ftur
addjob.address.salutation=Hitap
@@ -439,6 +439,10 @@ addjob.salutation.mr=Bay
addjob.salutation.ms=Bayan
addjob.salutation.other=Di\u011fer
addjob.address.company.placeholder=\u015eirketi girin
addjob.address.pickup.label=Al\u0131m adresi
addjob.address.pickup.placeholder=Al\u0131m adresi se\u00e7in veya girin
addjob.address.delivery.label=Teslimat adresi
addjob.address.delivery.placeholder=Teslimat adresi se\u00e7in veya girin
addjob.address.street.placeholder=Soka\u011f\u0131 girin
addjob.address.housenumber=Kap\u0131 Numaras\u0131
addjob.address.addition.placeholder=Adres eki
@@ -447,7 +451,8 @@ addjob.address.city.placeholder.pickup=\u015eehir (Al\u0131m)
addjob.address.city.placeholder.delivery=\u015eehir (Teslimat)
addjob.address.delivery.street.placeholder=Sokak (Teslimat)
addjob.address.delivery.addition.placeholder=Adres eki (Teslimat)
addjob.address.save=Adresi Kaydet
addjob.address.save=Adresi adres defterine ekle
addjob.address.update=Adres defterindeki adresi g\u00fcncelle
addjob.section.pickup=Al\u0131m
addjob.section.delivery=Teslimat
addjob.stations.apply=\u0130stasyonlar\u0131 \u00fcbernehmennehmen
@@ -458,6 +463,8 @@ addjob.station.max.reached=Maksimum 25 teslimat istasyonu s\u0131n\u0131r\u0131n
addjob.station.unused=Kullan\u0131lm\u0131yor
addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
addjob.tab.addresses=M\u00fc\u015fteri & Adresler
addjob.tab.pickup.address=Sipari\u015f veren ve al\u0131m adresi
addjob.tab.delivery.address=Teslimat adresi
addjob.tab.appointments=Randevular & \u0130\u015fleme
addjob.tab.cargo=Kargo
addjob.tab.tasks=G\u00f6revler
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. Foto\u011fraf
addjob.tasks.photo.max=Maks. Foto\u011fraf
addjob.tasks.barcode.min=Min. Barkod
addjob.tasks.barcode.max=Maks. Barkod
addjob.tasks.signature.noconfig=Yap\u0131land\u0131rma gerekli de\u011fil
addjob.tasks.signature.notelabel=Not (iste\u011fe ba\u011fl\u0131)
addjob.tasks.signature.notelabel.placeholder=Not i\u00e7in ipucu metnini girin
addjob.tasks.todolist.title=Yap\u0131lacaklar Listesi
addjob.tasks.todolist.item.placeholder=Yap\u0131lacak \u00f6\u011feyi girin
addjob.tasks.todolist.add=Yap\u0131lacak \u00d6\u011fe Ekle
@@ -610,6 +618,17 @@ jobsummary.task.photo.taken=\u00c7ekilen Foto\u011fraflar ({0})
jobsummary.task.button.text=Buton Metni
jobsummary.button.schliessen=Kapat
jobsummary.route.planned=Planlanan Rota
jobsummary.button.manualcomplete=Manuel olarak tamamla
jobsummary.dialog.manualcomplete.title=Siparişi manuel olarak tamamla
jobsummary.dialog.manualcomplete.text=Sipariş {0} şimdi manuel olarak tamamlanacak. Bundan sonra uygulama üzerinden işlenemez.
jobsummary.dialog.manualcomplete.reason=Gerekçe
jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin
jobsummary.dialog.manualcomplete.cancel=İptal
jobsummary.dialog.manualcomplete.confirm=Kabul et
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
jobmanualcomplete.route.hours=Saat
jobmanualcomplete.route.minutes=Dakika
jobmanualcomplete.route.manual.hint=Rota verisi mevcut değil lütfen mesafeyi ve süreyi elle girin.
# Jobs
jobs.title=\u0130\u015fler

View File

@@ -0,0 +1,134 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.UserRepository;
import java.util.List;
import java.util.Optional;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private JobRepository jobRepository;
@Mock
private TaskRepository taskRepository;
@Mock
private AppUserRepository appUserRepository;
@Mock
private JavaMailSender mailSender;
@Captor
private ArgumentCaptor<SimpleMailMessage> mailCaptor;
private EmailService emailService;
private TaskAssignmentService taskAssignmentService;
@BeforeEach
void setUp() {
taskAssignmentService = new TaskAssignmentService(taskRepository, jobRepository);
emailService = new EmailService(userRepository, jobRepository, taskAssignmentService, appUserRepository,
mailSender);
ReflectionTestUtils.setField(emailService, "smtpUsername", "noreply@example.com");
}
@Test
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
}
@Test
void sendJobCompletionNotificationUsesAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(List.of(
createConfirmationTask(0, "OK"),
createConfirmationTask(1, "Weiter")));
emailService.sendJobCompletionNotification(job.getId(), "ignored");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
}
private User createWebUser() {
User user = new User();
user.setId(new ObjectId());
user.setTitle("Dr.");
user.setFirstname("Anna");
user.setName("Unternehmer");
user.setEmail("anna@example.com");
return user;
}
private AppUser createAssignedAppUser() {
AppUser appUser = new AppUser();
appUser.setId(new ObjectId());
appUser.setVorname("Max");
appUser.setNachname("Mustermann");
appUser.setBezeichnung("Fahrer Max");
return appUser;
}
private Job createJob(User webUser, AppUser assignedAppUser) {
Job job = new Job();
job.setId(new ObjectId());
job.setCreatedBy(webUser.getId().toHexString());
job.setAppUser(assignedAppUser.getId().toHexString());
job.setJobNumber("JOB-2026-001");
job.setDeliveryCompany("Beispiel GmbH");
job.setPickupCity("Berlin");
job.setDeliveryCity("Hamburg");
return job;
}
private ConfirmationTask createConfirmationTask(int taskOrder, String buttonText) {
ConfirmationTask task = new ConfirmationTask(buttonText);
task.setTaskOrder(taskOrder);
return task;
}
}

View File

@@ -0,0 +1,304 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine
* Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade
* geprüft wird, und die Tests fungieren gleichzeitig als ausführbare
* Spezifikation.
*/
class InvoiceComplianceValidatorTest {
private InvoiceComplianceValidator validator;
@BeforeEach
void setUp() {
validator = new InvoiceComplianceValidator();
}
@Test
void acceptsCompleteInvoice() {
CustomerInvoice invoice = validInvoice();
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingInvoiceNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(" ");
assertSingleViolation(invoice, "Rechnungsnummer fehlt");
}
@Test
void rejectsMissingInvoiceDate() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceDate(null);
assertSingleViolation(invoice, "Rechnungsdatum");
}
@Test
void rejectsMissingDeliveryDate() {
CustomerInvoice invoice = validInvoice();
invoice.setDeliveryDate(null);
assertSingleViolation(invoice, "Leistungsdatum");
}
@Test
void rejectsMissingSenderName() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderName(null);
assertSingleViolation(invoice, "Name des Leistenden");
}
@Test
void rejectsIncompleteSenderAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderPostcode("");
assertSingleViolation(invoice, "Anschrift des Leistenden");
}
@Test
void rejectsMissingSenderTaxIdentification() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
invoice.setSenderVatId(null);
assertSingleViolation(invoice, "Steuernummer oder USt-IdNr");
}
@Test
void acceptsSenderWithOnlyTaxNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderVatId(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsSenderWithOnlyVatId() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingRecipientName() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientName(null);
assertSingleViolation(invoice, "Name des Leistungsempfängers");
}
@Test
void rejectsIncompleteRecipientAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientCity(null);
assertSingleViolation(invoice, "Anschrift des Leistungsempfängers");
}
@Test
void rejectsEmptyItems() {
CustomerInvoice invoice = validInvoice();
invoice.setItems(new ArrayList<>());
assertSingleViolation(invoice, "Keine Positionen erfasst");
}
@Test
void rejectsItemWithoutDescription() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setDescription("");
assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt");
}
@Test
void rejectsItemWithZeroQuantity() {
CustomerInvoice invoice = validInvoice();
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setQuantity(BigDecimal.ZERO);
// Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich
// die Konsistenzprüfung der Summen.
item.setNetTotal(BigDecimal.ZERO);
invoice.setNetAmount(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(BigDecimal.ZERO);
// VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus.
invoice.setVatRate(new BigDecimal("0.19"));
assertSingleViolation(invoice, "Menge muss größer 0");
}
@Test
void rejectsItemWithNegativeUnitPrice() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
}
@Test
void rejectsInconsistentTotals() {
CustomerInvoice invoice = validInvoice();
invoice.setTotalAmount(new BigDecimal("999.99"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
}
@Test
void rejectsItemSumMismatchingNet() {
CustomerInvoice invoice = validInvoice();
// Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt.
invoice.setNetAmount(new BigDecimal("80.00"));
invoice.setVatAmount(new BigDecimal("15.20"));
invoice.setTotalAmount(new BigDecimal("95.20"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
}
@Test
void rejectsMissingVatRate() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(null);
assertSingleViolation(invoice, "Steuersatz fehlt");
}
@Test
void rejectsZeroVatWithoutLegalNotice() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes(null);
assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich");
}
@Test
void acceptsZeroVatWithReverseChargeNote() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).");
invoice.setLegalNotes(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsZeroVatWithLegalNotes() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen.");
validator.validateForIssuance(invoice);
}
@Test
void rejectsMismatchingVatAmountForNonZeroRate() {
CustomerInvoice invoice = validInvoice();
// Erwartet wären 19,00 € — wir tragen 25,00 € ein.
invoice.setVatAmount(new BigDecimal("25.00"));
invoice.setTotalAmount(new BigDecimal("125.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
}
@Test
void collectsAllViolationsInOnePass() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(null);
invoice.setSenderName(null);
invoice.setItems(new ArrayList<>());
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations())
.as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
.hasSizeGreaterThanOrEqualTo(3);
}
@Test
void cancellationIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CANCELLATION);
invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void correctionIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CORRECTION);
invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void nullInvoiceIsRejectedDirectly() {
assertThatThrownBy(() -> validator.validateForIssuance(null))
.isInstanceOf(IllegalArgumentException.class);
}
private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
assertThat(ex.getViolations())
.as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())
.anyMatch(v -> v.contains(fragment));
}
private CustomerInvoice validInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setType(InvoiceType.INVOICE);
invoice.setInvoiceNumber("R-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 5, 3));
invoice.setDeliveryDate(LocalDate.of(2026, 5, 3));
invoice.setSenderName("Votianlt Test GmbH");
invoice.setSenderAddress("Teststraße 1");
invoice.setSenderPostcode("12345");
invoice.setSenderCity("Berlin");
invoice.setSenderCountry("DE");
invoice.setSenderTaxNumber("12/345/67890");
invoice.setSenderVatId("DE123456789");
invoice.setRecipientName("Empfänger AG");
invoice.setRecipientAddress("Kundenweg 2");
invoice.setRecipientPostcode("54321");
invoice.setRecipientCity("Hamburg");
invoice.setRecipientCountry("DE");
CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung",
new BigDecimal("100.00"), new BigDecimal("0.19"));
invoice.setItems(new ArrayList<>(List.of(item)));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setVatAmount(new BigDecimal("19.00"));
invoice.setTotalAmount(new BigDecimal("119.00"));
return invoice;
}
private CustomerInvoice zeroVatInvoice() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(invoice.getNetAmount());
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setVatRate(BigDecimal.ZERO);
item.setVatAmount(BigDecimal.ZERO);
item.setGrossTotal(item.getNetTotal());
return invoice;
}
}

View File

@@ -0,0 +1,171 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
@ExtendWith(MockitoExtension.class)
class InvoiceNumberAuditServiceTest {
@Mock
private InvoiceNumberReservationRepository repository;
private InvoiceNumberAuditService service;
private final ObjectId userId = new ObjectId();
@BeforeEach
void setUp() {
service = new InvoiceNumberAuditService(repository);
}
@Test
void markUsedTransitionsExistingReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42");
service.markUsed(invoice);
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42");
assertThat(saved.getUsedAt()).isNotNull();
}
@Test
void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() {
when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty());
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markUsed(invoice("RE-2024-0007", "legacy-invoice"));
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getNumber()).isEqualTo("RE-2024-0007");
assertThat(saved.getSequence()).isEqualTo(7L);
assertThat(saved.getReservedBy()).contains("bootstrap");
}
@Test
void markUsedSwallowsRepositoryFailures() {
when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down"));
// Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern.
service.markUsed(invoice("R-2026-1", "i-1"));
}
@Test
void markUsedIgnoresInvoiceWithoutNumberOrUserId() {
CustomerInvoice missingNumber = new CustomerInvoice();
missingNumber.setUserId(userId.toHexString());
service.markUsed(missingNumber);
CustomerInvoice missingUser = new CustomerInvoice();
missingUser.setInvoiceNumber("R-1");
service.markUsed(missingUser);
verify(repository, never()).findByUserIdAndNumber(any(), any());
verify(repository, never()).save(any());
}
@Test
void markVoidedRequiresReason() {
assertThatThrownBy(() -> service.markVoided(userId, "R-1", " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Grund");
assertThatThrownBy(() -> service.markVoided(userId, "R-1", null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void markVoidedTransitionsReservedToVoided() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen");
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED);
assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen");
assertThat(saved.getVoidedAt()).isNotNull();
}
@Test
void markVoidedRefusesToOverwriteUsedReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.USED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("ausgestellten Rechnung");
}
@Test
void markVoidedFailsWhenNoReservationFound() {
when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("Keine Reservierung");
}
@Test
void findUnusedReturnsOnlyReservedAndVoided() {
when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of(
reservation("R-1", 1L, InvoiceNumberReservationStatus.USED),
reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED),
reservation("R-3", 3L, InvoiceNumberReservationStatus.USED),
reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED)));
List<InvoiceNumberReservation> unused = service.findUnused(userId);
assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4");
}
private CustomerInvoice invoice(String number, String invoiceId) {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setId(invoiceId);
invoice.setInvoiceNumber(number);
invoice.setUserId(userId.toHexString());
return invoice;
}
private InvoiceNumberReservation reservation(String number, long sequence,
InvoiceNumberReservationStatus status) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setStatus(status);
return reservation;
}
}