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

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart'; import 'l10n/localization_helpers.dart';
import 'models/chat.dart'; import 'models/chat.dart';
@@ -52,10 +53,7 @@ class _ChatsViewState extends State<ChatsView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(AppLocalizations.of(context).chats)),
title: Text(AppLocalizations.of(context).chats),
backgroundColor: Colors.deepPurple[100],
),
body: Column( body: Column(
children: [const OfflineBanner(), Expanded(child: _buildBody())], children: [const OfflineBanner(), Expanded(child: _buildBody())],
), ),
@@ -72,11 +70,15 @@ class _ChatsViewState extends State<ChatsView> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
Text( Text(
AppLocalizations.of(context).noChatsAvailable, 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), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: isJobChat ? Colors.blue[100] : Colors.green[100], backgroundColor:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
child: Icon( child: Icon(
isJobChat ? Icons.work : Icons.support_agent, isJobChat ? Icons.work : Icons.support_agent,
color: isJobChat ? Colors.blue[700] : Colors.green[700], color: isJobChat ? AppColors.primaryStrong : AppColors.secondary,
), ),
), ),
title: Text(() { title: Text(() {
@@ -129,7 +132,7 @@ class _ChatsViewState extends State<ChatsView> {
previewText, previewText,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: Colors.grey[700]), style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
), ),
trailing: Column( trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -137,16 +140,17 @@ class _ChatsViewState extends State<ChatsView> {
children: [ children: [
Text( Text(
timeLabel, timeLabel,
style: TextStyle(fontSize: 12, color: Colors.grey[500]), style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isJobChat ? Colors.blue[50] : Colors.green[50], color:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: isJobChat ? Colors.blue[200]! : Colors.green[200]!, color: isJobChat ? AppColors.primary : AppColors.secondary,
), ),
), ),
child: Text( child: Text(
@@ -154,7 +158,8 @@ class _ChatsViewState extends State<ChatsView> {
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600, 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 'package:flutter/material.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart'; import 'l10n/localization_helpers.dart';
import 'services/websocket_service.dart'; import 'services/websocket_service.dart';
@@ -98,7 +99,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (isConnected && !_wasConnected) { if (isConnected && !_wasConnected) {
_showSnack( _showSnack(
AppLocalizations.of(context).connectionRestored, AppLocalizations.of(context).connectionRestored,
backgroundColor: Colors.green, backgroundColor: AppColors.success,
); );
if (_appState.isLoggedIn) { if (_appState.isLoggedIn) {
_loadJobs(); _loadJobs();
@@ -115,7 +116,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (_appState.isLoggedIn && !_isLoggingOut) { if (_appState.isLoggedIn && !_isLoggingOut) {
_showSnack( _showSnack(
AppLocalizations.of(context).connectionLost, AppLocalizations.of(context).connectionLost,
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
); );
} }
} }
@@ -146,7 +147,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
jobNumber != null jobNumber != null
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}' ? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
: 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 jobNumber.isNotEmpty
? '${AppLocalizations.of(context).newJobReceived}: $jobNumber' ? '${AppLocalizations.of(context).newJobReceived}: $jobNumber'
: AppLocalizations.of(context).newJobReceived; : AppLocalizations.of(context).newJobReceived;
_showSnack(message, backgroundColor: Colors.green); _showSnack(message, backgroundColor: AppColors.success);
} }
} catch (e) { } catch (e) {
developer.log('Error handling job_created event: $e', name: 'JobsView'); developer.log('Error handling job_created event: $e', name: 'JobsView');
@@ -204,7 +205,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
}); });
_showSnack( _showSnack(
AppLocalizations.of(context).jobsUpdated, AppLocalizations.of(context).jobsUpdated,
backgroundColor: Colors.green, backgroundColor: AppColors.success,
); );
} }
} finally { } finally {
@@ -560,7 +561,6 @@ class _JobsViewState extends State<JobsView> with RouteAware {
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: Text(AppLocalizations.of(context).availableJobs), title: Text(AppLocalizations.of(context).availableJobs),
backgroundColor: Colors.deepPurple[100],
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
onPressed: () { onPressed: () {
@@ -694,7 +694,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
child: Text(AppLocalizations.of(context).logout), child: Text(AppLocalizations.of(context).logout),
@@ -766,8 +766,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).refresh), label: Text(AppLocalizations.of(context).refresh),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple[100], backgroundColor: AppColors.primarySoft,
foregroundColor: Colors.deepPurple[700], foregroundColor: AppColors.primaryStrong,
), ),
), ),
], ],
@@ -792,7 +792,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
} else { } else {
_showSnack( _showSnack(
AppLocalizations.of(context).offline, AppLocalizations.of(context).offline,
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
); );
} }
} }
@@ -908,7 +908,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) { if (mounted) {
_showSnack( _showSnack(
AppLocalizations.of(context).jobDeleted, AppLocalizations.of(context).jobDeleted,
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
); );
} }
} catch (e, st) { } catch (e, st) {
@@ -917,7 +917,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) { if (mounted) {
_showSnack( _showSnack(
AppLocalizations.of(context).jobDeleteError, AppLocalizations.of(context).jobDeleteError,
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
); );
} }
} finally { } finally {
@@ -935,19 +935,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Color statusColor; Color statusColor;
switch (job.statusColor) { switch (job.statusColor) {
case 'green': case 'green':
statusColor = Colors.green; statusColor = AppColors.success;
break; break;
case 'blue': case 'blue':
statusColor = Colors.blue; statusColor = AppColors.primary;
break; break;
case 'orange': case 'orange':
statusColor = Colors.orange; statusColor = AppColors.warning;
break; break;
case 'red': case 'red':
statusColor = Colors.red; statusColor = AppColors.danger;
break; break;
default: default:
statusColor = Colors.grey; statusColor = AppColors.textMuted;
} }
// Determine card background color based on task completion // Determine card background color based on task completion
@@ -965,9 +965,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (totalTasks == 0 || completedTasks == 0) { if (totalTasks == 0 || completedTasks == 0) {
cardBg = null; // unchanged (default) cardBg = null; // unchanged (default)
} else if (completedTasks > 0 && completedTasks < totalTasks) { } else if (completedTasks > 0 && completedTasks < totalTasks) {
cardBg = Colors.yellow[50]; cardBg = AppColors.warningSoft;
} else if (completedTasks == totalTasks) { } else if (completedTasks == totalTasks) {
cardBg = Colors.green[50]; cardBg = AppColors.successSoft;
} }
// Build robust display strings with fallbacks // Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]); final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
@@ -1033,7 +1033,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
iconSize: 28, iconSize: 28,
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
splashRadius: 24, splashRadius: 24,
icon: const Icon(Icons.delete, color: Colors.red), icon: const Icon(Icons.delete, color: AppColors.danger),
tooltip: AppLocalizations.of(context).deleteJob, tooltip: AppLocalizations.of(context).deleteJob,
onPressed: () { onPressed: () {
if (isDeleting) { if (isDeleting) {
@@ -1233,13 +1233,13 @@ class _JobsViewState extends State<JobsView> with RouteAware {
? 0 ? 0
: completedTasks / totalTasks, : completedTasks / totalTasks,
minHeight: 8, minHeight: 8,
backgroundColor: Colors.grey[200], backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
completedTasks >= totalTasks completedTasks >= totalTasks
? Colors.green ? AppColors.success
: (completedTasks > 0 : (completedTasks > 0
? Colors.amber ? AppColors.warning
: Colors.deepPurpleAccent), : AppColors.primary),
), ),
), ),
), ),
@@ -1336,7 +1336,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Icon( Icon(
Icons.arrow_downward, Icons.arrow_downward,
size: 16, size: 16,
color: Colors.blue[600], color: AppColors.primary,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -1375,7 +1375,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
tooltip: 'Route planen', tooltip: 'Route planen',
icon: const Icon( icon: const Icon(
Icons.route, Icons.route,
color: Colors.blueAccent, color: AppColors.primary,
), ),
onPressed: () { onPressed: () {
if (_routeActionInProgress) return; if (_routeActionInProgress) return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import 'services/websocket_service.dart';
import 'services/dart_mq.dart'; import 'services/dart_mq.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
class LoginView extends StatefulWidget { class LoginView extends StatefulWidget {
@@ -57,7 +58,7 @@ class _LoginViewState extends State<LoginView> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context).loginSuccess), content: Text(AppLocalizations.of(context).loginSuccess),
backgroundColor: Colors.green, backgroundColor: AppColors.success,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -228,7 +229,7 @@ class _LoginViewState extends State<LoginView> {
content: Text( content: Text(
'${AppLocalizations.of(context).loginFailed}: $errorMessage', '${AppLocalizations.of(context).loginFailed}: $errorMessage',
), ),
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -292,7 +293,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text(localizations.connecting), content: Text(localizations.connecting),
backgroundColor: Colors.blue, backgroundColor: AppColors.primary,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -345,7 +346,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text(localizations.connectionTimeout), content: Text(localizations.connectionTimeout),
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -364,7 +365,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text('${localizations.connectionError}: $e'), content: Text('${localizations.connectionError}: $e'),
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -420,7 +421,7 @@ class _LoginViewState extends State<LoginView> {
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text('${localizations.loginError}: $e'), content: Text('${localizations.loginError}: $e'),
backgroundColor: Colors.red, backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -440,8 +441,9 @@ class _LoginViewState extends State<LoginView> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[50], body: DecoratedBox(
body: Column( decoration: const BoxDecoration(gradient: AppGradients.shellBackground),
child: Column(
children: [ children: [
Expanded( Expanded(
child: SafeArea( child: SafeArea(
@@ -454,11 +456,10 @@ class _LoginViewState extends State<LoginView> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Logo oder App-Name
Icon( Icon(
Icons.account_circle, Icons.account_circle,
size: 100, size: 100,
color: Colors.deepPurple, color: AppColors.primary,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -468,7 +469,7 @@ class _LoginViewState extends State<LoginView> {
context, context,
).textTheme.headlineMedium?.copyWith( ).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey[800], color: AppColors.textStrong,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -477,7 +478,7 @@ class _LoginViewState extends State<LoginView> {
Text( Text(
l10n.loginSubtitle, l10n.loginSubtitle,
style: Theme.of(context).textTheme.bodyLarge style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: Colors.grey[600]), ?.copyWith(color: AppColors.textMuted),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -493,7 +494,7 @@ class _LoginViewState extends State<LoginView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
filled: true, filled: true,
fillColor: Colors.white, fillColor: AppColors.surface,
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -533,7 +534,7 @@ class _LoginViewState extends State<LoginView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
filled: true, filled: true,
fillColor: Colors.white, fillColor: AppColors.surface,
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -563,7 +564,7 @@ class _LoginViewState extends State<LoginView> {
child: Text( child: Text(
l10n.forgotPassword, l10n.forgotPassword,
style: const TextStyle( style: const TextStyle(
color: Colors.deepPurple, color: AppColors.primaryStrong,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -576,18 +577,19 @@ class _LoginViewState extends State<LoginView> {
ElevatedButton( ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin, onPressed: _isLoggingIn ? null : _handleLogin,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
elevation: 2, elevation: 0,
), ),
child: child:
_isLoggingIn _isLoggingIn
? Row( ? Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
MainAxisAlignment.center,
children: [ children: [
const SizedBox( const SizedBox(
width: 18, width: 18,
@@ -626,18 +628,21 @@ class _LoginViewState extends State<LoginView> {
), ),
), ),
), ),
// Version number at the bottom
if (_appVersion.isNotEmpty) if (_appVersion.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: Text( child: Text(
'Version $_appVersion', 'Version $_appVersion',
style: TextStyle(fontSize: 12, color: Colors.grey[500]), style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
], ],
), ),
),
); );
} }
} }

View File

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

View File

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

View File

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

View File

@@ -240,7 +240,7 @@ final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity( obx_int.ModelEntity(
id: const obx_int.IdUid(5, 2194624907249454848), id: const obx_int.IdUid(5, 2194624907249454848),
name: 'TaskStatusEntity', name: 'TaskStatusEntity',
lastPropertyId: const obx_int.IdUid(6, 5035828038544573244), lastPropertyId: const obx_int.IdUid(7, 5673785903451668117),
flags: 0, flags: 0,
properties: <obx_int.ModelProperty>[ properties: <obx_int.ModelProperty>[
obx_int.ModelProperty( obx_int.ModelProperty(
@@ -371,7 +371,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
lastSequenceId: const obx_int.IdUid(0, 0), lastSequenceId: const obx_int.IdUid(0, 0),
retiredEntityUids: const [], retiredEntityUids: const [],
retiredIndexUids: const [], retiredIndexUids: const [],
retiredPropertyUids: const [], retiredPropertyUids: const [5673785903451668117],
retiredRelationUids: const [], retiredRelationUids: const [],
modelVersion: 5, modelVersion: 5,
modelVersionParserMinimum: 5, modelVersionParserMinimum: 5,
@@ -632,7 +632,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
}, },
objectToFB: (TaskStatusEntity object, fb.Builder fbb) { objectToFB: (TaskStatusEntity object, fb.Builder fbb) {
final taskIdOffset = fbb.writeString(object.taskId); final taskIdOffset = fbb.writeString(object.taskId);
fbb.startTable(7); fbb.startTable(8);
fbb.addInt64(0, object.id); fbb.addInt64(0, object.id);
fbb.addOffset(1, taskIdOffset); fbb.addOffset(1, taskIdOffset);
fbb.addBool(2, object.completed); 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 /// Load signature SVG for a task from user_data table
Future<String?> loadTaskSignature(String taskId) async { Future<String?> loadTaskSignature(String taskId) async {
try { 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 /// Dispose resources
void dispose() { void dispose() {
_stopReconnectTimer(); _stopReconnectTimer();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'app_theme.dart';
/// Supported languages with their display names and flag emojis /// Supported languages with their display names and flag emojis
class LanguageOption { class LanguageOption {
@@ -98,11 +99,9 @@ class _SettingsViewState extends State<SettingsView> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('${l10n.languageChanged}: $flagEmoji $languageName'),
'${l10n.languageChanged}: $flagEmoji $languageName',
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
backgroundColor: Colors.green, backgroundColor: AppColors.success,
), ),
); );
} }
@@ -129,10 +128,7 @@ class _SettingsViewState extends State<SettingsView> {
final languageOptions = _getLanguageOptions(); final languageOptions = _getLanguageOptions();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(l10n.settings)),
title: Text(l10n.settings),
backgroundColor: Colors.deepPurple[100],
),
body: ListView( body: ListView(
children: [ children: [
// Language Selection Section // Language Selection Section
@@ -143,7 +139,7 @@ class _SettingsViewState extends State<SettingsView> {
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.grey, color: AppColors.textMuted,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
@@ -160,7 +156,7 @@ class _SettingsViewState extends State<SettingsView> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Center( child: Center(
@@ -173,22 +169,27 @@ class _SettingsViewState extends State<SettingsView> {
title: Text( title: Text(
language.name, language.name,
style: TextStyle( style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, fontWeight:
color: isSelected ? Colors.deepPurple : Colors.black87, isSelected ? FontWeight.w600 : FontWeight.normal,
color:
isSelected
? AppColors.primaryStrong
: AppColors.textStrong,
), ),
), ),
trailing: isSelected trailing:
isSelected
? const Icon( ? const Icon(
Icons.check_circle, Icons.check_circle,
color: Colors.deepPurple, color: AppColors.primary,
) )
: const Icon( : const Icon(
Icons.circle_outlined, Icons.circle_outlined,
color: Colors.grey, color: AppColors.textMuted,
), ),
onTap: () => _onLanguageSelected(language.code), onTap: () => _onLanguageSelected(language.code),
selected: isSelected, selected: isSelected,
selectedTileColor: Colors.deepPurple.withValues(alpha: 0.05), selectedTileColor: AppColors.primarySoft,
), ),
const Divider(height: 1, indent: 72), const Divider(height: 1, indent: 72),
], ],
@@ -203,17 +204,14 @@ class _SettingsViewState extends State<SettingsView> {
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.grey, color: AppColors.textMuted,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: Icon( leading: Icon(Icons.info_outline, color: AppColors.textMuted),
Icons.info_outline,
color: Colors.grey[600],
),
title: Text(l10n.version), title: Text(l10n.version),
subtitle: const Text('0.9.2'), 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/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart'; import 'l10n/localization_helpers.dart';
import 'models/job.dart'; import 'models/job.dart';
@@ -40,7 +41,6 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> { class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {}; final Set<String> _completedTasks = {};
final Set<String> _skippedTasks = {};
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
// Store SVG representations of signatures per task for later use // Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {}; final Map<String, String> _signatureSvgByTask = {};
@@ -61,7 +61,7 @@ class _TaskViewState extends State<TaskView> {
.toList(); .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 { Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses(); final statuses = await _databaseService.loadAllTaskStatuses();
setState(() { setState(() {
@@ -90,7 +90,6 @@ class _TaskViewState extends State<TaskView> {
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}' ? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}', : '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
), ),
backgroundColor: Colors.deepPurple[100],
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
@@ -117,14 +116,14 @@ class _TaskViewState extends State<TaskView> {
constraints: const BoxConstraints(maxHeight: 150), constraints: const BoxConstraints(maxHeight: 150),
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8F9FA), color: AppColors.surfaceMuted,
border: Border.all(color: Colors.grey[300]!, width: 1), border: Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Text( child: Text(
_getRemark(), _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), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, 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() { Widget _buildTasksStepper() {
if (_visibleTasks.isEmpty) { if (_visibleTasks.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
Text( Text(
AppLocalizations.of(context).noTasks, AppLocalizations.of(context).noTasks,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).noTasksMessage, AppLocalizations.of(context).noTasksMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -170,36 +264,30 @@ class _TaskViewState extends State<TaskView> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final task = _visibleTasks[index]; final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id); final isCompleted = _completedTasks.contains(task.id);
final isSkipped = _skippedTasks.contains(task.id);
final canBeCompletedNow = final canBeCompletedNow =
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index); !isCompleted && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status: // 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 = final Color cardColor =
isCompleted isCompleted
? const Color(0xFFE8F5E9) // hellgrün ? AppColors.successSoft
: isSkipped
? const Color(0xFFFFF8E1) // hellgelb
: canBeCompletedNow : canBeCompletedNow
? Colors.white ? AppColors.surface
: const Color(0xFFF5F5F5); // hellgrau : AppColors.surfaceMuted;
final Color borderColor = final Color borderColor =
isCompleted isCompleted
? Colors.green[300]! ? AppColors.success.withValues(alpha: 0.35)
: isSkipped
? Colors.amber[300]!
: canBeCompletedNow : canBeCompletedNow
? Colors.grey[300]! ? AppColors.border
: Colors.grey[200]!; : AppColors.border.withValues(alpha: 0.7);
final Color circleColor = final Color circleColor =
isCompleted isCompleted
? Colors.green[600]! ? AppColors.success
: isSkipped
? Colors.amber[600]!
: canBeCompletedNow : canBeCompletedNow
? Colors.deepPurple[400]! ? AppColors.primary
: Colors.grey[400]!; : AppColors.textMuted;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@@ -220,7 +308,9 @@ class _TaskViewState extends State<TaskView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: cardColor, color: cardColor,
), ),
child: Row( child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Task number circle // Task number circle
@@ -245,14 +335,14 @@ class _TaskViewState extends State<TaskView> {
const SizedBox(width: 16), const SizedBox(width: 16),
// Task content // Task content
Expanded( Expanded(
child: Padding(
padding: EdgeInsets.only(
right: task.optional ? 72 : 0,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTaskDisplayText( _buildTaskDisplayText(task, isCompleted, index),
task,
isCompleted || isSkipped,
index,
),
if (_getTaskStationLabel(task) != null) ...[ if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@@ -267,14 +357,44 @@ class _TaskViewState extends State<TaskView> {
], ],
), ),
), ),
),
if (isCompleted) ...[ if (isCompleted) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Icon(Icons.check_circle, color: Colors.green[600]), const Icon(
Icons.check_circle,
color: AppColors.success,
),
], ],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
], ],
),
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,
),
),
),
),
),
], ],
), ),
), ),
@@ -502,10 +622,11 @@ class _TaskViewState extends State<TaskView> {
builder: builder:
(context) => SignatureCaptureScreen( (context) => SignatureCaptureScreen(
task: task, task: task,
onSignatureCompleted: (String svg) async { onSignatureCompleted: (String svg, String note) async {
try { try {
// Persist SVG only (no PNG) // Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg); await _databaseService.saveTaskSignature(task.id, svg);
await _databaseService.saveTaskSignatureNote(task.id, note);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log( developer.log(
'Error saving task signature: $e', 'Error saving task signature: $e',
@@ -529,6 +650,7 @@ class _TaskViewState extends State<TaskView> {
'signatureSvg': svg, 'signatureSvg': svg,
'svgLength': svg.length, 'svgLength': svg.length,
'hasSignature': true, 'hasSignature': true,
'signatureNote': note,
}, },
); );
}, },
@@ -612,6 +734,7 @@ class _TaskViewState extends State<TaskView> {
String? taskType, String? taskType,
Map<String, dynamic>? extraData, Map<String, dynamic>? extraData,
}) { }) {
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() { setState(() {
_completedTasks.add(taskId); _completedTasks.add(taskId);
}); });
@@ -628,15 +751,60 @@ class _TaskViewState extends State<TaskView> {
} catch (e) { } catch (e) {
developer.log('Error sending task completion: $e', name: 'TaskView'); 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) { bool _arePreviousTasksCompleted(int index) {
if (index <= 0) return true; if (index <= 0) return true;
for (int i = 0; i < index; i++) { for (int i = 0; i < index; i++) {
final t = _visibleTasks[i]; final t = _visibleTasks[i];
if (!t.optional && if (!t.optional && !_completedTasks.contains(t.id)) {
!_completedTasks.contains(t.id) &&
!_skippedTasks.contains(t.id)) {
return false; return false;
} }
} }
@@ -730,6 +898,10 @@ class _TaskViewState extends State<TaskView> {
task.description != null task.description != null
? localizeKnownText(context, task.description!) ? localizeKnownText(context, task.description!)
: null; : null;
final String? signatureNote =
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
? task.note!.trim()
: null;
if (displayName?.isNotEmpty == true) { if (displayName?.isNotEmpty == true) {
return Column( return Column(
@@ -740,14 +912,39 @@ class _TaskViewState extends State<TaskView> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text(description!, style: subtitleStyle), Text(description!, style: subtitleStyle),
], ],
if (signatureNote != null) ...[
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
], ],
); );
} }
if (description?.isNotEmpty == true) { 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); 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 // Fall back to standard text based on task type
return Text(_getStandardTaskDisplayText(task), style: titleStyle); return Text(_getStandardTaskDisplayText(task), style: titleStyle);
} }

View File

@@ -9,7 +9,11 @@ class BarcodeCaptureScreen extends StatefulWidget {
final BarcodeTask task; final BarcodeTask task;
final Function(List<String>) onBarcodesCompleted; 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 @override
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState(); State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
@@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
}); });
} catch (e) { } catch (e) {
if (mounted) { 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 @override
Widget build(BuildContext context) { 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() { Widget _buildMobileView() {
@@ -153,9 +182,33 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
flex: 3, flex: 3,
child: Stack( child: Stack(
children: [ children: [
MobileScanner(controller: _scannerController, onDetect: _onBarcodeDetected), MobileScanner(
controller: _scannerController,
onDetect: _onBarcodeDetected,
),
// Overlay with scanning frame // 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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), const SizedBox(height: 16),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: _scannedBarcodes.length, itemCount: _scannedBarcodes.length,
itemBuilder: (context, index) { 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), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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), const SizedBox(height: 24),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -207,7 +293,18 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: TextField( child: TextField(
controller: _textControllers[index], 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) { onChanged: (value) {
setState(() { setState(() {
// Trigger rebuild to update button state // Trigger rebuild to update button state
@@ -219,7 +316,16 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
), ),
), ),
const SizedBox(height: 16), 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_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart' as fsel; import 'package:file_selector/file_selector.dart' as fsel;
import 'package:votianlt_app/services/developer.dart' as developer; import 'package:votianlt_app/services/developer.dart' as developer;
import '../app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/tasks/photo_task.dart'; import '../models/tasks/photo_task.dart';
import '../widgets/offline_banner.dart'; import '../widgets/offline_banner.dart';
@@ -91,11 +92,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
} }
} }
} catch (e, stackTrace) { } 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'); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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 { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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'); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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 { Future<void> _pickPhotoFromFile() async {
try { try {
// Use file_selector for desktop and web for robust platform support // Use file_selector for desktop and web for robust platform support
final bool useFileSelector = kIsWeb || final bool useFileSelector =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux; defaultTargetPlatform == TargetPlatform.linux;
@@ -146,7 +157,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
label: 'images', label: 'images',
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'], 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) { if (picked != null) {
final data = await picked.readAsBytes(); final data = await picked.readAsBytes();
setState(() { setState(() {
@@ -187,11 +200,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
} }
} }
} catch (e, stackTrace) { } 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'); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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), 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),
),
), ),
], ],
); );
@@ -276,7 +297,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
} catch (e, stackTrace) { } 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'); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
_pageController.jumpToPage(clamped); _pageController.jumpToPage(clamped);
} }
@@ -304,7 +328,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(context).photoCapture), title: Text(AppLocalizations.of(context).photoCapture),
backgroundColor: Colors.blue, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
actions: [ actions: [
if (_canComplete) if (_canComplete)
@@ -315,7 +339,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
}, },
child: Text( child: Text(
AppLocalizations.of(context).finish, 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( Container(
width: double.infinity, width: double.infinity,
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
color: Colors.grey[100], color: AppColors.surfaceMuted,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -337,7 +364,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
), ),
Text( Text(
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}', '${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]), style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
), ),
], ],
), ),
@@ -345,7 +375,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
// Camera preview, photo gallery or empty state // Camera preview, photo gallery or empty state
Expanded( Expanded(
child: _capturedPhotos.isEmpty child:
_capturedPhotos.isEmpty
? _buildCameraOrEmptyState() ? _buildCameraOrEmptyState()
: _buildPhotoGallery(), : _buildPhotoGallery(),
), ),
@@ -354,10 +385,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
Container( Container(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.surface,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withValues(alpha: 0.3), color: AppColors.textStrong.withValues(alpha: 0.12),
spreadRadius: 1, spreadRadius: 1,
blurRadius: 5, blurRadius: 5,
offset: Offset(0, -3), offset: Offset(0, -3),
@@ -372,23 +403,50 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
// Camera or file select button // Camera or file select button
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _canTakeMore && _isCameraSupportedOnThisPlatform onPressed:
_canTakeMore && _isCameraSupportedOnThisPlatform
? (_useFilePickerMode ? (_useFilePickerMode
? _pickPhotoFromFile ? _pickPhotoFromFile
: (_isCameraInitialized ? _capturePhoto : null)) : (_isCameraInitialized
? _capturePhoto
: null))
: null, : null,
icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt), icon: Icon(
_useFilePickerMode
? Icons.photo_library
: Icons.camera_alt,
),
label: Text( label: Text(
!_isCameraSupportedOnThisPlatform !_isCameraSupportedOnThisPlatform
? AppLocalizations.of(context).cameraNotSupportedOnPlatform ? AppLocalizations.of(
context,
).cameraNotSupportedOnPlatform
: (!_canTakeMore : (!_canTakeMore
? AppLocalizations.of(context).maxPhotosReached ? AppLocalizations.of(
context,
).maxPhotosReached
: (_useFilePickerMode : (_useFilePickerMode
? AppLocalizations.of(context).selectPhoto ? 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( style: ElevatedButton.styleFrom(
backgroundColor: _canTakeMore && (_useFilePickerMode || _isCameraInitialized) ? Colors.blue : Colors.grey, backgroundColor:
_canTakeMore &&
(_useFilePickerMode ||
_isCameraInitialized)
? AppColors.primary
: AppColors.borderStrong,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12), padding: EdgeInsets.symmetric(vertical: 12),
), ),
@@ -405,7 +463,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Colors.red,
foregroundColor: Colors.white, 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( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: _canComplete onPressed:
_canComplete
? () { ? () {
widget.onPhotosCompleted(_capturedPhotos); widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
: null, : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _canComplete ? Colors.green : Colors.grey, backgroundColor:
_canComplete ? Colors.green : Colors.grey,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 14), 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), SizedBox(height: 16),
Text( Text(
AppLocalizations.of(context).cameraNotAvailable, 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), SizedBox(height: 8),
Text( Text(
@@ -477,7 +547,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
AppLocalizations.of(context).addPhotos, 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), SizedBox(height: 8),
Text( Text(
@@ -518,11 +592,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.camera_alt, size: 80, color: Colors.grey[400]),
Icons.camera_alt,
size: 80,
color: Colors.grey[400],
),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
AppLocalizations.of(context).cameraInitializing, AppLocalizations.of(context).cameraInitializing,
@@ -535,10 +605,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
SizedBox(height: 8), SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).cameraLoadingMessage, AppLocalizations.of(context).cameraLoadingMessage,
style: TextStyle( style: TextStyle(fontSize: 14, color: Colors.grey[500]),
fontSize: 14,
color: Colors.grey[500],
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -601,7 +668,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.error, size: 50, color: Colors.grey[600]), Icon(
Icons.error,
size: 50,
color: Colors.grey[600],
),
SizedBox(height: 8), SizedBox(height: 8),
Text(AppLocalizations.of(context).photoError), Text(AppLocalizations.of(context).photoError),
], ],
@@ -621,9 +692,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bottom: 0, bottom: 0,
child: Center( child: Center(
child: IconButton( child: IconButton(
onPressed: _currentPhotoIndex > 0 onPressed:
? _goToPreviousPhoto _currentPhotoIndex > 0 ? _goToPreviousPhoto : null,
: null,
icon: Icon(Icons.chevron_left, size: 36), icon: Icon(Icons.chevron_left, size: 36),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7), backgroundColor: Colors.white.withValues(alpha: 0.7),
@@ -638,7 +708,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bottom: 0, bottom: 0,
child: Center( child: Center(
child: IconButton( child: IconButton(
onPressed: _currentPhotoIndex < _capturedPhotos.length - 1 onPressed:
_currentPhotoIndex < _capturedPhotos.length - 1
? _goToNextPhoto ? _goToNextPhoto
: null, : null,
icon: Icon(Icons.chevron_right, size: 36), icon: Icon(Icons.chevron_right, size: 36),
@@ -658,15 +729,17 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: _capturedPhotos.asMap().entries.map((entry) { children:
_capturedPhotos.asMap().entries.map((entry) {
return Container( return Container(
width: 8, width: 8,
height: 8, height: 8,
margin: EdgeInsets.symmetric(horizontal: 4), margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: _currentPhotoIndex == entry.key color:
? Colors.blue _currentPhotoIndex == entry.key
? AppColors.primary
: Colors.grey[400], : Colors.grey[400],
), ),
); );

View File

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:signature/signature.dart'; import 'package:signature/signature.dart';
import '../app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/tasks/signature_task.dart'; import '../models/tasks/signature_task.dart';
import '../widgets/offline_banner.dart'; import '../widgets/offline_banner.dart';
class SignatureCaptureScreen extends StatefulWidget { class SignatureCaptureScreen extends StatefulWidget {
final SignatureTask task; final SignatureTask task;
final void Function(String svg) onSignatureCompleted; final void Function(String svg, String note) onSignatureCompleted;
const SignatureCaptureScreen({ const SignatureCaptureScreen({
super.key, super.key,
@@ -23,6 +23,7 @@ class SignatureCaptureScreen extends StatefulWidget {
class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> { class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
late final SignatureController _controller; late final SignatureController _controller;
final TextEditingController _noteController = TextEditingController();
bool _hasSignature = false; bool _hasSignature = false;
bool _isMobilePlatform = false; bool _isMobilePlatform = false;
@@ -84,11 +85,16 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
void dispose() { void dispose() {
_controller.removeListener(_onSignatureChanged); _controller.removeListener(_onSignatureChanged);
_controller.dispose(); _controller.dispose();
_noteController.dispose();
_restoreOrientation(); _restoreOrientation();
super.dispose(); 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 // Convert collected signature points (with null separators for stroke breaks) into an SVG string
// Determine bounds // Determine bounds
double? minX, minY, maxX, maxY; 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; return svg;
} }
@@ -141,25 +148,30 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
if (!hasAnyPoint) { if (!hasAnyPoint) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).signatureRequired)), SnackBar(
content: Text(AppLocalizations.of(context).signatureRequired),
),
); );
return; return;
} }
// Build SVG from the captured signature points // Build SVG from the captured signature points
final String svg = _buildSvgFromPoints(_controller.points); final String svg = _buildSvgFromPoints(_controller.points);
final String note = _noteController.text.trim();
// Close this screen first to show the updated TaskView quickly // Close this screen first to show the updated TaskView quickly
if (!mounted) return; if (!mounted) return;
_restoreOrientation(); _restoreOrientation();
Navigator.of(context).pop(); Navigator.of(context).pop();
// Then notify the caller (SVG only) // Then notify the caller (SVG + Bemerkung)
widget.onSignatureCompleted(svg); widget.onSignatureCompleted(svg, note);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(context).signatureCapture), title: Text(AppLocalizations.of(context).signatureCapture),
backgroundColor: Colors.deepPurple[100],
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
@@ -199,15 +210,15 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
children: [ children: [
Text( Text(
AppLocalizations.of(context).signatureInstruction, AppLocalizations.of(context).signatureInstruction,
style: TextStyle(color: Colors.grey[700]), style: const TextStyle(color: AppColors.textMuted),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[400]!), border: Border.all(color: AppColors.borderStrong),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.05), color: Colors.black.withValues(alpha: 0.05),
@@ -220,11 +231,21 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Signature( child: Signature(
controller: _controller, controller: _controller,
backgroundColor: Colors.white, 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), const SizedBox(height: 16),
Row( Row(
children: [ children: [

View File

@@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:votianlt_app/services/developer.dart' as developer; import 'package:votianlt_app/services/developer.dart' as developer;
import '../app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class ChatPhotoDialog extends StatefulWidget { class ChatPhotoDialog extends StatefulWidget {
@@ -278,7 +279,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.warning, color: Colors.orange[700], size: 40), const Icon(Icons.warning, color: AppColors.warning, size: 40),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(_errorMessage!, textAlign: TextAlign.center), Text(_errorMessage!, textAlign: TextAlign.center),
], ],
@@ -330,11 +331,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(Icons.photo_camera_back, color: AppColors.primary, size: 48),
Icons.photo_camera_back,
color: Colors.deepPurple[400],
size: 48,
),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( const Text(
'Wähle ein Foto von deinem Gerät aus.', '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/developer.dart' as developer;
import 'package:votianlt_app/services/websocket_service.dart'; import 'package:votianlt_app/services/websocket_service.dart';
import 'package:votianlt_app/services/dart_mq.dart'; import 'package:votianlt_app/services/dart_mq.dart';
import '../app_theme.dart';
class OfflineBanner extends StatefulWidget { class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key}); 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) // Check if we're already connected (e.g., coming back to this screen)
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated; _hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
// Initialize countdown based on current connection state // Initialize countdown based on current connection state
_onConnectionChange(_stompService.isConnected && _stompService.isAuthenticated); _onConnectionChange(
_connSub = DartMQ().subscribe<bool>(MQTopics.connectionStatus, _onConnectionChange); _stompService.isConnected && _stompService.isAuthenticated,
);
_connSub = DartMQ().subscribe<bool>(
MQTopics.connectionStatus,
_onConnectionChange,
);
} }
void _onConnectionChange(bool isConnected) { 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 // Only auto-reconnect if we already know the target; discovery remains user-initiated
await _stompService.connect(); await _stompService.connect();
} catch (e, stackTrace) { } 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'); developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
} }
@@ -114,19 +123,19 @@ class _OfflineBannerState extends State<OfflineBanner> {
title = 'Offline Verbindung verloren'; title = 'Offline Verbindung verloren';
subtitle = 'Verbindung wird wiederhergestellt.'; subtitle = 'Verbindung wird wiederhergestellt.';
icon = Icons.wifi_off; icon = Icons.wifi_off;
bgColor = Colors.red[50]; bgColor = AppColors.dangerSoft;
iconColor = Colors.red[700]; iconColor = AppColors.danger;
titleColor = Colors.red[900]; titleColor = AppColors.danger;
subtitleColor = Colors.red[800]; subtitleColor = AppColors.danger.withValues(alpha: 0.85);
} else { } else {
// Initial connection attempt // Initial connection attempt
title = 'Verbinde mit Server...'; title = 'Verbinde mit Server...';
subtitle = 'Bitte warten.'; subtitle = 'Bitte warten.';
icon = Icons.sync; icon = Icons.sync;
bgColor = Colors.orange[50]; bgColor = AppColors.warningSoft;
iconColor = Colors.orange[700]; iconColor = AppColors.warning;
titleColor = Colors.orange[900]; titleColor = AppColors.warning;
subtitleColor = Colors.orange[800]; subtitleColor = AppColors.warning.withValues(alpha: 0.85);
} }
return Container( return Container(
@@ -152,10 +161,7 @@ class _OfflineBannerState extends State<OfflineBanner> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle, subtitle,
style: TextStyle( style: TextStyle(color: subtitleColor, fontSize: 12),
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 # 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 # 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. # 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: environment:
sdk: ^3.7.0 sdk: ^3.7.0

View File

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

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.15</revision> <revision>0.9.17</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
@@ -44,6 +44,31 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </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> </dependencies>
</dependencyManagement> </dependencyManagement>

Binary file not shown.

View File

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

View File

@@ -1068,6 +1068,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-radius: 24px; border-radius: 24px;
background: rgba(255, 255, 255, 0.84); background: rgba(255, 255, 255, 0.84);
box-shadow: var(--app-shadow-sm); box-shadow: var(--app-shadow-sm);
box-sizing: border-box;
} }
.route-card, .route-card,
@@ -1095,6 +1096,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-radius: 24px; border-radius: 24px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
box-shadow: var(--app-shadow-sm); box-shadow: var(--app-shadow-sm);
box-sizing: border-box;
} }
.detail-card, .detail-card,
@@ -1143,6 +1145,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
.dialog-task-card { .dialog-task-card {
position: relative; 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; 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); 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; position: absolute;
top: 0.65rem; left: 0;
right: 0.65rem; right: 0;
z-index: 10; 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; padding: 0.2rem;
min-width: 1.7rem; min-width: 1.7rem;
min-height: 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, .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.CargoItem;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask; 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.pages.service.AppUserService;
import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.CargoItemRepository;
@@ -133,6 +134,14 @@ public class MessageController {
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> { List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId()); List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job); 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); return new JobWithRelatedDataDTO(job, cargoItems, tasks);
}).toList(); }).toList();
@@ -246,13 +255,18 @@ public class MessageController {
Object extra = payload.get("extraData"); Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) { if (extra instanceof Map<?, ?> extraData) {
Object signatureSvgObj = extraData.get("signatureSvg"); Object signatureSvgObj = extraData.get("signatureSvg");
Object signatureNoteObj = extraData.get("signatureNote");
String signatureNote = signatureNoteObj instanceof String s ? s : null;
if (signatureSvgObj instanceof String signatureSvg) { if (signatureSvgObj instanceof String signatureSvg) {
if (!signatureSvg.isBlank()) { if (!signatureSvg.isBlank()) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg, Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
completedBy); signatureNote, completedBy);
signatureRepository.save(signatureEntry); signatureRepository.save(signatureEntry);
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)"; extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
if (signatureNote != null && !signatureNote.isBlank()) {
extraDataSummary += ", Bemerkung: " + signatureNote;
}
} else { } else {
extraDataSummary = "Leere Unterschrift"; extraDataSummary = "Leere Unterschrift";
} }
@@ -375,7 +389,9 @@ public class MessageController {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy); 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) { } catch (Exception e) {
// Ignore email notification errors // 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 * Handle incoming message from a client via WebSocket. Client sends to
* /server/message with payload: { "content": "message payload", "contentType": * /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 // Chat message handler
webSocketService.registerMessageHandler("message", (appUserId, payload) -> { webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> { handlePayload(payload, payloadMap -> {

View File

@@ -1,10 +1,12 @@
package de.assecutor.votianlt.messaging; package de.assecutor.votianlt.messaging;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/** /**
@@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Value("${app.messaging.websocket.allowed-origins:*}") @Value("${app.messaging.websocket.allowed-origins:*}")
private String allowedOrigins; 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) { public WebSocketConfig(WebSocketService webSocketService) {
this.webSocketService = webSocketService; this.webSocketService = webSocketService;
} }
@@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(",")) registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
.addInterceptors(new HttpSessionHandshakeInterceptor()); .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") @Field("owner")
private ObjectId 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 ObjectId taskId;
private String signatureSvg; private String signatureSvg;
private String note;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private String completedBy; private String completedBy;
@@ -35,4 +36,9 @@ public class Signature {
this.signatureSvg = signatureSvg; this.signatureSvg = signatureSvg;
this.completedBy = completedBy; 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.mapping.Field;
import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.index.Indexed;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set; import java.util.Set;
@@ -68,4 +69,7 @@ public class User {
// Spracheinstellung (standardmäßig Deutsch) // Spracheinstellung (standardmäßig Deutsch)
@Field("language") @Field("language")
private Language language = Language.DE; 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.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Document(collection = "customerInvoices") @Document(collection = "customerInvoices")
@@ -12,6 +14,33 @@ public class CustomerInvoice {
@Id @Id
private String 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) // Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum private LocalDate invoiceDate; // Rechnungsdatum
@@ -372,4 +401,134 @@ public class CustomerInvoice {
public void setPdfData(byte[] pdfData) { public void setPdfData(byte[] pdfData) {
this.pdfData = 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; package de.assecutor.votianlt.model.task;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Transient;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class SignatureTask extends BaseTask { public class SignatureTask extends BaseTask {
@Transient
@JsonIgnore
private String note;
@Override @Override
public String getTaskType() { public String getTaskType() {
return "SIGNATURE"; return "SIGNATURE";
@@ -21,11 +27,17 @@ public class SignatureTask extends BaseTask {
@Override @Override
public Object getTaskSpecificData() { public Object getTaskSpecificData() {
return new TaskSpecificData(); return new TaskSpecificData(note);
} }
@Data
@NoArgsConstructor
public class TaskSpecificData { public class TaskSpecificData {
public String taskType = getTaskType(); 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.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog; 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.Div;
import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
@@ -52,10 +56,28 @@ public class DeliveryStationDialog extends Dialog {
private String zip; private String zip;
private String city; private String city;
private boolean saveAddress; 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 List<BaseTask> tasks = new ArrayList<>();
private boolean addressValidatedByGoogle; private boolean addressValidatedByGoogle;
private AddressValidationResult addressValidationResult; 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() { public boolean isAddressValidatedByGoogle() {
return addressValidatedByGoogle; return addressValidatedByGoogle;
} }
@@ -203,12 +225,14 @@ public class DeliveryStationDialog extends Dialog {
private final List<BaseTask> tasksState = new ArrayList<>(); private final List<BaseTask> tasksState = new ArrayList<>();
private VerticalLayout tasksList; private VerticalLayout tasksList;
private VerticalLayout draggedTaskContainer;
private Span addressTabError; private Span addressTabError;
private Span tasksTabError; private Span tasksTabError;
private final DeliveryStationTile.TranslationHelper translationHelper; private final DeliveryStationTile.TranslationHelper translationHelper;
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>(); 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, public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
@@ -230,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
formLayout.setSpacing(true); formLayout.setSpacing(true);
formLayout.setWidthFull(); formLayout.setWidthFull();
// Company with autocomplete // Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers); setupCompanyAutocomplete(company, customers);
@@ -366,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
addressTabError = createTabErrorIndicator(); addressTabError = createTabErrorIndicator();
tasksTabError = 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); addressTab.add(addressTabError);
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"), Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
createTasksTab(templates, templateSaveCallback)); createTasksTab(templates, templateSaveCallback));
@@ -507,6 +531,13 @@ public class DeliveryStationDialog extends Dialog {
zip.setValue(data.getZip()); zip.setValue(data.getZip());
if (data.getCity() != null) if (data.getCity() != null)
city.setValue(data.getCity()); 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()); saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState(); updateSaveAddressState();
@@ -543,10 +574,43 @@ public class DeliveryStationDialog extends Dialog {
data.setZip(zip.getValue()); data.setZip(zip.getValue());
data.setCity(city.getValue()); data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue()); data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setTasks(new ArrayList<>(tasksState)); data.setTasks(new ArrayList<>(tasksState));
return data; 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() { private boolean validateRequiredFields() {
// Address tab validation // Address tab validation
boolean addressValid = true; boolean addressValid = true;
@@ -596,11 +660,9 @@ public class DeliveryStationDialog extends Dialog {
String value = mail.getValue(); String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim(); String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty(); boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@"); boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty); applyErrorStyling(mail, invalid);
applyErrorStyling(mail, hasError); return !invalid;
return !hasError;
} }
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) { 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) { private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
companyAddressOptions.clear(); companyAddressOptions.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
String label = buildCompanyAddressLabel(customer); CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
if (label == null) { translationHelper.getTranslation("addjob.customer.unnamed"));
continue;
}
String uniqueLabel = label;
int counter = 2;
while (companyAddressOptions.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
companyAddressOptions.put(uniqueLabel, customer);
} }
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet())); companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
@@ -643,10 +696,12 @@ public class DeliveryStationDialog extends Dialog {
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
Customer customer = companyAddressOptions.get(event.getValue()); Customer customer = companyAddressOptions.get(event.getValue());
if (customer == null) { if (customer == null) {
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
return; return;
} }
selectedCustomerId = customer.getId();
if (customer.getTitle() != null if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) { || "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -675,74 +730,38 @@ public class DeliveryStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> { companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail()); companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
}); });
} }
private void updateSaveAddressState() { private void updateSaveAddressState() {
Customer selectedCustomer = companyAddressOptions.get(company.getValue()); 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.setValue(false);
saveAddress.setEnabled(false); saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement(); updateMailRequirement();
return; return;
} }
saveAddress.setEnabled(true); saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement(); updateMailRequirement();
} }
private void updateMailRequirement() { private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue())); mail.setRequiredIndicatorVisible(false);
}
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;
} }
private String resolveCompanyValue(String comboValue) { private String resolveCompanyValue(String comboValue) {
Customer customer = companyAddressOptions.get(comboValue); return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
return customer.getCompanyName();
}
return comboValue;
} }
private String findCompanyOptionLabel(DeliveryData data) { private String findCompanyOptionLabel(DeliveryData data) {
@@ -866,6 +885,15 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true); taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card"); 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 // Task type selection
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype")); ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values()); taskTypeCombo.setItems(TaskType.values());
@@ -877,6 +905,7 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout(); VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false); configContainer.setPadding(false);
configContainer.setSpacing(true); configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
// Red X button positioned in top-right corner // Red X button positioned in top-right corner
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
@@ -884,8 +913,14 @@ public class DeliveryStationDialog extends Dialog {
deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer)); deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer); HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
taskContainer.add(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 // Create Task and add to state with correct order
BaseTask task = new ConfirmationTask(""); BaseTask task = new ConfirmationTask("");
@@ -896,6 +931,7 @@ public class DeliveryStationDialog extends Dialog {
taskTypeCombo.setValue(TaskType.CONFIRMATION); taskTypeCombo.setValue(TaskType.CONFIRMATION);
updateTaskConfiguration(configContainer, currentTask[0]); updateTaskConfiguration(configContainer, currentTask[0]);
updateDragSummary(summaryRow, TaskType.CONFIRMATION, task);
taskTypeCombo.addValueChangeListener(ev -> { taskTypeCombo.addValueChangeListener(ev -> {
TaskType selectedType = ev.getValue(); TaskType selectedType = ev.getValue();
@@ -940,6 +976,7 @@ public class DeliveryStationDialog extends Dialog {
} }
updateTaskConfiguration(configContainer, newTask); updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
} }
}); });
@@ -953,6 +990,18 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true); taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card"); 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")); ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values()); taskTypeCombo.setItems(TaskType.values());
taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName); taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName);
@@ -962,21 +1011,27 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout(); VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false); configContainer.setPadding(false);
configContainer.setSpacing(true); configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer)); deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer); HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
taskContainer.add(deleteXButton); headerRow.setAlignItems(FlexComponent.Alignment.START);
headerRow.setWidthFull();
headerRow.setFlexGrow(1, taskTypeCombo);
taskContainer.add(headerRow, configContainer);
setupDragAndDrop(taskContainer);
final BaseTask[] currentTask = { task }; final BaseTask[] currentTask = { task };
// Set the combo value BEFORE registering the listener // Set the combo value BEFORE registering the listener
TaskType taskType = getTaskTypeFromTask(task); if (initialTaskType != null) {
if (taskType != null) { taskTypeCombo.setValue(initialTaskType);
taskTypeCombo.setValue(taskType);
} }
// Register the listener for user-initiated type changes only // Register the listener for user-initiated type changes only
@@ -1021,11 +1076,13 @@ public class DeliveryStationDialog extends Dialog {
} }
updateTaskConfiguration(configContainer, newTask); updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
} }
}); });
// Render the UI with the loaded task // Render the UI with the loaded task
updateTaskConfiguration(configContainer, task); updateTaskConfiguration(configContainer, task);
updateDragSummary(summaryRow, initialTaskType, task);
tasksList.add(taskContainer); tasksList.add(taskContainer);
updateTaskDeleteAvailability(); 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() { private void reorderTasksAfterDeletion() {
for (int i = 0; i < tasksState.size(); i++) { for (int i = 0; i < tasksState.size(); i++) {
BaseTask task = tasksState.get(i); BaseTask task = tasksState.get(i);
@@ -1094,11 +1305,15 @@ public class DeliveryStationDialog extends Dialog {
.filter(VerticalLayout.class::isInstance) .filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast) .map(VerticalLayout.class::cast)
.forEach(taskContainer -> taskContainer.getChildren() .forEach(taskContainer -> taskContainer.getChildren()
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.findFirst()
.ifPresent(headerRow -> headerRow.getChildren()
.filter(Button.class::isInstance) .filter(Button.class::isInstance)
.map(Button.class::cast) .map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete")) .filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst() .findFirst()
.ifPresent(button -> button.setEnabled(deletable))); .ifPresent(button -> button.setEnabled(deletable))));
} }
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) { private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
@@ -1142,10 +1357,14 @@ public class DeliveryStationDialog extends Dialog {
break; break;
case SIGNATURE: case SIGNATURE:
Span info = new Span(translationHelper.getTranslation("addjob.tasks.signature.noconfig")); TextField signatureNoteField = new TextField(
info.getStyle().set("color", "var(--lumo-secondary-text-color)"); translationHelper.getTranslation("addjob.tasks.signature.notelabel"));
info.getStyle().set("font-style", "italic"); signatureNoteField.setPlaceholder(
configContainer.add(info); 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; break;
case TODOLIST: 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.Customer;
import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.DeliveryStation;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; 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 * 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 TextField city;
private final Checkbox saveAddress; private final Checkbox saveAddress;
private final H3 title; private final H3 title;
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
private ChangeListener changeListener; private ChangeListener changeListener;
private DeleteListener deleteListener; private DeleteListener deleteListener;
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
add(titleLayout); add(titleLayout);
// Company with autocomplete // Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers, translationHelper); setupCompanyAutocomplete(company, customers, translationHelper);
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers, private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
TranslationHelper translationHelper) { TranslationHelper translationHelper) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName) companyAddressOptions.clear();
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); 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 -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream() Customer customer = companyAddressOptions.get(selectedAddress);
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst(); if (customer != null) {
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
if (customer.getTitle() != null if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) { || "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
*/ */
public DeliveryStation getDeliveryStation() { public DeliveryStation getDeliveryStation() {
DeliveryStation station = new DeliveryStation(); DeliveryStation station = new DeliveryStation();
station.setCompany(company.getValue()); station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
station.setSalutation(salutation.getValue()); station.setSalutation(salutation.getValue());
station.setFirstName(firstName.getValue()); station.setFirstName(firstName.getValue());
station.setLastName(lastName.getValue()); station.setLastName(lastName.getValue());

View File

@@ -35,7 +35,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
@@ -228,6 +227,25 @@ public class PickupStationDialog extends Dialog {
public void setCargoItems(List<CargoItem> cargoItems) { public void setCargoItems(List<CargoItem> cargoItems) {
this.cargoItems = cargoItems != null ? cargoItems : new ArrayList<>(); 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 { public interface SaveListener {
@@ -250,6 +268,7 @@ public class PickupStationDialog extends Dialog {
private final ComboBox<String> customerComboBox; private final ComboBox<String> customerComboBox;
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>(); private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>(); private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
private org.bson.types.ObjectId selectedCustomerId;
private DatePicker appointmentDatePicker; private DatePicker appointmentDatePicker;
private TimePicker appointmentTimePicker; private TimePicker appointmentTimePicker;
private Checkbox digitalProcessingCheckbox; private Checkbox digitalProcessingCheckbox;
@@ -283,7 +302,7 @@ public class PickupStationDialog extends Dialog {
formLayout.setSpacing(true); formLayout.setSpacing(true);
formLayout.setWidthFull(); formLayout.setWidthFull();
// Customer selection // Principal selection
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label")); customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder")); customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
customerComboBox.setRequiredIndicatorVisible(true); customerComboBox.setRequiredIndicatorVisible(true);
@@ -291,27 +310,14 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear(); customerLabelMap.clear();
for (Customer c : customers) { for (Customer c : customers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank()) CustomerAddressLabelHelper.putUnique(customerLabelMap, c,
? c.getCompanyName() + " | " translationHelper.getTranslation("addjob.customer.unnamed"));
+ ((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);
} }
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
// Company with autocomplete // Pickup address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers); setupCompanyAutocomplete(company, customers);
@@ -431,18 +437,18 @@ public class PickupStationDialog extends Dialog {
customerComboBox.addValueChangeListener(ev -> { customerComboBox.addValueChangeListener(ev -> {
String selected = ev.getValue(); String selected = ev.getValue();
if (selected == null) { if (selected == null) {
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
return; return;
} }
Customer c = customerLabelMap.get(selected); Customer c = customerLabelMap.get(selected);
if (c == null) { if (c == null) {
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
return; return;
} }
if (c.getCompanyName() != null) selectedCustomerId = c.getId();
company.setValue(c.getCompanyName()); setCompanySelection(c);
else
company.clear();
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle()) if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|| "Divers".equalsIgnoreCase(c.getTitle()))) || "Divers".equalsIgnoreCase(c.getTitle())))
salutation.setValue(c.getTitle()); salutation.setValue(c.getTitle());
@@ -488,7 +494,12 @@ public class PickupStationDialog extends Dialog {
updateSaveAddressState(); 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); addressAddition, zipCityLayout, saveAddress);
// TabSheet with address, appointments, and cargo tabs // TabSheet with address, appointments, and cargo tabs
@@ -500,7 +511,7 @@ public class PickupStationDialog extends Dialog {
appointmentsTabError = createTabErrorIndicator(); appointmentsTabError = createTabErrorIndicator();
cargoTabError = 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); addressTab.add(addressTabError);
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"), Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
createAppointmentsTab(availableAppUsers)); createAppointmentsTab(availableAppUsers));
@@ -614,13 +625,23 @@ public class PickupStationDialog extends Dialog {
public void setData(PickupData data) { public void setData(PickupData data) {
if (data == null) if (data == null)
return; return;
if (data.getCustomerSelection() != null) { String customerSelection = normalizeValue(data.getCustomerSelection());
customerComboBox.setValue(data.getCustomerSelection()); if (!customerSelection.isEmpty()) {
if (!customerLabelMap.containsKey(customerSelection)) {
customerLabelMap.put(customerSelection, null);
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
}
customerComboBox.setValue(customerSelection);
} else { } else {
customerComboBox.clear(); 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()); company.setValue(data.getCompany());
}
if (data.getSalutation() != null) if (data.getSalutation() != null)
salutation.setValue(data.getSalutation()); salutation.setValue(data.getSalutation());
if (data.getFirstName() != null) 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(); updateSaveAddressState();
} }
private PickupData collectData() { private PickupData collectData() {
PickupData data = new PickupData(); PickupData data = new PickupData();
data.setCompany(company.getValue()); data.setCompany(resolveCompanyValue(company.getValue()));
data.setSalutation(salutation.getValue()); data.setSalutation(salutation.getValue());
data.setFirstName(firstName.getValue()); data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue()); data.setLastName(lastName.getValue());
@@ -681,6 +708,8 @@ public class PickupStationDialog extends Dialog {
data.setZip(zip.getValue()); data.setZip(zip.getValue());
data.setCity(city.getValue()); data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue()); data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setCustomerSelection(customerComboBox.getValue()); data.setCustomerSelection(customerComboBox.getValue());
if (appointmentDatePicker != null) { if (appointmentDatePicker != null) {
data.setAppointmentDate(appointmentDatePicker.getValue()); data.setAppointmentDate(appointmentDatePicker.getValue());
@@ -747,12 +776,9 @@ public class PickupStationDialog extends Dialog {
private boolean validateMailField() { private boolean validateMailField() {
String value = mail.getValue(); String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim(); String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty(); boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
boolean required = Boolean.TRUE.equals(saveAddress.getValue()); applyErrorStyling(mail, invalid);
boolean invalid = !empty && !normalizedValue.contains("@"); return !invalid;
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
} }
private boolean validateCargoItems() { private boolean validateCargoItems() {
@@ -804,54 +830,24 @@ public class PickupStationDialog extends Dialog {
} }
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) { 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(); companyCustomerMap.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
String companyName = normalizeValue(customer.getCompanyName()); CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer,
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) { translationHelper.getTranslation("addjob.customer.unnamed"));
continue;
} }
companyCustomerMap.put(companyName, customer); companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
}
companyField.setItems(companyNames);
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream() Customer customer = companyCustomerMap.get(selectedAddress);
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst(); if (customer != null) {
applyCustomerAddress(customer);
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());
} }
updateSaveAddressState(); updateSaveAddressState();
@@ -859,33 +855,40 @@ public class PickupStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> { companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail()); companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
}); });
} }
private void updateSaveAddressState() { private void updateSaveAddressState() {
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue()); Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue())); Customer selectedCompanyCustomer = companyCustomerMap.get(company.getValue());
boolean existingCustomerSelected = selectedCustomer != null && matchesCustomer(selectedCustomer); boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean existingCompanySelected = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer); boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
if (existingCustomerSelected || existingCompanySelected) { if (customerDataMatches || companyDataMatches) {
saveAddress.setValue(false); saveAddress.setValue(false);
saveAddress.setEnabled(false); saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement(); updateMailRequirement();
return; return;
} }
saveAddress.setEnabled(true); saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement(); updateMailRequirement();
} }
private void updateMailRequirement() { private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue())); mail.setRequiredIndicatorVisible(false);
} }
private boolean matchesCustomer(Customer customer) { private boolean matchesCustomer(Customer customer) {
return sameValue(company.getValue(), customer.getCompanyName()) return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
&& sameValue(salutation.getValue(), customer.getTitle()) && sameValue(salutation.getValue(), customer.getTitle())
&& sameValue(firstName.getValue(), customer.getFirstname()) && sameValue(firstName.getValue(), customer.getFirstname())
&& sameValue(lastName.getValue(), customer.getLastName()) && sameValue(lastName.getValue(), customer.getLastName())
@@ -906,6 +909,142 @@ public class PickupStationDialog extends Dialog {
return value == null ? "" : value.trim(); 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 // 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.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List; import java.util.List;
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> { public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
@@ -13,4 +14,9 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
Slice<Customer> findAllBy(Pageable pageable); Slice<Customer> findAllBy(Pageable pageable);
List<Customer> findByOwner(ObjectId owner); 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 { public class AddCustomerService {
private final AddCustomerRepository addCustomerRepository; private final AddCustomerRepository addCustomerRepository;
private final SecurityService securityService; private final SecurityService securityService;
private final SequenceGeneratorService sequenceGeneratorService;
AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService) { AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService,
SequenceGeneratorService sequenceGeneratorService) {
this.addCustomerRepository = addCustomerRepository; this.addCustomerRepository = addCustomerRepository;
this.securityService = securityService; this.securityService = securityService;
this.sequenceGeneratorService = sequenceGeneratorService;
} }
public void addCustomer(Customer customer) { public void addCustomer(Customer customer) {
@@ -25,6 +28,35 @@ public class AddCustomerService {
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser(); de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
customer.setCreatedBy(currentUser.getId()); customer.setCreatedBy(currentUser.getId());
customer.setOwner(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); addCustomerRepository.save(customer);
} }
@@ -35,13 +67,10 @@ public class AddCustomerService {
} }
String mail = customer.getMail() != null ? customer.getMail().trim() : ""; String mail = customer.getMail() != null ? customer.getMail().trim() : "";
if (mail.isEmpty()) { if (!mail.isEmpty() && !mail.contains("@")) {
throw new IllegalArgumentException("E-Mail-Adresse ist ein Pflichtfeld");
}
if (!mail.contains("@")) {
throw new IllegalArgumentException("Bitte geben Sie eine gültige E-Mail-Adresse ein"); 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() { public List<Customer> findAllForCurrentOwner() {
ObjectId ownerId = securityService.getCurrentUserId(); ObjectId ownerId = securityService.getCurrentUserId();
return todoRepository.findByOwner(ownerId); return todoRepository.findByOwnerAndInternalFalse(ownerId);
} }
public Customer save(Customer customer) { public Customer save(Customer customer) {
@@ -43,4 +43,8 @@ public class CustomerService {
return todoRepository.findById(id).orElse(null); 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; package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.UserInvoiceData; 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.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId; 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.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria; 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.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UserInvoiceDataService { public class UserInvoiceDataService {
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
private final UserInvoiceDataRepository userInvoiceDataRepository; private final UserInvoiceDataRepository userInvoiceDataRepository;
private final MongoTemplate mongoTemplate; 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.userInvoiceDataRepository = userInvoiceDataRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.reservationRepository = reservationRepository;
this.securityService = securityService;
} }
public Optional<UserInvoiceData> findByUserId(ObjectId userId) { 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 * 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). * 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) { public String generateNextInvoiceNumber(ObjectId userId) {
Query query = Query.query(Criteria.where("userId").is(userId)); Query query = Query.query(Criteria.where("userId").is(userId));
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten // Kein Eintrag vorhanden - Fallback auf aktuelle Daten
return findByUserId(userId).map(d -> { return findByUserId(userId).map(d -> {
String prefix = d.getPrefix() != null ? d.getPrefix() : ""; 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"); }).orElse("000000");
} }
String prefix = before.getPrefix() != null ? before.getPrefix() : ""; 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) { public AddCustomerView(AddCustomerService todoService, Clock clock) {
this.addCustomerService = todoService; this.addCustomerService = todoService;
// Firma (Pflichtfeld) // Firma (optional; auch Privatpersonen können im Adressbuch stehen)
companyName = new TextField(getTranslation("profile.company")); companyName = new TextField(getTranslation("profile.company"));
companyName.setRequiredIndicatorVisible(true);
companyName.setWidthFull(); companyName.setWidthFull();
companyName.addBlurListener(e -> validateField(companyName));
// Anrede (Dropdown) // Anrede (Dropdown)
title = new ComboBox<>(getTranslation("addjob.address.salutation")); 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 = new TextField(getTranslation("profile.fax"));
fax.setWidthFull(); fax.setWidthFull();
// E-Mail (Pflichtfeld) // E-Mail (optional)
mail = new TextField(getTranslation("profile.email")); mail = new TextField(getTranslation("profile.email"));
mail.setRequiredIndicatorVisible(true);
mail.setWidthFull(); mail.setWidthFull();
mail.addBlurListener(e -> validateEmail()); mail.addBlurListener(e -> validateEmail());
@@ -163,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
} }
private void configureBinder() { private void configureBinder() {
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required")) binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
.bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(title).bind(Customer::getTitle, Customer::setTitle); 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(fax).bind(Customer::getFax, Customer::setFax);
binder.forField(mail).asRequired(getTranslation("profile.validation.email.required")) binder.forField(mail)
.withValidator(email -> email.contains("@"), getTranslation("profile.validation.email.invalid")) .withValidator(email -> email == null || email.isBlank() || email.contains("@"),
getTranslation("profile.validation.email.invalid"))
.bind(Customer::getMail, Customer::setMail); .bind(Customer::getMail, Customer::setMail);
binder.forField(street).asRequired(getTranslation("profile.validation.street.required")) binder.forField(street).asRequired(getTranslation("profile.validation.street.required"))
@@ -247,10 +244,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void validateEmail() { private void validateEmail() {
String value = mail.getValue(); String value = mail.getValue();
if (value == null || value.trim().isEmpty()) { if (value != null && !value.trim().isEmpty() && !value.contains("@")) {
mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.validation.email.required"));
} else if (!value.contains("@")) {
mail.setInvalid(true); mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.validation.email.invalid")); mail.setErrorMessage(getTranslation("profile.validation.email.invalid"));
} else { } else {
@@ -260,7 +254,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
} }
private boolean validateAllFields() { private boolean validateAllFields() {
validateField(companyName);
validateField(firstName); validateField(firstName);
validateField(lastName); validateField(lastName);
validateField(telephone); validateField(telephone);
@@ -270,9 +263,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city); validateField(city);
validateEmail(); validateEmail();
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
&& !city.isInvalid();
} }
@Override @Override

View File

@@ -63,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
import de.assecutor.votianlt.model.RouteCalculationResult; import de.assecutor.votianlt.model.RouteCalculationResult;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile; 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.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.PickupStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog; import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; 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 pickupZip;
private TextField pickupCity; private TextField pickupCity;
private Checkbox savePickupAddress; 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 // Delivery stations as tiles in a 3x3 grid (max 7 delivery + 1 pickup + 1 plus
// = 9) // = 9)
private final List<StationTile> deliveryStationTilesList = new ArrayList<>(); private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>(); private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
private final List<Boolean> deliveryStationsSaveAddress = 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<String> deliveryStationsMailState = new ArrayList<>();
private final List<Div> deliveryStationSlotList = new ArrayList<>(); private final List<Div> deliveryStationSlotList = new ArrayList<>();
private final List<Span> deliveryStationDistanceChips = 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.setPlaceholder(getTranslation("addjob.customer.placeholder"));
customerSelection.setWidthFull(); customerSelection.setWidthFull();
customerSelection.setRequiredIndicatorVisible(true); customerSelection.setRequiredIndicatorVisible(true);
customerSelection.setAllowCustomValue(true);
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen // Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner(); List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear(); customerLabelToEntity.clear();
for (Customer c : ownerCustomers) { for (Customer c : ownerCustomers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank()) CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
? 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);
} }
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
// Pickup address // Pickup address
pickupCompany = new ComboBox<>(getTranslation("profile.company")); pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder")); pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
pickupCompany.setAllowCustomValue(true); pickupCompany.setAllowCustomValue(true);
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation")); pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -721,6 +713,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Add empty state for this station // Add empty state for this station
deliveryStationsState.add(new DeliveryStation()); deliveryStationsState.add(new DeliveryStation());
deliveryStationsSaveAddress.add(true); deliveryStationsSaveAddress.add(true);
deliveryStationsCustomerId.add(null);
deliveryStationsAddressDiffers.add(false);
deliveryStationsMailState.add(null); deliveryStationsMailState.add(null);
deliveryStationsValidatedByGoogle.add(false); deliveryStationsValidatedByGoogle.add(false);
@@ -769,6 +763,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryStationTilesList.remove(removeIdx); deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx); deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx); deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsCustomerId.remove(removeIdx);
deliveryStationsAddressDiffers.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx); deliveryStationsMailState.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx); deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx); deliveryStationTasksState.remove(removeIdx);
@@ -849,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
translationHelper, data -> { translationHelper, data -> {
// Update customer selection from dialog // Update customer selection from dialog
if (data.getCustomerSelection() != null) { if (data.getCustomerSelection() != null) {
customerSelection.setValue(data.getCustomerSelection()); setCustomerSelectionValue(data.getCustomerSelection());
} else { } else {
customerSelection.clear(); customerSelection.clear();
} }
@@ -867,6 +863,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupZip.setValue(data.getZip() != null ? data.getZip() : ""); pickupZip.setValue(data.getZip() != null ? data.getZip() : "");
pickupCity.setValue(data.getCity() != null ? data.getCity() : ""); pickupCity.setValue(data.getCity() != null ? data.getCity() : "");
savePickupAddress.setValue(data.isSaveAddress()); savePickupAddress.setValue(data.isSaveAddress());
pickupCustomerId = data.getCustomerId();
pickupAddressDiffers = data.isAddressDiffersFromCustomer();
// Sync appointment fields for binder/submit // Sync appointment fields for binder/submit
pickupDate.setValue(data.getAppointmentDate()); pickupDate.setValue(data.getAppointmentDate());
@@ -913,6 +911,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(pickupZip.getValue()); currentData.setZip(pickupZip.getValue());
currentData.setCity(pickupCity.getValue()); currentData.setCity(pickupCity.getValue());
currentData.setSaveAddress(savePickupAddress.getValue()); currentData.setSaveAddress(savePickupAddress.getValue());
currentData.setCustomerId(pickupCustomerId);
currentData.setCustomerSelection(customerSelection.getValue()); currentData.setCustomerSelection(customerSelection.getValue());
// Pre-fill pickup-specific fields // Pre-fill pickup-specific fields
currentData.setAppointmentDate(pickupDate.getValue()); currentData.setAppointmentDate(pickupDate.getValue());
@@ -1105,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
return trimmed.isEmpty() ? null : trimmed; 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) { private void openDeliveryDialog(StationTile tile, int stationIndex) {
// Ensure index is valid (could have changed due to deletions) // Ensure index is valid (could have changed due to deletions)
int actualIndex = deliveryStationTilesList.indexOf(tile); int actualIndex = deliveryStationTilesList.indexOf(tile);
@@ -1137,6 +1149,14 @@ public class AddJobView extends Main implements HasDynamicTitle {
station.setCity(data.getCity()); station.setCity(data.getCity());
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>()); station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
deliveryStationsSaveAddress.set(idx, data.isSaveAddress()); 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())); deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
// Store tasks for this delivery station // Store tasks for this delivery station
@@ -1182,6 +1202,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(station.getZip()); currentData.setZip(station.getZip());
currentData.setCity(station.getCity()); currentData.setCity(station.getCity());
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex)); currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
if (actualIndex < deliveryStationsCustomerId.size()) {
currentData.setCustomerId(deliveryStationsCustomerId.get(actualIndex));
}
if (actualIndex < deliveryStationsValidatedByGoogle.size()) { if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex)); currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
} }
@@ -1390,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Get all customers for the current owner // Get all customers for the current owner
List<Customer> allCustomers = customerService.findAllForCurrentOwner(); List<Customer> allCustomers = customerService.findAllForCurrentOwner();
// Extract unique company names (filter out null/empty values) Map<String, Customer> addressOptions = new LinkedHashMap<>();
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName) for (Customer customer : allCustomers) {
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
}
// Set items for autocomplete // 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 // Add selection listener to auto-fill pickup address fields when company is
// selected // selected
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return; return;
} }
// Streckeninformationen zurücksetzen, da sich die Adresse ändert // Streckeninformationen zurücksetzen, da sich die Adresse ändert
resetRouteInformation(); resetRouteInformation();
// Find the first customer with this company name Customer customer = addressOptions.get(selectedAddress);
Optional<Customer> matchingCustomer = allCustomers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) { if (customer != null) {
Customer customer = matchingCustomer.get(); pickupCustomerId = customer.getId();
// Fill pickup address fields // Fill pickup address fields
if (customer.getTitle() != null if (customer.getTitle() != null
@@ -1454,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Reactivate save checkbox for custom values // Reactivate save checkbox for custom values
savePickupAddress.setValue(true); savePickupAddress.setValue(true);
pickupCustomerId = null;
pickupMail = null; pickupMail = null;
}); });
} }
@@ -1817,8 +1840,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
return; return;
} }
// NEU: Kunden anlegen, wenn Checkboxen aktiviert // Kunden anlegen/aktualisieren bzw. intern sichern
if (savePickupAddress.getValue()) {
Customer pickupCustomer = new Customer(); Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue()); pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue()); pickupCustomer.setTitle(pickupSalutation.getValue());
@@ -1831,11 +1853,18 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue()); pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue()); pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue()); pickupCustomer.setCity(pickupCity.getValue());
if (savePickupAddress.getValue()) {
if (pickupCustomerId != null) {
pickupCustomer.setId(pickupCustomerId);
addCustomerService.updateCustomer(pickupCustomer);
} else {
addCustomerService.addCustomer(pickupCustomer); addCustomerService.addCustomer(pickupCustomer);
} }
// Save delivery station addresses as customers if checkbox is checked } else if (pickupAddressDiffers) {
addCustomerService.addInternalCustomer(pickupCustomer);
}
// Delivery-Stationen: anlegen, aktualisieren oder als intern sichern
for (int i = 0; i < deliveryStationsState.size(); i++) { for (int i = 0; i < deliveryStationsState.size(); i++) {
if (i < deliveryStationsSaveAddress.size() && deliveryStationsSaveAddress.get(i)) {
DeliveryStation ds = deliveryStationsState.get(i); DeliveryStation ds = deliveryStationsState.get(i);
Customer deliveryCustomer = new Customer(); Customer deliveryCustomer = new Customer();
deliveryCustomer.setCompanyName(ds.getCompany()); deliveryCustomer.setCompanyName(ds.getCompany());
@@ -1849,8 +1878,23 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryCustomer.setAddressAddition(ds.getAddressAddition()); deliveryCustomer.setAddressAddition(ds.getAddressAddition());
deliveryCustomer.setZip(ds.getZip()); deliveryCustomer.setZip(ds.getZip());
deliveryCustomer.setCity(ds.getCity()); 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); addCustomerService.addCustomer(deliveryCustomer);
} }
} else if (addressDiffers) {
addCustomerService.addInternalCustomer(deliveryCustomer);
}
} }
// Collect tasks from all delivery stations and set stationOrder // Collect tasks from all delivery stations and set stationOrder
@@ -1944,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
*/ */
private void loadJobIntoForm(Job job) { private void loadJobIntoForm(Job job) {
if (job.getCustomerSelection() != null) { if (job.getCustomerSelection() != null) {
customerSelection.setValue(job.getCustomerSelection()); setCustomerSelectionValue(job.getCustomerSelection());
} }
} }
@@ -2093,7 +2137,105 @@ public class AddJobView extends Main implements HasDynamicTitle {
return null; 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() { private void handleApplyStations() {
removeEmptyDeliveryStations();
revealPriceAndDetailsSection(); revealPriceAndDetailsSection();
if (!areAllStationsValidatedByGoogle()) { 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.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; 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.HasDynamicTitle;
import com.vaadin.flow.router.Route; 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.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; 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 de.assecutor.votianlt.service.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -61,13 +63,15 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final SecurityService securityService; private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final CustomerService customerService; private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private User currentUser; private User currentUser;
private Job currentJob; private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>(); private List<ServiceRow> gridRows = new ArrayList<>();
private Grid<ServiceRow> servicesGrid; private Grid<ServiceRow> servicesGrid;
private Div servicesSection; private Div servicesSection;
private Div summarySection;
private NumberField vatField;
/** /**
* Helper class to represent a row in the services grid * 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, public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
UserRepository userRepository, CustomerInvoiceService customerInvoiceService, UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository, UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
CustomerService customerService) { InvoiceLifecycleService invoiceLifecycleService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -123,8 +127,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.securityService = securityService; this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceRepository = customerInvoiceRepository;
this.customerService = customerService; this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -176,6 +180,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return; return;
} }
currentUser = securityService.getAuthenticatedUser()
.flatMap(auth -> userRepository.findByEmail(auth.getUsername())).orElse(null);
createInvoiceView(); createInvoiceView();
} }
@@ -203,8 +210,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Div servicesSection = createServicesSelectionSection(); Div servicesSection = createServicesSelectionSection();
add(servicesSection); add(servicesSection);
// VAT Section (must exist before summary so effectiveVatRate() can read the field)
Div vatSection = createVatSection();
add(vatSection);
// Summary Section // Summary Section
Div summarySection = createSummarySection(); summarySection = createSummarySection();
add(summarySection); add(summarySection);
// Create Invoice Button // Create Invoice Button
@@ -336,13 +347,16 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
section.setWidthFull(); section.setWidthFull();
section.addClassName("invoice-section-card"); section.addClassName("invoice-section-card");
section.getStyle().set("margin-bottom", "var(--lumo-space-m)"); 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")); H3 sectionTitle = new H3(getTranslation("createinvoice.section.summary"));
section.add(sectionTitle); section.add(sectionTitle);
// Calculate totals
BigDecimal netAmount = calculateNetAmount(); BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -355,9 +369,40 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
totalAmount.setScale(2, RoundingMode.HALF_UP) + "", true)); totalAmount.setScale(2, RoundingMode.HALF_UP) + "", true));
section.add(priceTable); 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; return section;
} }
private void refreshSummarySection() {
if (summarySection == null) {
return;
}
summarySection.removeAll();
populateSummarySection(summarySection);
}
private Div createPriceRow(String label, String value, boolean bold) { private Div createPriceRow(String label, String value, boolean bold) {
Div row = new Div(); Div row = new Div();
row.addClassName("price-row"); row.addClassName("price-row");
@@ -427,6 +472,17 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return units; 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() { private BigDecimal calculateNetAmount() {
BigDecimal total = BigDecimal.ZERO; BigDecimal total = BigDecimal.ZERO;
@@ -514,7 +570,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId()); String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
BigDecimal netAmount = calculateNetAmount(); BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -529,8 +585,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatRate(vatRate); invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
invoice.setPdfData(pdfBytes); 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()); currentJob.setInvoiceId(savedInvoice.getId());
jobRepository.save(currentJob); jobRepository.save(currentJob);
@@ -539,6 +599,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000, Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
Notification.Position.BOTTOM_END); 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) { } catch (Exception ex) {
log.error("Fehler beim Speichern der Rechnung", ex); log.error("Fehler beim Speichern der Rechnung", ex);
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000, Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
@@ -553,7 +616,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
throws Exception { throws Exception {
// Calculate totals // Calculate totals
BigDecimal netAmount = calculateNetAmount(); BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);

View File

@@ -36,7 +36,8 @@ public class CustomersView extends Main implements HasDynamicTitle {
createBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); createBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
todoGrid = new Grid<>(); 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.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"));
todoGrid.setSizeFull(); todoGrid.setSizeFull();
todoGrid.addClassName("data-grid"); 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 -> { Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
if (customer != null && customer.getId() != null) { if (customer != null && customer.getId() != null) {
customerService.deleteById(customer.getId());
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000, Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
Notification.Position.MIDDLE); Notification.Position.MIDDLE);
confirmDialog.close(); confirmDialog.close();

View File

@@ -76,6 +76,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private UserInvoiceData currentInvoiceData; private UserInvoiceData currentInvoiceData;
private Checkbox billingEnabled; private Checkbox billingEnabled;
private NumberField vatRateField;
private VerticalLayout propertiesPanelProfile; private VerticalLayout propertiesPanelProfile;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
@@ -330,10 +331,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
termsTextArea = new TextArea(); termsTextArea = new TextArea();
pdfFrame = new IFrame(); pdfFrame = new IFrame();
// Nur die Checkbox "Rechnungslegung über votianLT" // Checkbox "Rechnungslegung über votianLT"
billingEnabled = new Checkbox(getTranslation("profile.billing.enabled")); billingEnabled = new Checkbox(getTranslation("profile.billing.enabled"));
billingEnabled.setValue(true); // Standardmäßig aktiviert billingEnabled.setValue(true); // Standardmäßig aktiviert
billingTab.add(billingEnabled);
prefixField.setLabel(getTranslation("profile.billing.prefix")); prefixField.setLabel(getTranslation("profile.billing.prefix"));
prefixField.setPlaceholder("z.B. RE-2024-"); 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', '" "if (window.updateProfileMasterdataValue) { window.updateProfileMasterdataValue('masterdata.invoice_number', '"
+ invNr.replace("'", "\\'") + "'); }"); + 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) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout(); final HorizontalLayout mainLayout = new HorizontalLayout();
@@ -451,6 +474,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingEnabled.addValueChangeListener(e -> { billingEnabled.addValueChangeListener(e -> {
boolean visible = e.getValue(); boolean visible = e.getValue();
prefixField.setVisible(visible); prefixField.setVisible(visible);
vatRateField.setVisible(visible);
mainLayout.setVisible(visible); mainLayout.setVisible(visible);
actionLayout.setVisible(visible); actionLayout.setVisible(visible);
}); });
@@ -843,6 +867,17 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
&& !houseNumberField.isInvalid() && !zipField.isInvalid() && !cityField.isInvalid(); && !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 // Methoden für den Rechnungsgenerator im Profil
private void generatePreviewPdfFromProfile() { private void generatePreviewPdfFromProfile() {
try { try {
@@ -862,8 +897,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} else { } else {
templateData = result.toString(); templateData = result.toString();
} }
BigDecimal previewVatRate = getPreviewVatRate();
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
currentUser, prefixField.getValue()); currentUser, prefixField.getValue(), previewVatRate);
showPdfInDialog(pdfBytes); showPdfInDialog(pdfBytes);
} catch (Exception ex) { } catch (Exception ex) {
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), 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("'", "\\'") + city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "'," + "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
+ "'masterdata.invoice_number': '" + invoiceNumber.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() { " getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " + " 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 + "'); " + masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
+ " window.loadProfileTemplate(templateData); " + " } else { " + " window.loadProfileTemplate(templateData); " + " } else { "
+ " console.error('loadProfileTemplate or canvas not available'); " + " } " + " console.error('loadProfileTemplate or canvas not available'); " + " } "

View File

@@ -1,39 +1,82 @@
package de.assecutor.votianlt.pages.view; 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.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div; 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.html.Span;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent; 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.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.HasDynamicTitle;
import com.vaadin.flow.router.Route; 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.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.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; 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 jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; 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) @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
public class InvoicesView extends VerticalLayout implements HasDynamicTitle { 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 Grid<CustomerInvoice> invoiceGrid;
private final CustomerInvoiceRepository customerInvoiceRepository; private final CustomerInvoiceRepository customerInvoiceRepository;
private final SecurityService securityService; 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.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
@@ -43,60 +86,502 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
addClassName("data-view"); addClassName("data-view");
add(new ViewToolbar(getTranslation("invoices.title"))); add(new ViewToolbar(getTranslation("invoices.title")));
add(buildLegalDisclaimer());
invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull(); invoiceGrid.setWidthFull();
invoiceGrid.addClassName("data-grid"); invoiceGrid.addClassName("data-grid");
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true); .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")) invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
invoiceGrid.getStyle().set("cursor", "pointer");
invoiceGrid.addItemClickListener(event -> { invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
CustomerInvoice invoice = event.getItem();
if (invoice != null) {
downloadInvoicePdf(invoice);
}
});
loadInvoices();
Div gridPanel = new Div(invoiceGrid); Div gridPanel = new Div(invoiceGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel"); gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull(); gridPanel.setWidthFull();
add(gridPanel); 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() { private void loadInvoices() {
String currentUserId = securityService.getCurrentUserId().toHexString(); String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData).sorted((left, right) -> { .sorted(Comparator
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { .comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
return 0; : i.getInvoiceDate())
} .reversed())
if (left.getInvoiceDate() == null) { .toList();
return 1;
}
if (right.getInvoiceDate() == null) {
return -1;
}
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}).toList();
invoiceGrid.setItems(invoices); 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) { 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) { private String getRecipientLabel(CustomerInvoice invoice) {
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), ""); 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)); 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 // Create Job History Button for toolbar
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory")); Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); 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())); 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 toolbar with buttons
if (manualCompleteButton != null) {
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
jobHistoryButton));
} else {
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton)); add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
}
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId); List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job); List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);

View File

@@ -86,7 +86,8 @@ public class ShowCustomersView extends VerticalLayout implements HasDynamicTitle
var customers = customerService.findAll(); var customers = customerService.findAll();
var currentUserId = securityService.getCurrentUserId(); var currentUserId = securityService.getCurrentUserId();
var ownCustomers = customers.stream() 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); grid.setItems(ownCustomers);
} }

View File

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

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.repository; package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
Optional<CustomerInvoice> findByJobId(String jobId); Optional<CustomerInvoice> findByJobId(String jobId);
List<CustomerInvoice> findByUserId(String userId); 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 @Repository
public interface SignatureRepository extends MongoRepository<Signature, ObjectId> { public interface SignatureRepository extends MongoRepository<Signature, ObjectId> {
List<Signature> findByTaskId(ObjectId taskId); 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, public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix) throws Exception { 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 // Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData); com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
@@ -458,7 +465,7 @@ public class CustomerInvoiceService {
} }
} else if ("services.list".equals(variable)) { } else if ("services.list".equals(variable)) {
// Render services list as a table // Render services list as a table
htmlBuilder.append(generateServicesTableHtml(mmWidth)); htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
} else if (text.contains("<br>")) { } else if (text.contains("<br>")) {
// Multi-line text: render without nowrap so <br> tags work // Multi-line text: render without nowrap so <br> tags work
htmlBuilder.append("<span>").append(text).append("</span>"); htmlBuilder.append("<span>").append(text).append("</span>");
@@ -484,16 +491,23 @@ public class CustomerInvoiceService {
/** /**
* Generate HTML table for services list with summary section below. * 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(); 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) // Sample data for preview (will be replaced with actual job data later)
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", "19%", "450,00 €" }, String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", vatLabel, "450,00 €" },
{ "Entsorgung Möbel", "19%", "85,00 €" }, { "Montage/De-Montage", "19%", "120,00 €" } }; { "Entsorgung Möbel", vatLabel, "85,00 €" }, { "Montage/De-Montage", vatLabel, "120,00 €" } };
// Calculate totals // Calculate totals
double netTotal = 655.00; double netTotal = 655.00;
double grossTotal = 779.45; double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
// Wrapper div // Wrapper div
html.append("<div style='width:100%;box-sizing:border-box;'>"); html.append("<div style='width:100%;box-sizing:border-box;'>");
@@ -797,7 +811,9 @@ public class CustomerInvoiceService {
// Get invoice data from variables // Get invoice data from variables
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €"); 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 grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
// Parse services JSON from variables // Parse services JSON from variables
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>(); java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
@@ -822,7 +838,9 @@ public class CustomerInvoiceService {
// Header row // Header row
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>"); html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
html.append( 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( html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>"); "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
html.append("</tr>"); html.append("</tr>");
@@ -832,7 +850,7 @@ public class CustomerInvoiceService {
// Fallback: show a single row with no data // Fallback: show a single row with no data
html.append("<tr style='border-bottom:1px solid #eeeeee;'>"); html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append( 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>"); html.append("</tr>");
} else { } else {
for (int i = 0; i < servicesData.size(); i++) { 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);" : ""; 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("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append( 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>"); .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%;'>") html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(netAmount).append(" €</td>"); .append(netAmount).append(" €</td>");
html.append("</tr>"); html.append("</tr>");
@@ -865,6 +885,15 @@ public class CustomerInvoiceService {
.append(netTotal).append("</td>"); .append(netTotal).append("</td>");
html.append("</tr>"); 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 // Gesamtsumme
html.append("<tr>"); html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>"); 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;") return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;"); .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; package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -22,6 +24,7 @@ public class EmailService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final TaskAssignmentService taskAssignmentService; private final TaskAssignmentService taskAssignmentService;
private final AppUserRepository appUserRepository;
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
@Value("${spring.mail.username}") @Value("${spring.mail.username}")
@@ -52,8 +55,10 @@ public class EmailService {
return; return;
} }
String completedByName = resolveCompletedByName(job, completedBy);
// Send email // 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(), log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId); 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(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
@@ -71,18 +76,17 @@ public class EmailService {
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); "Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user); String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
String taskTypeName = getTaskTypeDisplayName(taskType); String taskTypeName = getTaskTypeDisplayName(taskType);
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n"); 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"); body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
if (job.getDeliveryCompany() != null) { if (job.getDeliveryCompany() != null) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
} }
body.append("Aufgabe: ").append(taskTypeName).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(); String deliveryCities = job.getDeliveryCitiesDisplay();
if (job.getPickupCity() != null || deliveryCities != null) { if (job.getPickupCity() != null || deliveryCities != null) {
@@ -121,16 +125,55 @@ public class EmailService {
return fullName.isEmpty() ? "Benutzer" : fullName; return fullName.isEmpty() ? "Benutzer" : fullName;
} }
private String buildAppUserName(User user) { private String buildAppUserName(AppUser appUser) {
StringBuilder name = new StringBuilder(); StringBuilder name = new StringBuilder();
if (user.getFirstname() != null && !user.getFirstname().isBlank()) { if (appUser.getVorname() != null && !appUser.getVorname().isBlank()) {
name.append(user.getFirstname()).append(" "); name.append(appUser.getVorname()).append(" ");
} }
if (user.getName() != null && !user.getName().isBlank()) { if (appUser.getNachname() != null && !appUser.getNachname().isBlank()) {
name.append(user.getName()); name.append(appUser.getNachname());
} }
String fullName = name.toString().trim(); 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) { private String getTaskTypeDisplayName(String taskType) {
@@ -173,8 +216,10 @@ public class EmailService {
return; return;
} }
String completedByName = resolveCompletedByName(job, completedBy);
// Send job completion email // Send job completion email
sendJobCompletionEmail(user, job); sendJobCompletionEmail(user, job, completedByName);
log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber()); log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber());
} catch (Exception e) { } 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(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
@@ -190,7 +235,6 @@ public class EmailService {
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); "Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user); String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
// Count completed tasks // Count completed tasks
var allTasks = taskAssignmentService.findTasksForJob(job); var allTasks = taskAssignmentService.findTasksForJob(job);
@@ -220,7 +264,7 @@ public class EmailService {
} }
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n"); 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("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
body.append("Mit freundlichen Grüßen,\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 # WebSocket Configuration
app.messaging.websocket.path=/ws/messaging 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.max-session-idle-timeout=300000
app.messaging.websocket.allowed-origins=* app.messaging.websocket.allowed-origins=*

View File

@@ -5,7 +5,7 @@ dialog.confirm=Bestätigen
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Aufträge nav.jobs=Aufträge
nav.job.create=Auftragserstellung nav.job.create=Auftragserstellung
nav.customers=Kunden nav.customers=Adressbuch
nav.appusers=App-Nutzer nav.appusers=App-Nutzer
nav.statistics=Statistiken nav.statistics=Statistiken
nav.invoices=Rechnungen nav.invoices=Rechnungen
@@ -31,7 +31,7 @@ profile.lastname=Nachname
profile.phone=Telefonnummer profile.phone=Telefonnummer
profile.fax=Telefon (Fax) profile.fax=Telefon (Fax)
profile.mobile=Telefon (Mobil) profile.mobile=Telefon (Mobil)
profile.email=E-Mail-Adresse (Login)* profile.email=E-Mail-Adresse
profile.street=Straße profile.street=Straße
profile.housenr=Hausnr profile.housenr=Hausnr
profile.addressadd=Adresszusatz 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.digitalprocess.info=Aufträge werden digital über die App abgewickelt
profile.settings.locateappuser=App-Nutzer orten profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
profile.account=Konto profile.account=Konto
profile.security=Sicherheit profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -241,7 +242,7 @@ page.title.appuser.create=Neuen App-Nutzer anlegen
page.title.messages=Nachrichten page.title.messages=Nachrichten
page.title.register=Bei VotianLT registrieren page.title.register=Bei VotianLT registrieren
page.title.customers=Kunden page.title.customers=Kunden
page.title.customer.edit=Kunde bearbeiten page.title.customer.edit=Adresse bearbeiten
page.title.verwaltung=Verwaltung page.title.verwaltung=Verwaltung
page.title.company.create=Neue Firma anlegen page.title.company.create=Neue Firma anlegen
page.title.imprint=Impressum page.title.imprint=Impressum
@@ -338,13 +339,13 @@ customers.column.street=Straße
customers.column.city=Ort customers.column.city=Ort
# Edit Customer # Edit Customer
editcustomer.title=Kunde bearbeiten editcustomer.title=Adresse bearbeiten
editcustomer.notification.notfound=Kunde nicht gefunden editcustomer.notification.notfound=Adresse nicht gefunden
editcustomer.notification.invalid.id=Ungültige Kunden-ID editcustomer.notification.invalid.id=Ungültige Adress-ID
editcustomer.notification.saved=Kunde erfolgreich gespeichert editcustomer.notification.saved=Adresse erfolgreich gespeichert
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
editcustomer.notification.deleted=Kunde erfolgreich gelöscht editcustomer.notification.deleted=Adresse erfolgreich gelöscht
editcustomer.dialog.delete.text=Möchten Sie diesen Kunden wirklich löschen? editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
editcustomer.dialog.delete.confirm=Löschen editcustomer.dialog.delete.confirm=Löschen
# Add Customer # Add Customer
@@ -428,9 +429,9 @@ messages.sender.unknown=Unbekannter Absender
# Add Job # Add Job
addjob.title=Neuen Auftrag anlegen addjob.title=Neuen Auftrag anlegen
addjob.customer.label=Kunde addjob.customer.label=Auftraggeber
addjob.customer.placeholder=Kunde auswählen addjob.customer.placeholder=Auftraggeber auswählen
addjob.customer.unnamed=Unbenannter Kunde addjob.customer.unnamed=Unbenannter Auftraggeber
addjob.button.clearfields=Felder leeren addjob.button.clearfields=Felder leeren
addjob.button.submit=Auftrag anlegen addjob.button.submit=Auftrag anlegen
addjob.address.salutation=Anrede addjob.address.salutation=Anrede
@@ -439,6 +440,10 @@ addjob.salutation.mr=Herr
addjob.salutation.ms=Frau addjob.salutation.ms=Frau
addjob.salutation.other=Divers addjob.salutation.other=Divers
addjob.address.company.placeholder=Firma eingeben 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.street.placeholder=Straße eingeben
addjob.address.housenumber=Hausnummer addjob.address.housenumber=Hausnummer
addjob.address.addition.placeholder=Adresszusatz 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.city.placeholder.delivery=Ort (Lieferung)
addjob.address.delivery.street.placeholder=Straße (Lieferung) addjob.address.delivery.street.placeholder=Straße (Lieferung)
addjob.address.delivery.addition.placeholder=Adresszusatz (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.pickup=Abholung
addjob.section.delivery=Lieferung addjob.section.delivery=Lieferung
addjob.stations.apply=Stationen \u00fcbernehmen 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.station.unused=Nicht genutzt
addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt. addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
addjob.tab.addresses=Auftraggeber & Adressen addjob.tab.addresses=Auftraggeber & Adressen
addjob.tab.pickup.address=Auftraggeber & Abholadresse
addjob.tab.delivery.address=Lieferadresse
addjob.tab.appointments=Termine & Verarbeitung addjob.tab.appointments=Termine & Verarbeitung
addjob.tab.cargo=Fracht addjob.tab.cargo=Fracht
addjob.tab.tasks=Aufgaben addjob.tab.tasks=Aufgaben
@@ -513,7 +521,8 @@ addjob.tasks.photo.min=Min. Fotos
addjob.tasks.photo.max=Max. Fotos addjob.tasks.photo.max=Max. Fotos
addjob.tasks.barcode.min=Min. Barcodes addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. 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.title=To-Do Liste
addjob.tasks.todolist.item.placeholder=To-Do eingeben addjob.tasks.todolist.item.placeholder=To-Do eingeben
addjob.tasks.todolist.add=To-Do hinzufügen 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.task.button.text=Button-Text
jobsummary.button.schliessen=Schließen jobsummary.button.schliessen=Schließen
jobsummary.route.planned=Geplante Route 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
jobs.title=Aufträge jobs.title=Aufträge
@@ -652,6 +672,8 @@ createinvoice.section.job=Auftragsdetails
createinvoice.section.route=Streckeninfo createinvoice.section.route=Streckeninfo
createinvoice.section.services=Leistungen createinvoice.section.services=Leistungen
createinvoice.section.summary=Zusammenfassung createinvoice.section.summary=Zusammenfassung
createinvoice.section.vat=Umsatzsteuer
createinvoice.field.vatrate=USt-Satz
createinvoice.field.jobnumber=Auftragsnummer createinvoice.field.jobnumber=Auftragsnummer
createinvoice.field.customer=Kunde createinvoice.field.customer=Kunde
createinvoice.field.status=Status createinvoice.field.status=Status
@@ -684,6 +706,72 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt. invoices.empty=Es wurden noch keine Rechnungen erstellt.
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert. 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 # My Invoices
myinvoices.title=Rechnungen myinvoices.title=Rechnungen

View File

@@ -3,7 +3,7 @@ dialog.cancel=T\u00fchista
dialog.confirm=Kinnita dialog.confirm=Kinnita
nav.jobs=Tellimused nav.jobs=Tellimused
nav.job.create=Tellimuse loomine nav.job.create=Tellimuse loomine
nav.customers=Kliendid nav.customers=Aadressiraamat
nav.appusers=\u00c4pikasutajad nav.appusers=\u00c4pikasutajad
nav.statistics=Statistika nav.statistics=Statistika
nav.invoices=Arved nav.invoices=Arved
@@ -27,7 +27,7 @@ profile.lastname=Perekonnanimi
profile.phone=Telefoninumber profile.phone=Telefoninumber
profile.fax=Telefon (faks) profile.fax=Telefon (faks)
profile.mobile=Telefon (mobiil) profile.mobile=Telefon (mobiil)
profile.email=E-posti aadress (sisselogimine)* profile.email=E-posti aadress
profile.street=T\u00e4nav profile.street=T\u00e4nav
profile.housenr=Majanumber profile.housenr=Majanumber
profile.addressadd=Aadressi t\u00e4iend profile.addressadd=Aadressi t\u00e4iend
@@ -377,9 +377,9 @@ messages.preview.image=Pilt
messages.preview.empty=Eelvaade puudub messages.preview.empty=Eelvaade puudub
messages.sender.unknown=Tundmatu saatja messages.sender.unknown=Tundmatu saatja
addjob.title=Uue tellimuse loomine addjob.title=Uue tellimuse loomine
addjob.customer.label=Klient addjob.customer.label=Tellija
addjob.customer.placeholder=Valige klient addjob.customer.placeholder=Vali tellija
addjob.customer.unnamed=Nimetu klient addjob.customer.unnamed=Nimetu tellija
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
addjob.button.submit=Loo tellimus addjob.button.submit=Loo tellimus
addjob.address.salutation=P\u00f6\u00f6rdumine addjob.address.salutation=P\u00f6\u00f6rdumine
@@ -388,6 +388,10 @@ addjob.salutation.mr=Hr
addjob.salutation.ms=Pr addjob.salutation.ms=Pr
addjob.salutation.other=Muu addjob.salutation.other=Muu
addjob.address.company.placeholder=Sisestage ettev\u00f5te 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.street.placeholder=Sisestage t\u00e4nav
addjob.address.housenumber=Majanumber addjob.address.housenumber=Majanumber
addjob.address.addition.placeholder=Aadressi t\u00e4iend 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.city.placeholder.delivery=Asukoht (kohaletoimetamine)
addjob.address.delivery.street.placeholder=T\u00e4nav (kohaletoimetamine) addjob.address.delivery.street.placeholder=T\u00e4nav (kohaletoimetamine)
addjob.address.delivery.addition.placeholder=Aadressi t\u00e4iend (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.pickup=Pealekorje
addjob.section.delivery=Kohaletoimetamine addjob.section.delivery=Kohaletoimetamine
addjob.stations.apply=Rakenda jaamad 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.station.unused=Kasutamata
addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades. addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
addjob.tab.addresses=Tellija ja aadressid 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.appointments=Ajad ja t\u00f6\u00f6tlemine
addjob.tab.cargo=Veosed addjob.tab.cargo=Veosed
addjob.tab.tasks=\u00dclesanded addjob.tab.tasks=\u00dclesanded
@@ -462,7 +469,8 @@ addjob.tasks.photo.min=Min. fotosid
addjob.tasks.photo.max=Max. fotosid addjob.tasks.photo.max=Max. fotosid
addjob.tasks.barcode.min=Min. v\u00f6\u00f6tkoode addjob.tasks.barcode.min=Min. v\u00f6\u00f6tkoode
addjob.tasks.barcode.max=Max. 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.title=\u00dclesannete nimekiri
addjob.tasks.todolist.item.placeholder=Sisestage \u00fclesanne addjob.tasks.todolist.item.placeholder=Sisestage \u00fclesanne
addjob.tasks.todolist.add=Lisa \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.task.button.text=Nupu tekst
jobsummary.button.schliessen=Sulge jobsummary.button.schliessen=Sulge
jobsummary.route.planned=Planeeritud marsruut 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.title=Tellimused
jobs.filter.search=Otsi jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi... jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirm
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Jobs nav.jobs=Jobs
nav.job.create=Create Job nav.job.create=Create Job
nav.customers=Customers nav.customers=Address Book
nav.appusers=App Users nav.appusers=App Users
nav.statistics=Statistics nav.statistics=Statistics
nav.invoices=Invoices nav.invoices=Invoices
@@ -31,7 +31,7 @@ profile.lastname=Last Name
profile.phone=Phone Number profile.phone=Phone Number
profile.fax=Phone (Fax) profile.fax=Phone (Fax)
profile.mobile=Phone (Mobile) profile.mobile=Phone (Mobile)
profile.email=Email Address (Login)* profile.email=Email Address
profile.street=Street profile.street=Street
profile.housenr=House No. profile.housenr=House No.
profile.addressadd=Address Suffix 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.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
profile.account=Account profile.account=Account
profile.security=Security profile.security=Security
profile.security.twofactor=Two-Factor Authentication profile.security.twofactor=Two-Factor Authentication
@@ -428,9 +429,9 @@ messages.sender.unknown=Unknown Sender
# Add Job # Add Job
addjob.title=Create New Job addjob.title=Create New Job
addjob.customer.label=Customer addjob.customer.label=Principal
addjob.customer.placeholder=Select Customer addjob.customer.placeholder=Select principal
addjob.customer.unnamed=Unnamed Customer addjob.customer.unnamed=Unnamed principal
addjob.button.clearfields=Clear Fields addjob.button.clearfields=Clear Fields
addjob.button.submit=Create Job addjob.button.submit=Create Job
addjob.address.salutation=Salutation addjob.address.salutation=Salutation
@@ -439,6 +440,10 @@ addjob.salutation.mr=Mr
addjob.salutation.ms=Ms addjob.salutation.ms=Ms
addjob.salutation.other=Other addjob.salutation.other=Other
addjob.address.company.placeholder=Enter company 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.street.placeholder=Enter street
addjob.address.housenumber=House Number addjob.address.housenumber=House Number
addjob.address.addition.placeholder=Address suffix 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.city.placeholder.delivery=City (Delivery)
addjob.address.delivery.street.placeholder=Street (Delivery) addjob.address.delivery.street.placeholder=Street (Delivery)
addjob.address.delivery.addition.placeholder=Address suffix (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.pickup=Pickup
addjob.section.delivery=Delivery addjob.section.delivery=Delivery
addjob.stations.apply=Apply Stations 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.station.unused=Not used
addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations. addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
addjob.tab.addresses=Client & Addresses 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.appointments=Appointments & Processing
addjob.tab.cargo=Cargo addjob.tab.cargo=Cargo
addjob.tab.tasks=Tasks addjob.tab.tasks=Tasks
@@ -513,7 +521,8 @@ addjob.tasks.photo.min=Min. Photos
addjob.tasks.photo.max=Max. Photos addjob.tasks.photo.max=Max. Photos
addjob.tasks.barcode.min=Min. Barcodes addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. 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.title=To-Do List
addjob.tasks.todolist.item.placeholder=Enter to-do addjob.tasks.todolist.item.placeholder=Enter to-do
addjob.tasks.todolist.add=Add 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.task.button.text=Button Text
jobsummary.button.schliessen=Close jobsummary.button.schliessen=Close
jobsummary.route.planned=Planned Route 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
jobs.title=Jobs jobs.title=Jobs
@@ -652,6 +672,8 @@ createinvoice.section.job=Job Details
createinvoice.section.route=Route Info createinvoice.section.route=Route Info
createinvoice.section.services=Services createinvoice.section.services=Services
createinvoice.section.summary=Summary createinvoice.section.summary=Summary
createinvoice.section.vat=VAT
createinvoice.field.vatrate=VAT rate
createinvoice.field.jobnumber=Job Number createinvoice.field.jobnumber=Job Number
createinvoice.field.customer=Customer createinvoice.field.customer=Customer
createinvoice.field.status=Status createinvoice.field.status=Status
@@ -684,6 +706,72 @@ invoices.column.amount=Amount
invoices.column.description=Description invoices.column.description=Description
invoices.empty=No invoices have been created yet. invoices.empty=No invoices have been created yet.
invoices.notification.pdf.missing=No PDF is stored for this invoice. 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 # My Invoices
myinvoices.title=Invoices myinvoices.title=Invoices

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmar
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Pedidos nav.jobs=Pedidos
nav.job.create=Crear pedido nav.job.create=Crear pedido
nav.customers=Clientes nav.customers=Libreta de direcciones
nav.appusers=Usuarios de la app nav.appusers=Usuarios de la app
nav.statistics=Estad\u00edsticas nav.statistics=Estad\u00edsticas
nav.invoices=Facturas nav.invoices=Facturas
@@ -31,7 +31,7 @@ profile.lastname=Apellido
profile.phone=N\u00famero de tel\u00e9fono profile.phone=N\u00famero de tel\u00e9fono
profile.fax=Tel\u00e9fono (Fax) profile.fax=Tel\u00e9fono (Fax)
profile.mobile=Tel\u00e9fono (M\u00f3vil) 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.street=Calle
profile.housenr=N\u00famero profile.housenr=N\u00famero
profile.addressadd=Complemento de direcci\u00f3n profile.addressadd=Complemento de direcci\u00f3n
@@ -428,9 +428,9 @@ messages.sender.unknown=Remitente desconocido
# Add Job # Add Job
addjob.title=Crear nuevo pedido addjob.title=Crear nuevo pedido
addjob.customer.label=Cliente addjob.customer.label=Ordenante
addjob.customer.placeholder=Seleccionar cliente addjob.customer.placeholder=Seleccionar ordenante
addjob.customer.unnamed=Cliente sin nombre addjob.customer.unnamed=Ordenante sin nombre
addjob.button.clearfields=Vaciar campos addjob.button.clearfields=Vaciar campos
addjob.button.submit=Crear pedido addjob.button.submit=Crear pedido
addjob.address.salutation=Tratamiento addjob.address.salutation=Tratamiento
@@ -439,6 +439,10 @@ addjob.salutation.mr=Sr.
addjob.salutation.ms=Sra. addjob.salutation.ms=Sra.
addjob.salutation.other=Otro addjob.salutation.other=Otro
addjob.address.company.placeholder=Introducir empresa 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.street.placeholder=Introducir calle
addjob.address.housenumber=N\u00famero de casa addjob.address.housenumber=N\u00famero de casa
addjob.address.addition.placeholder=Complemento de direcci\u00f3n 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.city.placeholder.delivery=Localidad (Entrega)
addjob.address.delivery.street.placeholder=Calle (Entrega) addjob.address.delivery.street.placeholder=Calle (Entrega)
addjob.address.delivery.addition.placeholder=Complemento de direcci\u00f3n (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.pickup=Recogida
addjob.section.delivery=Entrega addjob.section.delivery=Entrega
addjob.stations.apply=Aplicar estaciones 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.station.unused=No utilizada
addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega. addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
addjob.tab.addresses=Cliente y direcciones 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.appointments=Citas y procesamiento
addjob.tab.cargo=Carga addjob.tab.cargo=Carga
addjob.tab.tasks=Tareas addjob.tab.tasks=Tareas
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=M\u00edn. fotos
addjob.tasks.photo.max=M\u00e1x. fotos addjob.tasks.photo.max=M\u00e1x. fotos
addjob.tasks.barcode.min=M\u00edn. c\u00f3digos de barras addjob.tasks.barcode.min=M\u00edn. c\u00f3digos de barras
addjob.tasks.barcode.max=M\u00e1x. 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.title=Lista de tareas pendientes
addjob.tasks.todolist.item.placeholder=Introducir tarea pendiente addjob.tasks.todolist.item.placeholder=Introducir tarea pendiente
addjob.tasks.todolist.add=A\u00f1adir 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.task.button.text=Texto del bot\u00f3n
jobsummary.button.schliessen=Cerrar jobsummary.button.schliessen=Cerrar
jobsummary.route.planned=Ruta planificada 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
jobs.title=Pedidos jobs.title=Pedidos

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmer
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Missions nav.jobs=Missions
nav.job.create=Cr\u00e9ation de mission nav.job.create=Cr\u00e9ation de mission
nav.customers=Clients nav.customers=Carnet d'adresses
nav.appusers=Utilisateurs d'app nav.appusers=Utilisateurs d'app
nav.statistics=Statistiques nav.statistics=Statistiques
nav.invoices=Factures nav.invoices=Factures
@@ -31,7 +31,7 @@ profile.lastname=Nom
profile.phone=Num\u00e9ro de t\u00e9l\u00e9phone profile.phone=Num\u00e9ro de t\u00e9l\u00e9phone
profile.fax=T\u00e9l\u00e9phone (fax) profile.fax=T\u00e9l\u00e9phone (fax)
profile.mobile=T\u00e9l\u00e9phone (mobile) profile.mobile=T\u00e9l\u00e9phone (mobile)
profile.email=Adresse e-mail (connexion)* profile.email=Adresse e-mail
profile.street=Rue profile.street=Rue
profile.housenr=N\u00b0 profile.housenr=N\u00b0
profile.addressadd=Compl\u00e9ment d'adresse profile.addressadd=Compl\u00e9ment d'adresse
@@ -428,9 +428,9 @@ messages.sender.unknown=Exp\u00e9diteur inconnu
# Add Job # Add Job
addjob.title=Cr\u00e9er une nouvelle mission addjob.title=Cr\u00e9er une nouvelle mission
addjob.customer.label=Client addjob.customer.label=Donneur d'ordre
addjob.customer.placeholder=S\u00e9lectionner un client addjob.customer.placeholder=S\u00e9lectionner le donneur d'ordre
addjob.customer.unnamed=Client sans nom addjob.customer.unnamed=Donneur d'ordre sans nom
addjob.button.clearfields=Vider les champs addjob.button.clearfields=Vider les champs
addjob.button.submit=Cr\u00e9er la mission addjob.button.submit=Cr\u00e9er la mission
addjob.address.salutation=Civilit\u00e9 addjob.address.salutation=Civilit\u00e9
@@ -439,6 +439,10 @@ addjob.salutation.mr=Monsieur
addjob.salutation.ms=Madame addjob.salutation.ms=Madame
addjob.salutation.other=Autre addjob.salutation.other=Autre
addjob.address.company.placeholder=Saisir l'entreprise 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.street.placeholder=Saisir la rue
addjob.address.housenumber=Num\u00e9ro addjob.address.housenumber=Num\u00e9ro
addjob.address.addition.placeholder=Compl\u00e9ment d'adresse 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.city.placeholder.delivery=Ville (livraison)
addjob.address.delivery.street.placeholder=Rue (livraison) addjob.address.delivery.street.placeholder=Rue (livraison)
addjob.address.delivery.addition.placeholder=Compl\u00e9ment d'adresse (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.pickup=Enl\u00e8vement
addjob.section.delivery=Livraison addjob.section.delivery=Livraison
addjob.stations.apply=Appliquer les stations 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.station.unused=Non utilis\u00e9e
addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison. 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.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.appointments=Rendez-vous & traitement
addjob.tab.cargo=Fret addjob.tab.cargo=Fret
addjob.tab.tasks=T\u00e2ches addjob.tab.tasks=T\u00e2ches
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. photos
addjob.tasks.photo.max=Max. photos addjob.tasks.photo.max=Max. photos
addjob.tasks.barcode.min=Min. codes-barres addjob.tasks.barcode.min=Min. codes-barres
addjob.tasks.barcode.max=Max. 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.title=Liste de t\u00e2ches
addjob.tasks.todolist.item.placeholder=Saisir la t\u00e2che addjob.tasks.todolist.item.placeholder=Saisir la t\u00e2che
addjob.tasks.todolist.add=Ajouter une 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.task.button.text=Texte du bouton
jobsummary.button.schliessen=Fermer jobsummary.button.schliessen=Fermer
jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu 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
jobs.title=Missions jobs.title=Missions

View File

@@ -5,7 +5,7 @@ dialog.confirm=Patvirtinti
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Užsakymai nav.jobs=Užsakymai
nav.job.create=Užsakymo kūrimas nav.job.create=Užsakymo kūrimas
nav.customers=Klientai nav.customers=Adres\u0173 knyga
nav.appusers=Programėlės naudotojai nav.appusers=Programėlės naudotojai
nav.statistics=Statistika nav.statistics=Statistika
nav.invoices=Sąskaitos faktūros nav.invoices=Sąskaitos faktūros
@@ -31,7 +31,7 @@ profile.lastname=Pavardė
profile.phone=Telefono numeris profile.phone=Telefono numeris
profile.fax=Telefonas (faksas) profile.fax=Telefonas (faksas)
profile.mobile=Telefonas (mob.) profile.mobile=Telefonas (mob.)
profile.email=El. pašto adresas (prisijungimas)* profile.email=El. pašto adresas*
profile.street=Gatvė profile.street=Gatvė
profile.housenr=Namo nr. profile.housenr=Namo nr.
profile.addressadd=Adreso priedas profile.addressadd=Adreso priedas
@@ -428,9 +428,9 @@ messages.sender.unknown=Nežinomas siuntėjas
# Add Job # Add Job
addjob.title=Sukurti naują užsakymą addjob.title=Sukurti naują užsakymą
addjob.customer.label=Klientas addjob.customer.label=Užsakovas
addjob.customer.placeholder=Pasirinkite klientą addjob.customer.placeholder=Pasirinkite užsakovą
addjob.customer.unnamed=Klientas be pavadinimo addjob.customer.unnamed=Neįvardytas užsakovas
addjob.button.clearfields=Išvalyti laukus addjob.button.clearfields=Išvalyti laukus
addjob.button.submit=Sukurti užsakymą addjob.button.submit=Sukurti užsakymą
addjob.address.salutation=Kreipinys addjob.address.salutation=Kreipinys
@@ -439,6 +439,10 @@ addjob.salutation.mr=Ponas
addjob.salutation.ms=Ponia addjob.salutation.ms=Ponia
addjob.salutation.other=Kita addjob.salutation.other=Kita
addjob.address.company.placeholder=Įveskite įmonę 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.street.placeholder=Įveskite gatvę
addjob.address.housenumber=Namo numeris addjob.address.housenumber=Namo numeris
addjob.address.addition.placeholder=Adreso priedas 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.city.placeholder.delivery=Vietovė (pristatymas)
addjob.address.delivery.street.placeholder=Gatvė (pristatymas) addjob.address.delivery.street.placeholder=Gatvė (pristatymas)
addjob.address.delivery.addition.placeholder=Adreso priedas (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.pickup=Atsiėmimas
addjob.section.delivery=Pristatymas addjob.section.delivery=Pristatymas
addjob.stations.apply=Pritaikyti stotis 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.station.unused=Nenaudojama
addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse. addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
addjob.tab.addresses=Užsakovas ir adresai 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.appointments=Terminai ir apdorojimas
addjob.tab.cargo=Krovinys addjob.tab.cargo=Krovinys
addjob.tab.tasks=Užduotys addjob.tab.tasks=Užduotys
@@ -513,7 +520,8 @@ addjob.tasks.photo.min=Min. nuotraukų
addjob.tasks.photo.max=Maks. nuotraukų addjob.tasks.photo.max=Maks. nuotraukų
addjob.tasks.barcode.min=Min. brūkšninių kodų addjob.tasks.barcode.min=Min. brūkšninių kodų
addjob.tasks.barcode.max=Maks. 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.title=Užduočių sąrašas
addjob.tasks.todolist.item.placeholder=Įveskite užduotį addjob.tasks.todolist.item.placeholder=Įveskite užduotį
addjob.tasks.todolist.add=Pridėti 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.task.button.text=Mygtuko tekstas
jobsummary.button.schliessen=Uždaryti jobsummary.button.schliessen=Uždaryti
jobsummary.route.planned=Planuotas maršrutas 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
jobs.title=Užsakymai jobs.title=Užsakymai
@@ -652,6 +671,8 @@ createinvoice.section.job=Užsakymo informacija
createinvoice.section.route=Maršruto informacija createinvoice.section.route=Maršruto informacija
createinvoice.section.services=Paslaugos createinvoice.section.services=Paslaugos
createinvoice.section.summary=Santrauka createinvoice.section.summary=Santrauka
createinvoice.section.vat=PVM
createinvoice.field.vatrate=PVM tarifas
createinvoice.field.jobnumber=Užsakymo numeris createinvoice.field.jobnumber=Užsakymo numeris
createinvoice.field.customer=Klientas createinvoice.field.customer=Klientas
createinvoice.field.status=Būsena createinvoice.field.status=Būsena

View File

@@ -5,7 +5,7 @@ dialog.confirm=Apstiprināt
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Uzdevumi nav.jobs=Uzdevumi
nav.job.create=Izveidot uzdevumu nav.job.create=Izveidot uzdevumu
nav.customers=Klienti nav.customers=Adrešu gr\u0101mata
nav.appusers=Lietotnes lietotāji nav.appusers=Lietotnes lietotāji
nav.statistics=Statistika nav.statistics=Statistika
nav.invoices=Rēķini nav.invoices=Rēķini
@@ -31,7 +31,7 @@ profile.lastname=Uzvārds
profile.phone=Tālruņa numurs profile.phone=Tālruņa numurs
profile.fax=Tālrunis (fakss) profile.fax=Tālrunis (fakss)
profile.mobile=Tālrunis (mobilais) profile.mobile=Tālrunis (mobilais)
profile.email=E-pasta adrese (pieteikšanās)* profile.email=E-pasta adrese
profile.street=Iela profile.street=Iela
profile.housenr=Mājas nr. profile.housenr=Mājas nr.
profile.addressadd=Adreses papildinājums profile.addressadd=Adreses papildinājums
@@ -428,9 +428,9 @@ messages.sender.unknown=Nezināms sūtītājs
# Add Job # Add Job
addjob.title=Izveidot jaunu uzdevumu addjob.title=Izveidot jaunu uzdevumu
addjob.customer.label=Klients addjob.customer.label=Pasūtītājs
addjob.customer.placeholder=Izvēlēties klientu addjob.customer.placeholder=Izvēlēties pasūtītāju
addjob.customer.unnamed=Nenosaukts klients addjob.customer.unnamed=Nenosaukts pasūtītājs
addjob.button.clearfields=Notīrīt laukus addjob.button.clearfields=Notīrīt laukus
addjob.button.submit=Izveidot uzdevumu addjob.button.submit=Izveidot uzdevumu
addjob.address.salutation=Uzruna addjob.address.salutation=Uzruna
@@ -439,6 +439,10 @@ addjob.salutation.mr=Kungs
addjob.salutation.ms=Kundze addjob.salutation.ms=Kundze
addjob.salutation.other=Cits addjob.salutation.other=Cits
addjob.address.company.placeholder=Ievadiet uzņēmumu 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.street.placeholder=Ievadiet ielu
addjob.address.housenumber=Mājas numurs addjob.address.housenumber=Mājas numurs
addjob.address.addition.placeholder=Adreses papildinājums 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.city.placeholder.delivery=Vieta (piegāde)
addjob.address.delivery.street.placeholder=Iela (piegāde) addjob.address.delivery.street.placeholder=Iela (piegāde)
addjob.address.delivery.addition.placeholder=Adreses papildinājums (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.pickup=Saņemšana
addjob.section.delivery=Piegāde addjob.section.delivery=Piegāde
addjob.stations.apply=Pārņemt stacijas 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.station.unused=Netiek izmantots
addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās. 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.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.appointments=Termiņi un apstrāde
addjob.tab.cargo=Krava addjob.tab.cargo=Krava
addjob.tab.tasks=Uzdevuma darbības 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.photo.max=Maks. fotogrāfijas
addjob.tasks.barcode.min=Min. svītrkodi addjob.tasks.barcode.min=Min. svītrkodi
addjob.tasks.barcode.max=Maks. 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.title=Uzdevumu saraksts
addjob.tasks.todolist.item.placeholder=Ievadiet uzdevumu addjob.tasks.todolist.item.placeholder=Ievadiet uzdevumu
addjob.tasks.todolist.add=Pievienot 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.task.button.text=Pogas teksts
jobsummary.button.schliessen=Aizvērt jobsummary.button.schliessen=Aizvērt
jobsummary.route.planned=Plānotais maršruts 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
jobs.title=Uzdevumi jobs.title=Uzdevumi

View File

@@ -5,7 +5,7 @@ dialog.confirm=Potwierd\u017a
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=Zlecenia nav.jobs=Zlecenia
nav.job.create=Tworzenie zlecenia nav.job.create=Tworzenie zlecenia
nav.customers=Klienci nav.customers=Ksi\u0105\u017cka adresowa
nav.appusers=U\u017cytkownicy aplikacji nav.appusers=U\u017cytkownicy aplikacji
nav.statistics=Statystyki nav.statistics=Statystyki
nav.invoices=Faktury nav.invoices=Faktury
@@ -31,7 +31,7 @@ profile.lastname=Nazwisko
profile.phone=Numer telefonu profile.phone=Numer telefonu
profile.fax=Telefon (faks) profile.fax=Telefon (faks)
profile.mobile=Telefon (kom\u00f3rkowy) profile.mobile=Telefon (kom\u00f3rkowy)
profile.email=Adres e-mail (login)* profile.email=Adres e-mail
profile.street=Ulica profile.street=Ulica
profile.housenr=Nr domu profile.housenr=Nr domu
profile.addressadd=Dodatek do adresu profile.addressadd=Dodatek do adresu
@@ -428,9 +428,9 @@ messages.sender.unknown=Nieznany nadawca
# Add Job # Add Job
addjob.title=Dodaj nowe zlecenie addjob.title=Dodaj nowe zlecenie
addjob.customer.label=Klient addjob.customer.label=Zleceniodawca
addjob.customer.placeholder=Wybierz klienta addjob.customer.placeholder=Wybierz zleceniodawcę
addjob.customer.unnamed=Klient bez nazwy addjob.customer.unnamed=Nienazwany zleceniodawca
addjob.button.clearfields=Wyczy\u015b\u0107 pola addjob.button.clearfields=Wyczy\u015b\u0107 pola
addjob.button.submit=Utw\u00f3rz zlecenie addjob.button.submit=Utw\u00f3rz zlecenie
addjob.address.salutation=Zwrot grzeczno\u015bciowy addjob.address.salutation=Zwrot grzeczno\u015bciowy
@@ -439,6 +439,10 @@ addjob.salutation.mr=Pan
addjob.salutation.ms=Pani addjob.salutation.ms=Pani
addjob.salutation.other=Inna addjob.salutation.other=Inna
addjob.address.company.placeholder=Wprowad\u017a firm\u0119 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.street.placeholder=Wprowad\u017a ulic\u0119
addjob.address.housenumber=Numer domu addjob.address.housenumber=Numer domu
addjob.address.addition.placeholder=Dodatek do adresu 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.city.placeholder.delivery=Miejscowo\u015b\u0107 (dostawa)
addjob.address.delivery.street.placeholder=Ulica (dostawa) addjob.address.delivery.street.placeholder=Ulica (dostawa)
addjob.address.delivery.addition.placeholder=Dodatek do adresu (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.pickup=Odbi\u00f3r
addjob.section.delivery=Dostawa addjob.section.delivery=Dostawa
addjob.stations.apply=Zastosuj stacje 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.station.unused=Nieu\u017cywana
addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy. addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
addjob.tab.addresses=Zleceniodawca i adresy 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.appointments=Terminy i przetwarzanie
addjob.tab.cargo=\u0141adunek addjob.tab.cargo=\u0141adunek
addjob.tab.tasks=Zadania 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.photo.max=Maks. zdj\u0119\u0107
addjob.tasks.barcode.min=Min. kod\u00f3w kreskowych addjob.tasks.barcode.min=Min. kod\u00f3w kreskowych
addjob.tasks.barcode.max=Maks. 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.title=Lista zada\u0144
addjob.tasks.todolist.item.placeholder=Wprowad\u017a zadanie addjob.tasks.todolist.item.placeholder=Wprowad\u017a zadanie
addjob.tasks.todolist.add=Dodaj 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.task.button.text=Tekst przycisku
jobsummary.button.schliessen=Zamknij jobsummary.button.schliessen=Zamknij
jobsummary.route.planned=Planowana trasa 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
jobs.title=Zlecenia jobs.title=Zlecenia

View File

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

View File

@@ -5,7 +5,7 @@ dialog.confirm=Onayla
# Navigation and Main Layout # Navigation and Main Layout
nav.jobs=\u0130\u015fler nav.jobs=\u0130\u015fler
nav.job.create=\u0130\u015f Olu\u015ftur nav.job.create=\u0130\u015f Olu\u015ftur
nav.customers=M\u00fc\u015fteriler nav.customers=Adres Defteri
nav.appusers=Uygulama Kullan\u0131c\u0131lar\u0131 nav.appusers=Uygulama Kullan\u0131c\u0131lar\u0131
nav.statistics=\u0130statistikler nav.statistics=\u0130statistikler
nav.invoices=Faturalar nav.invoices=Faturalar
@@ -31,7 +31,7 @@ profile.lastname=Soyad
profile.phone=Telefon Numaras\u0131 profile.phone=Telefon Numaras\u0131
profile.fax=Telefon (Faks) profile.fax=Telefon (Faks)
profile.mobile=Telefon (Mobil) profile.mobile=Telefon (Mobil)
profile.email=E-Posta Adresi (Giri\u015f)* profile.email=E-Posta Adresi*
profile.street=Sokak profile.street=Sokak
profile.housenr=Kap\u0131 No profile.housenr=Kap\u0131 No
profile.addressadd=Adres Eki profile.addressadd=Adres Eki
@@ -428,9 +428,9 @@ messages.sender.unknown=Bilinmeyen G\u00f6nderici
# Add Job # Add Job
addjob.title=Yeni \u0130\u015f Olu\u015ftur addjob.title=Yeni \u0130\u015f Olu\u015ftur
addjob.customer.label=M\u00fc\u015fteri addjob.customer.label=Sipari\u015f veren
addjob.customer.placeholder=M\u00fc\u015fteri Se\u00e7in addjob.customer.placeholder=Sipari\u015f vereni se\u00e7
addjob.customer.unnamed=\u0130simsiz M\u00fc\u015fteri addjob.customer.unnamed=\u0130simsiz sipari\u015f veren
addjob.button.clearfields=Alanlar\u0131 Temizle addjob.button.clearfields=Alanlar\u0131 Temizle
addjob.button.submit=\u0130\u015f Olu\u015ftur addjob.button.submit=\u0130\u015f Olu\u015ftur
addjob.address.salutation=Hitap addjob.address.salutation=Hitap
@@ -439,6 +439,10 @@ addjob.salutation.mr=Bay
addjob.salutation.ms=Bayan addjob.salutation.ms=Bayan
addjob.salutation.other=Di\u011fer addjob.salutation.other=Di\u011fer
addjob.address.company.placeholder=\u015eirketi girin 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.street.placeholder=Soka\u011f\u0131 girin
addjob.address.housenumber=Kap\u0131 Numaras\u0131 addjob.address.housenumber=Kap\u0131 Numaras\u0131
addjob.address.addition.placeholder=Adres eki 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.city.placeholder.delivery=\u015eehir (Teslimat)
addjob.address.delivery.street.placeholder=Sokak (Teslimat) addjob.address.delivery.street.placeholder=Sokak (Teslimat)
addjob.address.delivery.addition.placeholder=Adres eki (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.pickup=Al\u0131m
addjob.section.delivery=Teslimat addjob.section.delivery=Teslimat
addjob.stations.apply=\u0130stasyonlar\u0131 \u00fcbernehmennehmen 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.station.unused=Kullan\u0131lm\u0131yor
addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir. addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
addjob.tab.addresses=M\u00fc\u015fteri & Adresler 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.appointments=Randevular & \u0130\u015fleme
addjob.tab.cargo=Kargo addjob.tab.cargo=Kargo
addjob.tab.tasks=G\u00f6revler 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.photo.max=Maks. Foto\u011fraf
addjob.tasks.barcode.min=Min. Barkod addjob.tasks.barcode.min=Min. Barkod
addjob.tasks.barcode.max=Maks. 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.title=Yap\u0131lacaklar Listesi
addjob.tasks.todolist.item.placeholder=Yap\u0131lacak \u00f6\u011feyi girin addjob.tasks.todolist.item.placeholder=Yap\u0131lacak \u00f6\u011feyi girin
addjob.tasks.todolist.add=Yap\u0131lacak \u00d6\u011fe Ekle 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.task.button.text=Buton Metni
jobsummary.button.schliessen=Kapat jobsummary.button.schliessen=Kapat
jobsummary.route.planned=Planlanan Rota 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
jobs.title=\u0130\u015fler 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;
}
}