Compare commits

...

8 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
bba5733783 feat: erweiterte Chat-Funktionalität, UI-Verbesserungen und Lokalisierungsupdates
- Chat: Nachrichten-Status (read/unread), WebSocket-Verbesserungen
- App: Login-Optimierung, Job-Übersicht verbessert, neue Übersetzungen
- Backend: Dialog-Styling, Invoice-Generator, Job-Verwaltung erweitert
- Mehrsprachigkeit: Neue Übersetzungen für DE, EN, ES, ET, FR, LT, LV, PL, RU, TR
2026-04-04 10:30:36 +02:00
116 changed files with 9538 additions and 1562 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,6 +1,8 @@
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 'models/delivery_station.dart'; import 'models/delivery_station.dart';
import 'models/job.dart'; import 'models/job.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
@@ -19,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 {
@@ -51,10 +53,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
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: () {
@@ -93,7 +96,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Text( Text(
widget.job.jobNumber.isNotEmpty widget.job.jobNumber.isNotEmpty
? widget.job.jobNumber ? widget.job.jobNumber
: widget.job.title, : localizeKnownText(context, widget.job.title),
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -136,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(
@@ -160,11 +163,11 @@ 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(
'Lieferstationen (${_deliveryStations.length})', l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -221,12 +224,12 @@ class _CargoItemsViewState extends State<CargoItemsView> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Keine Lieferstationen', AppLocalizations.of(context).noDeliveryStations,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Dieser Job enthält aktuell keine Lieferstationen.', AppLocalizations.of(context).noDeliveryStationsMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -255,6 +258,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
station.company.isNotEmpty && station.company != title station.company.isNotEmpty && station.company != title
? station.company ? station.company
: null; : null;
final l10n = AppLocalizations.of(context);
final addressLines = final addressLines =
<String>[ <String>[
[ [
@@ -289,7 +293,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
stationTitle: stationTitle:
station.displayName.isNotEmpty station.displayName.isNotEmpty
? station.displayName ? station.displayName
: 'Station ${station.stationOrder + 1}', : l10n.stationNumber(station.stationOrder + 1),
), ),
), ),
); );
@@ -309,15 +313,15 @@ 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(
'Station ${station.stationOrder + 1}', l10n.stationNumber(station.stationOrder + 1),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.deepPurple[700], color: AppColors.primaryStrong,
), ),
), ),
), ),
@@ -327,7 +331,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title.isNotEmpty ? title : 'Unbenannte Station', title.isNotEmpty
? localizeKnownText(context, title)
: l10n.unnamedStation,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -353,15 +359,15 @@ 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),
_buildDetailItem( _buildDetailItem(
Icons.phone_outlined, Icons.phone_outlined,
'Telefon', l10n.phone,
station.phone, station.phone,
Colors.green, AppColors.success,
), ),
], ],
if (station.deliveryDate.trim().isNotEmpty || if (station.deliveryDate.trim().isNotEmpty ||
@@ -374,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),
@@ -382,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,7 +4,9 @@ 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 'app_state.dart'; import 'app_state.dart';
import 'models/chat.dart'; import 'models/chat.dart';
import 'models/chat_message.dart'; import 'models/chat_message.dart';
@@ -195,9 +197,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (sender == null || sender.isEmpty) { if (sender == null || sender.isEmpty) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
content: Text(AppLocalizations.of(context).noSenderMessage),
),
); );
} }
return; return;
@@ -233,7 +233,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return; return;
} }
await _chatService.saveOutgoingMessage(result);
_syncActiveChatFromService(); _syncActiveChatFromService();
_messageController.clear(); _messageController.clear();
@@ -250,19 +249,21 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(_activeChat.title, style: const TextStyle(fontSize: 16)), Text(
localizedChatTitle(context, _activeChat),
style: const TextStyle(fontSize: 16),
),
if (isJobChat && _activeChat.jobNumber != null) if (isJobChat && _activeChat.jobNumber != null)
Text( Text(
'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),
@@ -280,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),
@@ -324,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),
@@ -350,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),
@@ -361,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),
), ),
], ],
], ],
@@ -383,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),
); );
} }
@@ -454,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(
@@ -465,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,
), ),
), ),
@@ -479,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(
@@ -507,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),
@@ -540,9 +544,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (sender == null || sender.isEmpty) { if (sender == null || sender.isEmpty) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
content: Text(AppLocalizations.of(context).noSenderMessage),
),
); );
} }
return; return;
@@ -589,7 +591,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return; return;
} }
await _chatService.saveOutgoingMessage(result);
_syncActiveChatFromService(); _syncActiveChatFromService();
if (prepared.bytes.isNotEmpty) { if (prepared.bytes.isNotEmpty) {
@@ -645,7 +646,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
} else if (messageDate == today.subtract(const Duration(days: 1))) { } else if (messageDate == today.subtract(const Duration(days: 1))) {
// Yesterday // Yesterday
return 'Gestern ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; return '${AppLocalizations.of(context).yesterday} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
} else { } else {
// Older - show date and time // Older - show date and time
return '${dateTime.day.toString().padLeft(2, '0')}.${dateTime.month.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; return '${dateTime.day.toString().padLeft(2, '0')}.${dateTime.month.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
@@ -659,21 +660,27 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(_activeChat.title), title: Text(localizedChatTitle(context, _activeChat)),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${AppLocalizations.of(context).status}: ${isJobChat ? AppLocalizations.of(context).chatTypeJob : AppLocalizations.of(context).chatTypeGeneral}'), Text(
'${AppLocalizations.of(context).status}: ${isJobChat ? AppLocalizations.of(context).chatTypeJob : AppLocalizations.of(context).chatTypeGeneral}',
),
const SizedBox(height: 8), const SizedBox(height: 8),
if (isJobChat && _activeChat.jobNumber != null) ...[ if (isJobChat && _activeChat.jobNumber != null) ...[
Text('${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}'), Text(
'${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}',
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
Text('${AppLocalizations.of(context).messages}: ${_messages.length}'), Text(
'${AppLocalizations.of(context).messages}: ${_messages.length}',
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Erstellt: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}', '${AppLocalizations.of(context).created}: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}',
), ),
], ],
), ),

View File

@@ -1,7 +1,9 @@
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 'models/chat.dart'; import 'models/chat.dart';
import 'services/chat_service.dart'; import 'services/chat_service.dart';
import 'widgets/offline_banner.dart'; import 'widgets/offline_banner.dart';
@@ -51,15 +53,9 @@ 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: [ children: [const OfflineBanner(), Expanded(child: _buildBody())],
const OfflineBanner(),
Expanded(child: _buildBody()),
],
), ),
); );
} }
@@ -70,15 +66,19 @@ class _ChatsViewState extends State<ChatsView> {
} }
if (_chats.isEmpty) { if (_chats.isEmpty) {
return const Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.chat_outlined, size: 64, color: Colors.grey), const Icon(
SizedBox(height: 16), Icons.chat_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text( Text(
'Keine Chats verfügbar', AppLocalizations.of(context).noChatsAvailable,
style: TextStyle(fontSize: 16, color: Colors.grey), style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
), ),
], ],
), ),
@@ -98,7 +98,9 @@ class _ChatsViewState extends State<ChatsView> {
final isJobChat = chat.type == ChatType.jobSpecific; final isJobChat = chat.type == ChatType.jobSpecific;
final hasMessages = chat.messages.isNotEmpty; final hasMessages = chat.messages.isNotEmpty;
final previewText = final previewText =
hasMessages ? chat.lastMessagePreview : 'Noch keine Nachrichten'; hasMessages
? chat.lastMessagePreview
: AppLocalizations.of(context).noMessagesYet;
final timeLabel = hasMessages ? _formatTime(chat.lastMessageTime) : '--'; final timeLabel = hasMessages ? _formatTime(chat.lastMessageTime) : '--';
final jobId = chat.jobId?.trim(); final jobId = chat.jobId?.trim();
final jobNumber = chat.jobNumber?.trim(); final jobNumber = chat.jobNumber?.trim();
@@ -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(() {
@@ -123,15 +126,13 @@ class _ChatsViewState extends State<ChatsView> {
return 'Job $jobId'; return 'Job $jobId';
} }
} }
return chat.type == ChatType.general return localizedChatTitle(context, chat);
? 'Allgemeine Nachrichten'
: chat.title;
}(), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), }(), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
subtitle: Text( subtitle: Text(
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,
@@ -139,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(
@@ -156,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,6 +1,8 @@
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 'services/websocket_service.dart'; import 'services/websocket_service.dart';
import 'services/dart_mq.dart'; import 'services/dart_mq.dart';
import 'services/chat_service.dart'; import 'services/chat_service.dart';
@@ -97,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();
@@ -114,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,
); );
} }
} }
@@ -145,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);
} }
}); });
@@ -176,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');
@@ -203,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 {
@@ -559,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: () {
@@ -693,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),
@@ -765,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,
), ),
), ),
], ],
@@ -791,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,
); );
} }
} }
@@ -907,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) {
@@ -916,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 {
@@ -934,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
@@ -964,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]);
@@ -996,7 +997,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
: job.deliveryCompany)); : job.deliveryCompany));
final deliveryAddress = final deliveryAddress =
hasMultipleDeliveryStations hasMultipleDeliveryStations
? '${job.deliveryStations.length} Stationen' ? AppLocalizations.of(
context,
).deliveryStationsCount(job.deliveryStations.length)
: (firstDeliveryStation?.formattedAddress.isNotEmpty == true : (firstDeliveryStation?.formattedAddress.isNotEmpty == true
? firstDeliveryStation!.formattedAddress ? firstDeliveryStation!.formattedAddress
: _joinNonEmpty([ : _joinNonEmpty([
@@ -1030,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) {
@@ -1116,7 +1119,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Text( Text(
job.jobNumber.isNotEmpty job.jobNumber.isNotEmpty
? job.jobNumber ? job.jobNumber
: job.title, : localizeKnownText(context, job.title),
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -1230,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),
), ),
), ),
), ),
@@ -1333,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(
@@ -1372,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;
@@ -1571,19 +1574,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(job.title), title: Text(localizeKnownText(context, job.title)),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'${AppLocalizations.of(context).status}: ${job.statusDisplayText}', '${AppLocalizations.of(context).status}: ${_localizedStatusText(job.status)}',
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${AppLocalizations.of(context).priority}: ${job.priorityDisplayText}', '${AppLocalizations.of(context).priority}: ${_localizedPriorityText(job.priority)}',
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -1612,7 +1615,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(job.description), Text(localizeKnownText(context, job.description)),
], ],
// CargoItems section // CargoItems section
if (job.cargoItems.isNotEmpty) ...[ if (job.cargoItems.isNotEmpty) ...[
@@ -1657,7 +1660,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (job.deliveryStations.isNotEmpty) ...[ if (job.deliveryStations.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'${AppLocalizations.of(context).delivery} (${job.deliveryStations.length})', AppLocalizations.of(
context,
).deliveryStationsCount(job.deliveryStations.length),
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -1674,7 +1679,11 @@ class _JobsViewState extends State<JobsView> with RouteAware {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Station ${station.stationOrder + 1}: ${station.displayName}', localizedStationLabel(
context,
station.stationOrder + 1,
suffix: station.displayName,
),
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
if (station.formattedAddress.isNotEmpty) ...[ if (station.formattedAddress.isNotEmpty) ...[
@@ -1855,12 +1864,49 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (station.stationOrder == stationOrder) { if (station.stationOrder == stationOrder) {
final suffix = final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city; station.displayName.isNotEmpty ? station.displayName : station.city;
return suffix.isNotEmpty return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
? 'Station ${stationOrder + 1}: $suffix'
: 'Station ${stationOrder + 1}';
} }
} }
return 'Station ${stationOrder + 1}'; return AppLocalizations.of(context).stationNumber(stationOrder + 1);
}
String _localizedStatusText(String status) {
final l10n = AppLocalizations.of(context);
switch (status.toLowerCase()) {
case 'created':
return l10n.statusCreated;
case 'pending':
return l10n.statusPending;
case 'assigned':
return l10n.statusAssigned;
case 'in_progress':
case 'started':
return l10n.statusInProgress;
case 'completed':
case 'done':
return l10n.statusCompleted;
case 'cancelled':
return l10n.statusCancelled;
case 'failed':
return l10n.statusFailed;
default:
return localizeKnownText(context, status);
}
}
String _localizedPriorityText(String priority) {
final l10n = AppLocalizations.of(context);
switch (priority.toLowerCase()) {
case 'low':
return l10n.priorityLow;
case 'high':
return l10n.priorityHigh;
case 'urgent':
return l10n.priorityUrgent;
case 'normal':
default:
return l10n.priorityMedium;
}
} }
} }

View File

@@ -11,15 +11,28 @@ import 'app_localizations_lv.dart';
import 'app_localizations_lt.dart'; import 'app_localizations_lt.dart';
/// Supported language codes /// Supported language codes
const List<String> supportedLanguageCodes = ['de', 'en', 'es', 'fr', 'pl', 'ru', 'tr', 'et', 'lv', 'lt']; const List<String> supportedLanguageCodes = [
'de',
'en',
'es',
'fr',
'pl',
'ru',
'tr',
'et',
'lv',
'lt',
];
/// AppLocalizations provides localized strings for the app /// AppLocalizations provides localized strings for the app
abstract class AppLocalizations { abstract class AppLocalizations {
static AppLocalizations of(BuildContext context) { static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations) ?? AppLocalizationsDe(); return Localizations.of<AppLocalizations>(context, AppLocalizations) ??
AppLocalizationsDe();
} }
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate(); static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// Language name /// Language name
String get languageName; String get languageName;
@@ -41,6 +54,7 @@ abstract class AppLocalizations {
String get refresh; String get refresh;
String get version; String get version;
String get unknown; String get unknown;
String get yesterday;
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
String get jobs; String get jobs;
@@ -58,7 +72,14 @@ abstract class AppLocalizations {
String get welcomeBack; String get welcomeBack;
String get loginSubtitle; String get loginSubtitle;
String get email; String get email;
String get emailAddress;
String get emailAddressHint;
String get emailAddressRequired;
String get emailAddressInvalid;
String get password; String get password;
String get passwordHint;
String get passwordRequired;
String get passwordMinLength;
String get login; String get login;
String get loggingIn; String get loggingIn;
String get forgotPassword; String get forgotPassword;
@@ -101,6 +122,15 @@ abstract class AppLocalizations {
String get deleteJob; String get deleteJob;
String get jobRemoved; String get jobRemoved;
String get newJobReceived; String get newJobReceived;
String get jobDetails;
String get jobTasks;
String get deliveryStations;
String deliveryStationsCount(int count);
String get noDeliveryStations;
String get noDeliveryStationsMessage;
String get phone;
String get unnamedStation;
String stationNumber(int number);
// ==================== TASKS ==================== // ==================== TASKS ====================
String get tasks; String get tasks;
@@ -182,6 +212,9 @@ abstract class AppLocalizations {
String get chatTypeGeneral; String get chatTypeGeneral;
String get jobNumber; String get jobNumber;
String get messages; String get messages;
String get generalMessages;
String get noMessagesYet;
String get noChatsAvailable;
String get selectPhoto; String get selectPhoto;
String get unreadMessages; String get unreadMessages;
@@ -217,16 +250,20 @@ abstract class AppLocalizations {
// ==================== STATUS ==================== // ==================== STATUS ====================
String get statusCreated; String get statusCreated;
String get statusPending;
String get statusAssigned; String get statusAssigned;
String get statusInProgress; String get statusInProgress;
String get statusCompleted; String get statusCompleted;
String get statusCancelled;
String get statusFailed;
String get priorityLow; String get priorityLow;
String get priorityMedium; String get priorityMedium;
String get priorityHigh; String get priorityHigh;
String get priorityUrgent; String get priorityUrgent;
} }
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> { class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate(); const _AppLocalizationsDelegate();
@override @override

View File

@@ -47,12 +47,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get unknown => 'Unbekannt'; String get unknown => 'Unbekannt';
@override
String get yesterday => 'Gestern';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@override @override
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';
@@ -88,9 +91,32 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get email => 'E-Mail'; String get email => 'E-Mail';
@override
String get emailAddress => 'E-Mail-Adresse';
@override
String get emailAddressHint => 'Geben Sie Ihre E-Mail-Adresse ein';
@override
String get emailAddressRequired => 'Bitte geben Sie Ihre E-Mail-Adresse ein';
@override
String get emailAddressInvalid =>
'Bitte geben Sie eine gültige E-Mail-Adresse ein';
@override @override
String get password => 'Passwort'; String get password => 'Passwort';
@override
String get passwordHint => 'Geben Sie Ihr Passwort ein';
@override
String get passwordRequired => 'Bitte geben Sie Ihr Passwort ein';
@override
String get passwordMinLength =>
'Das Passwort muss mindestens 6 Zeichen lang sein';
@override @override
String get login => 'Anmelden'; String get login => 'Anmelden';
@@ -101,7 +127,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get forgotPassword => 'Passwort vergessen?'; String get forgotPassword => 'Passwort vergessen?';
@override @override
String get forgotPasswordMessage => 'Passwort vergessen Funktion noch nicht implementiert'; String get forgotPasswordMessage =>
'Passwort vergessen Funktion noch nicht implementiert';
@override @override
String get loginSuccess => 'Erfolgreich abgemeldet'; String get loginSuccess => 'Erfolgreich abgemeldet';
@@ -110,10 +137,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get loginFailed => 'Anmeldung fehlgeschlagen'; String get loginFailed => 'Anmeldung fehlgeschlagen';
@override @override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen (Timeout).'; String get connectionFailed =>
'Verbindung zum Server fehlgeschlagen (Timeout).';
@override @override
String get connectionTimeout => 'Verbindung zum Server fehlgeschlagen (Timeout).'; String get connectionTimeout =>
'Verbindung zum Server fehlgeschlagen (Timeout).';
@override @override
String get connecting => 'Verbindung zum Server wird hergestellt...'; String get connecting => 'Verbindung zum Server wird hergestellt...';
@@ -212,6 +241,34 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get newJobReceived => 'Neuer Job erhalten'; String get newJobReceived => 'Neuer Job erhalten';
@override
String get jobDetails => 'Auftragsdetails';
@override
String get jobTasks => 'Aufgaben eines Auftrags';
@override
String get deliveryStations => 'Lieferstationen';
@override
String deliveryStationsCount(int count) => 'Lieferstationen ($count)';
@override
String get noDeliveryStations => 'Keine Lieferstationen';
@override
String get noDeliveryStationsMessage =>
'Dieser Job enthält aktuell keine Lieferstationen.';
@override
String get phone => 'Telefon';
@override
String get unnamedStation => 'Unbenannte Station';
@override
String stationNumber(int number) => 'Station $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Aufgaben'; String get tasks => 'Aufgaben';
@@ -229,7 +286,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get confirmationRequired => 'Bestätigung erforderlich'; String get confirmationRequired => 'Bestätigung erforderlich';
@override @override
String get confirmationDescription => 'Klicken Sie auf den Button um die Aufgabe zu erledigen.'; String get confirmationDescription =>
'Klicken Sie auf den Button um die Aufgabe zu erledigen.';
@override @override
String get checklist => 'Checkliste'; String get checklist => 'Checkliste';
@@ -241,7 +299,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get completeTask => 'Aufgabe abschließen'; String get completeTask => 'Aufgabe abschließen';
@override @override
String get completeTaskConfirm => 'Möchten Sie diese Aufgabe als erledigt markieren?'; String get completeTaskConfirm =>
'Möchten Sie diese Aufgabe als erledigt markieren?';
@override @override
String get completeTaskNote => 'Notiz (optional)'; String get completeTaskNote => 'Notiz (optional)';
@@ -280,7 +339,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get signatureError => 'Fehler beim Speichern der Unterschrift'; String get signatureError => 'Fehler beim Speichern der Unterschrift';
@override @override
String get signatureInstruction => 'Bitte unterschreiben Sie im Feld unten (Maus oder Finger).'; String get signatureInstruction =>
'Bitte unterschreiben Sie im Feld unten (Maus oder Finger).';
@override @override
String get photoCapture => 'Fotos aufnehmen'; String get photoCapture => 'Fotos aufnehmen';
@@ -371,10 +431,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get cameraNotAvailable => 'Kamera nicht verfügbar'; String get cameraNotAvailable => 'Kamera nicht verfügbar';
@override @override
String get cameraNotSupportedMessage => 'Auf dieser Plattform wird die Kamera nicht unterstützt.'; String get cameraNotSupportedMessage =>
'Auf dieser Plattform wird die Kamera nicht unterstützt.';
@override @override
String get cameraNotSupportedOnPlatform => 'Nicht unterstützt auf dieser Plattform'; String get cameraNotSupportedOnPlatform =>
'Nicht unterstützt auf dieser Plattform';
@override @override
String get maxPhotosReached => 'Maximum erreicht'; String get maxPhotosReached => 'Maximum erreicht';
@@ -389,13 +451,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get cameraInitializing => 'Kamera wird initialisiert...'; String get cameraInitializing => 'Kamera wird initialisiert...';
@override @override
String get cameraLoadingMessage => 'Bitte warten Sie, während die Kamera geladen wird'; String get cameraLoadingMessage =>
'Bitte warten Sie, während die Kamera geladen wird';
@override @override
String get addPhotos => 'Fotos hinzufügen'; String get addPhotos => 'Fotos hinzufügen';
@override @override
String get addPhotosInstruction => 'Verwenden Sie den Button „Foto auswählen", um Bilder von Ihrer Kamera oder Festplatte hinzuzufügen.'; String get addPhotosInstruction =>
'Verwenden Sie den Button „Foto auswählen", um Bilder von Ihrer Kamera oder Festplatte hinzuzufügen.';
@override @override
String get photoOf => 'von'; String get photoOf => 'von';
@@ -411,13 +475,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get noSender => 'Kein Absender verfügbar'; String get noSender => 'Kein Absender verfügbar';
@override @override
String get noSenderMessage => 'Kein Absender verfügbar. Bitte erneut anmelden.'; String get noSenderMessage =>
'Kein Absender verfügbar. Bitte erneut anmelden.';
@override @override
String get noRecipient => 'Kein Empfänger konfiguriert'; String get noRecipient => 'Kein Empfänger konfiguriert';
@override @override
String get noRecipientMessage => 'Kein Empfänger für diesen Chat konfiguriert.'; String get noRecipientMessage =>
'Kein Empfänger für diesen Chat konfiguriert.';
@override @override
String get messageSendError => 'Nachricht konnte nicht gesendet werden.'; String get messageSendError => 'Nachricht konnte nicht gesendet werden.';
@@ -443,6 +509,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get messages => 'Nachrichten'; String get messages => 'Nachrichten';
@override
String get generalMessages => 'Allgemeine Nachrichten';
@override
String get noMessagesYet => 'Noch keine Nachrichten';
@override
String get noChatsAvailable => 'Keine Chats verfügbar';
@override @override
String get selectPhoto => 'Foto auswählen'; String get selectPhoto => 'Foto auswählen';
@@ -482,7 +557,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get noCargoItems => 'Keine Frachtgüter'; String get noCargoItems => 'Keine Frachtgüter';
@override @override
String get noCargoItemsMessage => 'Für diesen Job sind keine Frachtgüter definiert.'; String get noCargoItemsMessage =>
'Für diesen Job sind keine Frachtgüter definiert.';
@override @override
String get article => 'Artikel'; String get article => 'Artikel';
@@ -528,6 +604,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get statusCreated => 'Erstellt'; String get statusCreated => 'Erstellt';
@override
String get statusPending => 'Wartend';
@override @override
String get statusAssigned => 'Zugewiesen'; String get statusAssigned => 'Zugewiesen';
@@ -537,6 +616,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get statusCompleted => 'Abgeschlossen'; String get statusCompleted => 'Abgeschlossen';
@override
String get statusCancelled => 'Abgebrochen';
@override
String get statusFailed => 'Fehlgeschlagen';
@override @override
String get priorityLow => 'Niedrig'; String get priorityLow => 'Niedrig';

View File

@@ -47,12 +47,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get unknown => 'Unknown'; String get unknown => 'Unknown';
@override
String get yesterday => 'Yesterday';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@override @override
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';
@@ -88,9 +91,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get email => 'Email'; String get email => 'Email';
@override
String get emailAddress => 'Email Address';
@override
String get emailAddressHint => 'Enter your email address';
@override
String get emailAddressRequired => 'Please enter your email address';
@override
String get emailAddressInvalid => 'Please enter a valid email address';
@override @override
String get password => 'Password'; String get password => 'Password';
@override
String get passwordHint => 'Enter your password';
@override
String get passwordRequired => 'Please enter your password';
@override
String get passwordMinLength => 'Password must be at least 6 characters long';
@override @override
String get login => 'Login'; String get login => 'Login';
@@ -101,7 +125,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get forgotPassword => 'Forgot Password?'; String get forgotPassword => 'Forgot Password?';
@override @override
String get forgotPasswordMessage => 'Forgot password feature not yet implemented'; String get forgotPasswordMessage =>
'Forgot password feature not yet implemented';
@override @override
String get loginSuccess => 'Successfully logged out'; String get loginSuccess => 'Successfully logged out';
@@ -212,6 +237,34 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get newJobReceived => 'New job received'; String get newJobReceived => 'New job received';
@override
String get jobDetails => 'Job Details';
@override
String get jobTasks => 'Job Tasks';
@override
String get deliveryStations => 'Delivery Stations';
@override
String deliveryStationsCount(int count) => 'Delivery Stations ($count)';
@override
String get noDeliveryStations => 'No Delivery Stations';
@override
String get noDeliveryStationsMessage =>
'This job currently contains no delivery stations.';
@override
String get phone => 'Phone';
@override
String get unnamedStation => 'Unnamed Station';
@override
String stationNumber(int number) => 'Station $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Tasks'; String get tasks => 'Tasks';
@@ -229,7 +282,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get confirmationRequired => 'Confirmation Required'; String get confirmationRequired => 'Confirmation Required';
@override @override
String get confirmationDescription => 'Click the button to complete the task.'; String get confirmationDescription =>
'Click the button to complete the task.';
@override @override
String get checklist => 'Checklist'; String get checklist => 'Checklist';
@@ -241,7 +295,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get completeTask => 'Complete Task'; String get completeTask => 'Complete Task';
@override @override
String get completeTaskConfirm => 'Do you want to mark this task as completed?'; String get completeTaskConfirm =>
'Do you want to mark this task as completed?';
@override @override
String get completeTaskNote => 'Note (optional)'; String get completeTaskNote => 'Note (optional)';
@@ -280,7 +335,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get signatureError => 'Error saving signature'; String get signatureError => 'Error saving signature';
@override @override
String get signatureInstruction => 'Please sign in the field below (mouse or finger).'; String get signatureInstruction =>
'Please sign in the field below (mouse or finger).';
@override @override
String get photoCapture => 'Take Photos'; String get photoCapture => 'Take Photos';
@@ -371,7 +427,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get cameraNotAvailable => 'Camera not available'; String get cameraNotAvailable => 'Camera not available';
@override @override
String get cameraNotSupportedMessage => 'The camera is not supported on this platform.'; String get cameraNotSupportedMessage =>
'The camera is not supported on this platform.';
@override @override
String get cameraNotSupportedOnPlatform => 'Not supported on this platform'; String get cameraNotSupportedOnPlatform => 'Not supported on this platform';
@@ -395,7 +452,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get addPhotos => 'Add photos'; String get addPhotos => 'Add photos';
@override @override
String get addPhotosInstruction => 'Use the "Select photo" button to add images from your camera or hard drive.'; String get addPhotosInstruction =>
'Use the "Select photo" button to add images from your camera or hard drive.';
@override @override
String get photoOf => 'of'; String get photoOf => 'of';
@@ -443,6 +501,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get messages => 'Messages'; String get messages => 'Messages';
@override
String get generalMessages => 'General Messages';
@override
String get noMessagesYet => 'No messages yet';
@override
String get noChatsAvailable => 'No chats available';
@override @override
String get selectPhoto => 'Select Photo'; String get selectPhoto => 'Select Photo';
@@ -528,6 +595,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get statusCreated => 'Created'; String get statusCreated => 'Created';
@override
String get statusPending => 'Pending';
@override @override
String get statusAssigned => 'Assigned'; String get statusAssigned => 'Assigned';
@@ -537,6 +607,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get statusCompleted => 'Completed'; String get statusCompleted => 'Completed';
@override
String get statusCancelled => 'Cancelled';
@override
String get statusFailed => 'Failed';
@override @override
String get priorityLow => 'Low'; String get priorityLow => 'Low';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get unknown => 'Desconocido'; String get unknown => 'Desconocido';
@override
String get yesterday => 'Ayer';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,33 @@ class AppLocalizationsEs extends AppLocalizations {
String get loginSubtitle => 'Inicie sesión en su cuenta'; String get loginSubtitle => 'Inicie sesión en su cuenta';
@override @override
String get email => 'Correo electrónico'; String get email => 'Correo electrónico';
@override
String get emailAddress => 'Dirección de correo electrónico';
@override
String get emailAddressHint =>
'Introduzca su dirección de correo electrónico';
@override
String get emailAddressRequired =>
'Por favor, introduzca su dirección de correo electrónico';
@override
String get emailAddressInvalid =>
'Por favor, introduzca una dirección de correo electrónico válida';
@override @override
String get password => 'Contraseña'; String get password => 'Contraseña';
@override
String get passwordHint => 'Introduzca su contraseña';
@override
String get passwordRequired => 'Por favor, introduzca su contraseña';
@override
String get passwordMinLength =>
'La contraseña debe tener al menos 6 caracteres';
@override @override
String get login => 'Iniciar sesión'; String get login => 'Iniciar sesión';
@override @override
@@ -73,15 +101,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get forgotPassword => '¿Olvidó su contraseña?'; String get forgotPassword => '¿Olvidó su contraseña?';
@override @override
String get forgotPasswordMessage => 'Función de contraseña olvidada aún no implementada'; String get forgotPasswordMessage =>
'Función de contraseña olvidada aún no implementada';
@override @override
String get loginSuccess => 'Sesión cerrada correctamente'; String get loginSuccess => 'Sesión cerrada correctamente';
@override @override
String get loginFailed => 'Error al iniciar sesión'; String get loginFailed => 'Error al iniciar sesión';
@override @override
String get connectionFailed => 'Error de conexión al servidor (Tiempo agotado).'; String get connectionFailed =>
'Error de conexión al servidor (Tiempo agotado).';
@override @override
String get connectionTimeout => 'Error de conexión al servidor (Tiempo agotado).'; String get connectionTimeout =>
'Error de conexión al servidor (Tiempo agotado).';
@override @override
String get connecting => 'Conectando al servidor...'; String get connecting => 'Conectando al servidor...';
@override @override
@@ -149,6 +180,34 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get newJobReceived => 'Nuevo trabajo recibido'; String get newJobReceived => 'Nuevo trabajo recibido';
@override
String get jobDetails => 'Detalles del pedido';
@override
String get jobTasks => 'Tareas del pedido';
@override
String get deliveryStations => 'Estaciones de entrega';
@override
String deliveryStationsCount(int count) => 'Estaciones de entrega ($count)';
@override
String get noDeliveryStations => 'No hay estaciones de entrega';
@override
String get noDeliveryStationsMessage =>
'Este trabajo no contiene estaciones de entrega actualmente.';
@override
String get phone => 'Teléfono';
@override
String get unnamedStation => 'Estación sin nombre';
@override
String stationNumber(int number) => 'Estación $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Tareas'; String get tasks => 'Tareas';
@@ -161,7 +220,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get confirmationRequired => 'Confirmación requerida'; String get confirmationRequired => 'Confirmación requerida';
@override @override
String get confirmationDescription => 'Haga clic en el botón para completar la tarea.'; String get confirmationDescription =>
'Haga clic en el botón para completar la tarea.';
@override @override
String get checklist => 'Lista de verificación'; String get checklist => 'Lista de verificación';
@override @override
@@ -195,7 +255,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get signatureError => 'Error al guardar la firma'; String get signatureError => 'Error al guardar la firma';
@override @override
String get signatureInstruction => 'Por favor, firme en el campo de abajo (ratón o dedo).'; String get signatureInstruction =>
'Por favor, firme en el campo de abajo (ratón o dedo).';
@override @override
String get photoCapture => 'Tomar fotos'; String get photoCapture => 'Tomar fotos';
@override @override
@@ -243,11 +304,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get enterBarcode => 'Ingresar código de barras'; String get enterBarcode => 'Ingresar código de barras';
@override @override
String get barcodeEnterDescription => 'Por favor ingrese los códigos de barras:'; String get barcodeEnterDescription =>
'Por favor ingrese los códigos de barras:';
@override @override
String barcodeNumberRequired(int number) => 'Código de barras $number (requerido)'; String barcodeNumberRequired(int number) =>
'Código de barras $number (requerido)';
@override @override
String barcodeNumberOptional(int number) => 'Código de barras $number (opcional)'; String barcodeNumberOptional(int number) =>
'Código de barras $number (opcional)';
@override @override
String get barcodeError => 'Error al escanear el código de barras'; String get barcodeError => 'Error al escanear el código de barras';
@override @override
@@ -257,7 +321,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Cámara no disponible'; String get cameraNotAvailable => 'Cámara no disponible';
@override @override
String get cameraNotSupportedMessage => 'La cámara no es compatible con esta plataforma.'; String get cameraNotSupportedMessage =>
'La cámara no es compatible con esta plataforma.';
@override @override
String get cameraNotSupportedOnPlatform => 'No soportado en esta plataforma'; String get cameraNotSupportedOnPlatform => 'No soportado en esta plataforma';
@override @override
@@ -269,11 +334,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get cameraInitializing => 'Inicializando cámara...'; String get cameraInitializing => 'Inicializando cámara...';
@override @override
String get cameraLoadingMessage => 'Por favor espere mientras se carga la cámara'; String get cameraLoadingMessage =>
'Por favor espere mientras se carga la cámara';
@override @override
String get addPhotos => 'Añadir fotos'; String get addPhotos => 'Añadir fotos';
@override @override
String get addPhotosInstruction => 'Use el botón "Seleccionar foto" para añadir imágenes de su cámara o disco duro.'; String get addPhotosInstruction =>
'Use el botón "Seleccionar foto" para añadir imágenes de su cámara o disco duro.';
@override @override
String get photoOf => 'de'; String get photoOf => 'de';
@@ -285,11 +352,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get noSender => 'No hay remitente disponible'; String get noSender => 'No hay remitente disponible';
@override @override
String get noSenderMessage => 'No hay remitente disponible. Por favor inicie sesión de nuevo.'; String get noSenderMessage =>
'No hay remitente disponible. Por favor inicie sesión de nuevo.';
@override @override
String get noRecipient => 'No hay destinatario configurado'; String get noRecipient => 'No hay destinatario configurado';
@override @override
String get noRecipientMessage => 'No hay destinatario configurado para este chat.'; String get noRecipientMessage =>
'No hay destinatario configurado para este chat.';
@override @override
String get messageSendError => 'El mensaje no pudo ser enviado.'; String get messageSendError => 'El mensaje no pudo ser enviado.';
@override @override
@@ -306,6 +375,15 @@ class AppLocalizationsEs extends AppLocalizations {
String get jobNumber => 'Número de trabajo'; String get jobNumber => 'Número de trabajo';
@override @override
String get messages => 'Mensajes'; String get messages => 'Mensajes';
@override
String get generalMessages => 'Mensajes generales';
@override
String get noMessagesYet => 'Todavía no hay mensajes';
@override
String get noChatsAvailable => 'No hay chats disponibles';
@override @override
String get selectPhoto => 'Seleccionar foto'; String get selectPhoto => 'Seleccionar foto';
@override @override
@@ -327,7 +405,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get noCargoItems => 'Sin artículos de carga'; String get noCargoItems => 'Sin artículos de carga';
@override @override
String get noCargoItemsMessage => 'No hay artículos de carga definidos para este trabajo.'; String get noCargoItemsMessage =>
'No hay artículos de carga definidos para este trabajo.';
@override @override
String get article => 'Artículo'; String get article => 'Artículo';
@@ -369,12 +448,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get statusCreated => 'Creado'; String get statusCreated => 'Creado';
@override @override
String get statusPending => 'Pendiente';
@override
String get statusAssigned => 'Asignado'; String get statusAssigned => 'Asignado';
@override @override
String get statusInProgress => 'En progreso'; String get statusInProgress => 'En progreso';
@override @override
String get statusCompleted => 'Completado'; String get statusCompleted => 'Completado';
@override @override
String get statusCancelled => 'Cancelado';
@override
String get statusFailed => 'Fallido';
@override
String get priorityLow => 'Baja'; String get priorityLow => 'Baja';
@override @override
String get priorityMedium => 'Media'; String get priorityMedium => 'Media';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get unknown => 'Tundmatu'; String get unknown => 'Tundmatu';
@override
String get yesterday => 'Eile';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,29 @@ class AppLocalizationsEt extends AppLocalizations {
String get loginSubtitle => 'Logige oma kontosse sisse'; String get loginSubtitle => 'Logige oma kontosse sisse';
@override @override
String get email => 'E-post'; String get email => 'E-post';
@override
String get emailAddress => 'E-posti aadress';
@override
String get emailAddressHint => 'Sisestage oma e-posti aadress';
@override
String get emailAddressRequired => 'Palun sisestage oma e-posti aadress';
@override
String get emailAddressInvalid => 'Palun sisestage kehtiv e-posti aadress';
@override @override
String get password => 'Parool'; String get password => 'Parool';
@override
String get passwordHint => 'Sisestage oma parool';
@override
String get passwordRequired => 'Palun sisestage oma parool';
@override
String get passwordMinLength => 'Parool peab olema vähemalt 6 tähemärki pikk';
@override @override
String get login => 'Logi sisse'; String get login => 'Logi sisse';
@override @override
@@ -73,15 +97,18 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get forgotPassword => 'Unustasid parooli?'; String get forgotPassword => 'Unustasid parooli?';
@override @override
String get forgotPasswordMessage => 'Unustatud parooli funktsioon pole veel rakendatud'; String get forgotPasswordMessage =>
'Unustatud parooli funktsioon pole veel rakendatud';
@override @override
String get loginSuccess => 'Edukalt välja logitud'; String get loginSuccess => 'Edukalt välja logitud';
@override @override
String get loginFailed => 'Sisselogimine ebaõnnestus'; String get loginFailed => 'Sisselogimine ebaõnnestus';
@override @override
String get connectionFailed => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).'; String get connectionFailed =>
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
@override @override
String get connectionTimeout => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).'; String get connectionTimeout =>
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
@override @override
String get connecting => 'Serveriga ühendamine...'; String get connecting => 'Serveriga ühendamine...';
@override @override
@@ -149,6 +176,34 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get newJobReceived => 'Uus töö saadud'; String get newJobReceived => 'Uus töö saadud';
@override
String get jobDetails => 'Töö üksikasjad';
@override
String get jobTasks => 'Töö ülesanded';
@override
String get deliveryStations => 'Tarnejaamad';
@override
String deliveryStationsCount(int count) => 'Tarnejaamad ($count)';
@override
String get noDeliveryStations => 'Tarnejaamu pole';
@override
String get noDeliveryStationsMessage =>
'Sellel tööl ei ole praegu tarnejaamu.';
@override
String get phone => 'Telefon';
@override
String get unnamedStation => 'Nimetu jaam';
@override
String stationNumber(int number) => 'Jaam $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Ülesanded'; String get tasks => 'Ülesanded';
@@ -161,7 +216,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get confirmationRequired => 'Vajalik kinnitus'; String get confirmationRequired => 'Vajalik kinnitus';
@override @override
String get confirmationDescription => 'Ülesande lõpuleviimiseks klõpsake nuppu.'; String get confirmationDescription =>
'Ülesande lõpuleviimiseks klõpsake nuppu.';
@override @override
String get checklist => 'Kontrollnimekiri'; String get checklist => 'Kontrollnimekiri';
@override @override
@@ -169,7 +225,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get completeTask => 'Lõpeta ülesanne'; String get completeTask => 'Lõpeta ülesanne';
@override @override
String get completeTaskConfirm => 'Kas soovite selle ülesande lõpetatuks märgistada?'; String get completeTaskConfirm =>
'Kas soovite selle ülesande lõpetatuks märgistada?';
@override @override
String get completeTaskNote => 'Märkus (valikuline)'; String get completeTaskNote => 'Märkus (valikuline)';
@override @override
@@ -195,7 +252,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get signatureError => 'Viga allkirja salvestamisel'; String get signatureError => 'Viga allkirja salvestamisel';
@override @override
String get signatureInstruction => 'Palun allkirjastage allolevas väljas (hiir või sõrm).'; String get signatureInstruction =>
'Palun allkirjastage allolevas väljas (hiir või sõrm).';
@override @override
String get photoCapture => 'Tee pilte'; String get photoCapture => 'Tee pilte';
@override @override
@@ -257,7 +315,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Kaamera pole saadaval'; String get cameraNotAvailable => 'Kaamera pole saadaval';
@override @override
String get cameraNotSupportedMessage => 'Kaamerat ei toetata sellel platvormil.'; String get cameraNotSupportedMessage =>
'Kaamerat ei toetata sellel platvormil.';
@override @override
String get cameraNotSupportedOnPlatform => 'Sellel platvormil ei toetata'; String get cameraNotSupportedOnPlatform => 'Sellel platvormil ei toetata';
@override @override
@@ -273,7 +332,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get addPhotos => 'Lisa fotod'; String get addPhotos => 'Lisa fotod';
@override @override
String get addPhotosInstruction => 'Kasutage nuppu "Vali foto", et lisada pilte kaamerast või kõvakettalt.'; String get addPhotosInstruction =>
'Kasutage nuppu "Vali foto", et lisada pilte kaamerast või kõvakettalt.';
@override @override
String get photoOf => '/'; String get photoOf => '/';
@@ -285,11 +345,13 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get noSender => 'Saatja pole saadaval'; String get noSender => 'Saatja pole saadaval';
@override @override
String get noSenderMessage => 'Saatja pole saadaval. Palun logige uuesti sisse.'; String get noSenderMessage =>
'Saatja pole saadaval. Palun logige uuesti sisse.';
@override @override
String get noRecipient => 'Vastuvõtjat pole konfigureeritud'; String get noRecipient => 'Vastuvõtjat pole konfigureeritud';
@override @override
String get noRecipientMessage => 'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.'; String get noRecipientMessage =>
'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.';
@override @override
String get messageSendError => 'Sõnumi saatmine ebaõnnestus.'; String get messageSendError => 'Sõnumi saatmine ebaõnnestus.';
@override @override
@@ -306,6 +368,15 @@ class AppLocalizationsEt extends AppLocalizations {
String get jobNumber => 'Töö number'; String get jobNumber => 'Töö number';
@override @override
String get messages => 'Sõnumid'; String get messages => 'Sõnumid';
@override
String get generalMessages => 'Üldised sõnumid';
@override
String get noMessagesYet => 'Sõnumeid veel pole';
@override
String get noChatsAvailable => 'Vestlusi pole saadaval';
@override @override
String get selectPhoto => 'Vali foto'; String get selectPhoto => 'Vali foto';
@override @override
@@ -369,12 +440,18 @@ class AppLocalizationsEt extends AppLocalizations {
@override @override
String get statusCreated => 'Loodud'; String get statusCreated => 'Loodud';
@override @override
String get statusPending => 'Ootel';
@override
String get statusAssigned => 'Määratud'; String get statusAssigned => 'Määratud';
@override @override
String get statusInProgress => 'Töös'; String get statusInProgress => 'Töös';
@override @override
String get statusCompleted => 'Lõpetatud'; String get statusCompleted => 'Lõpetatud';
@override @override
String get statusCancelled => 'Tühistatud';
@override
String get statusFailed => 'Ebaõnnestunud';
@override
String get priorityLow => 'Madal'; String get priorityLow => 'Madal';
@override @override
String get priorityMedium => 'Keskmine'; String get priorityMedium => 'Keskmine';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get unknown => 'Inconnu'; String get unknown => 'Inconnu';
@override
String get yesterday => 'Hier';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,30 @@ class AppLocalizationsFr extends AppLocalizations {
String get loginSubtitle => 'Connectez-vous à votre compte'; String get loginSubtitle => 'Connectez-vous à votre compte';
@override @override
String get email => 'E-mail'; String get email => 'E-mail';
@override
String get emailAddress => 'Adresse e-mail';
@override
String get emailAddressHint => 'Saisissez votre adresse e-mail';
@override
String get emailAddressRequired => 'Veuillez saisir votre adresse e-mail';
@override
String get emailAddressInvalid => 'Veuillez saisir une adresse e-mail valide';
@override @override
String get password => 'Mot de passe'; String get password => 'Mot de passe';
@override
String get passwordHint => 'Saisissez votre mot de passe';
@override
String get passwordRequired => 'Veuillez saisir votre mot de passe';
@override
String get passwordMinLength =>
'Le mot de passe doit contenir au moins 6 caractères';
@override @override
String get login => 'Connexion'; String get login => 'Connexion';
@override @override
@@ -73,15 +98,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get forgotPassword => 'Mot de passe oublié?'; String get forgotPassword => 'Mot de passe oublié?';
@override @override
String get forgotPasswordMessage => 'Fonction mot de passe oublié pas encore implémentée'; String get forgotPasswordMessage =>
'Fonction mot de passe oublié pas encore implémentée';
@override @override
String get loginSuccess => 'Déconnexion réussie'; String get loginSuccess => 'Déconnexion réussie';
@override @override
String get loginFailed => 'Échec de la connexion'; String get loginFailed => 'Échec de la connexion';
@override @override
String get connectionFailed => 'Échec de la connexion au serveur (Délai dépassé).'; String get connectionFailed =>
'Échec de la connexion au serveur (Délai dépassé).';
@override @override
String get connectionTimeout => 'Échec de la connexion au serveur (Délai dépassé).'; String get connectionTimeout =>
'Échec de la connexion au serveur (Délai dépassé).';
@override @override
String get connecting => 'Connexion au serveur...'; String get connecting => 'Connexion au serveur...';
@override @override
@@ -137,7 +165,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get jobsUpdated => 'Emplois actualisés'; String get jobsUpdated => 'Emplois actualisés';
@override @override
String get connectionRestored => 'Connexion restaurée. Chargement des emplois...'; String get connectionRestored =>
'Connexion restaurée. Chargement des emplois...';
@override @override
String get connectionLost => 'Connexion perdue. Hors ligne.'; String get connectionLost => 'Connexion perdue. Hors ligne.';
@override @override
@@ -149,6 +178,34 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get newJobReceived => 'Nouvel emploi reçu'; String get newJobReceived => 'Nouvel emploi reçu';
@override
String get jobDetails => 'Détails de la commande';
@override
String get jobTasks => 'Tâches de la commande';
@override
String get deliveryStations => 'Stations de livraison';
@override
String deliveryStationsCount(int count) => 'Stations de livraison ($count)';
@override
String get noDeliveryStations => 'Aucune station de livraison';
@override
String get noDeliveryStationsMessage =>
'Cette mission ne contient actuellement aucune station de livraison.';
@override
String get phone => 'Téléphone';
@override
String get unnamedStation => 'Station sans nom';
@override
String stationNumber(int number) => 'Station $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Tâches'; String get tasks => 'Tâches';
@@ -161,7 +218,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get confirmationRequired => 'Confirmation requise'; String get confirmationRequired => 'Confirmation requise';
@override @override
String get confirmationDescription => 'Cliquez sur le bouton pour terminer la tâche.'; String get confirmationDescription =>
'Cliquez sur le bouton pour terminer la tâche.';
@override @override
String get checklist => 'Liste de contrôle'; String get checklist => 'Liste de contrôle';
@override @override
@@ -169,7 +227,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get completeTask => 'Terminer la tâche'; String get completeTask => 'Terminer la tâche';
@override @override
String get completeTaskConfirm => 'Voulez-vous marquer cette tâche comme terminée?'; String get completeTaskConfirm =>
'Voulez-vous marquer cette tâche comme terminée?';
@override @override
String get completeTaskNote => 'Note (optionnelle)'; String get completeTaskNote => 'Note (optionnelle)';
@override @override
@@ -193,9 +252,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get clear => 'Effacer'; String get clear => 'Effacer';
@override @override
String get signatureError => 'Erreur lors de l\'enregistrement de la signature'; String get signatureError =>
'Erreur lors de l\'enregistrement de la signature';
@override @override
String get signatureInstruction => 'Veuillez signer dans le champ ci-dessous (souris ou doigt).'; String get signatureInstruction =>
'Veuillez signer dans le champ ci-dessous (souris ou doigt).';
@override @override
String get photoCapture => 'Prendre des photos'; String get photoCapture => 'Prendre des photos';
@override @override
@@ -221,7 +282,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get deletePhoto => 'Supprimer la photo'; String get deletePhoto => 'Supprimer la photo';
@override @override
String get deletePhotoConfirm => 'Voulez-vous vraiment supprimer cette photo?'; String get deletePhotoConfirm =>
'Voulez-vous vraiment supprimer cette photo?';
@override @override
String get barcode => 'Code-barres'; String get barcode => 'Code-barres';
@override @override
@@ -257,9 +319,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Caméra non disponible'; String get cameraNotAvailable => 'Caméra non disponible';
@override @override
String get cameraNotSupportedMessage => 'La caméra n\'est pas prise en charge sur cette plateforme.'; String get cameraNotSupportedMessage =>
'La caméra n\'est pas prise en charge sur cette plateforme.';
@override @override
String get cameraNotSupportedOnPlatform => 'Non supporté sur cette plateforme'; String get cameraNotSupportedOnPlatform =>
'Non supporté sur cette plateforme';
@override @override
String get maxPhotosReached => 'Maximum atteint'; String get maxPhotosReached => 'Maximum atteint';
@override @override
@@ -269,11 +333,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get cameraInitializing => 'Initialisation de la caméra...'; String get cameraInitializing => 'Initialisation de la caméra...';
@override @override
String get cameraLoadingMessage => 'Veuillez patienter pendant le chargement de la caméra'; String get cameraLoadingMessage =>
'Veuillez patienter pendant le chargement de la caméra';
@override @override
String get addPhotos => 'Ajouter des photos'; String get addPhotos => 'Ajouter des photos';
@override @override
String get addPhotosInstruction => 'Utilisez le bouton "Sélectionner une photo" pour ajouter des images depuis votre appareil photo ou disque dur.'; String get addPhotosInstruction =>
'Utilisez le bouton "Sélectionner une photo" pour ajouter des images depuis votre appareil photo ou disque dur.';
@override @override
String get photoOf => 'sur'; String get photoOf => 'sur';
@@ -285,11 +351,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get noSender => 'Aucun expéditeur disponible'; String get noSender => 'Aucun expéditeur disponible';
@override @override
String get noSenderMessage => 'Aucun expéditeur disponible. Veuillez vous reconnecter.'; String get noSenderMessage =>
'Aucun expéditeur disponible. Veuillez vous reconnecter.';
@override @override
String get noRecipient => 'Aucun destinataire configuré'; String get noRecipient => 'Aucun destinataire configuré';
@override @override
String get noRecipientMessage => 'Aucun destinataire configuré pour cette discussion.'; String get noRecipientMessage =>
'Aucun destinataire configuré pour cette discussion.';
@override @override
String get messageSendError => 'Le message n\'a pas pu être envoyé.'; String get messageSendError => 'Le message n\'a pas pu être envoyé.';
@override @override
@@ -306,6 +374,15 @@ class AppLocalizationsFr extends AppLocalizations {
String get jobNumber => 'Numéro d\'emploi'; String get jobNumber => 'Numéro d\'emploi';
@override @override
String get messages => 'Messages'; String get messages => 'Messages';
@override
String get generalMessages => 'Messages généraux';
@override
String get noMessagesYet => 'Pas encore de messages';
@override
String get noChatsAvailable => 'Aucune discussion disponible';
@override @override
String get selectPhoto => 'Sélectionner une photo'; String get selectPhoto => 'Sélectionner une photo';
@override @override
@@ -327,7 +404,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get noCargoItems => 'Aucun article de cargaison'; String get noCargoItems => 'Aucun article de cargaison';
@override @override
String get noCargoItemsMessage => 'Aucun article de cargaison défini pour cet emploi.'; String get noCargoItemsMessage =>
'Aucun article de cargaison défini pour cet emploi.';
@override @override
String get article => 'Article'; String get article => 'Article';
@@ -369,12 +447,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get statusCreated => 'Créé'; String get statusCreated => 'Créé';
@override @override
String get statusPending => 'En attente';
@override
String get statusAssigned => 'Assigné'; String get statusAssigned => 'Assigné';
@override @override
String get statusInProgress => 'En cours'; String get statusInProgress => 'En cours';
@override @override
String get statusCompleted => 'Terminé'; String get statusCompleted => 'Terminé';
@override @override
String get statusCancelled => 'Annulé';
@override
String get statusFailed => 'Échoué';
@override
String get priorityLow => 'Basse'; String get priorityLow => 'Basse';
@override @override
String get priorityMedium => 'Moyenne'; String get priorityMedium => 'Moyenne';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get unknown => 'Nežinoma'; String get unknown => 'Nežinoma';
@override
String get yesterday => 'Vakar';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,30 @@ class AppLocalizationsLt extends AppLocalizations {
String get loginSubtitle => 'Prisijunkite prie savo paskyros'; String get loginSubtitle => 'Prisijunkite prie savo paskyros';
@override @override
String get email => 'El. paštas'; String get email => 'El. paštas';
@override
String get emailAddress => 'El. pašto adresas';
@override
String get emailAddressHint => 'Įveskite savo el. pašto adresą';
@override
String get emailAddressRequired => 'Prašome įvesti savo el. pašto adresą';
@override
String get emailAddressInvalid =>
'Prašome įvesti galiojantį el. pašto adresą';
@override @override
String get password => 'Slaptažodis'; String get password => 'Slaptažodis';
@override
String get passwordHint => 'Įveskite savo slaptažodį';
@override
String get passwordRequired => 'Prašome įvesti savo slaptažodį';
@override
String get passwordMinLength => 'Slaptažodis turi būti bent 6 simbolių ilgio';
@override @override
String get login => 'Prisijungti'; String get login => 'Prisijungti';
@override @override
@@ -73,15 +98,18 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get forgotPassword => 'Pamiršote slaptažodį?'; String get forgotPassword => 'Pamiršote slaptažodį?';
@override @override
String get forgotPasswordMessage => 'Pamiršto slaptažodžio funkcija dar neįdiegta'; String get forgotPasswordMessage =>
'Pamiršto slaptažodžio funkcija dar neįdiegta';
@override @override
String get loginSuccess => 'Sėkmingai atsijungta'; String get loginSuccess => 'Sėkmingai atsijungta';
@override @override
String get loginFailed => 'Prisijungimas nepavyko'; String get loginFailed => 'Prisijungimas nepavyko';
@override @override
String get connectionFailed => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).'; String get connectionFailed =>
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
@override @override
String get connectionTimeout => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).'; String get connectionTimeout =>
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
@override @override
String get connecting => 'Jungiamasi prie serverio...'; String get connecting => 'Jungiamasi prie serverio...';
@override @override
@@ -149,6 +177,34 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get newJobReceived => 'Gautas naujas darbas'; String get newJobReceived => 'Gautas naujas darbas';
@override
String get jobDetails => 'Užsakymo detalės';
@override
String get jobTasks => 'Užsakymo užduotys';
@override
String get deliveryStations => 'Pristatymo stotelės';
@override
String deliveryStationsCount(int count) => 'Pristatymo stotelės ($count)';
@override
String get noDeliveryStations => 'Nėra pristatymo stotelių';
@override
String get noDeliveryStationsMessage =>
'Ši užduotis šiuo metu neturi pristatymo stotelių.';
@override
String get phone => 'Telefonas';
@override
String get unnamedStation => 'Neįvardyta stotelė';
@override
String stationNumber(int number) => 'Stotelė $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Užduotys'; String get tasks => 'Užduotys';
@@ -161,7 +217,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get confirmationRequired => 'Reikalingas patvirtinimas'; String get confirmationRequired => 'Reikalingas patvirtinimas';
@override @override
String get confirmationDescription => 'Spustelėkite mygtuką, kad atliktumėte užduotį.'; String get confirmationDescription =>
'Spustelėkite mygtuką, kad atliktumėte užduotį.';
@override @override
String get checklist => 'Patikros sąrašas'; String get checklist => 'Patikros sąrašas';
@override @override
@@ -169,7 +226,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get completeTask => 'Baigti užduotį'; String get completeTask => 'Baigti užduotį';
@override @override
String get completeTaskConfirm => 'Ar norite pažymėti šią užduotį kaip baigtą?'; String get completeTaskConfirm =>
'Ar norite pažymėti šią užduotį kaip baigtą?';
@override @override
String get completeTaskNote => 'Pastaba (neprivaloma)'; String get completeTaskNote => 'Pastaba (neprivaloma)';
@override @override
@@ -195,7 +253,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get signatureError => 'Klaida išsaugant parašą'; String get signatureError => 'Klaida išsaugant parašą';
@override @override
String get signatureInstruction => 'Prašome pasirašyti laukelyje žemiau (pele arba pirštu).'; String get signatureInstruction =>
'Prašome pasirašyti laukelyje žemiau (pele arba pirštu).';
@override @override
String get photoCapture => 'Daryti nuotraukas'; String get photoCapture => 'Daryti nuotraukas';
@override @override
@@ -245,9 +304,11 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get barcodeEnterDescription => 'Prašome įvesti brūkšninius kodus:'; String get barcodeEnterDescription => 'Prašome įvesti brūkšninius kodus:';
@override @override
String barcodeNumberRequired(int number) => 'Brūkšninis kodas $number (būtinas)'; String barcodeNumberRequired(int number) =>
'Brūkšninis kodas $number (būtinas)';
@override @override
String barcodeNumberOptional(int number) => 'Brūkšninis kodas $number (neprivalomas)'; String barcodeNumberOptional(int number) =>
'Brūkšninis kodas $number (neprivalomas)';
@override @override
String get barcodeError => 'Klaida skaitant brūkšninį kodą'; String get barcodeError => 'Klaida skaitant brūkšninį kodą';
@override @override
@@ -257,7 +318,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Kamera nepasiekiama'; String get cameraNotAvailable => 'Kamera nepasiekiama';
@override @override
String get cameraNotSupportedMessage => 'Šioje platformoje kamera nepalaikoma.'; String get cameraNotSupportedMessage =>
'Šioje platformoje kamera nepalaikoma.';
@override @override
String get cameraNotSupportedOnPlatform => 'Nepalaikoma šioje platformoje'; String get cameraNotSupportedOnPlatform => 'Nepalaikoma šioje platformoje';
@override @override
@@ -273,7 +335,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get addPhotos => 'Pridėti nuotraukas'; String get addPhotos => 'Pridėti nuotraukas';
@override @override
String get addPhotosInstruction => 'Naudokite mygtuką "Pasirinkti nuotrauką", norėdami pridėti vaizdų iš fotoaparato ar standžiojo disko.'; String get addPhotosInstruction =>
'Naudokite mygtuką "Pasirinkti nuotrauką", norėdami pridėti vaizdų iš fotoaparato ar standžiojo disko.';
@override @override
String get photoOf => ''; String get photoOf => '';
@@ -285,7 +348,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get noSender => 'Siuntėjas nepasiekiamas'; String get noSender => 'Siuntėjas nepasiekiamas';
@override @override
String get noSenderMessage => 'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.'; String get noSenderMessage =>
'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.';
@override @override
String get noRecipient => 'Gavėjas nesukonfigūruotas'; String get noRecipient => 'Gavėjas nesukonfigūruotas';
@override @override
@@ -306,6 +370,15 @@ class AppLocalizationsLt extends AppLocalizations {
String get jobNumber => 'Darbo numeris'; String get jobNumber => 'Darbo numeris';
@override @override
String get messages => 'Žinutės'; String get messages => 'Žinutės';
@override
String get generalMessages => 'Bendri pranešimai';
@override
String get noMessagesYet => 'Pranešimų dar nėra';
@override
String get noChatsAvailable => 'Nėra galimų pokalbių';
@override @override
String get selectPhoto => 'Pasirinkti nuotrauką'; String get selectPhoto => 'Pasirinkti nuotrauką';
@override @override
@@ -327,7 +400,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get noCargoItems => 'Nėra krovinių pozicijų'; String get noCargoItems => 'Nėra krovinių pozicijų';
@override @override
String get noCargoItemsMessage => 'Šiam darbui nėra apibrėžtų krovinių pozicijų.'; String get noCargoItemsMessage =>
'Šiam darbui nėra apibrėžtų krovinių pozicijų.';
@override @override
String get article => 'Pozicija'; String get article => 'Pozicija';
@@ -369,12 +443,18 @@ class AppLocalizationsLt extends AppLocalizations {
@override @override
String get statusCreated => 'Sukurta'; String get statusCreated => 'Sukurta';
@override @override
String get statusPending => 'Laukiama';
@override
String get statusAssigned => 'Priskirta'; String get statusAssigned => 'Priskirta';
@override @override
String get statusInProgress => 'Vykdoma'; String get statusInProgress => 'Vykdoma';
@override @override
String get statusCompleted => 'Baigta'; String get statusCompleted => 'Baigta';
@override @override
String get statusCancelled => 'Atšaukta';
@override
String get statusFailed => 'Nepavyko';
@override
String get priorityLow => 'Žemas'; String get priorityLow => 'Žemas';
@override @override
String get priorityMedium => 'Vidutinis'; String get priorityMedium => 'Vidutinis';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get unknown => 'Nezināms'; String get unknown => 'Nezināms';
@override
String get yesterday => 'Vakar';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,29 @@ class AppLocalizationsLv extends AppLocalizations {
String get loginSubtitle => 'Pierakstieties savā kontā'; String get loginSubtitle => 'Pierakstieties savā kontā';
@override @override
String get email => 'E-pasts'; String get email => 'E-pasts';
@override
String get emailAddress => 'E-pasta adrese';
@override
String get emailAddressHint => 'Ievadiet savu e-pasta adresi';
@override
String get emailAddressRequired => 'Lūdzu, ievadiet savu e-pasta adresi';
@override
String get emailAddressInvalid => 'Lūdzu, ievadiet derīgu e-pasta adresi';
@override @override
String get password => 'Parole'; String get password => 'Parole';
@override
String get passwordHint => 'Ievadiet savu paroli';
@override
String get passwordRequired => 'Lūdzu, ievadiet savu paroli';
@override
String get passwordMinLength => 'Parolei jābūt vismaz 6 rakstzīmes garai';
@override @override
String get login => 'Pierakstīties'; String get login => 'Pierakstīties';
@override @override
@@ -73,7 +97,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get forgotPassword => 'Aizmirsāt paroli?'; String get forgotPassword => 'Aizmirsāt paroli?';
@override @override
String get forgotPasswordMessage => 'Aizmirstās paroles funkcija vēl nav ieviesta'; String get forgotPasswordMessage =>
'Aizmirstās paroles funkcija vēl nav ieviesta';
@override @override
String get loginSuccess => 'Veiksmīgi izrakstījās'; String get loginSuccess => 'Veiksmīgi izrakstījās';
@override @override
@@ -149,6 +174,34 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get newJobReceived => 'Saņemts jauns darbs'; String get newJobReceived => 'Saņemts jauns darbs';
@override
String get jobDetails => 'Darba detaļas';
@override
String get jobTasks => 'Darba uzdevumi';
@override
String get deliveryStations => 'Piegādes stacijas';
@override
String deliveryStationsCount(int count) => 'Piegādes stacijas ($count)';
@override
String get noDeliveryStations => 'Nav piegādes staciju';
@override
String get noDeliveryStationsMessage =>
'Šajā darbā pašlaik nav piegādes staciju.';
@override
String get phone => 'Tālrunis';
@override
String get unnamedStation => 'Nenosaukta stacija';
@override
String stationNumber(int number) => 'Stacija $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Uzdevumi'; String get tasks => 'Uzdevumi';
@@ -161,7 +214,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get confirmationRequired => 'Nepieciešams apstiprinājums'; String get confirmationRequired => 'Nepieciešams apstiprinājums';
@override @override
String get confirmationDescription => 'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.'; String get confirmationDescription =>
'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.';
@override @override
String get checklist => 'Pārbaudes saraksts'; String get checklist => 'Pārbaudes saraksts';
@override @override
@@ -169,7 +223,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get completeTask => 'Pabeigt uzdevumu'; String get completeTask => 'Pabeigt uzdevumu';
@override @override
String get completeTaskConfirm => 'Vai vēlaties atzīmēt šo uzdevumu kā pabeigtu?'; String get completeTaskConfirm =>
'Vai vēlaties atzīmēt šo uzdevumu kā pabeigtu?';
@override @override
String get completeTaskNote => 'Piezīme (neobligāta)'; String get completeTaskNote => 'Piezīme (neobligāta)';
@override @override
@@ -195,7 +250,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get signatureError => 'Kļūda saglabājot parakstu'; String get signatureError => 'Kļūda saglabājot parakstu';
@override @override
String get signatureInstruction => 'Lūdzu parakstieties zemāk esošajā laukā (pele vai pirksts).'; String get signatureInstruction =>
'Lūdzu parakstieties zemāk esošajā laukā (pele vai pirksts).';
@override @override
String get photoCapture => 'Uzņemt fotogrāfijas'; String get photoCapture => 'Uzņemt fotogrāfijas';
@override @override
@@ -257,7 +313,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Kamera nav pieejama'; String get cameraNotAvailable => 'Kamera nav pieejama';
@override @override
String get cameraNotSupportedMessage => 'Šajā platformā kamera netiek atbalstīta.'; String get cameraNotSupportedMessage =>
'Šajā platformā kamera netiek atbalstīta.';
@override @override
String get cameraNotSupportedOnPlatform => 'Šajā platformā netiek atbalstīts'; String get cameraNotSupportedOnPlatform => 'Šajā platformā netiek atbalstīts';
@override @override
@@ -269,11 +326,13 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get cameraInitializing => 'Kamera tiek inicializēta...'; String get cameraInitializing => 'Kamera tiek inicializēta...';
@override @override
String get cameraLoadingMessage => 'Lūdzu, uzgaidiet, kamēr kamera tiek ielādēta'; String get cameraLoadingMessage =>
'Lūdzu, uzgaidiet, kamēr kamera tiek ielādēta';
@override @override
String get addPhotos => 'Pievienot fotogrāfijas'; String get addPhotos => 'Pievienot fotogrāfijas';
@override @override
String get addPhotosInstruction => 'Izmantojiet pogu "Izvēlēties fotogrāfiju", lai pievienotu attēlus no kameras vai cietā diska.'; String get addPhotosInstruction =>
'Izmantojiet pogu "Izvēlēties fotogrāfiju", lai pievienotu attēlus no kameras vai cietā diska.';
@override @override
String get photoOf => 'no'; String get photoOf => 'no';
@@ -285,7 +344,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get noSender => 'Sūtītājs nav pieejams'; String get noSender => 'Sūtītājs nav pieejams';
@override @override
String get noSenderMessage => 'Sūtītājs nav pieejams. Lūdzu, piesakieties vēlreiz.'; String get noSenderMessage =>
'Sūtītājs nav pieejams. Lūdzu, piesakieties vēlreiz.';
@override @override
String get noRecipient => 'Saņēmējs nav konfigurēts'; String get noRecipient => 'Saņēmējs nav konfigurēts';
@override @override
@@ -306,6 +366,15 @@ class AppLocalizationsLv extends AppLocalizations {
String get jobNumber => 'Darba numurs'; String get jobNumber => 'Darba numurs';
@override @override
String get messages => 'Ziņojumi'; String get messages => 'Ziņojumi';
@override
String get generalMessages => 'Vispārīgi ziņojumi';
@override
String get noMessagesYet => 'Ziņojumu vēl nav';
@override
String get noChatsAvailable => 'Nav pieejamu tērzēšanu';
@override @override
String get selectPhoto => 'Izvēlēties fotogrāfiju'; String get selectPhoto => 'Izvēlēties fotogrāfiju';
@override @override
@@ -369,12 +438,18 @@ class AppLocalizationsLv extends AppLocalizations {
@override @override
String get statusCreated => 'Izveidots'; String get statusCreated => 'Izveidots';
@override @override
String get statusPending => 'Gaida';
@override
String get statusAssigned => 'Piešķirts'; String get statusAssigned => 'Piešķirts';
@override @override
String get statusInProgress => 'Procesā'; String get statusInProgress => 'Procesā';
@override @override
String get statusCompleted => 'Pabeigts'; String get statusCompleted => 'Pabeigts';
@override @override
String get statusCancelled => 'Atcelts';
@override
String get statusFailed => 'Neizdevās';
@override
String get priorityLow => 'Zema'; String get priorityLow => 'Zema';
@override @override
String get priorityMedium => 'Vidēja'; String get priorityMedium => 'Vidēja';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get unknown => 'Nieznany'; String get unknown => 'Nieznany';
@override
String get yesterday => 'Wczoraj';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,29 @@ class AppLocalizationsPl extends AppLocalizations {
String get loginSubtitle => 'Zaloguj się do swojego konta'; String get loginSubtitle => 'Zaloguj się do swojego konta';
@override @override
String get email => 'E-mail'; String get email => 'E-mail';
@override
String get emailAddress => 'Adres e-mail';
@override
String get emailAddressHint => 'Wpisz adres e-mail';
@override
String get emailAddressRequired => 'Proszę wpisać adres e-mail';
@override
String get emailAddressInvalid => 'Proszę wpisać prawidłowy adres e-mail';
@override @override
String get password => 'Hasło'; String get password => 'Hasło';
@override
String get passwordHint => 'Wpisz hasło';
@override
String get passwordRequired => 'Proszę wpisać hasło';
@override
String get passwordMinLength => 'Hasło musi mieć co najmniej 6 znaków';
@override @override
String get login => 'Zaloguj'; String get login => 'Zaloguj';
@override @override
@@ -73,7 +97,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get forgotPassword => 'Zapomniałeś hasła?'; String get forgotPassword => 'Zapomniałeś hasła?';
@override @override
String get forgotPasswordMessage => 'Funkcja zapomnianego hasła jeszcze nie zaimplementowana'; String get forgotPasswordMessage =>
'Funkcja zapomnianego hasła jeszcze nie zaimplementowana';
@override @override
String get loginSuccess => 'Pomyślnie wylogowano'; String get loginSuccess => 'Pomyślnie wylogowano';
@override @override
@@ -93,7 +118,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get noJobsAssigned => 'Brak przypisanych zadań'; String get noJobsAssigned => 'Brak przypisanych zadań';
@override @override
String get noJobsMessage => 'Twoje przypisane zadania będą wyświetlane tutaj.'; String get noJobsMessage =>
'Twoje przypisane zadania będą wyświetlane tutaj.';
@override @override
String get pullToRefresh => 'Przeciągnij w dół, aby odświeżyć'; String get pullToRefresh => 'Przeciągnij w dół, aby odświeżyć';
@override @override
@@ -149,6 +175,34 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get newJobReceived => 'Otrzymano nowe zadanie'; String get newJobReceived => 'Otrzymano nowe zadanie';
@override
String get jobDetails => 'Szczegóły zlecenia';
@override
String get jobTasks => 'Zadania zlecenia';
@override
String get deliveryStations => 'Stacje dostawy';
@override
String deliveryStationsCount(int count) => 'Stacje dostawy ($count)';
@override
String get noDeliveryStations => 'Brak stacji dostawy';
@override
String get noDeliveryStationsMessage =>
'To zlecenie nie zawiera obecnie żadnych stacji dostawy.';
@override
String get phone => 'Telefon';
@override
String get unnamedStation => 'Nienazwana stacja';
@override
String stationNumber(int number) => 'Stacja $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Zadania'; String get tasks => 'Zadania';
@@ -161,7 +215,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get confirmationRequired => 'Wymagane potwierdzenie'; String get confirmationRequired => 'Wymagane potwierdzenie';
@override @override
String get confirmationDescription => 'Kliknij przycisk, aby ukończyć zadanie.'; String get confirmationDescription =>
'Kliknij przycisk, aby ukończyć zadanie.';
@override @override
String get checklist => 'Lista kontrolna'; String get checklist => 'Lista kontrolna';
@override @override
@@ -169,7 +224,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get completeTask => 'Ukończ zadanie'; String get completeTask => 'Ukończ zadanie';
@override @override
String get completeTaskConfirm => 'Czy chcesz oznaczyć to zadanie jako ukończone?'; String get completeTaskConfirm =>
'Czy chcesz oznaczyć to zadanie jako ukończone?';
@override @override
String get completeTaskNote => 'Notatka (opcjonalnie)'; String get completeTaskNote => 'Notatka (opcjonalnie)';
@override @override
@@ -195,7 +251,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get signatureError => 'Błąd podczas zapisywania podpisu'; String get signatureError => 'Błąd podczas zapisywania podpisu';
@override @override
String get signatureInstruction => 'Proszę podpisać się w polu poniżej (mysz lub palec).'; String get signatureInstruction =>
'Proszę podpisać się w polu poniżej (mysz lub palec).';
@override @override
String get photoCapture => 'Zrób zdjęcia'; String get photoCapture => 'Zrób zdjęcia';
@override @override
@@ -247,7 +304,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String barcodeNumberRequired(int number) => 'Kod kreskowy $number (wymagany)'; String barcodeNumberRequired(int number) => 'Kod kreskowy $number (wymagany)';
@override @override
String barcodeNumberOptional(int number) => 'Kod kreskowy $number (opcjonalny)'; String barcodeNumberOptional(int number) =>
'Kod kreskowy $number (opcjonalny)';
@override @override
String get barcodeError => 'Błąd podczas skanowania kodu kreskowego'; String get barcodeError => 'Błąd podczas skanowania kodu kreskowego';
@override @override
@@ -257,7 +315,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Kamera niedostępna'; String get cameraNotAvailable => 'Kamera niedostępna';
@override @override
String get cameraNotSupportedMessage => 'Kamera nie jest obsługiwana na tej platformie.'; String get cameraNotSupportedMessage =>
'Kamera nie jest obsługiwana na tej platformie.';
@override @override
String get cameraNotSupportedOnPlatform => 'Nieobsługiwane na tej platformie'; String get cameraNotSupportedOnPlatform => 'Nieobsługiwane na tej platformie';
@override @override
@@ -273,7 +332,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get addPhotos => 'Dodaj zdjęcia'; String get addPhotos => 'Dodaj zdjęcia';
@override @override
String get addPhotosInstruction => 'Użyj przycisku "Wybierz zdjęcie", aby dodać obrazy z kamery lub dysku twardego.'; String get addPhotosInstruction =>
'Użyj przycisku "Wybierz zdjęcie", aby dodać obrazy z kamery lub dysku twardego.';
@override @override
String get photoOf => 'z'; String get photoOf => 'z';
@@ -285,11 +345,13 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get noSender => 'Brak dostępnego nadawcy'; String get noSender => 'Brak dostępnego nadawcy';
@override @override
String get noSenderMessage => 'Brak dostępnego nadawcy. Proszę zalogować się ponownie.'; String get noSenderMessage =>
'Brak dostępnego nadawcy. Proszę zalogować się ponownie.';
@override @override
String get noRecipient => 'Brak skonfigurowanego odbiorcy'; String get noRecipient => 'Brak skonfigurowanego odbiorcy';
@override @override
String get noRecipientMessage => 'Brak skonfigurowanego odbiorcy dla tego czatu.'; String get noRecipientMessage =>
'Brak skonfigurowanego odbiorcy dla tego czatu.';
@override @override
String get messageSendError => 'Wiadomość nie mogła zostać wysłana.'; String get messageSendError => 'Wiadomość nie mogła zostać wysłana.';
@override @override
@@ -306,6 +368,15 @@ class AppLocalizationsPl extends AppLocalizations {
String get jobNumber => 'Numer zadania'; String get jobNumber => 'Numer zadania';
@override @override
String get messages => 'Wiadomości'; String get messages => 'Wiadomości';
@override
String get generalMessages => 'Wiadomości ogólne';
@override
String get noMessagesYet => 'Brak wiadomości';
@override
String get noChatsAvailable => 'Brak dostępnych czatów';
@override @override
String get selectPhoto => 'Wybierz zdjęcie'; String get selectPhoto => 'Wybierz zdjęcie';
@override @override
@@ -327,7 +398,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get noCargoItems => 'Brak pozycji ładunku'; String get noCargoItems => 'Brak pozycji ładunku';
@override @override
String get noCargoItemsMessage => 'Brak pozycji ładunku zdefiniowanych dla tego zadania.'; String get noCargoItemsMessage =>
'Brak pozycji ładunku zdefiniowanych dla tego zadania.';
@override @override
String get article => 'Pozycja'; String get article => 'Pozycja';
@@ -369,12 +441,18 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get statusCreated => 'Utworzono'; String get statusCreated => 'Utworzono';
@override @override
String get statusPending => 'Oczekujące';
@override
String get statusAssigned => 'Przypisano'; String get statusAssigned => 'Przypisano';
@override @override
String get statusInProgress => 'W trakcie'; String get statusInProgress => 'W trakcie';
@override @override
String get statusCompleted => 'Ukończono'; String get statusCompleted => 'Ukończono';
@override @override
String get statusCancelled => 'Anulowano';
@override
String get statusFailed => 'Nieudane';
@override
String get priorityLow => 'Niski'; String get priorityLow => 'Niski';
@override @override
String get priorityMedium => 'Średni'; String get priorityMedium => 'Średni';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get unknown => 'Неизвестно'; String get unknown => 'Неизвестно';
@override
String get yesterday => 'Вчера';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,30 @@ class AppLocalizationsRu extends AppLocalizations {
String get loginSubtitle => 'Войдите в свою учетную запись'; String get loginSubtitle => 'Войдите в свою учетную запись';
@override @override
String get email => 'Эл. почта'; String get email => 'Эл. почта';
@override
String get emailAddress => 'Адрес эл. почты';
@override
String get emailAddressHint => 'Введите адрес эл. почты';
@override
String get emailAddressRequired => 'Пожалуйста, введите адрес эл. почты';
@override
String get emailAddressInvalid =>
'Пожалуйста, введите корректный адрес эл. почты';
@override @override
String get password => 'Пароль'; String get password => 'Пароль';
@override
String get passwordHint => 'Введите пароль';
@override
String get passwordRequired => 'Пожалуйста, введите пароль';
@override
String get passwordMinLength => 'Пароль должен содержать не менее 6 символов';
@override @override
String get login => 'Войти'; String get login => 'Войти';
@override @override
@@ -73,7 +98,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get forgotPassword => 'Забыли пароль?'; String get forgotPassword => 'Забыли пароль?';
@override @override
String get forgotPasswordMessage => 'Функция восстановления пароля еще не реализована'; String get forgotPasswordMessage =>
'Функция восстановления пароля еще не реализована';
@override @override
String get loginSuccess => 'Успешный выход из системы'; String get loginSuccess => 'Успешный выход из системы';
@override @override
@@ -93,7 +119,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get noJobsAssigned => 'Нет назначенных заданий'; String get noJobsAssigned => 'Нет назначенных заданий';
@override @override
String get noJobsMessage => 'Ваши назначенные задания будут отображаться здесь.'; String get noJobsMessage =>
'Ваши назначенные задания будут отображаться здесь.';
@override @override
String get pullToRefresh => 'Потяните вниз, чтобы обновить'; String get pullToRefresh => 'Потяните вниз, чтобы обновить';
@override @override
@@ -137,7 +164,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get jobsUpdated => 'Задания обновлены'; String get jobsUpdated => 'Задания обновлены';
@override @override
String get connectionRestored => 'Соединение восстановлено. Загрузка заданий...'; String get connectionRestored =>
'Соединение восстановлено. Загрузка заданий...';
@override @override
String get connectionLost => 'Соединение потеряно. Офлайн.'; String get connectionLost => 'Соединение потеряно. Офлайн.';
@override @override
@@ -149,6 +177,34 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get newJobReceived => 'Получено новое задание'; String get newJobReceived => 'Получено новое задание';
@override
String get jobDetails => 'Детали заказа';
@override
String get jobTasks => 'Задачи заказа';
@override
String get deliveryStations => 'Точки доставки';
@override
String deliveryStationsCount(int count) => 'Точки доставки ($count)';
@override
String get noDeliveryStations => 'Нет точек доставки';
@override
String get noDeliveryStationsMessage =>
'Для этого заказа сейчас нет точек доставки.';
@override
String get phone => 'Телефон';
@override
String get unnamedStation => 'Станция без названия';
@override
String stationNumber(int number) => 'Станция $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Задачи'; String get tasks => 'Задачи';
@@ -161,7 +217,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get confirmationRequired => 'Требуется подтверждение'; String get confirmationRequired => 'Требуется подтверждение';
@override @override
String get confirmationDescription => 'Нажмите кнопку, чтобы выполнить задачу.'; String get confirmationDescription =>
'Нажмите кнопку, чтобы выполнить задачу.';
@override @override
String get checklist => 'Контрольный список'; String get checklist => 'Контрольный список';
@override @override
@@ -169,7 +226,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get completeTask => 'Завершить задачу'; String get completeTask => 'Завершить задачу';
@override @override
String get completeTaskConfirm => 'Хотите отметить эту задачу как выполненную?'; String get completeTaskConfirm =>
'Хотите отметить эту задачу как выполненную?';
@override @override
String get completeTaskNote => 'Примечание (необязательно)'; String get completeTaskNote => 'Примечание (необязательно)';
@override @override
@@ -195,7 +253,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get signatureError => 'Ошибка при сохранении подписи'; String get signatureError => 'Ошибка при сохранении подписи';
@override @override
String get signatureInstruction => 'Пожалуйста, подпишитесь в поле ниже (мышь или палец).'; String get signatureInstruction =>
'Пожалуйста, подпишитесь в поле ниже (мышь или палец).';
@override @override
String get photoCapture => 'Сделать фото'; String get photoCapture => 'Сделать фото';
@override @override
@@ -247,7 +306,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String barcodeNumberRequired(int number) => 'Штрих-код $number (обязательно)'; String barcodeNumberRequired(int number) => 'Штрих-код $number (обязательно)';
@override @override
String barcodeNumberOptional(int number) => 'Штрих-код $number (необязательно)'; String barcodeNumberOptional(int number) =>
'Штрих-код $number (необязательно)';
@override @override
String get barcodeError => 'Ошибка при сканировании штрих-кода'; String get barcodeError => 'Ошибка при сканировании штрих-кода';
@override @override
@@ -257,9 +317,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Камера недоступна'; String get cameraNotAvailable => 'Камера недоступна';
@override @override
String get cameraNotSupportedMessage => 'Камера не поддерживается на этой платформе.'; String get cameraNotSupportedMessage =>
'Камера не поддерживается на этой платформе.';
@override @override
String get cameraNotSupportedOnPlatform => 'Не поддерживается на этой платформе'; String get cameraNotSupportedOnPlatform =>
'Не поддерживается на этой платформе';
@override @override
String get maxPhotosReached => 'Максимум достигнут'; String get maxPhotosReached => 'Максимум достигнут';
@override @override
@@ -269,11 +331,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cameraInitializing => 'Инициализация камеры...'; String get cameraInitializing => 'Инициализация камеры...';
@override @override
String get cameraLoadingMessage => 'Пожалуйста, подождите, пока загружается камера'; String get cameraLoadingMessage =>
'Пожалуйста, подождите, пока загружается камера';
@override @override
String get addPhotos => 'Добавить фото'; String get addPhotos => 'Добавить фото';
@override @override
String get addPhotosInstruction => 'Используйте кнопку "Выбрать фото", чтобы добавить изображения с камеры или жёсткого диска.'; String get addPhotosInstruction =>
'Используйте кнопку "Выбрать фото", чтобы добавить изображения с камеры или жёсткого диска.';
@override @override
String get photoOf => 'из'; String get photoOf => 'из';
@@ -285,7 +349,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get noSender => 'Отправитель недоступен'; String get noSender => 'Отправитель недоступен';
@override @override
String get noSenderMessage => 'Отправитель недоступен. Пожалуйста, войдите снова.'; String get noSenderMessage =>
'Отправитель недоступен. Пожалуйста, войдите снова.';
@override @override
String get noRecipient => 'Получатель не настроен'; String get noRecipient => 'Получатель не настроен';
@override @override
@@ -306,6 +371,15 @@ class AppLocalizationsRu extends AppLocalizations {
String get jobNumber => 'Номер задания'; String get jobNumber => 'Номер задания';
@override @override
String get messages => 'Сообщения'; String get messages => 'Сообщения';
@override
String get generalMessages => 'Общие сообщения';
@override
String get noMessagesYet => 'Сообщений пока нет';
@override
String get noChatsAvailable => 'Нет доступных чатов';
@override @override
String get selectPhoto => 'Выбрать фото'; String get selectPhoto => 'Выбрать фото';
@override @override
@@ -327,7 +401,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get noCargoItems => 'Нет позиций груза'; String get noCargoItems => 'Нет позиций груза';
@override @override
String get noCargoItemsMessage => 'Для этого задания не определены позиции груза.'; String get noCargoItemsMessage =>
'Для этого задания не определены позиции груза.';
@override @override
String get article => 'Позиция'; String get article => 'Позиция';
@@ -369,12 +444,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get statusCreated => 'Создано'; String get statusCreated => 'Создано';
@override @override
String get statusPending => 'В ожидании';
@override
String get statusAssigned => 'Назначено'; String get statusAssigned => 'Назначено';
@override @override
String get statusInProgress => 'В процессе'; String get statusInProgress => 'В процессе';
@override @override
String get statusCompleted => 'Завершено'; String get statusCompleted => 'Завершено';
@override @override
String get statusCancelled => 'Отменено';
@override
String get statusFailed => 'Не удалось';
@override
String get priorityLow => 'Низкий'; String get priorityLow => 'Низкий';
@override @override
String get priorityMedium => 'Средний'; String get priorityMedium => 'Средний';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get unknown => 'Bilinmiyor'; String get unknown => 'Bilinmiyor';
@override
String get yesterday => 'Dün';
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@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
@@ -64,8 +67,29 @@ class AppLocalizationsTr extends AppLocalizations {
String get loginSubtitle => 'Hesabınıza giriş yapın'; String get loginSubtitle => 'Hesabınıza giriş yapın';
@override @override
String get email => 'E-posta'; String get email => 'E-posta';
@override
String get emailAddress => 'E-posta adresi';
@override
String get emailAddressHint => 'E-posta adresinizi girin';
@override
String get emailAddressRequired => 'Lütfen e-posta adresinizi girin';
@override
String get emailAddressInvalid => 'Lütfen geçerli bir e-posta adresi girin';
@override @override
String get password => 'Şifre'; String get password => 'Şifre';
@override
String get passwordHint => 'Şifrenizi girin';
@override
String get passwordRequired => 'Lütfen şifrenizi girin';
@override
String get passwordMinLength => 'Şifre en az 6 karakter olmalıdır';
@override @override
String get login => 'Giriş'; String get login => 'Giriş';
@override @override
@@ -73,7 +97,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get forgotPassword => 'Şifrenizi mi unuttunuz?'; String get forgotPassword => 'Şifrenizi mi unuttunuz?';
@override @override
String get forgotPasswordMessage => 'Şifremi unuttum özelliği henüz uygulanmadı'; String get forgotPasswordMessage =>
'Şifremi unuttum özelliği henüz uygulanmadı';
@override @override
String get loginSuccess => 'Başarıyla çıkış yapıldı'; String get loginSuccess => 'Başarıyla çıkış yapıldı';
@override @override
@@ -137,7 +162,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get jobsUpdated => 'İşler güncellendi'; String get jobsUpdated => 'İşler güncellendi';
@override @override
String get connectionRestored => 'Bağlantı geri yüklendi. İşler yükleniyor...'; String get connectionRestored =>
'Bağlantı geri yüklendi. İşler yükleniyor...';
@override @override
String get connectionLost => 'Bağlantı kesildi. Çevrimdışı.'; String get connectionLost => 'Bağlantı kesildi. Çevrimdışı.';
@override @override
@@ -149,6 +175,34 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get newJobReceived => 'Yeni iş alındı'; String get newJobReceived => 'Yeni iş alındı';
@override
String get jobDetails => 'İş detayları';
@override
String get jobTasks => 'İş görevleri';
@override
String get deliveryStations => 'Teslimat durakları';
@override
String deliveryStationsCount(int count) => 'Teslimat durakları ($count)';
@override
String get noDeliveryStations => 'Teslimat durağı yok';
@override
String get noDeliveryStationsMessage =>
'Bu iş şu anda hiçbir teslimat durağı içermiyor.';
@override
String get phone => 'Telefon';
@override
String get unnamedStation => 'Adsız durak';
@override
String stationNumber(int number) => 'Durak $number';
// ==================== TASKS ==================== // ==================== TASKS ====================
@override @override
String get tasks => 'Görevler'; String get tasks => 'Görevler';
@@ -161,7 +215,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get confirmationRequired => 'Onay gerekli'; String get confirmationRequired => 'Onay gerekli';
@override @override
String get confirmationDescription => 'Görevi tamamlamak için butona tıklayın.'; String get confirmationDescription =>
'Görevi tamamlamak için butona tıklayın.';
@override @override
String get checklist => 'Kontrol listesi'; String get checklist => 'Kontrol listesi';
@override @override
@@ -169,7 +224,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get completeTask => 'Görevi tamamla'; String get completeTask => 'Görevi tamamla';
@override @override
String get completeTaskConfirm => 'Bu görevi tamamlandı olarak işaretlemek istiyor musunuz?'; String get completeTaskConfirm =>
'Bu görevi tamamlandı olarak işaretlemek istiyor musunuz?';
@override @override
String get completeTaskNote => 'Not (isteğe bağlı)'; String get completeTaskNote => 'Not (isteğe bağlı)';
@override @override
@@ -195,7 +251,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get signatureError => 'İmza kaydedilirken hata oluştu'; String get signatureError => 'İmza kaydedilirken hata oluştu';
@override @override
String get signatureInstruction => 'Lütfen aşağıdaki alana imzanızı atın (fare veya parmak).'; String get signatureInstruction =>
'Lütfen aşağıdaki alana imzanızı atın (fare veya parmak).';
@override @override
String get photoCapture => 'Fotoğraf çek'; String get photoCapture => 'Fotoğraf çek';
@override @override
@@ -221,7 +278,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get deletePhoto => 'Fotoğrafı sil'; String get deletePhoto => 'Fotoğrafı sil';
@override @override
String get deletePhotoConfirm => 'Bu fotoğrafı gerçekten silmek istiyor musunuz?'; String get deletePhotoConfirm =>
'Bu fotoğrafı gerçekten silmek istiyor musunuz?';
@override @override
String get barcode => 'Barkod'; String get barcode => 'Barkod';
@override @override
@@ -257,7 +315,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get cameraNotAvailable => 'Kamera kullanılamıyor'; String get cameraNotAvailable => 'Kamera kullanılamıyor';
@override @override
String get cameraNotSupportedMessage => 'Bu platformda kamera desteklenmiyor.'; String get cameraNotSupportedMessage =>
'Bu platformda kamera desteklenmiyor.';
@override @override
String get cameraNotSupportedOnPlatform => 'Bu platformda desteklenmiyor'; String get cameraNotSupportedOnPlatform => 'Bu platformda desteklenmiyor';
@override @override
@@ -273,7 +332,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get addPhotos => 'Fotoğraf ekle'; String get addPhotos => 'Fotoğraf ekle';
@override @override
String get addPhotosInstruction => 'Kamera veya sabit diskten görüntü eklemek için "Fotoğraf seç" düğmesini kullanın.'; String get addPhotosInstruction =>
'Kamera veya sabit diskten görüntü eklemek için "Fotoğraf seç" düğmesini kullanın.';
@override @override
String get photoOf => '/'; String get photoOf => '/';
@@ -285,7 +345,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get noSender => 'Gönderen mevcut değil'; String get noSender => 'Gönderen mevcut değil';
@override @override
String get noSenderMessage => 'Gönderen mevcut değil. Lütfen tekrar giriş yapın.'; String get noSenderMessage =>
'Gönderen mevcut değil. Lütfen tekrar giriş yapın.';
@override @override
String get noRecipient => 'Alıcı yapılandırılmamış'; String get noRecipient => 'Alıcı yapılandırılmamış';
@override @override
@@ -306,6 +367,15 @@ class AppLocalizationsTr extends AppLocalizations {
String get jobNumber => 'İş numarası'; String get jobNumber => 'İş numarası';
@override @override
String get messages => 'Mesajlar'; String get messages => 'Mesajlar';
@override
String get generalMessages => 'Genel mesajlar';
@override
String get noMessagesYet => 'Henüz mesaj yok';
@override
String get noChatsAvailable => 'Kullanılabilir sohbet yok';
@override @override
String get selectPhoto => 'Fotoğraf seç'; String get selectPhoto => 'Fotoğraf seç';
@override @override
@@ -369,12 +439,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get statusCreated => 'Oluşturuldu'; String get statusCreated => 'Oluşturuldu';
@override @override
String get statusPending => 'Beklemede';
@override
String get statusAssigned => 'Atandı'; String get statusAssigned => 'Atandı';
@override @override
String get statusInProgress => 'Devam ediyor'; String get statusInProgress => 'Devam ediyor';
@override @override
String get statusCompleted => 'Tamamlandı'; String get statusCompleted => 'Tamamlandı';
@override @override
String get statusCancelled => 'İptal edildi';
@override
String get statusFailed => 'Başarısız';
@override
String get priorityLow => 'Düşük'; String get priorityLow => 'Düşük';
@override @override
String get priorityMedium => 'Orta'; String get priorityMedium => 'Orta';

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import '../models/chat.dart';
import 'app_localizations.dart';
String localizeKnownText(BuildContext context, String text) {
final l10n = AppLocalizations.of(context);
switch (text.trim()) {
case 'Auftragsdetails':
return l10n.jobDetails;
case 'Aufgaben eines Auftrags':
return l10n.jobTasks;
case 'Unterschrift':
return l10n.signature;
case 'Allgemeine Nachrichten':
return l10n.generalMessages;
case 'Telefon':
return l10n.phone;
case 'Erstellt':
return l10n.created;
case 'E-Mail-Adresse':
return l10n.emailAddress;
case 'Passwort':
return l10n.password;
case 'Anmelden':
return l10n.login;
default:
return text;
}
}
String localizedStationLabel(
BuildContext context,
int number, {
String? suffix,
}) {
final base = AppLocalizations.of(context).stationNumber(number);
final trimmedSuffix = suffix?.trim() ?? '';
if (trimmedSuffix.isEmpty) {
return base;
}
return '$base: ${localizeKnownText(context, trimmedSuffix)}';
}
String localizedChatTitle(BuildContext context, Chat chat) {
if (chat.type == ChatType.general) {
return AppLocalizations.of(context).generalMessages;
}
return localizeKnownText(context, chat.title);
}

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 {
@@ -34,6 +35,8 @@ class _LoginViewState extends State<LoginView> {
bool _logoutNoticeShown = false; bool _logoutNoticeShown = false;
bool _hasNavigatedToJobs = false; bool _hasNavigatedToJobs = false;
String _appVersion = ''; String _appVersion = '';
String? _pendingLoginEmail;
String? _pendingLoginPassword;
@override @override
void initState() { void initState() {
@@ -52,7 +55,13 @@ class _LoginViewState extends State<LoginView> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_logoutNoticeShown = true; _logoutNoticeShown = true;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).loginSuccess), backgroundColor: Colors.green, duration: const Duration(seconds: 1))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).loginSuccess),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 1),
),
);
}); });
} }
} }
@@ -86,70 +95,144 @@ class _LoginViewState extends State<LoginView> {
// Listen to connection status changes via dart_mq // Listen to connection status changes via dart_mq
// Note: Don't reset _isLoggingIn here - the login flow in _handleLogin // Note: Don't reset _isLoggingIn here - the login flow in _handleLogin
// manages button state through its own error/success handling. // manages button state through its own error/success handling.
_connectionStatusSubscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) { _connectionStatusSubscription = DartMQ().subscribe<bool>(
if (mounted) { MQTopics.connectionStatus,
setState(() {}); (isConnected) {
} if (mounted) {
}); setState(() {});
}
},
);
// Listen to authentication responses via dart_mq // Listen to authentication responses via dart_mq
_authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(MQTopics.authResponse, (response) { _authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(
final responseTime = DateTime.now(); MQTopics.authResponse,
developer.log('=== AUTHENTICATION RESPONSE RECEIVED ===', name: 'LoginView'); (response) {
developer.log('Timestamp: ${responseTime.toIso8601String()}', name: 'LoginView'); final responseTime = DateTime.now();
developer.log('Response data: $response', name: 'LoginView'); developer.log(
'=== AUTHENTICATION RESPONSE RECEIVED ===',
name: 'LoginView',
);
developer.log(
'Timestamp: ${responseTime.toIso8601String()}',
name: 'LoginView',
);
developer.log('Response data: $response', name: 'LoginView');
if (mounted) { if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; _handleAuthResponse(response);
setState(() {
_isLoggingIn = false;
}); });
} else {
developer.log(
'Widget not mounted - skipping UI updates for auth response',
name: 'LoginView',
);
}
if (response['success'] == true) { developer.log(
// Prevent duplicate navigation from multiple auth responses 'Authentication response processing completed',
if (_hasNavigatedToJobs) { name: 'LoginView',
developer.log('Already navigated to jobs view - ignoring duplicate auth response', name: 'LoginView'); );
return; },
} );
_hasNavigatedToJobs = true; }
final message = response['message'] ?? 'Anmeldung erfolgreich'; void _clearPendingLoginCredentials() {
final email = _emailController.text.trim(); _pendingLoginEmail = null;
final password = _passwordController.text; _pendingLoginPassword = null;
}
developer.log('=== LOGIN SUCCESS ===', name: 'LoginView'); Future<void> _handleAuthResponse(Map<String, dynamic> response) async {
developer.log('Email: $email', name: 'LoginView'); if (!mounted) return;
developer.log('Message: $message', name: 'LoginView');
// Store email as login identifier final pendingEmail = _pendingLoginEmail?.trim();
_appState.setLoggedInEmail(email); final pendingPassword = _pendingLoginPassword;
final hadPendingLogin =
pendingEmail != null &&
pendingEmail.isNotEmpty &&
pendingPassword != null &&
pendingPassword.isNotEmpty;
_clearPendingLoginCredentials();
// Save credentials for auto-login on app restart setState(() {
DatabaseService().saveCredentials(email, password); _isLoggingIn = false;
});
// Navigate directly to jobs view - jobs will be loaded there if (response['success'] == true) {
developer.log('Navigating to jobs view - jobs will be loaded there...', name: 'LoginView'); // Prevent duplicate navigation from multiple auth responses
Navigator.of(context).pushReplacementNamed('/jobs'); if (_hasNavigatedToJobs) {
} else { developer.log(
final errorMessage = response['message'] ?? 'Unbekannter Fehler'; 'Already navigated to jobs view - ignoring duplicate auth response',
final errorCode = response['code'] ?? 'No code'; name: 'LoginView',
);
developer.log('=== LOGIN FAILURE ===', name: 'LoginView'); return;
developer.log('Error message: $errorMessage', name: 'LoginView');
developer.log('Error code: $errorCode', name: 'LoginView');
developer.log('Full error response: $response', name: 'LoginView');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).loginFailed}: $errorMessage'), backgroundColor: Colors.red, duration: const Duration(seconds: 1)));
}
});
} else {
developer.log('Widget not mounted - skipping UI updates for auth response', name: 'LoginView');
} }
developer.log('Authentication response processing completed', name: 'LoginView'); final message = response['message'] ?? 'Anmeldung erfolgreich';
}); final savedCredentials = await DatabaseService().loadCredentials();
final effectiveEmail =
(pendingEmail != null && pendingEmail.isNotEmpty)
? pendingEmail
: (savedCredentials?.email ??
_appState.loggedInEmail ??
_emailController.text.trim());
final effectivePassword =
(pendingPassword != null && pendingPassword.isNotEmpty)
? pendingPassword
: (savedCredentials?.password ?? _passwordController.text);
developer.log('=== LOGIN SUCCESS ===', name: 'LoginView');
developer.log('Email: $effectiveEmail', name: 'LoginView');
developer.log('Message: $message', name: 'LoginView');
if (effectiveEmail.isNotEmpty) {
_appState.setLoggedInEmail(effectiveEmail);
}
if (effectiveEmail.isNotEmpty && effectivePassword.isNotEmpty) {
await DatabaseService().saveCredentials(
effectiveEmail,
effectivePassword,
);
}
if (!mounted) return;
_hasNavigatedToJobs = true;
// Navigate directly to jobs view - jobs will be loaded there
developer.log(
'Navigating to jobs view - jobs will be loaded there...',
name: 'LoginView',
);
Navigator.of(context).pushReplacementNamed('/jobs');
return;
}
final errorMessage = response['message'] ?? 'Unbekannter Fehler';
final errorCode = response['code'] ?? 'No code';
developer.log('=== LOGIN FAILURE ===', name: 'LoginView');
developer.log('Error message: $errorMessage', name: 'LoginView');
developer.log('Error code: $errorCode', name: 'LoginView');
developer.log('Full error response: $response', name: 'LoginView');
if (!hadPendingLogin || !mounted) {
developer.log(
'Ignoring auth failure in LoginView because no manual login attempt is pending',
name: 'LoginView',
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${AppLocalizations.of(context).loginFailed}: $errorMessage',
),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
} }
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
@@ -158,34 +241,62 @@ class _LoginViewState extends State<LoginView> {
developer.log('=== LOGIN ATTEMPT STARTED ===', name: 'LoginView'); developer.log('=== LOGIN ATTEMPT STARTED ===', name: 'LoginView');
developer.log('Session ID: $sessionId', name: 'LoginView'); developer.log('Session ID: $sessionId', name: 'LoginView');
developer.log('Timestamp: ${loginStartTime.toIso8601String()}', name: 'LoginView'); developer.log(
'Timestamp: ${loginStartTime.toIso8601String()}',
name: 'LoginView',
);
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
developer.log('Login validation failed - form is invalid', name: 'LoginView'); developer.log(
'Login validation failed - form is invalid',
name: 'LoginView',
);
return; return;
} }
if (_isLoggingIn) { if (_isLoggingIn) {
developer.log('Login already in progress - ignoring duplicate request', name: 'LoginView'); developer.log(
'Login already in progress - ignoring duplicate request',
name: 'LoginView',
);
return; return;
} }
String email = _emailController.text.trim(); String email = _emailController.text.trim();
String password = _passwordController.text;
_pendingLoginEmail = email;
_pendingLoginPassword = password;
developer.log('Login attempt for email: $email', name: 'LoginView'); developer.log('Login attempt for email: $email', name: 'LoginView');
developer.log('Password length: ${_passwordController.text.length} characters', name: 'LoginView'); developer.log(
'Password length: ${_passwordController.text.length} characters',
name: 'LoginView',
);
// Capture ScaffoldMessenger and localizations before any async operations // Capture ScaffoldMessenger and localizations before any async operations
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final localizations = AppLocalizations.of(context); final localizations = AppLocalizations.of(context);
if (!_stompService.isConnected) { if (!_stompService.isConnected) {
developer.log('Not connected to STOMP server - establishing connection first', name: 'LoginView'); developer.log(
developer.log('STOMP service connection state: ${_stompService.isConnected}', name: 'LoginView'); 'Not connected to STOMP server - establishing connection first',
name: 'LoginView',
);
developer.log(
'STOMP service connection state: ${_stompService.isConnected}',
name: 'LoginView',
);
// Always attempt connection to fixed STOMP endpoint (no discovery gating) // Always attempt connection to fixed STOMP endpoint (no discovery gating)
// Show connecting message // Show connecting message
if (!widget.suppressConnectionSnack) { if (!widget.suppressConnectionSnack) {
scaffoldMessenger.showSnackBar(SnackBar(content: Text(localizations.connecting), backgroundColor: Colors.blue, duration: const Duration(seconds: 1))); scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(localizations.connecting),
backgroundColor: AppColors.primary,
duration: const Duration(seconds: 1),
),
);
} }
// Set loading state // Set loading state
@@ -202,20 +313,29 @@ class _LoginViewState extends State<LoginView> {
// Wait for connection to be established with a timeout // Wait for connection to be established with a timeout
try { try {
final completer = Completer<bool>(); final completer = Completer<bool>();
final subscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) { final subscription = DartMQ().subscribe<bool>(
if (isConnected && !completer.isCompleted) { MQTopics.connectionStatus,
completer.complete(true); (isConnected) {
} if (isConnected && !completer.isCompleted) {
}); completer.complete(true);
}
},
);
await completer.future.timeout(const Duration(seconds: 12)); await completer.future.timeout(const Duration(seconds: 12));
subscription.cancel(); subscription.cancel();
developer.log('STOMP connection established - proceeding with login', name: 'LoginView'); developer.log(
'STOMP connection established - proceeding with login',
name: 'LoginView',
);
} on TimeoutException { } on TimeoutException {
developer.log('STOMP connection timed out', name: 'LoginView'); developer.log('STOMP connection timed out', name: 'LoginView');
} }
} else { } else {
developer.log('STOMP already connected after connect - proceeding with login', name: 'LoginView'); developer.log(
'STOMP already connected after connect - proceeding with login',
name: 'LoginView',
);
} }
// Check if connection was successful // Check if connection was successful
@@ -223,43 +343,74 @@ class _LoginViewState extends State<LoginView> {
setState(() { setState(() {
_isLoggingIn = false; _isLoggingIn = false;
}); });
scaffoldMessenger.showSnackBar(SnackBar(content: Text(localizations.connectionTimeout), backgroundColor: Colors.red, duration: const Duration(seconds: 2))); scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(localizations.connectionTimeout),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 2),
),
);
_clearPendingLoginCredentials();
return; return;
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
setState(() { setState(() {
_isLoggingIn = false; _isLoggingIn = false;
}); });
developer.log('Error connecting to STOMP server: $e', name: 'LoginView'); developer.log(
'Error connecting to STOMP server: $e',
name: 'LoginView',
);
developer.log('Stack trace: $stackTrace', name: 'LoginView'); developer.log('Stack trace: $stackTrace', name: 'LoginView');
scaffoldMessenger.showSnackBar(SnackBar(content: Text('${localizations.connectionError}: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 1))); scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('${localizations.connectionError}: $e'),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
_clearPendingLoginCredentials();
return; return;
} }
} }
developer.log('Pre-login checks passed - initiating login request', name: 'LoginView'); developer.log(
developer.log('Connection status: connected=${_stompService.isConnected}', name: 'LoginView'); 'Pre-login checks passed - initiating login request',
name: 'LoginView',
);
developer.log(
'Connection status: connected=${_stompService.isConnected}',
name: 'LoginView',
);
setState(() { setState(() {
_isLoggingIn = true; _isLoggingIn = true;
}); });
String password = _passwordController.text; developer.log(
'Sending login request via STOMP service...',
developer.log('Sending login request via STOMP service...', name: 'LoginView'); name: 'LoginView',
);
try { try {
// Send login request via STOMP // Send login request via STOMP
await _stompService.login(email, password); await _stompService.login(email, password);
final requestSentTime = DateTime.now(); final requestSentTime = DateTime.now();
final requestDuration = requestSentTime.difference(loginStartTime).inMilliseconds; final requestDuration =
developer.log('Login request sent successfully after ${requestDuration}ms', name: 'LoginView'); requestSentTime.difference(loginStartTime).inMilliseconds;
developer.log(
'Login request sent successfully after ${requestDuration}ms',
name: 'LoginView',
);
} catch (e, stackTrace) { } catch (e, stackTrace) {
final errorTime = DateTime.now(); final errorTime = DateTime.now();
final errorDuration = errorTime.difference(loginStartTime).inMilliseconds; final errorDuration = errorTime.difference(loginStartTime).inMilliseconds;
developer.log('LOGIN ERROR: Exception during login request after ${errorDuration}ms', name: 'LoginView'); developer.log(
'LOGIN ERROR: Exception during login request after ${errorDuration}ms',
name: 'LoginView',
);
developer.log('Error: $e', name: 'LoginView'); developer.log('Error: $e', name: 'LoginView');
developer.log('Stack trace: $stackTrace', name: 'LoginView'); developer.log('Stack trace: $stackTrace', name: 'LoginView');
@@ -267,116 +418,230 @@ class _LoginViewState extends State<LoginView> {
_isLoggingIn = false; _isLoggingIn = false;
}); });
scaffoldMessenger.showSnackBar(SnackBar(content: Text('${localizations.loginError}: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 1))); scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('${localizations.loginError}: $e'),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 1),
),
);
_clearPendingLoginCredentials();
} }
// The auth response will be handled by the stream listener // The auth response will be handled by the stream listener
// _isLoggingIn will be set to false in the listener // _isLoggingIn will be set to false in the listener
developer.log('Login request phase completed - waiting for auth response', name: 'LoginView'); developer.log(
'Login request phase completed - waiting for auth response',
name: 'LoginView',
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[50], body: DecoratedBox(
body: Column( decoration: const BoxDecoration(gradient: AppGradients.shellBackground),
children: [ child: Column(
Expanded( children: [
child: SafeArea( Expanded(
child: Center( child: SafeArea(
child: SingleChildScrollView( child: Center(
padding: const EdgeInsets.all(24.0), child: SingleChildScrollView(
child: Form( padding: const EdgeInsets.all(24.0),
key: _formKey, child: Form(
child: Column( key: _formKey,
mainAxisAlignment: MainAxisAlignment.center, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
// Logo oder App-Name children: [
Icon(Icons.account_circle, size: 100, color: Colors.deepPurple), Icon(
const SizedBox(height: 32), Icons.account_circle,
size: 100,
color: AppColors.primary,
),
const SizedBox(height: 32),
Text(AppLocalizations.of(context).welcomeBack, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[800]), textAlign: TextAlign.center), Text(
const SizedBox(height: 8), l10n.welcomeBack,
style: Theme.of(
Text(AppLocalizations.of(context).loginSubtitle, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]), textAlign: TextAlign.center), context,
const SizedBox(height: 32), ).textTheme.headlineMedium?.copyWith(
// E-Mail-Feld fontWeight: FontWeight.bold,
TextFormField( color: AppColors.textStrong,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(labelText: 'E-Mail-Adresse', hintText: 'Geben Sie Ihre E-Mail-Adresse ein', prefixIcon: const Icon(Icons.email_outlined), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), filled: true, fillColor: Colors.white),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Bitte geben Sie Ihre E-Mail-Adresse ein';
}
if (!RegExp(r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$').hasMatch(value)) {
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein';
}
return null;
},
),
const SizedBox(height: 16),
// Passwort-Feld
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Passwort',
hintText: 'Geben Sie Ihr Passwort ein',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(_isPasswordVisible ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
), ),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), textAlign: TextAlign.center,
filled: true,
fillColor: Colors.white,
), ),
validator: (value) { const SizedBox(height: 8),
if (value == null || value.isEmpty) {
return 'Bitte geben Sie Ihr Passwort ein';
}
if (value.length < 6) {
return 'Das Passwort muss mindestens 6 Zeichen lang sein';
}
return null;
},
),
const SizedBox(height: 24),
// Passwort vergessen Link Text(
Align( l10n.loginSubtitle,
alignment: Alignment.centerRight, style: Theme.of(context).textTheme.bodyLarge
child: TextButton( ?.copyWith(color: AppColors.textMuted),
onPressed: () { textAlign: TextAlign.center,
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden ),
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).forgotPasswordMessage), duration: const Duration(seconds: 1))); const SizedBox(height: 32),
// E-Mail-Feld
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailAddress,
hintText: l10n.emailAddressHint,
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: AppColors.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.emailAddressRequired;
}
if (!RegExp(
r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$',
).hasMatch(value)) {
return l10n.emailAddressInvalid;
}
return null;
}, },
child: Text(AppLocalizations.of(context).forgotPassword, style: const TextStyle(color: Colors.deepPurple, fontWeight: FontWeight.w500)),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 24),
// Verbindungsstatus // Passwort-Feld
// Anmelden Button TextFormField(
ElevatedButton(onPressed: _isLoggingIn ? null : _handleLogin, style: ElevatedButton.styleFrom(backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2), child: _isLoggingIn ? Row(mainAxisAlignment: MainAxisAlignment.center, children: const [SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.5, valueColor: AlwaysStoppedAnimation<Color>(Colors.white))), SizedBox(width: 12), Text('Verbinden…', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))]) : const Text('Anmelden', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))), controller: _passwordController,
const SizedBox(height: 24), obscureText: !_isPasswordVisible,
], decoration: InputDecoration(
labelText: l10n.password,
hintText: l10n.passwordHint,
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: AppColors.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.passwordRequired;
}
if (value.length < 6) {
return l10n.passwordMinLength;
}
return null;
},
),
const SizedBox(height: 24),
// Passwort vergessen Link
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.forgotPasswordMessage),
duration: const Duration(seconds: 1),
),
);
},
child: Text(
l10n.forgotPassword,
style: const TextStyle(
color: AppColors.primaryStrong,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 24),
// Verbindungsstatus
// Anmelden Button
ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child:
_isLoggingIn
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(width: 12),
Text(
l10n.loggingIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
)
: Text(
l10n.login,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 24),
],
),
), ),
), ),
), ),
), ),
), ),
), if (_appVersion.isNotEmpty)
// Version number at the bottom Padding(
if (_appVersion.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16.0), child: Text('Version $_appVersion', style: TextStyle(fontSize: 12, color: Colors.grey[500]), textAlign: TextAlign.center)), padding: const EdgeInsets.only(bottom: 16.0),
], child: Text(
'Version $_appVersion',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
textAlign: TextAlign.center,
),
),
],
),
), ),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/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';
@@ -13,6 +14,7 @@ import 'services/chat_service.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'navigation_observer.dart'; import 'navigation_observer.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/websocket_service.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
void main() async { void main() async {
@@ -43,14 +45,59 @@ void main() async {
runApp(const MyApp()); runApp(const MyApp());
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
static const Duration _resumeReconnectThreshold = Duration(seconds: 30);
final AppState _appState = AppState();
final WebSocketService _webSocketService = WebSocketService();
DateTime? _lastPausedAt;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_lastPausedAt = DateTime.now();
return;
}
if (state == AppLifecycleState.resumed) {
final pausedAt = _lastPausedAt;
_lastPausedAt = null;
if (pausedAt == null) {
return;
}
final standbyDuration = DateTime.now().difference(pausedAt);
if (standbyDuration < _resumeReconnectThreshold ||
!_appState.isLoggedIn) {
return;
}
_webSocketService.reconnectForAppResume();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Check if user is already logged in // Check if user is already logged in
final appState = AppState(); final initialRoute = _appState.isLoggedIn ? '/jobs' : '/login';
final initialRoute = appState.isLoggedIn ? '/jobs' : '/login';
return ValueListenableBuilder<Locale>( return ValueListenableBuilder<Locale>(
valueListenable: localeNotifier, valueListenable: localeNotifier,
@@ -58,11 +105,17 @@ class MyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'VotianLT App', title: 'VotianLT App',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true), theme: buildAppTheme(),
// Localization configuration // Localization configuration
locale: locale, locale: locale,
localizationsDelegates: const [AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate], localizationsDelegates: const [
supportedLocales: supportedLanguageCodes.map((code) => Locale(code)).toList(), AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales:
supportedLanguageCodes.map((code) => Locale(code)).toList(),
navigatorObservers: [routeObserver], navigatorObservers: [routeObserver],
initialRoute: initialRoute, initialRoute: initialRoute,
onGenerateRoute: (settings) { onGenerateRoute: (settings) {
@@ -70,21 +123,30 @@ class MyApp extends StatelessWidget {
case '/login': case '/login':
final arg = settings.arguments; final arg = settings.arguments;
final suppress = (arg is bool) ? arg : false; final suppress = (arg is bool) ? arg : false;
return MaterialPageRoute(builder: (_) => LoginView(suppressConnectionSnack: suppress)); return MaterialPageRoute(
builder: (_) => LoginView(suppressConnectionSnack: suppress),
);
case '/jobs': case '/jobs':
return MaterialPageRoute(builder: (_) => const JobsView()); return MaterialPageRoute(builder: (_) => const JobsView());
case '/cargo_items': case '/cargo_items':
final job = settings.arguments as Job; final job = settings.arguments as Job;
return MaterialPageRoute(builder: (_) => CargoItemsView(job: job)); return MaterialPageRoute(
builder: (_) => CargoItemsView(job: job),
);
case '/chats': case '/chats':
return MaterialPageRoute(builder: (_) => const ChatsView()); return MaterialPageRoute(builder: (_) => const ChatsView());
case '/chat_details': case '/chat_details':
final chat = settings.arguments as Chat; final chat = settings.arguments as Chat;
return MaterialPageRoute(builder: (_) => ChatDetailsView(chat: chat)); return MaterialPageRoute(
builder: (_) => ChatDetailsView(chat: chat),
);
case '/settings': case '/settings':
return MaterialPageRoute(builder: (_) => const SettingsView()); return MaterialPageRoute(builder: (_) => const SettingsView());
default: default:
return MaterialPageRoute(builder: (_) => const LoginView(suppressConnectionSnack: false)); return MaterialPageRoute(
builder:
(_) => const LoginView(suppressConnectionSnack: false),
);
} }
}, },
); );
@@ -114,9 +176,24 @@ class _MyHomePageState extends State<MyHomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title)), appBar: AppBar(title: Text(widget.title)),
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[const Text('You have pushed the button this many times:'), Text('$_counter', style: Theme.of(context).textTheme.headlineMedium)])), body: Center(
floatingActionButton: FloatingActionButton(onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add)), // This trailing comma makes auto-formatting nicer for build methods. child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
); );
} }
} }

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

@@ -15,6 +15,8 @@ class ChatService {
static const _jobIdPrefix = 'job:'; static const _jobIdPrefix = 'job:';
static const _jobNumberPrefix = 'job_number:'; static const _jobNumberPrefix = 'job_number:';
static const _generalPrefix = 'general:'; static const _generalPrefix = 'general:';
static const _defaultGeneralConversationKey =
'general:allgemeine-nachrichten';
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
final AppState _appState = AppState(); final AppState _appState = AppState();
@@ -103,9 +105,11 @@ class ChatService {
_chats.removeWhere((chat) { _chats.removeWhere((chat) {
final matchesKey = conversationKeys.contains(chat.id); final matchesKey = conversationKeys.contains(chat.id);
final matchesId = trimmedJobId.isNotEmpty && final matchesId =
trimmedJobId.isNotEmpty &&
(chat.jobId?.trim().toLowerCase() == lowerJobId); (chat.jobId?.trim().toLowerCase() == lowerJobId);
final matchesNumber = trimmedJobNumber.isNotEmpty && final matchesNumber =
trimmedJobNumber.isNotEmpty &&
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber); (chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
return matchesKey || matchesId || matchesNumber; return matchesKey || matchesId || matchesNumber;
}); });
@@ -129,18 +133,11 @@ class ChatService {
// Messages with GENERAL messageType should always go to the default general chat // Messages with GENERAL messageType should always go to the default general chat
if (message.messageType == ChatMessageType.general) { if (message.messageType == ChatMessageType.general) {
final localId = _primaryLocalIdentifier(); developer.log(
if (localId != null && localId.isNotEmpty) { '[DEBUG_LOG] GENERAL message detected, routing to conversation key: $_defaultGeneralConversationKey',
final key = _conversationKeyForParticipants( name: 'ChatService',
localId, );
_appState.loggedInEmail!, return _defaultGeneralConversationKey;
);
developer.log(
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $key (localId=$localId, receiver=${_appState.loggedInEmail})',
name: 'ChatService',
);
return key;
}
} }
// Job-related messages go to job-specific chats // Job-related messages go to job-specific chats
@@ -165,30 +162,11 @@ class ChatService {
return key; return key;
} }
// Fallback: create conversation based on userId
final localId = _primaryLocalIdentifier();
if (localId != null && localId.isNotEmpty) {
final key = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
developer.log(
'[DEBUG_LOG] Using fallback routing, conversation key: $key',
name: 'ChatService',
);
return key;
}
developer.log( developer.log(
'[DEBUG_LOG] No local identifier available for fallback routing', '[DEBUG_LOG] No job context available, routing to default general chat',
name: 'ChatService', name: 'ChatService',
); );
return '$_generalPrefix${_appState.loggedInEmail!}'; return _defaultGeneralConversationKey;
}
String _conversationKeyForParticipants(String a, String b) {
final participants = <String>[a.toLowerCase(), b.toLowerCase()]..sort();
return '$_generalPrefix${participants.join('|')}';
} }
Future<void> saveIncomingMessage(ChatMessage message) async { Future<void> saveIncomingMessage(ChatMessage message) async {
@@ -205,6 +183,20 @@ class ChatService {
await _persistMessage(message); await _persistMessage(message);
} }
Future<void> markOutgoingMessageSynced(String messageId) async {
if (!_initialized) {
await initialize();
}
final conversationKey = await _databaseService.updateChatMessagePendingSync(
messageId,
false,
);
if (conversationKey != null && conversationKey.isNotEmpty) {
await _refreshConversation(conversationKey);
}
}
Future<void> _persistMessage(ChatMessage message) async { Future<void> _persistMessage(ChatMessage message) async {
final conversationKey = conversationKeyForMessage(message); final conversationKey = conversationKeyForMessage(message);
@@ -239,7 +231,7 @@ class ChatService {
Future<void> _loadChatsFromDatabase() async { Future<void> _loadChatsFromDatabase() async {
await _databaseService.ensureInitialized(); await _databaseService.ensureInitialized();
final grouped = await _databaseService.loadAllChatMessagesGrouped(); final grouped = await _loadNormalizedChatGroups();
_chats.clear(); _chats.clear();
grouped.forEach((conversationKey, messages) { grouped.forEach((conversationKey, messages) {
final chat = _buildChat(conversationKey, messages); final chat = _buildChat(conversationKey, messages);
@@ -254,6 +246,14 @@ class ChatService {
} }
Future<void> _refreshConversation(String conversationKey) async { Future<void> _refreshConversation(String conversationKey) async {
if (_isLegacyGeneralConversationKey(conversationKey)) {
await _databaseService.migrateConversationKey(
conversationKey,
_defaultGeneralConversationKey,
);
conversationKey = _defaultGeneralConversationKey;
}
final messages = await _databaseService.loadChatMessages( final messages = await _databaseService.loadChatMessages(
conversationKey: conversationKey, conversationKey: conversationKey,
); );
@@ -317,15 +317,13 @@ class ChatService {
final counterpartNormalized = final counterpartNormalized =
counterpart != null && counterpart != null &&
counterpart.toLowerCase() == _appState.loggedInEmail!.toLowerCase() counterpart.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase()
? _appState.loggedInEmail! ? _appState.loggedInEmail!
: counterpart; : counterpart;
final bool isDefaultGeneral = final bool isDefaultGeneral =
!isJobChat && !isJobChat && conversationKey == _defaultGeneralConversationKey;
conversationKey.startsWith(_generalPrefix) &&
(counterpartNormalized?.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase());
final title = final title =
isJobChat isJobChat
@@ -406,23 +404,46 @@ class ChatService {
} }
} }
Future<Map<String, List<ChatMessage>>> _loadNormalizedChatGroups() async {
var grouped = await _databaseService.loadAllChatMessagesGrouped();
final legacyGeneralKeys =
grouped.keys.where(_isLegacyGeneralConversationKey).toList();
if (legacyGeneralKeys.isEmpty) {
return grouped;
}
for (final key in legacyGeneralKeys) {
await _databaseService.migrateConversationKey(
key,
_defaultGeneralConversationKey,
);
}
grouped = await _databaseService.loadAllChatMessagesGrouped();
return grouped;
}
bool _isLegacyGeneralConversationKey(String conversationKey) {
return conversationKey != _defaultGeneralConversationKey &&
conversationKey.startsWith(_generalPrefix) &&
!conversationKey.startsWith(_jobIdPrefix) &&
!conversationKey.startsWith(_jobNumberPrefix);
}
void _ensureDefaultGeneralChat() { void _ensureDefaultGeneralChat() {
final localId = _primaryLocalIdentifier(); final receiver = _appState.loggedInEmail;
if (localId == null || localId.isEmpty) { if (receiver == null || receiver.isEmpty) {
developer.log( developer.log(
'[DEBUG_LOG] _ensureDefaultGeneralChat: No local identifier available, skipping', '[DEBUG_LOG] _ensureDefaultGeneralChat: No receiver available, skipping',
name: 'ChatService', name: 'ChatService',
); );
return; return;
} }
final conversationKey = _conversationKeyForParticipants( const conversationKey = _defaultGeneralConversationKey;
localId,
_appState.loggedInEmail!,
);
developer.log( developer.log(
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (localId=$localId, receiver=${_appState.loggedInEmail})', '[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (receiver=$receiver)',
name: 'ChatService', name: 'ChatService',
); );
@@ -431,8 +452,7 @@ class ChatService {
chat.id != conversationKey && chat.id != conversationKey &&
chat.type == ChatType.general && chat.type == ChatType.general &&
chat.receiver != null && chat.receiver != null &&
chat.receiver!.toLowerCase() == chat.receiver!.toLowerCase() == receiver.toLowerCase() &&
_appState.loggedInEmail!.toLowerCase() &&
chat.messages.isEmpty, chat.messages.isEmpty,
); );
final index = _chats.indexWhere((chat) => chat.id == conversationKey); final index = _chats.indexWhere((chat) => chat.id == conversationKey);
@@ -446,7 +466,7 @@ class ChatService {
Chat( Chat(
id: conversationKey, id: conversationKey,
title: 'Allgemeine Nachrichten', title: 'Allgemeine Nachrichten',
receiver: _appState.loggedInEmail!, receiver: receiver,
type: ChatType.general, type: ChatType.general,
jobId: null, jobId: null,
jobNumber: null, jobNumber: null,
@@ -463,8 +483,7 @@ class ChatService {
final existing = _chats[index]; final existing = _chats[index];
if (existing.type != ChatType.general || if (existing.type != ChatType.general ||
existing.receiver == null || existing.receiver == null ||
existing.receiver!.toLowerCase() != existing.receiver!.toLowerCase() != receiver.toLowerCase() ||
_appState.loggedInEmail!.toLowerCase() ||
(existing.messages.isEmpty && (existing.messages.isEmpty &&
existing.title != 'Allgemeine Nachrichten')) { existing.title != 'Allgemeine Nachrichten')) {
developer.log( developer.log(
@@ -477,7 +496,7 @@ class ChatService {
existing.messages.isEmpty existing.messages.isEmpty
? 'Allgemeine Nachrichten' ? 'Allgemeine Nachrichten'
: existing.title, : existing.title,
receiver: _appState.loggedInEmail!, receiver: receiver,
type: ChatType.general, type: ChatType.general,
jobId: existing.jobId, jobId: existing.jobId,
jobNumber: existing.jobNumber, jobNumber: existing.jobNumber,
@@ -493,8 +512,4 @@ class ChatService {
} }
} }
} }
String? _primaryLocalIdentifier() {
return _appState.loggedInEmail;
}
} }

View File

@@ -38,7 +38,10 @@ class DatabaseService {
final completer = Completer<void>(); final completer = Completer<void>();
_initializingCompleter = completer; _initializingCompleter = completer;
try { try {
developer.log('Initializing ObjectBox database...', name: 'DatabaseService'); developer.log(
'Initializing ObjectBox database...',
name: 'DatabaseService',
);
// Get database path // Get database path
final docsDir = await getApplicationDocumentsDirectory(); final docsDir = await getApplicationDocumentsDirectory();
@@ -75,8 +78,6 @@ class DatabaseService {
await initialize(); await initialize();
} }
/// Log database statistics /// Log database statistics
Future<void> _logDatabaseStats() async { Future<void> _logDatabaseStats() async {
try { try {
@@ -164,7 +165,10 @@ class DatabaseService {
return; return;
} }
developer.log('Deleting job $jobId from database...', name: 'DatabaseService'); developer.log(
'Deleting job $jobId from database...',
name: 'DatabaseService',
);
final jobBox = _store!.box<JobEntity>(); final jobBox = _store!.box<JobEntity>();
final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build(); final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
@@ -173,9 +177,15 @@ class DatabaseService {
if (entities.isNotEmpty) { if (entities.isNotEmpty) {
jobBox.remove(entities.first.id); jobBox.remove(entities.first.id);
developer.log('Job $jobId deleted successfully', name: 'DatabaseService'); developer.log(
'Job $jobId deleted successfully',
name: 'DatabaseService',
);
} else { } else {
developer.log('Job $jobId not found in database', name: 'DatabaseService'); developer.log(
'Job $jobId not found in database',
name: 'DatabaseService',
);
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log('Error deleting job: $e', name: 'DatabaseService'); developer.log('Error deleting job: $e', name: 'DatabaseService');
@@ -220,9 +230,13 @@ class DatabaseService {
if (jobs.isNotEmpty) { if (jobs.isNotEmpty) {
try { try {
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query( final query =
(ChatMessageEntity_.jobId.notNull() | ChatMessageEntity_.jobNumber.notNull()) chatBox
).build(); .query(
(ChatMessageEntity_.jobId.notNull() |
ChatMessageEntity_.jobNumber.notNull()),
)
.build();
final messagesWithJobs = query.find(); final messagesWithJobs = query.find();
query.close(); query.close();
@@ -282,7 +296,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>(); final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing entity by taskId // Find existing entity by taskId
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build(); final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final existing = query.findFirst(); final existing = query.findFirst();
query.close(); query.close();
@@ -321,7 +336,8 @@ class DatabaseService {
} }
final taskStatusBox = _store!.box<TaskStatusEntity>(); final taskStatusBox = _store!.box<TaskStatusEntity>();
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build(); final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final entity = query.findFirst(); final entity = query.findFirst();
query.close(); query.close();
@@ -449,7 +465,8 @@ class DatabaseService {
final keys = jobIds.map((id) => 'job_seen:$id').toList(); final keys = jobIds.map((id) => 'job_seen:$id').toList();
for (final key in keys) { for (final key in keys) {
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build(); final query =
userDataBox.query(UserDataEntity_.key.equals(key)).build();
final entity = query.findFirst(); final entity = query.findFirst();
query.close(); query.close();
@@ -545,7 +562,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>(); final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing job entity by jobId // Find existing job entity by jobId
final jobQuery = jobBox.query(JobEntity_.jobId.equals(normalized.id)).build(); final jobQuery =
jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
final existingJob = jobQuery.findFirst(); final existingJob = jobQuery.findFirst();
jobQuery.close(); jobQuery.close();
@@ -568,7 +586,10 @@ class DatabaseService {
final taskIds = normalized.tasks.map((t) => t.id).toList(); final taskIds = normalized.tasks.map((t) => t.id).toList();
if (taskIds.isNotEmpty) { if (taskIds.isNotEmpty) {
for (final taskId in taskIds) { for (final taskId in taskIds) {
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build(); final query =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
for (final entity in entities) { for (final entity in entities) {
@@ -617,7 +638,8 @@ class DatabaseService {
if (trimmedJobId.isNotEmpty) { if (trimmedJobId.isNotEmpty) {
// Delete job // Delete job
final jobQuery = jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build(); final jobQuery =
jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobEntities = jobQuery.find(); final jobEntities = jobQuery.find();
jobQuery.close(); jobQuery.close();
for (final entity in jobEntities) { for (final entity in jobEntities) {
@@ -625,7 +647,10 @@ class DatabaseService {
} }
// Delete job_seen flag // Delete job_seen flag
final seenQuery = userDataBox.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId')).build(); final seenQuery =
userDataBox
.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId'))
.build();
final seenEntities = seenQuery.find(); final seenEntities = seenQuery.find();
seenQuery.close(); seenQuery.close();
for (final entity in seenEntities) { for (final entity in seenEntities) {
@@ -633,15 +658,19 @@ class DatabaseService {
} }
} }
final taskIds = job.tasks final taskIds =
.map((task) => task.id.trim()) job.tasks
.where((id) => id.isNotEmpty) .map((task) => task.id.trim())
.toList(); .where((id) => id.isNotEmpty)
.toList();
if (taskIds.isNotEmpty) { if (taskIds.isNotEmpty) {
for (final taskId in taskIds) { for (final taskId in taskIds) {
// Delete task status // Delete task status
final taskQuery = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build(); final taskQuery =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final taskEntities = taskQuery.find(); final taskEntities = taskQuery.find();
taskQuery.close(); taskQuery.close();
for (final entity in taskEntities) { for (final entity in taskEntities) {
@@ -649,7 +678,8 @@ class DatabaseService {
} }
// Delete photos // Delete photos
final photoQuery = photoBox.query(PhotoEntity_.taskId.equals(taskId)).build(); final photoQuery =
photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoEntities = photoQuery.find(); final photoEntities = photoQuery.find();
photoQuery.close(); photoQuery.close();
for (final entity in photoEntities) { for (final entity in photoEntities) {
@@ -801,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 {
@@ -960,9 +1026,21 @@ class DatabaseService {
/// Save login credentials for auto-login on app restart /// Save login credentials for auto-login on app restart
Future<void> saveCredentials(String email, String password) async { Future<void> saveCredentials(String email, String password) async {
await saveKeyValue('auth_email', email); final normalizedEmail = email.trim();
if (normalizedEmail.isEmpty || password.isEmpty) {
developer.log(
'Skipping credential save because email or password is empty',
name: 'DatabaseService',
);
return;
}
await saveKeyValue('auth_email', normalizedEmail);
await saveKeyValue('auth_password', password); await saveKeyValue('auth_password', password);
developer.log('Credentials saved for $email', name: 'DatabaseService'); developer.log(
'Credentials saved for $normalizedEmail',
name: 'DatabaseService',
);
} }
/// Load saved login credentials /// Load saved login credentials
@@ -970,11 +1048,29 @@ class DatabaseService {
Future<({String email, String password})?> loadCredentials() async { Future<({String email, String password})?> loadCredentials() async {
final email = await loadKeyValue('auth_email'); final email = await loadKeyValue('auth_email');
final password = await loadKeyValue('auth_password'); final password = await loadKeyValue('auth_password');
if (email != null && password != null) { final normalizedEmail = email?.trim();
developer.log('Credentials loaded for $email', name: 'DatabaseService');
return (email: email, password: password); if (normalizedEmail != null &&
normalizedEmail.isNotEmpty &&
password != null &&
password.isNotEmpty) {
developer.log(
'Credentials loaded for $normalizedEmail',
name: 'DatabaseService',
);
return (email: normalizedEmail, password: password);
} }
developer.log('No credentials found', name: 'DatabaseService');
if ((email != null && email.isNotEmpty) ||
(password != null && password.isNotEmpty)) {
developer.log(
'Stored credentials are incomplete or empty - removing them',
name: 'DatabaseService',
);
await deleteCredentials();
}
developer.log('No valid credentials found', name: 'DatabaseService');
return null; return null;
} }
@@ -1008,7 +1104,10 @@ class DatabaseService {
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
// Find existing entity by messageId // Find existing entity by messageId
final query = chatBox.query(ChatMessageEntity_.messageId.equals(message.id)).build(); final query =
chatBox
.query(ChatMessageEntity_.messageId.equals(message.id))
.build();
final existing = query.findFirst(); final existing = query.findFirst();
query.close(); query.close();
@@ -1060,7 +1159,10 @@ class DatabaseService {
return; return;
} }
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(fromKey)).build(); final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(fromKey))
.build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
@@ -1089,13 +1191,18 @@ class DatabaseService {
} }
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query( final query =
ChatMessageEntity_.conversationKey.equals(conversationKey) & chatBox
ChatMessageEntity_.pendingSync.equals(true) & .query(
ChatMessageEntity_.content.equals(message.content) & ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.contentType.equals(chatContentTypeToString(message.contentType)) & ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.messageId.notEquals(message.id) ChatMessageEntity_.content.equals(message.content) &
).build(); ChatMessageEntity_.contentType.equals(
chatContentTypeToString(message.contentType),
) &
ChatMessageEntity_.messageId.notEquals(message.id),
)
.build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
@@ -1123,9 +1230,13 @@ class DatabaseService {
List<ChatMessageEntity> entities; List<ChatMessageEntity> entities;
if (conversationKey != null) { if (conversationKey != null) {
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey)) final query =
.order(ChatMessageEntity_.createdAt) chatBox
.build(); .query(
ChatMessageEntity_.conversationKey.equals(conversationKey),
)
.order(ChatMessageEntity_.createdAt)
.build();
entities = query.find(); entities = query.find();
query.close(); query.close();
} else { } else {
@@ -1186,7 +1297,10 @@ class DatabaseService {
} }
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey)).build(); final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
.build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
@@ -1211,7 +1325,8 @@ class DatabaseService {
} }
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build(); final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
@@ -1241,15 +1356,18 @@ class DatabaseService {
final trimmedJobId = jobId?.trim() ?? ''; final trimmedJobId = jobId?.trim() ?? '';
final trimmedJobNumber = jobNumber?.trim() ?? ''; final trimmedJobNumber = jobNumber?.trim() ?? '';
final keysList = conversationKeys == null final keysList =
? <String>[] conversationKeys == null
: conversationKeys ? <String>[]
.map((key) => key.trim()) : conversationKeys
.where((key) => key.isNotEmpty) .map((key) => key.trim())
.toSet() .where((key) => key.isNotEmpty)
.toList(); .toSet()
.toList();
if (trimmedJobId.isEmpty && trimmedJobNumber.isEmpty && keysList.isEmpty) { if (trimmedJobId.isEmpty &&
trimmedJobNumber.isEmpty &&
keysList.isEmpty) {
developer.log( developer.log(
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber', 'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
name: 'DatabaseService', name: 'DatabaseService',
@@ -1261,20 +1379,29 @@ class DatabaseService {
final entitiesToDelete = <ChatMessageEntity>[]; final entitiesToDelete = <ChatMessageEntity>[];
if (trimmedJobId.isNotEmpty) { if (trimmedJobId.isNotEmpty) {
final query = chatBox.query(ChatMessageEntity_.jobId.equals(trimmedJobId)).build(); final query =
chatBox
.query(ChatMessageEntity_.jobId.equals(trimmedJobId))
.build();
entitiesToDelete.addAll(query.find()); entitiesToDelete.addAll(query.find());
query.close(); query.close();
} }
if (trimmedJobNumber.isNotEmpty) { if (trimmedJobNumber.isNotEmpty) {
final query = chatBox.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber)).build(); final query =
chatBox
.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber))
.build();
entitiesToDelete.addAll(query.find()); entitiesToDelete.addAll(query.find());
query.close(); query.close();
} }
if (keysList.isNotEmpty) { if (keysList.isNotEmpty) {
for (final key in keysList) { for (final key in keysList) {
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(key)).build(); final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(key))
.build();
entitiesToDelete.addAll(query.find()); entitiesToDelete.addAll(query.find());
query.close(); query.close();
} }
@@ -1309,7 +1436,8 @@ class DatabaseService {
} }
final chatBox = _store!.box<ChatMessageEntity>(); final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.read.equals(false)).build(); final query =
chatBox.query(ChatMessageEntity_.read.equals(false)).build();
final count = query.count(); final count = query.count();
query.close(); query.close();
@@ -1349,6 +1477,7 @@ class DatabaseService {
/// Save a failed message to the queue /// Save a failed message to the queue
Future<void> queueMessage(QueuedMessage message) async { Future<void> queueMessage(QueuedMessage message) async {
try { try {
await ensureInitialized();
if (_store == null) { if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService'); developer.log('Database not initialized', name: 'DatabaseService');
return; return;
@@ -1357,7 +1486,8 @@ class DatabaseService {
final box = _store!.box<QueuedMessageEntity>(); final box = _store!.box<QueuedMessageEntity>();
// Find existing entity by messageId // Find existing entity by messageId
final query = box.query(QueuedMessageEntity_.messageId.equals(message.id)).build(); final query =
box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
final existing = query.findFirst(); final existing = query.findFirst();
query.close(); query.close();
@@ -1391,6 +1521,7 @@ class DatabaseService {
/// Get all queued messages /// Get all queued messages
Future<List<QueuedMessage>> getQueuedMessages() async { Future<List<QueuedMessage>> getQueuedMessages() async {
try { try {
await ensureInitialized();
if (_store == null) { if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService'); developer.log('Database not initialized', name: 'DatabaseService');
return []; return [];
@@ -1424,13 +1555,15 @@ class DatabaseService {
/// Remove a successfully sent message from the queue /// Remove a successfully sent message from the queue
Future<void> removeQueuedMessage(String messageId) async { Future<void> removeQueuedMessage(String messageId) async {
try { try {
await ensureInitialized();
if (_store == null) { if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService'); developer.log('Database not initialized', name: 'DatabaseService');
return; return;
} }
final box = _store!.box<QueuedMessageEntity>(); final box = _store!.box<QueuedMessageEntity>();
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build(); final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find(); final entities = query.find();
query.close(); query.close();
@@ -1452,18 +1585,17 @@ class DatabaseService {
} }
/// Update retry count for a message /// Update retry count for a message
Future<void> updateMessageRetryCount( Future<void> updateMessageRetryCount(String messageId, int retryCount) async {
String messageId,
int retryCount,
) async {
try { try {
await ensureInitialized();
if (_store == null) { if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService'); developer.log('Database not initialized', name: 'DatabaseService');
return; return;
} }
final box = _store!.box<QueuedMessageEntity>(); final box = _store!.box<QueuedMessageEntity>();
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build(); final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst(); final entity = query.findFirst();
query.close(); query.close();
@@ -1488,16 +1620,14 @@ class DatabaseService {
/// Clear all queued messages (for cleanup) /// Clear all queued messages (for cleanup)
Future<void> clearQueuedMessages() async { Future<void> clearQueuedMessages() async {
try { try {
await ensureInitialized();
if (_store == null) { if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService'); developer.log('Database not initialized', name: 'DatabaseService');
return; return;
} }
_store!.box<QueuedMessageEntity>().removeAll(); _store!.box<QueuedMessageEntity>().removeAll();
developer.log( developer.log('Cleared all queued messages', name: 'DatabaseService');
'Cleared all queued messages',
name: 'DatabaseService',
);
} catch (e, st) { } catch (e, st) {
developer.log( developer.log(
'Error clearing queued messages: $e', 'Error clearing queued messages: $e',
@@ -1507,12 +1637,49 @@ class DatabaseService {
} }
} }
Future<String?> updateChatMessagePendingSync(
String messageId,
bool pendingSync,
) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst();
query.close();
if (entity == null) {
return null;
}
entity.pendingSync = pendingSync;
chatBox.put(entity);
return entity.conversationKey;
} catch (e, st) {
developer.log(
'Error updating pendingSync for message $messageId: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
// Language preference persistence ---------------------------------------------------- // Language preference persistence ----------------------------------------------------
/// Save language preference /// Save language preference
Future<void> saveLanguagePreference(String languageCode) async { Future<void> saveLanguagePreference(String languageCode) async {
await saveKeyValue('language_preference', languageCode); await saveKeyValue('language_preference', languageCode);
developer.log('Language preference saved: $languageCode', name: 'DatabaseService'); developer.log(
'Language preference saved: $languageCode',
name: 'DatabaseService',
);
} }
/// Load saved language preference /// Load saved language preference
@@ -1520,7 +1687,10 @@ class DatabaseService {
Future<String?> loadLanguagePreference() async { Future<String?> loadLanguagePreference() async {
final languageCode = await loadKeyValue('language_preference'); final languageCode = await loadKeyValue('language_preference');
if (languageCode != null) { if (languageCode != null) {
developer.log('Language preference loaded: $languageCode', name: 'DatabaseService'); developer.log(
'Language preference loaded: $languageCode',
name: 'DatabaseService',
);
return languageCode; return languageCode;
} }
developer.log('No language preference found', name: 'DatabaseService'); developer.log('No language preference found', name: 'DatabaseService');

View File

@@ -13,6 +13,7 @@ import 'location_service.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../models/chat_message.dart'; import '../models/chat_message.dart';
import '../models/job.dart'; import '../models/job.dart';
import '../models/queued_message.dart';
import 'dart_mq.dart'; import 'dart_mq.dart';
class WebSocketService { class WebSocketService {
@@ -193,6 +194,73 @@ class WebSocketService {
_reconnectTimer = null; _reconnectTimer = null;
} }
/// Force a clean reconnect after the app resumes from standby.
/// Keeps buffered outbound messages intact and relies on saved credentials
/// for the subsequent auto-login inside [connect].
Future<void> reconnectForAppResume() async {
final credentials = await _databaseService.loadCredentials();
if (credentials == null) {
developer.log(
'Skipping reconnect after resume - no saved credentials',
name: 'WebSocketService',
);
return;
}
if (_isConnecting) {
developer.log(
'Skipping reconnect after resume - connection attempt already running',
name: 'WebSocketService',
);
return;
}
developer.log(
'Restarting WebSocket connection after app resume',
name: 'WebSocketService',
);
_stopReconnectTimer();
final existingSubscription = _wsSubscription;
final existingChannel = _wsChannel;
_wsSubscription = null;
_wsChannel = null;
_disconnectCompleter = null;
_isConnected = false;
_isConnecting = false;
_isAuthenticated = false;
_authToken = null;
_lastAuthResponse = null;
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
});
try {
await existingSubscription?.cancel();
} catch (e, st) {
developer.log(
'Error cancelling old WebSocket subscription on resume: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
try {
await existingChannel?.sink.close(ws_status.goingAway);
} catch (e, st) {
developer.log(
'Error closing old WebSocket channel on resume: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
await connect();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WebSocket Send / Receive // WebSocket Send / Receive
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -290,6 +358,8 @@ class WebSocketService {
_handleJobDeletedMessage(data); _handleJobDeletedMessage(data);
} else if (topic.endsWith('/job_created')) { } else if (topic.endsWith('/job_created')) {
_handleJobCreatedMessage(data); _handleJobCreatedMessage(data);
} else if (topic.endsWith('/message_ack')) {
await _handleChatMessageAck(data);
} else if (topic.endsWith('/message')) { } else if (topic.endsWith('/message')) {
await _handleChatMessage(topic, data); await _handleChatMessage(topic, data);
} else { } else {
@@ -598,6 +668,20 @@ class WebSocketService {
} }
} }
Future<void> _handleChatMessageAck(Map<String, dynamic> data) async {
final clientMessageId = data['clientMessageId']?.toString().trim() ?? '';
if (clientMessageId.isEmpty) {
developer.log(
'Received message ACK without clientMessageId',
name: 'WebSocketService',
);
return;
}
await _databaseService.removeQueuedMessage(clientMessageId);
await ChatService().markOutgoingMessageSynced(clientMessageId);
}
void _handleOtherClientMessage(String topic, Map<String, dynamic> data) { void _handleOtherClientMessage(String topic, Map<String, dynamic> data) {
final type = data['type']; final type = data['type'];
if (topic.contains('/tasks/') || type == 'task') { if (topic.contains('/tasks/') || type == 'task') {
@@ -731,6 +815,7 @@ class WebSocketService {
/// Clears all local jobs and related data, then notifies the server. /// Clears all local jobs and related data, then notifies the server.
Future<void> _flushMessageBuffer() async { Future<void> _flushMessageBuffer() async {
final initialBufferSize = _messageBuffer.length; final initialBufferSize = _messageBuffer.length;
final sentQueuedChatCount = await _flushQueuedChatMessages();
if (initialBufferSize > 0) { if (initialBufferSize > 0) {
developer.log( developer.log(
@@ -766,7 +851,8 @@ class WebSocketService {
await _databaseService.clearAllJobsAndRelatedData(); await _databaseService.clearAllJobsAndRelatedData();
// Notify server that buffer flush is complete // Notify server that buffer flush is complete
final sentCount = initialBufferSize - _messageBuffer.length; final sentCount =
(initialBufferSize - _messageBuffer.length) + sentQueuedChatCount;
final bufferFlushedPayload = jsonEncode({ final bufferFlushedPayload = jsonEncode({
'timestamp': DateTime.now().toIso8601String(), 'timestamp': DateTime.now().toIso8601String(),
'messageCount': sentCount, 'messageCount': sentCount,
@@ -774,9 +860,51 @@ class WebSocketService {
_sendWebSocket('/server/buffer_flushed', bufferFlushedPayload); _sendWebSocket('/server/buffer_flushed', bufferFlushedPayload);
} }
Future<int> _flushQueuedChatMessages() async {
final queuedMessages = await _databaseService.getQueuedMessages();
if (queuedMessages.isEmpty) {
return 0;
}
developer.log(
'Flushing ${queuedMessages.length} queued chat messages',
name: 'WebSocketService',
);
var sentCount = 0;
for (final message in queuedMessages) {
final success = await _trySendQueuedChatMessage(
message,
incrementRetryOnFailure: true,
);
if (success) {
sentCount++;
}
}
return sentCount;
}
Future<bool> _trySendQueuedChatMessage(
QueuedMessage message, {
bool incrementRetryOnFailure = false,
}) async {
if (!_isConnected || !_isAuthenticated || _wsChannel == null) {
return false;
}
final success = _sendWebSocket(message.topic, jsonEncode(message.payload));
if (!success && incrementRetryOnFailure) {
await _databaseService.updateMessageRetryCount(
message.id,
message.retryCount + 1,
);
}
return success;
}
/// Publish a chat message according to the backend contract. /// Publish a chat message according to the backend contract.
/// Returns the locally constructed message so callers can persist it locally. /// The message is stored locally and remains queued until the server confirms it.
/// Messages are buffered if offline and sent automatically when reconnected.
Future<ChatMessage?> sendChatMessage({ Future<ChatMessage?> sendChatMessage({
required String sender, required String sender,
required String receiver, required String receiver,
@@ -790,6 +918,9 @@ class WebSocketService {
final trimmedContent = content.trim(); final trimmedContent = content.trim();
final normalizedJobId = jobId?.trim(); final normalizedJobId = jobId?.trim();
final normalizedJobNumber = jobNumber?.trim(); final normalizedJobNumber = jobNumber?.trim();
final hasJobContext =
(normalizedJobId?.isNotEmpty ?? false) ||
(normalizedJobNumber?.isNotEmpty ?? false);
if (trimmedSender.isEmpty || if (trimmedSender.isEmpty ||
trimmedReceiver.isEmpty || trimmedReceiver.isEmpty ||
@@ -816,6 +947,9 @@ class WebSocketService {
'receiver': trimmedReceiver, 'receiver': trimmedReceiver,
'content': trimmedContent, 'content': trimmedContent,
}; };
final now = DateTime.now();
final clientMessageId = 'local-${now.microsecondsSinceEpoch}';
payload['messageId'] = clientMessageId;
if (normalizedJobId != null && normalizedJobId.isNotEmpty) { if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
payload['jobId'] = normalizedJobId; payload['jobId'] = normalizedJobId;
@@ -828,18 +962,13 @@ class WebSocketService {
const topic = '/server/message'; const topic = '/server/message';
try { try {
final jsonPayload = jsonEncode(payload);
// sendMessage buffers automatically if not connected/authenticated
sendMessage(topic, jsonPayload);
final now = DateTime.now();
final message = ChatMessage( final message = ChatMessage(
id: 'local-${now.microsecondsSinceEpoch}', id: clientMessageId,
content: trimmedContent, content: trimmedContent,
createdAt: now, createdAt: now,
direction: ChatDirection.outgoing, direction: ChatDirection.outgoing,
messageType: messageType:
normalizedJobId != null && normalizedJobId.isNotEmpty hasJobContext
? ChatMessageType.jobRelated ? ChatMessageType.jobRelated
: ChatMessageType.general, : ChatMessageType.general,
contentType: contentType, contentType: contentType,
@@ -849,13 +978,26 @@ class WebSocketService {
read: false, read: false,
pendingSync: true, pendingSync: true,
); );
final queuedMessage = QueuedMessage(
id: clientMessageId,
topic: topic,
payload: payload,
createdAt: now,
);
await _databaseService.queueMessage(queuedMessage);
await ChatService().saveOutgoingMessage(message);
final sentImmediately = await _trySendQueuedChatMessage(queuedMessage);
if (!sentImmediately) {
developer.log(
'Chat message $clientMessageId queued for retry after reconnect',
name: 'WebSocketService',
);
}
return message; return message;
} catch (e, st) { } catch (e, st) {
developer.log( developer.log('Error sending chat message: $e', name: 'WebSocketService');
'Error encoding chat message payload: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService'); developer.log('Stack: $st', name: 'WebSocketService');
return null; return null;
} }
@@ -1047,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 {
@@ -36,17 +37,17 @@ class _SettingsViewState extends State<SettingsView> {
setState(() { setState(() {
_selectedLanguageCode = languageCode; _selectedLanguageCode = languageCode;
}); });
// Save language preference // Save language preference
await _appState.setLanguage(languageCode); await _appState.setLanguage(languageCode);
// Show confirmation snackbar // Show confirmation snackbar
_showLanguageChangedSnackBar(languageCode); _showLanguageChangedSnackBar(languageCode);
} }
void _showLanguageChangedSnackBar(String languageCode) { void _showLanguageChangedSnackBar(String languageCode) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
// Get the language name from the corresponding localization // Get the language name from the corresponding localization
String languageName; String languageName;
String flagEmoji; String flagEmoji;
@@ -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:
? const Icon( isSelected
Icons.check_circle, ? const Icon(
color: Colors.deepPurple, Icons.check_circle,
) color: AppColors.primary,
: const Icon( )
Icons.circle_outlined, : const Icon(
color: Colors.grey, Icons.circle_outlined,
), 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,7 +5,9 @@ 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 'models/job.dart'; import 'models/job.dart';
import 'models/task.dart'; import 'models/task.dart';
import 'models/tasks/confirmation_task.dart'; import 'models/tasks/confirmation_task.dart';
@@ -39,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 = {};
@@ -60,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(() {
@@ -89,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: () {
@@ -116,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),
), ),
), ),
), ),
@@ -132,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(),
],
],
), ),
), ),
), ),
@@ -141,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,
), ),
], ],
@@ -169,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),
@@ -219,61 +308,93 @@ class _TaskViewState extends State<TaskView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: cardColor, color: cardColor,
), ),
child: Row( child: Stack(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Task number circle Row(
Container( crossAxisAlignment: CrossAxisAlignment.center,
width: 40, children: [
height: 40, // Task number circle
decoration: BoxDecoration( Container(
shape: BoxShape.circle, width: 40,
color: circleColor, height: 40,
), decoration: BoxDecoration(
child: Center( shape: BoxShape.circle,
child: Text( color: circleColor,
'${index + 1}', ),
style: TextStyle( child: Center(
color: Colors.white, child: Text(
fontWeight: FontWeight.bold, '${index + 1}',
fontSize: 16, style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Padding(
padding: EdgeInsets.only(
right: task.optional ? 72 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(task, isCompleted, index),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
const Icon(
Icons.check_circle,
color: AppColors.success,
),
],
],
),
if (task.optional)
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
),
),
child: const Text(
'Optional',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
),
), ),
), ),
), ),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(
task,
isCompleted || isSkipped,
index,
),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
Icon(Icons.check_circle, color: Colors.green[600]),
],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
],
], ],
), ),
), ),
@@ -501,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',
@@ -528,6 +650,7 @@ class _TaskViewState extends State<TaskView> {
'signatureSvg': svg, 'signatureSvg': svg,
'svgLength': svg.length, 'svgLength': svg.length,
'hasSignature': true, 'hasSignature': true,
'signatureNote': note,
}, },
); );
}, },
@@ -611,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);
}); });
@@ -627,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;
} }
} }
@@ -721,8 +890,18 @@ class _TaskViewState extends State<TaskView> {
decoration: isCompleted ? TextDecoration.lineThrough : null, decoration: isCompleted ? TextDecoration.lineThrough : null,
); );
final displayName = task.displayName; final displayName =
final description = task.description; task.displayName != null
? localizeKnownText(context, task.displayName!)
: null;
final description =
task.description != null
? localizeKnownText(context, task.description!)
: null;
final String? signatureNote =
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
? task.note!.trim()
: null;
if (displayName?.isNotEmpty == true) { if (displayName?.isNotEmpty == true) {
return Column( return Column(
@@ -733,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);
} }
@@ -785,12 +989,10 @@ class _TaskViewState extends State<TaskView> {
if (station.stationOrder == stationOrder) { if (station.stationOrder == stationOrder) {
final suffix = final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city; station.displayName.isNotEmpty ? station.displayName : station.city;
return suffix.isNotEmpty return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
? 'Station ${stationOrder + 1}: $suffix'
: 'Station ${stationOrder + 1}';
} }
} }
return 'Station ${stationOrder + 1}'; return AppLocalizations.of(context).stationNumber(stationOrder + 1);
} }
} }

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),
),
), ),
], ],
); );
@@ -240,7 +261,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bool get _canComplete { bool get _canComplete {
return _capturedPhotos.length >= widget.task.minPhotoCount && return _capturedPhotos.length >= widget.task.minPhotoCount &&
_capturedPhotos.length <= widget.task.maxPhotoCount; _capturedPhotos.length <= widget.task.maxPhotoCount;
} }
bool get _canTakeMore { bool get _canTakeMore {
@@ -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,19 +375,20 @@ 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:
? _buildCameraOrEmptyState() _capturedPhotos.isEmpty
: _buildPhotoGallery(), ? _buildCameraOrEmptyState()
: _buildPhotoGallery(),
), ),
// Bottom controls // Bottom controls
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:
? (_useFilePickerMode _canTakeMore && _isCameraSupportedOnThisPlatform
? _pickPhotoFromFile ? (_useFilePickerMode
: (_isCameraInitialized ? _capturePhoto : null)) ? _pickPhotoFromFile
: null, : (_isCameraInitialized
icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt), ? _capturePhoto
: null))
: null,
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); ? () {
Navigator.of(context).pop(); widget.onPhotosCompleted(_capturedPhotos);
} 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,9 +708,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
bottom: 0, bottom: 0,
child: Center( child: Center(
child: IconButton( child: IconButton(
onPressed: _currentPhotoIndex < _capturedPhotos.length - 1 onPressed:
? _goToNextPhoto _currentPhotoIndex < _capturedPhotos.length - 1
: null, ? _goToNextPhoto
: null,
icon: Icon(Icons.chevron_right, size: 36), icon: Icon(Icons.chevron_right, size: 36),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7), backgroundColor: Colors.white.withValues(alpha: 0.7),
@@ -658,22 +729,24 @@ 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:
return Container( _capturedPhotos.asMap().entries.map((entry) {
width: 8, return Container(
height: 8, width: 8,
margin: EdgeInsets.symmetric(horizontal: 4), height: 8,
decoration: BoxDecoration( margin: EdgeInsets.symmetric(horizontal: 4),
shape: BoxShape.circle, decoration: BoxDecoration(
color: _currentPhotoIndex == entry.key shape: BoxShape.circle,
? Colors.blue color:
: Colors.grey[400], _currentPhotoIndex == entry.key
), ? AppColors.primary
); : Colors.grey[400],
}).toList(), ),
);
}).toList(),
), ),
), ),
], ],
); );
} }
} }

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: () {
@@ -197,61 +208,71 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
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),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[400]!),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
backgroundColor: Colors.white,
), ),
), const SizedBox(height: 12),
Expanded(
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderStrong),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
backgroundColor: AppColors.surface,
),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _noteController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).completeTaskNote,
border: const OutlineInputBorder(),
),
maxLines: 2,
minLines: 1,
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).clear),
),
const Spacer(),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: _hasSignature ? _finish : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
],
), ),
), ),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).clear),
),
const Spacer(),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: _hasSignature ? _finish : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
],
),
),
), ),
], ],
), ),

View File

@@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart'; import 'package:flutter/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.12+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.14</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

@@ -8,8 +8,8 @@ import org.bson.types.ObjectId;
* Normalized payload for chat messages sent by mobile clients via WebSocket. * Normalized payload for chat messages sent by mobile clients via WebSocket.
* receiver = AppUser ID (clientId) extracted from topic * receiver = AppUser ID (clientId) extracted from topic
*/ */
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId, public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType,
String jobNumber) { String messageId, ObjectId jobId, String jobNumber) {
public ChatMessageInboundPayload { public ChatMessageInboundPayload {
contentType = contentType != null ? contentType : MessageContentType.TEXT; contentType = contentType != null ? contentType : MessageContentType.TEXT;
@@ -23,10 +23,11 @@ public record ChatMessageInboundPayload(String receiver, String content, Message
String receiver = extractRequiredString(payload, "receiver"); String receiver = extractRequiredString(payload, "receiver");
String content = extractRequiredString(payload, "content"); String content = extractRequiredString(payload, "content");
MessageContentType contentType = extractContentType(payload.get("contentType")); MessageContentType contentType = extractContentType(payload.get("contentType"));
String messageId = extractOptionalString(payload.get("messageId"));
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId"); ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
String jobNumber = extractOptionalString(payload.get("jobNumber")); String jobNumber = extractOptionalString(payload.get("jobNumber"));
return new ChatMessageInboundPayload(receiver, content, contentType, jobId, jobNumber); return new ChatMessageInboundPayload(receiver, content, contentType, messageId, jobId, jobNumber);
} }
public boolean hasJobContext() { public boolean hasJobContext() {

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

@@ -42,6 +42,13 @@ public class Message {
@Field("receiver") @Field("receiver")
private String receiver; private String receiver;
/**
* Optional stable client-side ID used for idempotent retries from the mobile
* app.
*/
@Field("client_message_id")
private String clientMessageId;
/** /**
* Timestamp when the message was created * Timestamp when the message was created
*/ */

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

@@ -4,8 +4,11 @@ import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; 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.confirmdialog.ConfirmDialog;
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;
@@ -53,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;
} }
@@ -204,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,
@@ -231,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);
@@ -367,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));
@@ -441,25 +464,21 @@ public class DeliveryStationDialog extends Dialog {
close(); close();
} else { } else {
// Adresse nicht gefunden: Benutzer fragen // Adresse nicht gefunden: Benutzer fragen
ConfirmDialog confirmDialog = new ConfirmDialog(); Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
confirmDialog.setHeader( translationHelper.getTranslation("addjob.validation.address.not.found.title"),
translationHelper.getTranslation("addjob.validation.address.not.found.title")); translationHelper.getTranslation("addjob.validation.address.not.found.message"),
confirmDialog.setText( "560px",
translationHelper.getTranslation("addjob.validation.address.not.found.message")); translationHelper.getTranslation("addjob.validation.address.correct"),
confirmDialog.setConfirmText( translationHelper.getTranslation("addjob.validation.address.save.anyway"),
translationHelper.getTranslation("addjob.validation.address.save.anyway")); () -> {
confirmDialog.setConfirmButtonTheme("primary"); data.setAddressValidatedByGoogle(false);
confirmDialog.setCancelable(true); data.setAddressValidationResult(validationResult);
confirmDialog.setCancelText( if (saveListener != null) {
translationHelper.getTranslation("addjob.validation.address.correct")); saveListener.onSave(data);
confirmDialog.addConfirmListener(ev -> { }
data.setAddressValidatedByGoogle(false); close();
data.setAddressValidationResult(validationResult); },
if (saveListener != null) { ButtonVariant.LUMO_PRIMARY);
saveListener.onSave(data);
}
close();
});
confirmDialog.open(); confirmDialog.open();
} }
})); }));
@@ -512,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();
@@ -548,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;
@@ -601,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) {
@@ -630,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()));
@@ -648,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()))) {
@@ -680,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) {
@@ -871,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());
@@ -882,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));
@@ -889,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("");
@@ -901,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();
@@ -945,6 +976,7 @@ public class DeliveryStationDialog extends Dialog {
} }
updateTaskConfiguration(configContainer, newTask); updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
} }
}); });
@@ -958,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);
@@ -967,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
@@ -1026,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();
@@ -1047,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);
@@ -1099,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(Button.class::isInstance) .filter(HorizontalLayout.class::isInstance)
.map(Button.class::cast) .map(HorizontalLayout.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst() .findFirst()
.ifPresent(button -> button.setEnabled(deletable))); .ifPresent(headerRow -> headerRow.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst()
.ifPresent(button -> button.setEnabled(deletable))));
} }
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) { private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
@@ -1147,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

@@ -1,8 +1,11 @@
package de.assecutor.votianlt.pages.base.ui.component; package de.assecutor.votianlt.pages.base.ui.component;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
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.dialog.Dialog;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
@@ -20,6 +23,33 @@ public final class DialogStylingHelper {
return dialog; return dialog;
} }
public static Dialog createConfirmationDialog(String title, String message, String width, String cancelText,
String confirmText, Runnable onConfirm, ButtonVariant... confirmVariants) {
Dialog dialog = createStyledDialog(title, width);
dialog.setCloseOnEsc(true);
dialog.setCloseOnOutsideClick(true);
VerticalLayout dialogContent = createContentLayout("320px");
if (message != null && !message.isBlank()) {
dialogContent.add(new Span(message));
}
Button cancelButton = new Button(cancelText, event -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmButton = new Button(confirmText, event -> {
dialog.close();
onConfirm.run();
});
if (confirmVariants != null && confirmVariants.length > 0) {
confirmButton.addThemeVariants(confirmVariants);
}
dialog.add(wrapContent(dialogContent));
dialog.getFooter().add(cancelButton, confirmButton);
return dialog;
}
public static void apply(Dialog dialog, String title, String width) { public static void apply(Dialog dialog, String title, String width) {
if (title != null && !title.isBlank()) { if (title != null && !title.isBlank()) {
dialog.setHeaderTitle(title); dialog.setHeaderTitle(title);
@@ -32,16 +62,40 @@ public final class DialogStylingHelper {
} }
public static Component wrapContent(Component content) { public static Component wrapContent(Component content) {
return wrapContent(content, false);
}
public static Component wrapContent(Component content, boolean fillHeight) {
Div frame = new Div(); Div frame = new Div();
frame.getStyle().set("border", "10px solid transparent"); frame.getStyle().set("border", "10px solid transparent");
frame.getStyle().set("border-radius", "0"); frame.getStyle().set("border-radius", "0");
frame.getStyle().set("box-sizing", "border-box"); frame.getStyle().set("box-sizing", "border-box");
if (fillHeight) {
frame.getStyle().set("display", "flex");
frame.getStyle().set("flex-direction", "column");
frame.getStyle().set("height", "100%");
frame.getStyle().set("min-height", "0");
frame.getStyle().set("flex", "1");
}
frame.setWidthFull(); frame.setWidthFull();
Div whiteCard = new Div(); Div whiteCard = new Div();
whiteCard.getStyle().set("background", "white"); whiteCard.getStyle().set("background", "white");
whiteCard.getStyle().set("border-radius", "24px"); whiteCard.getStyle().set("border-radius", "24px");
whiteCard.getStyle().set("overflow", "auto"); if (fillHeight) {
whiteCard.getStyle().set("display", "flex");
whiteCard.getStyle().set("flex-direction", "column");
whiteCard.getStyle().set("height", "100%");
whiteCard.getStyle().set("min-height", "0");
whiteCard.getStyle().set("flex", "1");
whiteCard.getStyle().set("overflow", "hidden");
content.getElement().getStyle().set("width", "100%");
content.getElement().getStyle().set("height", "100%");
content.getElement().getStyle().set("min-height", "0");
content.getElement().getStyle().set("flex", "1");
} else {
whiteCard.getStyle().set("overflow", "auto");
}
whiteCard.setWidthFull(); whiteCard.setWidthFull();
whiteCard.add(content); whiteCard.add(content);

View File

@@ -21,7 +21,6 @@ import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.timepicker.TimePicker; import com.vaadin.flow.component.timepicker.TimePicker;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.progressbar.ProgressBar; import com.vaadin.flow.component.progressbar.ProgressBar;
import de.assecutor.votianlt.model.AddressValidationResult; import de.assecutor.votianlt.model.AddressValidationResult;
@@ -36,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;
/** /**
@@ -229,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 {
@@ -251,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;
@@ -284,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);
@@ -292,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);
@@ -432,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());
@@ -489,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
@@ -501,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));
@@ -577,25 +587,21 @@ public class PickupStationDialog extends Dialog {
close(); close();
} else { } else {
// Adresse nicht gefunden: Benutzer fragen // Adresse nicht gefunden: Benutzer fragen
ConfirmDialog confirmDialog = new ConfirmDialog(); Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
confirmDialog.setHeader( translationHelper.getTranslation("addjob.validation.address.not.found.title"),
translationHelper.getTranslation("addjob.validation.address.not.found.title")); translationHelper.getTranslation("addjob.validation.address.not.found.message"),
confirmDialog.setText( "560px",
translationHelper.getTranslation("addjob.validation.address.not.found.message")); translationHelper.getTranslation("addjob.validation.address.correct"),
confirmDialog.setConfirmText( translationHelper.getTranslation("addjob.validation.address.save.anyway"),
translationHelper.getTranslation("addjob.validation.address.save.anyway")); () -> {
confirmDialog.setConfirmButtonTheme("primary"); data.setAddressValidatedByGoogle(false);
confirmDialog.setCancelable(true); data.setAddressValidationResult(validationResult);
confirmDialog.setCancelText( if (saveListener != null) {
translationHelper.getTranslation("addjob.validation.address.correct")); saveListener.onSave(data);
confirmDialog.addConfirmListener(ev -> { }
data.setAddressValidatedByGoogle(false); close();
data.setAddressValidationResult(validationResult); },
if (saveListener != null) { ButtonVariant.LUMO_PRIMARY);
saveListener.onSave(data);
}
close();
});
confirmDialog.open(); confirmDialog.open();
} }
})); }));
@@ -619,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)
@@ -668,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());
@@ -686,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());
@@ -752,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() {
@@ -809,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(companyNames); companyField.setItems(new ArrayList<>(companyCustomerMap.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()) {
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();
@@ -864,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())
@@ -911,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

@@ -17,6 +17,7 @@ import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout; import com.vaadin.flow.router.Layout;
import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
@@ -136,7 +137,7 @@ public final class AdminLayout extends AppLayout {
// Profile display with navigation // Profile display with navigation
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class)); userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem("Admin-Einstellungen"); userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout()); userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
// Update function for username and avatar // Update function for username and avatar
Runnable updateUserInfo = () -> { Runnable updateUserInfo = () -> {
@@ -151,4 +152,17 @@ public final class AdminLayout extends AppLayout {
return userMenu; return userMenu;
} }
private void openLogoutConfirmDialog() {
var dialog = DialogStylingHelper.createConfirmationDialog(
getTranslation("logout.confirm.title"),
getTranslation("logout.confirm.message"),
"460px",
getTranslation("button.cancel"),
getTranslation("nav.logout"),
securityService::logout,
com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY,
com.vaadin.flow.component.button.ButtonVariant.LUMO_ERROR);
dialog.open();
}
} }

View File

@@ -27,6 +27,7 @@ import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
@@ -327,8 +328,7 @@ public final class MainLayout extends AppLayout {
// Profil anzeigen mit Navigation // Profil anzeigen mit Navigation
userMenuItem.getSubMenu().addItem(getTranslation("nav.showprofile"), userMenuItem.getSubMenu().addItem(getTranslation("nav.showprofile"),
e -> UI.getCurrent().navigate(EditProfileView.class)); e -> UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem(getTranslation("nav.settings")); userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> openLogoutConfirmDialog());
userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> securityService.logout());
// Update-Funktion für Benutzername und Avatar // Update-Funktion für Benutzername und Avatar
Runnable updateUserInfo = () -> { Runnable updateUserInfo = () -> {
@@ -344,6 +344,19 @@ public final class MainLayout extends AppLayout {
return userMenu; return userMenu;
} }
private void openLogoutConfirmDialog() {
var dialog = DialogStylingHelper.createConfirmationDialog(
getTranslation("logout.confirm.title"),
getTranslation("logout.confirm.message"),
"460px",
getTranslation("button.cancel"),
getTranslation("nav.logout"),
securityService::logout,
com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY,
com.vaadin.flow.component.button.ButtonVariant.LUMO_ERROR);
dialog.open();
}
@Override @Override
protected void onAttach(AttachEvent attachEvent) { protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent); super.onAttach(attachEvent);

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

@@ -4,7 +4,6 @@ import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; 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.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
@@ -64,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;
@@ -138,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<>();
@@ -233,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"));
@@ -722,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);
@@ -756,86 +749,91 @@ public class AddJobView extends Main implements HasDynamicTitle {
if (idx < 0) if (idx < 0)
return; return;
ConfirmDialog dialog = new ConfirmDialog(); Dialog dialog = DialogStylingHelper.createConfirmationDialog(
dialog.setHeader(getTranslation("addjob.station.remove.confirm", idx + 1)); getTranslation("addjob.station.remove.confirm", idx + 1),
dialog.setCancelable(true); null,
dialog.setCancelText(getTranslation("dialog.cancel")); "460px",
dialog.setConfirmText(getTranslation("dialog.confirm")); getTranslation("dialog.cancel"),
dialog.addConfirmListener(e -> { getTranslation("dialog.confirm"),
int removeIdx = deliveryStationTilesList.indexOf(tile); () -> {
if (removeIdx < 0) int removeIdx = deliveryStationTilesList.indexOf(tile);
return; if (removeIdx < 0)
return;
deliveryStationTilesList.remove(removeIdx); deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx); deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx); deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx); deliveryStationsCustomerId.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx); deliveryStationsAddressDiffers.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx); deliveryStationsMailState.remove(removeIdx);
Div removedSlot = deliveryStationSlotList.remove(removeIdx); deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationDistanceChips.remove(removeIdx); deliveryStationTasksState.remove(removeIdx);
pickupToDeliveryRouteResults.remove(removeIdx); Div removedSlot = deliveryStationSlotList.remove(removeIdx);
// Re-index tasks state for remaining stations deliveryStationDistanceChips.remove(removeIdx);
Map<Integer, List<BaseTask>> reindexed = new HashMap<>(); pickupToDeliveryRouteResults.remove(removeIdx);
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) { // Re-index tasks state for remaining stations
int oldIdx = entry.getKey(); Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx; for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
reindexed.put(newIdx, entry.getValue()); int oldIdx = entry.getKey();
} int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
deliveryStationTasksState.clear(); reindexed.put(newIdx, entry.getValue());
deliveryStationTasksState.putAll(reindexed); }
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexed);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>(); Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) { for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey(); int oldIdx = entry.getKey();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx; int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
reindexedRoutes.put(newIdx, entry.getValue()); reindexedRoutes.put(newIdx, entry.getValue());
} }
pickupToDeliveryRouteResults.clear(); pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes); pickupToDeliveryRouteResults.putAll(reindexedRoutes);
for (SelectedServiceEntry selectedService : selectedServices) { for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder(); Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) { if (stationOrder == null) {
continue; continue;
} }
if (stationOrder == removeIdx) { if (stationOrder == removeIdx) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0); selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else if (stationOrder > removeIdx) { } else if (stationOrder > removeIdx) {
selectedService.setDeliveryStationOrder(stationOrder - 1); selectedService.setDeliveryStationOrder(stationOrder - 1);
} }
} }
stationsGridContainer.remove(removedSlot); stationsGridContainer.remove(removedSlot);
// Renumber remaining tiles and update click listeners // Renumber remaining tiles and update click listeners
for (int i = 0; i < deliveryStationTilesList.size(); i++) { for (int i = 0; i < deliveryStationTilesList.size(); i++) {
StationTile t = deliveryStationTilesList.get(i); StationTile t = deliveryStationTilesList.get(i);
int newNumber = i + 1; int newNumber = i + 1;
t.updateStationNumber(newNumber); t.updateStationNumber(newNumber);
t.updateTitle(getTranslation("addjob.station.delivery", newNumber)); t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
// Update click listener to use correct index // Update click listener to use correct index
final int newIdx = i; final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx)); t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
// First station should not be removable // First station should not be removable
if (i == 0) { if (i == 0) {
t.setDeleteListener(null); t.setDeleteListener(null);
} }
} }
// Ensure "+" button is visible if under max // Ensure "+" button is visible if under max
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS && addStationButtonSlot.getParent().isEmpty()) { if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
stationsGridContainer.add(addStationButtonSlot); && addStationButtonSlot.getParent().isEmpty()) {
} stationsGridContainer.add(addStationButtonSlot);
}
resetRouteInformation(); resetRouteInformation();
resetStationsAppliedState(); resetStationsAppliedState();
if (servicesGrid != null) { if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll(); servicesGrid.getDataProvider().refreshAll();
} }
updatePriceSummary(); updatePriceSummary();
triggerValidation(); triggerValidation();
updateTabLabels(); updateTabLabels();
}); },
ButtonVariant.LUMO_PRIMARY);
dialog.open(); dialog.open();
} }
@@ -847,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();
} }
@@ -865,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());
@@ -911,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());
@@ -1103,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);
@@ -1135,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
@@ -1180,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));
} }
@@ -1388,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
@@ -1452,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;
}); });
} }
@@ -1815,39 +1840,60 @@ public class AddJobView extends Main implements HasDynamicTitle {
return; return;
} }
// NEU: Kunden anlegen, wenn Checkboxen aktiviert // Kunden anlegen/aktualisieren bzw. intern sichern
Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue());
pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
if (savePickupAddress.getValue()) { if (savePickupAddress.getValue()) {
Customer pickupCustomer = new Customer(); if (pickupCustomerId != null) {
pickupCustomer.setCompanyName(pickupCompany.getValue()); pickupCustomer.setId(pickupCustomerId);
pickupCustomer.setTitle(pickupSalutation.getValue()); addCustomerService.updateCustomer(pickupCustomer);
pickupCustomer.setFirstname(pickupFirstName.getValue()); } else {
pickupCustomer.setLastName(pickupLastName.getValue()); addCustomerService.addCustomer(pickupCustomer);
pickupCustomer.setTelephone(pickupPhone.getValue()); }
pickupCustomer.setMail(pickupMail); } else if (pickupAddressDiffers) {
pickupCustomer.setStreet(pickupStreet.getValue()); addCustomerService.addInternalCustomer(pickupCustomer);
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
addCustomerService.addCustomer(pickupCustomer);
} }
// Save delivery station addresses as customers if checkbox is checked // Delivery-Stationen: anlegen, aktualisieren oder als intern sichern
for (int i = 0; i < deliveryStationsState.size(); i++) { 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()); deliveryCustomer.setTitle(ds.getSalutation());
deliveryCustomer.setTitle(ds.getSalutation()); deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setFirstname(ds.getFirstName()); deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setLastName(ds.getLastName()); deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setTelephone(ds.getPhone()); deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null); deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setStreet(ds.getStreet()); deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setHouseNumber(ds.getHouseNumber()); 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()
addCustomerService.addCustomer(deliveryCustomer); && deliveryStationsSaveAddress.get(i);
org.bson.types.ObjectId existingId = i < deliveryStationsCustomerId.size()
? deliveryStationsCustomerId.get(i)
: null;
boolean addressDiffers = i < deliveryStationsAddressDiffers.size()
&& deliveryStationsAddressDiffers.get(i);
if (saveRequested) {
if (existingId != null) {
deliveryCustomer.setId(existingId);
addCustomerService.updateCustomer(deliveryCustomer);
} else {
addCustomerService.addCustomer(deliveryCustomer);
}
} else if (addressDiffers) {
addCustomerService.addInternalCustomer(deliveryCustomer);
} }
} }
@@ -1942,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());
} }
} }
@@ -2091,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;
@@ -23,14 +24,16 @@ import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.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;
@@ -45,7 +48,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame; import com.vaadin.flow.component.html.IFrame;
@@ -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);
@@ -659,9 +722,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private void showPdfPreviewDialog(byte[] pdfBytes, String templateData, User user) { private void showPdfPreviewDialog(byte[] pdfBytes, String templateData, User user) {
String title = getTranslation("createinvoice.preview.title"); String title = getTranslation("createinvoice.preview.title");
Dialog pdfDialog = new Dialog(); Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
pdfDialog.setHeaderTitle(title);
pdfDialog.setWidth("90vw");
pdfDialog.setHeight("90vh"); pdfDialog.setHeight("90vh");
IFrame pdfFrame = new IFrame(); IFrame pdfFrame = new IFrame();
@@ -676,19 +737,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> { Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> {
ConfirmDialog confirm = new ConfirmDialog(); Dialog confirm = DialogStylingHelper.createConfirmationDialog(
confirm.setHeader(getTranslation("createinvoice.confirm.save.title")); getTranslation("createinvoice.confirm.save.title"),
confirm.setText(getTranslation("createinvoice.confirm.save.message")); getTranslation("createinvoice.confirm.save.message"),
confirm.setConfirmText(getTranslation("createinvoice.confirm.save.confirm")); "560px",
confirm.setConfirmButtonTheme("primary"); getTranslation("button.cancel"),
confirm.setCancelText(getTranslation("button.cancel")); getTranslation("createinvoice.confirm.save.confirm"),
confirm.setCancelable(true); () -> saveInvoice(templateData, user, pdfDialog),
confirm.addConfirmListener(ev -> saveInvoice(templateData, user, pdfDialog)); ButtonVariant.LUMO_PRIMARY);
confirm.open(); confirm.open();
}); });
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
pdfDialog.add(pdfFrame); pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
pdfDialog.getFooter().add(closeButton, saveButton); pdfDialog.getFooter().add(closeButton, saveButton);
pdfDialog.open(); pdfDialog.open();
} }
@@ -696,9 +757,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public static void showSavedInvoiceDialog(byte[] pdfBytes, String invoiceNumber, public static void showSavedInvoiceDialog(byte[] pdfBytes, String invoiceNumber,
com.vaadin.flow.component.Component parent) { com.vaadin.flow.component.Component parent) {
String title = "Rechnung " + invoiceNumber; String title = "Rechnung " + invoiceNumber;
Dialog pdfDialog = new Dialog(); Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
pdfDialog.setHeaderTitle(title);
pdfDialog.setWidth("90vw");
pdfDialog.setHeight("90vh"); pdfDialog.setHeight("90vh");
IFrame pdfFrame = new IFrame(); IFrame pdfFrame = new IFrame();
@@ -720,7 +779,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Button closeButton = new Button("Schließen", e -> pdfDialog.close()); Button closeButton = new Button("Schließen", e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
pdfDialog.add(pdfFrame); pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
pdfDialog.getFooter().add(downloadButton, closeButton); pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open(); pdfDialog.open();
} }

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;
@@ -145,7 +146,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
TextField faxField = new TextField(getTranslation("profile.fax")); TextField faxField = new TextField(getTranslation("profile.fax"));
TextField mobileField = new TextField(getTranslation("profile.mobile")); TextField mobileField = new TextField(getTranslation("profile.mobile"));
EmailField emailField = new EmailField(getTranslation("profile.email.required")); EmailField emailField = new EmailField(getTranslation("profile.email"));
emailField.addBlurListener(e -> validateEmailField(emailField)); emailField.addBlurListener(e -> validateEmailField(emailField));
TextField streetField = new TextField(getTranslation("profile.street")); TextField streetField = new TextField(getTranslation("profile.street"));
@@ -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()),
@@ -882,9 +918,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
String dataUrl = "data:application/pdf;base64," + base64Pdf; String dataUrl = "data:application/pdf;base64," + base64Pdf;
// Create dialog // Create dialog
Dialog pdfDialog = new Dialog(); Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("profile.invoice.pdf.preview"), "90vw");
pdfDialog.setHeaderTitle(getTranslation("profile.invoice.pdf.preview"));
pdfDialog.setWidth("90vw");
pdfDialog.setHeight("90vh"); pdfDialog.setHeight("90vh");
// Create a Div to hold the PDF viewer // Create a Div to hold the PDF viewer
@@ -914,7 +948,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
}); });
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
pdfDialog.add(pdfContainer); pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
pdfDialog.getFooter().add(downloadButton, closeButton); pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open(); pdfDialog.open();
} }
@@ -1426,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,9 +1,12 @@
package de.assecutor.votianlt.pages.view; package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
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.H5;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import org.springframework.core.io.ClassPathResource;
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 jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
@@ -25,29 +28,82 @@ public class ImprintView extends VerticalLayout implements HasDynamicTitle {
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint")); ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint"));
content.add(toolbar); content.add(toolbar);
try { Div imprintCard = new Div();
// Load HTML content from resources imprintCard.addClassNames("form-card", "form-shell");
ClassPathResource resource = new ClassPathResource("html/imprint.html"); imprintCard.getStyle().set("max-width", "800px").set("margin", "0 auto");
String htmlContent = new String(resource.getInputStream().readAllBytes());
// Create a Div to hold the HTML content VerticalLayout imprintContent = new VerticalLayout();
Div imprintDiv = new Div(); imprintContent.setPadding(false);
imprintDiv.addClassNames("form-card", "form-shell"); imprintContent.setSpacing(false);
imprintDiv.getElement().setProperty("innerHTML", htmlContent); imprintContent.getStyle().set("gap", "var(--lumo-space-l)");
content.add(imprintDiv); imprintContent.add(
createSection("Assecutor Data Service GmbH",
createLinesBlock(createLine("Gerhart-Hauptmann-Weg 14"), createLine("21502 Geesthacht"),
createLine(getTranslation("imprint.country")),
createLine(getTranslation("imprint.phone") + ": +49 40 18 123 771 0"),
createEmailLine())),
createSection(getTranslation("imprint.management"),
createLinesBlock(createLine("Carsten Annacker"), createLine("Gunnar Timm"))),
createSection(getTranslation("imprint.registeredoffice"),
createLinesBlock(createLine("Gerhart-Hauptmann-Weg 14, 21502 Geesthacht"))),
createSection(getTranslation("imprint.commercialregister"),
createLinesBlock(createLine("HRB 8595 HL"))),
createSection(getTranslation("imprint.vatid"), createLinesBlock(createLine("DE261094748"))),
createSection(getTranslation("imprint.imagecredits"),
createLinesBlock(createSectionHeading(getTranslation("imprint.backgroundimage")),
createLine("MAN Financial Services (EURO-Leasing), flickr"),
createLine(
"(Creative Commons, Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0))"),
createExternalLink(
"https://www.flickr.com/photos/mbwa_pr/15969764443/in/album-72157632488355514/"))));
} catch (Exception e) { imprintCard.add(imprintContent);
// Fallback content in case of error content.add(imprintCard);
Div errorDiv = new Div();
errorDiv.addClassNames("form-card", "form-shell");
errorDiv.setText(getTranslation("imprint.error", e.getMessage()));
content.add(errorDiv);
}
add(content); add(content);
} }
private Component createSection(String title, Component body) {
Div section = new Div();
section.getStyle().set("text-align", "left");
section.add(createSectionHeading(title), body);
return section;
}
private H5 createSectionHeading(String title) {
H5 heading = new H5(title);
heading.getStyle().set("margin", "0 0 var(--lumo-space-s) 0");
return heading;
}
private Div createLinesBlock(Component... lines) {
Div block = new Div();
block.getStyle().set("display", "flex").set("flex-direction", "column").set("gap", "4px");
block.add(lines);
return block;
}
private Span createLine(String text) {
Span line = new Span(text);
line.getStyle().set("display", "block");
return line;
}
private Div createEmailLine() {
Div line = new Div();
line.getStyle().set("display", "block");
line.add(new Span(getTranslation("imprint.email") + ": "));
line.add(new Anchor("mailto:ahoi@assecutor.de", "ahoi@assecutor.de"));
return line;
}
private Anchor createExternalLink(String href) {
Anchor link = new Anchor(href, href);
link.setTarget("_blank");
return link;
}
@Override @Override
public String getPageTitle() { public String getPageTitle() {
return getTranslation("page.title.imprint"); return getTranslation("page.title.imprint");

View File

@@ -16,7 +16,6 @@ import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.html.Input; import com.vaadin.flow.component.html.Input;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame; import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.server.StreamResource;
import elemental.json.JsonValue; import elemental.json.JsonValue;
import elemental.json.JsonType; import elemental.json.JsonType;
import com.vaadin.flow.component.upload.Upload; import com.vaadin.flow.component.upload.Upload;
@@ -306,15 +305,8 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
} }
private void showPdfInDialog(byte[] pdfBytes) { private void showPdfInDialog(byte[] pdfBytes) {
// Create a stream resource for the PDF Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
StreamResource resource = new StreamResource("preview.pdf", () -> new java.io.ByteArrayInputStream(pdfBytes)); "90vw");
resource.setContentType("application/pdf");
resource.setCacheTime(0);
// Create dialog
Dialog pdfDialog = new Dialog();
pdfDialog.setHeaderTitle(getTranslation("invoicegenerator.pdf.preview.title"));
pdfDialog.setWidth("90vw");
pdfDialog.setHeight("90vh"); pdfDialog.setHeight("90vh");
// Create a Div to hold the PDF viewer // Create a Div to hold the PDF viewer
@@ -347,7 +339,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
}); });
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
pdfDialog.add(pdfContainer); pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
pdfDialog.getFooter().add(downloadButton, closeButton); pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open(); pdfDialog.open();
} }

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()) { private Component renderStatusBadge(CustomerInvoice invoice) {
Span emptyState = new Span(getTranslation("invoices.empty")); InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)"); Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
add(emptyState); 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

@@ -20,6 +20,7 @@ import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Photo; import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Signature; import de.assecutor.votianlt.model.Signature;
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.repository.BarcodeRepository; import de.assecutor.votianlt.repository.BarcodeRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
@@ -182,8 +183,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
Icon typeIcon = getTypeIcon(entry.getChangeType()); Icon typeIcon = getTypeIcon(entry.getChangeType());
typeIcon.getStyle().set("color", getTypeColor(entry.getChangeType())); typeIcon.getStyle().set("color", getTypeColor(entry.getChangeType()));
Span reason = new Span( Span reason = new Span(getLocalizedReason(entry));
entry.getReason() != null ? entry.getReason() : getTranslation("jobhistory.entry.unknown"));
reason.addClassName("timeline-reason"); reason.addClassName("timeline-reason");
Span timestamp = new Span(formatDateTime(entry.getTimestamp())); Span timestamp = new Span(formatDateTime(entry.getTimestamp()));
@@ -202,8 +202,9 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
cardContent.add(headerRow); cardContent.add(headerRow);
// Description // Description
if (entry.getDescription() != null && !entry.getDescription().isBlank()) { String localizedDescription = getLocalizedDescription(entry);
Span description = new Span(entry.getDescription()); if (localizedDescription != null && !localizedDescription.isBlank()) {
Span description = new Span(localizedDescription);
description.addClassName("timeline-description"); description.addClassName("timeline-description");
cardContent.add(description); cardContent.add(description);
} }
@@ -252,6 +253,37 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
return card; return card;
} }
private String getLocalizedReason(JobHistory entry) {
if (entry == null) {
return "";
}
if (entry.getChangeType() == JobHistoryType.CREATE) {
return getTranslation("jobhistory.entry.create.reason");
}
return entry.getReason() != null ? entry.getReason() : "";
}
private String getLocalizedDescription(JobHistory entry) {
if (entry == null || entry.getDescription() == null || entry.getDescription().isBlank()) {
return entry != null ? entry.getDescription() : "";
}
if (entry.getChangeType() == JobHistoryType.CREATE) {
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription()));
}
return entry.getDescription();
}
private String extractDescriptionValue(String description) {
if (description == null || description.isBlank()) {
return "";
}
int separatorIndex = description.indexOf(':');
if (separatorIndex < 0 || separatorIndex == description.length() - 1) {
return description.trim();
}
return description.substring(separatorIndex + 1).trim();
}
private Icon getTypeIcon(JobHistoryType type) { private Icon getTypeIcon(JobHistoryType type) {
if (type == null) if (type == null)
return new Icon(VaadinIcon.INFO_CIRCLE); return new Icon(VaadinIcon.INFO_CIRCLE);
@@ -300,15 +332,15 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
private String formatStatus(de.assecutor.votianlt.model.JobStatus status) { private String formatStatus(de.assecutor.votianlt.model.JobStatus status) {
if (status == null) if (status == null)
return getTranslation("jobhistory.entry.unknown"); return "";
return switch (status) { return switch (status) {
case CREATED -> getTranslation("jobstatus.CREATED"); case CREATED -> getTranslation("jobstatus.CREATED");
case IN_PROGRESS -> getTranslation("jobstatus.IN_PROGRESS"); case IN_PROGRESS -> getTranslation("jobstatus.IN_PROGRESS");
case PICKUP_SCHEDULED -> "Abholung geplant"; case PICKUP_SCHEDULED -> getTranslation("jobhistory.status.pickupscheduled");
case PICKED_UP -> "Abgeholt"; case PICKED_UP -> getTranslation("jobhistory.status.pickedup");
case IN_TRANSIT -> "Unterwegs"; case IN_TRANSIT -> getTranslation("jobhistory.status.intransit");
case DELIVERED -> "Zugestellt"; case DELIVERED -> getTranslation("jobhistory.status.delivered");
case COMPLETED -> getTranslation("jobstatus.COMPLETED"); case COMPLETED -> getTranslation("jobstatus.COMPLETED");
case CANCELLED -> getTranslation("jobstatus.CANCELLED"); case CANCELLED -> getTranslation("jobstatus.CANCELLED");
}; };
@@ -393,8 +425,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
} }
private void showEnlargedPhoto(String base64Photo) { private void showEnlargedPhoto(String base64Photo) {
Dialog photoDialog = new Dialog(); Dialog photoDialog = DialogStylingHelper.createStyledDialog(getTranslation("jobhistory.image.alt"), "80vw");
photoDialog.setWidth("80vw");
photoDialog.setHeight("80vh"); photoDialog.setHeight("80vh");
photoDialog.setModal(true); photoDialog.setModal(true);
photoDialog.setCloseOnOutsideClick(true); photoDialog.setCloseOnOutsideClick(true);
@@ -412,7 +443,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER); dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER);
dialogContent.setSizeFull(); dialogContent.setSizeFull();
photoDialog.add(dialogContent); photoDialog.add(DialogStylingHelper.wrapContent(dialogContent, true));
photoDialog.open(); photoDialog.open();
} catch (Exception e) { } catch (Exception e) {
@@ -549,8 +580,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
} }
private void showEnlargedSignature(String svgContent) { private void showEnlargedSignature(String svgContent) {
Dialog signatureDialog = new Dialog(); Dialog signatureDialog = DialogStylingHelper.createStyledDialog(getTranslation("tasktype.SIGNATURE"), "60vw");
signatureDialog.setWidth("60vw");
signatureDialog.setHeight("40vh"); signatureDialog.setHeight("40vh");
signatureDialog.setModal(true); signatureDialog.setModal(true);
signatureDialog.setCloseOnOutsideClick(true); signatureDialog.setCloseOnOutsideClick(true);
@@ -567,7 +597,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
dialogContent.setPadding(true); dialogContent.setPadding(true);
dialogContent.add(enlargedSignature); dialogContent.add(enlargedSignature);
signatureDialog.add(dialogContent); signatureDialog.add(DialogStylingHelper.wrapContent(dialogContent, true));
signatureDialog.open(); signatureDialog.open();
} catch (Exception e) { } catch (Exception e) {

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

@@ -60,7 +60,6 @@ import de.assecutor.votianlt.service.LocationService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.service.TaskAssignmentService; import de.assecutor.votianlt.service.TaskAssignmentService;
import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -135,8 +134,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
if (parameter == null || parameter.isBlank()) { if (parameter == null || parameter.isBlank()) {
content.removeAll(); content.removeAll();
removeAll(); removeAll();
add(new ViewToolbar("Zusammenfassung")); add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span("Fehler: Keine Job-ID angegeben")); content.add(new Span(getTranslation("jobsummary.error.noid")));
add(content); add(content);
return; return;
} }
@@ -146,8 +145,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
} catch (Exception e) { } catch (Exception e) {
content.removeAll(); content.removeAll();
removeAll(); removeAll();
add(new ViewToolbar("Zusammenfassung")); add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); content.add(new Span(getTranslation("jobsummary.error.invalidid", parameter)));
add(content); add(content);
return; return;
} }
@@ -186,8 +185,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
Job job = jobRepository.findById(currentJobId).orElse(null); Job job = jobRepository.findById(currentJobId).orElse(null);
if (job == null) { if (job == null) {
add(new ViewToolbar("Zusammenfassung")); add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span("Fehler: Job mit ID " + currentJobId.toHexString() + " nicht gefunden")); content.add(new Span(getTranslation("jobsummary.error.notfound", currentJobId.toHexString())));
add(content); add(content);
return; return;
} }
@@ -213,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);
@@ -220,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
add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); if (manualCompleteButton != null) {
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
jobHistoryButton));
} else {
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
}
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId); List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job); List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
@@ -325,33 +339,33 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
new Icon(VaadinIcon.CHECK_CIRCLE)); new Icon(VaadinIcon.CHECK_CIRCLE));
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
completeButton.addClickListener(e -> { completeButton.addClickListener(e -> {
ConfirmDialog dialog = new ConfirmDialog(); Dialog dialog = DialogStylingHelper.createConfirmationDialog(
dialog.setHeader(getTranslation("jobsummary.dialog.complete.title")); getTranslation("jobsummary.dialog.complete.title"),
dialog.setText(getTranslation("jobsummary.dialog.complete.text", job.getJobNumber())); getTranslation("jobsummary.dialog.complete.text", job.getJobNumber()),
dialog.setCancelable(true); "560px",
dialog.setCancelText(getTranslation("jobsummary.dialog.complete.cancel")); getTranslation("jobsummary.dialog.complete.cancel"),
dialog.setConfirmText(getTranslation("jobsummary.dialog.complete.confirm")); getTranslation("jobsummary.dialog.complete.confirm"),
dialog.setConfirmButtonTheme("primary"); () -> {
dialog.addConfirmListener(ev -> { try {
try { JobStatus oldStatus = job.getStatus();
JobStatus oldStatus = job.getStatus(); job.setStatus(JobStatus.COMPLETED);
job.setStatus(JobStatus.COMPLETED); job.setUpdatedAt(LocalDateTime.now());
job.setUpdatedAt(LocalDateTime.now()); jobRepository.save(job);
jobRepository.save(job); jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); Notification
Notification .show(getTranslation("jobsummary.notification.completed", job.getJobNumber()),
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000, 3000, Notification.Position.BOTTOM_END)
Notification.Position.BOTTOM_END) .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
.addThemeVariants(NotificationVariant.LUMO_SUCCESS); // Re-render the page
// Re-render the page getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); } catch (Exception ex) {
} catch (Exception ex) { Notification
Notification .show(getTranslation("jobsummary.notification.complete.error",
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000, ex.getMessage()), 5000, Notification.Position.BOTTOM_END)
Notification.Position.BOTTOM_END) .addThemeVariants(NotificationVariant.LUMO_ERROR);
.addThemeVariants(NotificationVariant.LUMO_ERROR); }
} },
}); ButtonVariant.LUMO_PRIMARY);
dialog.open(); dialog.open();
}); });
@@ -896,8 +910,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
// Gespeicherte Dauer formatieren // Gespeicherte Dauer formatieren
int hours = savedDuration / 3600; int hours = savedDuration / 3600;
int minutes = (savedDuration % 3600) / 60; int minutes = (savedDuration % 3600) / 60;
String savedDurationText = hours > 0 ? String.format("%d Std. %d Min.", hours, minutes) String savedDurationText = formatDurationShort(hours, minutes);
: String.format("%d Min.", minutes); String plannedRouteLabel = escapeJs(getTranslation("jobsummary.route.planned"));
String durationLabel = escapeJs(getTranslation("createinvoice.route.duration"));
// Build waypoints JS array // Build waypoints JS array
StringBuilder waypointsJs = new StringBuilder("["); StringBuilder waypointsJs = new StringBuilder("[");
@@ -925,6 +940,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
var hasSavedRouteData = %s; var hasSavedRouteData = %s;
var savedDistance = %s; var savedDistance = %s;
var savedDurationText = '%s'; var savedDurationText = '%s';
var plannedRouteLabel = '%s';
var durationLabel = '%s';
var waypoints = %s; var waypoints = %s;
var appUserMarker = null; var appUserMarker = null;
@@ -968,7 +985,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
savedRouteDiv.style.backgroundColor = '#e3f2fd'; savedRouteDiv.style.backgroundColor = '#e3f2fd';
savedRouteDiv.style.borderRadius = '4px'; savedRouteDiv.style.borderRadius = '4px';
savedRouteDiv.style.fontWeight = 'bold'; savedRouteDiv.style.fontWeight = 'bold';
savedRouteDiv.textContent = '📍 Geplante Route: ' + savedDistance.toFixed(1) + ' km • Fahrtzeit: ' + savedDurationText; savedRouteDiv.textContent = '📍 ' + plannedRouteLabel + ': ' + savedDistance.toFixed(1) + ' km • ' + durationLabel + ': ' + savedDurationText;
infoEl.appendChild(savedRouteDiv); infoEl.appendChild(savedRouteDiv);
} }
@@ -1075,7 +1092,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng, .formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate), Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate),
Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText), Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText),
waypointsJs.toString()); plannedRouteLabel, durationLabel, waypointsJs.toString());
}
private String formatDurationShort(int hours, int minutes) {
String hourUnit = getTranslation("duration.hours.short");
String minuteUnit = getTranslation("duration.minutes.short");
if (hours > 0) {
return String.format("%d %s %d %s", hours, hourUnit, minutes, minuteUnit);
}
return String.format("%d %s", minutes, minuteUnit);
} }
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings

View File

@@ -693,7 +693,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
private HorizontalLayout createMessageInputArea() { private HorizontalLayout createMessageInputArea() {
messageInput = new TextArea(); messageInput = new TextArea();
messageInput.setPlaceholder("Nachricht eingeben..."); messageInput.setPlaceholder(getTranslation("messagedetails.placeholder"));
messageInput.setWidthFull(); messageInput.setWidthFull();
messageInput.getStyle().set("min-height", "60px"); messageInput.getStyle().set("min-height", "60px");
messageInput.getStyle().set("max-height", "120px"); messageInput.getStyle().set("max-height", "120px");

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

@@ -3,7 +3,7 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.datepicker.DatePicker; import com.vaadin.flow.component.datepicker.DatePicker;
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.Anchor;
@@ -21,6 +21,7 @@ import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.messaging.MessagingPublisher; import de.assecutor.votianlt.messaging.MessagingPublisher;
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.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
@@ -49,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
@@ -60,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);
@@ -214,53 +217,54 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
} }
private void showCompleteJobDialog(Job job) { private void showCompleteJobDialog(Job job) {
ConfirmDialog dialog = new ConfirmDialog(); Dialog dialog = DialogStylingHelper.createConfirmationDialog(
dialog.setHeader(getTranslation("jobs.dialog.complete.title")); getTranslation("jobs.dialog.complete.title"),
dialog.setText(getTranslation("jobs.dialog.complete.text", job.getJobNumber())); getTranslation("jobs.dialog.complete.text", job.getJobNumber()),
dialog.setCancelable(true); "560px",
dialog.setCancelText(getTranslation("button.cancel")); getTranslation("button.cancel"),
dialog.setConfirmText(getTranslation("jobs.dialog.complete.confirm")); getTranslation("jobs.dialog.complete.confirm"),
dialog.setConfirmButtonTheme("primary"); () -> {
dialog.addConfirmListener(e -> { try {
try { JobStatus oldStatus = job.getStatus();
JobStatus oldStatus = job.getStatus(); job.setStatus(JobStatus.COMPLETED);
job.setStatus(JobStatus.COMPLETED); job.setUpdatedAt(LocalDateTime.now());
job.setUpdatedAt(LocalDateTime.now()); jobRepository.save(job);
jobRepository.save(job); jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); Notification.show(getTranslation("jobs.notification.completed", job.getJobNumber()), 3000,
Notification.show(getTranslation("jobs.notification.completed", job.getJobNumber()), 3000, Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS); loadData();
loadData(); } catch (Exception ex) {
} catch (Exception ex) { Notification.show(getTranslation("jobs.notification.complete.error", ex.getMessage()), 5000,
Notification.show(getTranslation("jobs.notification.complete.error", ex.getMessage()), 5000, Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR); }
} },
}); ButtonVariant.LUMO_PRIMARY);
dialog.open(); dialog.open();
} }
private void showDeleteJobDialog(Job job) { private void showDeleteJobDialog(Job job) {
ConfirmDialog dialog = new ConfirmDialog(); Dialog dialog = DialogStylingHelper.createConfirmationDialog(
dialog.setHeader(getTranslation("jobs.dialog.delete.title")); getTranslation("jobs.dialog.delete.title"),
dialog.setText(getTranslation("jobs.dialog.delete.text", job.getJobNumber())); getTranslation("jobs.dialog.delete.text", job.getJobNumber()),
dialog.setCancelable(true); "560px",
dialog.setCancelText(getTranslation("button.cancel")); getTranslation("button.cancel"),
dialog.setConfirmText(getTranslation("button.delete")); getTranslation("button.delete"),
dialog.setConfirmButtonTheme("error primary"); () -> {
dialog.addConfirmListener(e -> { try {
try { // Notify client before deleting if online
// Notify client before deleting if online notifyClientJobDeleted(job);
notifyClientJobDeleted(job);
jobRepository.delete(job); jobRepository.delete(job);
Notification.show(getTranslation("jobs.notification.deleted", job.getJobNumber()), 3000, Notification.show(getTranslation("jobs.notification.deleted", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS); Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
loadData(); loadData();
} catch (Exception ex) { } catch (Exception ex) {
Notification.show(getTranslation("jobs.notification.delete.error", ex.getMessage()), 5000, Notification.show(getTranslation("jobs.notification.delete.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR); Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
}); },
ButtonVariant.LUMO_PRIMARY,
ButtonVariant.LUMO_ERROR);
dialog.open(); dialog.open();
} }

View File

@@ -20,6 +20,7 @@ import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.security.SessionAuthenticationService; import de.assecutor.votianlt.security.SessionAuthenticationService;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.DemoModeService; import de.assecutor.votianlt.service.DemoModeService;
@@ -241,17 +242,15 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
String currentUser = securityService.getCurrentUsername(); String currentUser = securityService.getCurrentUsername();
ComboBox<String> userCombo = new ComboBox<>(); ComboBox<String> userCombo = new ComboBox<>();
userCombo.setPlaceholder(currentUser); userCombo.setPlaceholder(currentUser);
userCombo.setItems("Profil anzeigen", "Einstellungen", "Abmelden"); userCombo.setItems("Profil anzeigen", "Abmelden");
userCombo.addValueChangeListener(event -> { userCombo.addValueChangeListener(event -> {
String value = event.getValue(); String value = event.getValue();
if (value != null) { if (value != null) {
switch (value) { switch (value) {
case "Profil anzeigen": case "Profil anzeigen":
break; break;
case "Einstellungen":
break;
case "Abmelden": case "Abmelden":
securityService.logout(); openLogoutConfirmDialog();
break; break;
} }
userCombo.clear(); // Reset selection userCombo.clear(); // Reset selection
@@ -450,6 +449,19 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
return footer; return footer;
} }
private void openLogoutConfirmDialog() {
var dialog = DialogStylingHelper.createConfirmationDialog(
getTranslation("logout.confirm.title"),
getTranslation("logout.confirm.message"),
"460px",
getTranslation("button.cancel"),
getTranslation("nav.logout"),
securityService::logout,
ButtonVariant.LUMO_PRIMARY,
ButtonVariant.LUMO_ERROR);
dialog.open();
}
private void register() { private void register() {
UI.getCurrent().navigate("register"); UI.getCurrent().navigate("register");
} }

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

@@ -9,6 +9,7 @@ import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface MessageRepository extends MongoRepository<Message, ObjectId> { public interface MessageRepository extends MongoRepository<Message, ObjectId> {
@@ -66,6 +67,9 @@ public interface MessageRepository extends MongoRepository<Message, ObjectId> {
List<Message> findByReceiverAndDeliveryStatusOrderByCreatedAtAsc(String receiver, List<Message> findByReceiverAndDeliveryStatusOrderByCreatedAtAsc(String receiver,
MessageDeliveryStatus deliveryStatus); MessageDeliveryStatus deliveryStatus);
Optional<Message> findFirstByReceiverAndOriginAndClientMessageId(String receiver, MessageOrigin origin,
String clientMessageId);
/** /**
* Find all undelivered messages (NOTSEND status) for a receiver * Find all undelivered messages (NOTSEND status) for a receiver
*/ */

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

Some files were not shown because too many files have changed in this diff Show More