Compare commits

...

5 Commits

Author SHA1 Message Date
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
89 changed files with 5349 additions and 1287 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 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/delivery_station.dart';
import 'models/job.dart';
import 'services/database_service.dart';
@@ -19,7 +21,7 @@ Color? deliveryStationCardBackgroundColor(
final isCompleted = station.tasks.every(
(task) => taskStatuses[task.id] ?? task.completed,
);
return isCompleted ? Colors.green[50] : null;
return isCompleted ? AppColors.successSoft : null;
}
class CargoItemsView extends StatefulWidget {
@@ -51,10 +53,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.job.jobNumber),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -93,7 +96,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Text(
widget.job.jobNumber.isNotEmpty
? widget.job.jobNumber
: widget.job.title,
: localizeKnownText(context, widget.job.title),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -136,7 +139,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icon(
Icons.arrow_downward,
size: 16,
color: Colors.blue[600],
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
@@ -160,11 +163,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icon(
Icons.local_shipping_outlined,
size: 24,
color: Colors.deepPurple[600],
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
'Lieferstationen (${_deliveryStations.length})',
l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
@@ -221,12 +224,12 @@ class _CargoItemsViewState extends State<CargoItemsView> {
),
const SizedBox(height: 16),
Text(
'Keine Lieferstationen',
AppLocalizations.of(context).noDeliveryStations,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Dieser Job enthält aktuell keine Lieferstationen.',
AppLocalizations.of(context).noDeliveryStationsMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
@@ -255,6 +258,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
station.company.isNotEmpty && station.company != title
? station.company
: null;
final l10n = AppLocalizations.of(context);
final addressLines =
<String>[
[
@@ -289,7 +293,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
stationTitle:
station.displayName.isNotEmpty
? station.displayName
: 'Station ${station.stationOrder + 1}',
: l10n.stationNumber(station.stationOrder + 1),
),
),
);
@@ -309,15 +313,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.deepPurple[100],
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Station ${station.stationOrder + 1}',
l10n.stationNumber(station.stationOrder + 1),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.deepPurple[700],
color: AppColors.primaryStrong,
),
),
),
@@ -327,7 +331,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.isNotEmpty ? title : 'Unbenannte Station',
title.isNotEmpty
? localizeKnownText(context, title)
: l10n.unnamedStation,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@@ -353,15 +359,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icons.location_on_outlined,
AppLocalizations.of(context).location,
addressLines.join('\n'),
Colors.blue,
AppColors.primary,
),
if (station.phone.trim().isNotEmpty) ...[
const SizedBox(height: 12),
_buildDetailItem(
Icons.phone_outlined,
'Telefon',
l10n.phone,
station.phone,
Colors.green,
AppColors.success,
),
],
if (station.deliveryDate.trim().isNotEmpty ||
@@ -374,7 +380,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
station.deliveryDate,
station.deliveryTime,
].where((part) => part.trim().isNotEmpty).join(' '),
Colors.orange,
AppColors.warning,
),
],
const SizedBox(height: 12),
@@ -382,7 +388,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Icons.task_alt,
AppLocalizations.of(context).tasks,
'${station.tasks.length}',
Colors.deepPurple,
AppColors.primaryStrong,
),
],
),

View File

@@ -4,7 +4,9 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'app_state.dart';
import 'models/chat.dart';
import 'models/chat_message.dart';
@@ -195,9 +197,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (sender == null || sender.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).noSenderMessage),
),
SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
);
}
return;
@@ -233,7 +233,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return;
}
await _chatService.saveOutgoingMessage(result);
_syncActiveChatFromService();
_messageController.clear();
@@ -250,19 +249,21 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_activeChat.title, style: const TextStyle(fontSize: 16)),
Text(
localizedChatTitle(context, _activeChat),
style: const TextStyle(fontSize: 16),
),
if (isJobChat && _activeChat.jobNumber != null)
Text(
'Job-Nr: ${_activeChat.jobNumber}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
color: AppColors.textMuted,
fontWeight: FontWeight.normal,
),
),
],
),
backgroundColor: Colors.deepPurple[100],
actions: [
IconButton(
icon: Icon(isJobChat ? Icons.work : Icons.support_agent),
@@ -280,7 +281,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
// Messages list
Expanded(
child: Container(
decoration: BoxDecoration(color: Colors.grey[50]),
decoration: const BoxDecoration(color: AppColors.surfaceMuted),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
@@ -324,7 +325,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
vertical: isImage ? 6 : 8,
),
decoration: BoxDecoration(
color: isOwn ? Colors.deepPurple[100] : Colors.white,
color: isOwn ? AppColors.primarySoft : AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
@@ -350,7 +351,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
children: [
Text(
_formatMessageTime(message.createdAt),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
if (isOwn) ...[
const SizedBox(width: 4),
@@ -361,10 +365,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
size: 14,
color:
message.pendingSync
? Colors.orange[700]
? AppColors.warning
: (message.read
? Colors.deepPurple[400]
: Colors.grey[600]),
? AppColors.primary
: AppColors.textMuted),
),
],
],
@@ -383,7 +387,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (!isImage) {
return Text(
message.content,
style: TextStyle(fontSize: 15, color: Colors.grey[800]),
style: const TextStyle(fontSize: 15, color: AppColors.textStrong),
);
}
@@ -454,8 +458,8 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey[300]!)),
color: AppColors.surface,
border: const Border(top: BorderSide(color: AppColors.border)),
),
child: SafeArea(
child: Row(
@@ -465,12 +469,12 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.attach_file,
color: Colors.black87,
color: AppColors.text,
size: 20,
),
),
@@ -479,7 +483,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
),
child: TextField(
@@ -507,7 +511,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.deepPurple,
color: AppColors.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.send, color: Colors.white, size: 20),
@@ -540,9 +544,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
if (sender == null || sender.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).noSenderMessage),
),
SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
);
}
return;
@@ -589,7 +591,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
return;
}
await _chatService.saveOutgoingMessage(result);
_syncActiveChatFromService();
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')}';
} else if (messageDate == today.subtract(const Duration(days: 1))) {
// 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 {
// 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')}';
@@ -659,21 +660,27 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(_activeChat.title),
title: Text(localizedChatTitle(context, _activeChat)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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),
if (isJobChat && _activeChat.jobNumber != null) ...[
Text('${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}'),
Text(
'${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}',
),
const SizedBox(height: 8),
],
Text('${AppLocalizations.of(context).messages}: ${_messages.length}'),
Text(
'${AppLocalizations.of(context).messages}: ${_messages.length}',
),
const SizedBox(height: 8),
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 'package:flutter/material.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/chat.dart';
import 'services/chat_service.dart';
import 'widgets/offline_banner.dart';
@@ -51,15 +53,9 @@ class _ChatsViewState extends State<ChatsView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).chats),
backgroundColor: Colors.deepPurple[100],
),
appBar: AppBar(title: Text(AppLocalizations.of(context).chats)),
body: Column(
children: [
const OfflineBanner(),
Expanded(child: _buildBody()),
],
children: [const OfflineBanner(), Expanded(child: _buildBody())],
),
);
}
@@ -70,15 +66,19 @@ class _ChatsViewState extends State<ChatsView> {
}
if (_chats.isEmpty) {
return const Center(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
const Icon(
Icons.chat_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text(
'Keine Chats verfügbar',
style: TextStyle(fontSize: 16, color: Colors.grey),
AppLocalizations.of(context).noChatsAvailable,
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
),
],
),
@@ -98,7 +98,9 @@ class _ChatsViewState extends State<ChatsView> {
final isJobChat = chat.type == ChatType.jobSpecific;
final hasMessages = chat.messages.isNotEmpty;
final previewText =
hasMessages ? chat.lastMessagePreview : 'Noch keine Nachrichten';
hasMessages
? chat.lastMessagePreview
: AppLocalizations.of(context).noMessagesYet;
final timeLabel = hasMessages ? _formatTime(chat.lastMessageTime) : '--';
final jobId = chat.jobId?.trim();
final jobNumber = chat.jobNumber?.trim();
@@ -108,10 +110,11 @@ class _ChatsViewState extends State<ChatsView> {
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isJobChat ? Colors.blue[100] : Colors.green[100],
backgroundColor:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
child: Icon(
isJobChat ? Icons.work : Icons.support_agent,
color: isJobChat ? Colors.blue[700] : Colors.green[700],
color: isJobChat ? AppColors.primaryStrong : AppColors.secondary,
),
),
title: Text(() {
@@ -123,15 +126,13 @@ class _ChatsViewState extends State<ChatsView> {
return 'Job $jobId';
}
}
return chat.type == ChatType.general
? 'Allgemeine Nachrichten'
: chat.title;
return localizedChatTitle(context, chat);
}(), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
subtitle: Text(
previewText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -139,16 +140,17 @@ class _ChatsViewState extends State<ChatsView> {
children: [
Text(
timeLabel,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isJobChat ? Colors.blue[50] : Colors.green[50],
color:
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isJobChat ? Colors.blue[200]! : Colors.green[200]!,
color: isJobChat ? AppColors.primary : AppColors.secondary,
),
),
child: Text(
@@ -156,7 +158,8 @@ class _ChatsViewState extends State<ChatsView> {
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isJobChat ? Colors.blue[700] : Colors.green[700],
color:
isJobChat ? AppColors.primaryStrong : AppColors.secondary,
),
),
),

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'services/websocket_service.dart';
import 'services/dart_mq.dart';
import 'services/chat_service.dart';
@@ -97,7 +99,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (isConnected && !_wasConnected) {
_showSnack(
AppLocalizations.of(context).connectionRestored,
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
);
if (_appState.isLoggedIn) {
_loadJobs();
@@ -114,7 +116,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (_appState.isLoggedIn && !_isLoggingOut) {
_showSnack(
AppLocalizations.of(context).connectionLost,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
}
@@ -145,7 +147,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
jobNumber != null
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
: AppLocalizations.of(context).jobRemoved;
_showSnack(message, backgroundColor: Colors.orange);
_showSnack(message, backgroundColor: AppColors.warning);
}
});
@@ -176,7 +178,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
jobNumber.isNotEmpty
? '${AppLocalizations.of(context).newJobReceived}: $jobNumber'
: AppLocalizations.of(context).newJobReceived;
_showSnack(message, backgroundColor: Colors.green);
_showSnack(message, backgroundColor: AppColors.success);
}
} catch (e) {
developer.log('Error handling job_created event: $e', name: 'JobsView');
@@ -203,7 +205,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
});
_showSnack(
AppLocalizations.of(context).jobsUpdated,
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
);
}
} finally {
@@ -559,7 +561,6 @@ class _JobsViewState extends State<JobsView> with RouteAware {
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(AppLocalizations.of(context).availableJobs),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
@@ -693,7 +694,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
foregroundColor: Colors.white,
),
child: Text(AppLocalizations.of(context).logout),
@@ -765,8 +766,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).refresh),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple[100],
foregroundColor: Colors.deepPurple[700],
backgroundColor: AppColors.primarySoft,
foregroundColor: AppColors.primaryStrong,
),
),
],
@@ -791,7 +792,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
} else {
_showSnack(
AppLocalizations.of(context).offline,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
}
@@ -907,7 +908,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) {
_showSnack(
AppLocalizations.of(context).jobDeleted,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
} catch (e, st) {
@@ -916,7 +917,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) {
_showSnack(
AppLocalizations.of(context).jobDeleteError,
backgroundColor: Colors.red,
backgroundColor: AppColors.danger,
);
}
} finally {
@@ -934,19 +935,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Color statusColor;
switch (job.statusColor) {
case 'green':
statusColor = Colors.green;
statusColor = AppColors.success;
break;
case 'blue':
statusColor = Colors.blue;
statusColor = AppColors.primary;
break;
case 'orange':
statusColor = Colors.orange;
statusColor = AppColors.warning;
break;
case 'red':
statusColor = Colors.red;
statusColor = AppColors.danger;
break;
default:
statusColor = Colors.grey;
statusColor = AppColors.textMuted;
}
// Determine card background color based on task completion
@@ -964,9 +965,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (totalTasks == 0 || completedTasks == 0) {
cardBg = null; // unchanged (default)
} else if (completedTasks > 0 && completedTasks < totalTasks) {
cardBg = Colors.yellow[50];
cardBg = AppColors.warningSoft;
} else if (completedTasks == totalTasks) {
cardBg = Colors.green[50];
cardBg = AppColors.successSoft;
}
// Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
@@ -996,7 +997,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
: job.deliveryCompany));
final deliveryAddress =
hasMultipleDeliveryStations
? '${job.deliveryStations.length} Stationen'
? AppLocalizations.of(
context,
).deliveryStationsCount(job.deliveryStations.length)
: (firstDeliveryStation?.formattedAddress.isNotEmpty == true
? firstDeliveryStation!.formattedAddress
: _joinNonEmpty([
@@ -1030,7 +1033,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
iconSize: 28,
padding: const EdgeInsets.all(10),
splashRadius: 24,
icon: const Icon(Icons.delete, color: Colors.red),
icon: const Icon(Icons.delete, color: AppColors.danger),
tooltip: AppLocalizations.of(context).deleteJob,
onPressed: () {
if (isDeleting) {
@@ -1116,7 +1119,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Text(
job.jobNumber.isNotEmpty
? job.jobNumber
: job.title,
: localizeKnownText(context, job.title),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@@ -1230,13 +1233,13 @@ class _JobsViewState extends State<JobsView> with RouteAware {
? 0
: completedTasks / totalTasks,
minHeight: 8,
backgroundColor: Colors.grey[200],
backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>(
completedTasks >= totalTasks
? Colors.green
? AppColors.success
: (completedTasks > 0
? Colors.amber
: Colors.deepPurpleAccent),
? AppColors.warning
: AppColors.primary),
),
),
),
@@ -1333,7 +1336,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Icon(
Icons.arrow_downward,
size: 16,
color: Colors.blue[600],
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
@@ -1372,7 +1375,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
tooltip: 'Route planen',
icon: const Icon(
Icons.route,
color: Colors.blueAccent,
color: AppColors.primary,
),
onPressed: () {
if (_routeActionInProgress) return;
@@ -1571,19 +1574,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(job.title),
title: Text(localizeKnownText(context, job.title)),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${AppLocalizations.of(context).status}: ${job.statusDisplayText}',
'${AppLocalizations.of(context).status}: ${_localizedStatusText(job.status)}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).priority}: ${job.priorityDisplayText}',
'${AppLocalizations.of(context).priority}: ${_localizedPriorityText(job.priority)}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
@@ -1612,7 +1615,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(job.description),
Text(localizeKnownText(context, job.description)),
],
// CargoItems section
if (job.cargoItems.isNotEmpty) ...[
@@ -1657,7 +1660,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (job.deliveryStations.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'${AppLocalizations.of(context).delivery} (${job.deliveryStations.length})',
AppLocalizations.of(
context,
).deliveryStationsCount(job.deliveryStations.length),
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
@@ -1674,7 +1679,11 @@ class _JobsViewState extends State<JobsView> with RouteAware {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Station ${station.stationOrder + 1}: ${station.displayName}',
localizedStationLabel(
context,
station.stationOrder + 1,
suffix: station.displayName,
),
style: const TextStyle(fontWeight: FontWeight.w500),
),
if (station.formattedAddress.isNotEmpty) ...[
@@ -1855,12 +1864,49 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
return suffix.isNotEmpty
? 'Station ${stationOrder + 1}: $suffix'
: 'Station ${stationOrder + 1}';
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
}
}
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';
/// 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
abstract class AppLocalizations {
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
String get languageName;
@@ -41,6 +54,7 @@ abstract class AppLocalizations {
String get refresh;
String get version;
String get unknown;
String get yesterday;
// ==================== NAVIGATION ====================
String get jobs;
@@ -58,7 +72,14 @@ abstract class AppLocalizations {
String get welcomeBack;
String get loginSubtitle;
String get email;
String get emailAddress;
String get emailAddressHint;
String get emailAddressRequired;
String get emailAddressInvalid;
String get password;
String get passwordHint;
String get passwordRequired;
String get passwordMinLength;
String get login;
String get loggingIn;
String get forgotPassword;
@@ -101,6 +122,15 @@ abstract class AppLocalizations {
String get deleteJob;
String get jobRemoved;
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 ====================
String get tasks;
@@ -182,6 +212,9 @@ abstract class AppLocalizations {
String get chatTypeGeneral;
String get jobNumber;
String get messages;
String get generalMessages;
String get noMessagesYet;
String get noChatsAvailable;
String get selectPhoto;
String get unreadMessages;
@@ -217,16 +250,20 @@ abstract class AppLocalizations {
// ==================== STATUS ====================
String get statusCreated;
String get statusPending;
String get statusAssigned;
String get statusInProgress;
String get statusCompleted;
String get statusCancelled;
String get statusFailed;
String get priorityLow;
String get priorityMedium;
String get priorityHigh;
String get priorityUrgent;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override

View File

@@ -47,12 +47,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get unknown => 'Unbekannt';
@override
String get yesterday => 'Gestern';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Jobs';
@override
String get availableJobs => 'Verfügbare Jobs';
String get availableJobs => 'Auftragsliste';
@override
String get chats => 'Chats';
@@ -88,9 +91,32 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
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
String get login => 'Anmelden';
@@ -101,7 +127,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get forgotPassword => 'Passwort vergessen?';
@override
String get forgotPasswordMessage => 'Passwort vergessen Funktion noch nicht implementiert';
String get forgotPasswordMessage =>
'Passwort vergessen Funktion noch nicht implementiert';
@override
String get loginSuccess => 'Erfolgreich abgemeldet';
@@ -110,10 +137,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get loginFailed => 'Anmeldung fehlgeschlagen';
@override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen (Timeout).';
String get connectionFailed =>
'Verbindung zum Server fehlgeschlagen (Timeout).';
@override
String get connectionTimeout => 'Verbindung zum Server fehlgeschlagen (Timeout).';
String get connectionTimeout =>
'Verbindung zum Server fehlgeschlagen (Timeout).';
@override
String get connecting => 'Verbindung zum Server wird hergestellt...';
@@ -212,6 +241,34 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Aufgaben';
@@ -229,7 +286,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get confirmationRequired => 'Bestätigung erforderlich';
@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
String get checklist => 'Checkliste';
@@ -241,7 +299,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get completeTask => 'Aufgabe abschließen';
@override
String get completeTaskConfirm => 'Möchten Sie diese Aufgabe als erledigt markieren?';
String get completeTaskConfirm =>
'Möchten Sie diese Aufgabe als erledigt markieren?';
@override
String get completeTaskNote => 'Notiz (optional)';
@@ -280,7 +339,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get signatureError => 'Fehler beim Speichern der Unterschrift';
@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
String get photoCapture => 'Fotos aufnehmen';
@@ -371,10 +431,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get cameraNotAvailable => 'Kamera nicht verfügbar';
@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
String get cameraNotSupportedOnPlatform => 'Nicht unterstützt auf dieser Plattform';
String get cameraNotSupportedOnPlatform =>
'Nicht unterstützt auf dieser Plattform';
@override
String get maxPhotosReached => 'Maximum erreicht';
@@ -389,13 +451,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get cameraInitializing => 'Kamera wird initialisiert...';
@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
String get addPhotos => 'Fotos hinzufügen';
@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
String get photoOf => 'von';
@@ -411,13 +475,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get noSender => 'Kein Absender verfügbar';
@override
String get noSenderMessage => 'Kein Absender verfügbar. Bitte erneut anmelden.';
String get noSenderMessage =>
'Kein Absender verfügbar. Bitte erneut anmelden.';
@override
String get noRecipient => 'Kein Empfänger konfiguriert';
@override
String get noRecipientMessage => 'Kein Empfänger für diesen Chat konfiguriert.';
String get noRecipientMessage =>
'Kein Empfänger für diesen Chat konfiguriert.';
@override
String get messageSendError => 'Nachricht konnte nicht gesendet werden.';
@@ -443,6 +509,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get selectPhoto => 'Foto auswählen';
@@ -482,7 +557,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get noCargoItems => 'Keine Frachtgüter';
@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
String get article => 'Artikel';
@@ -528,6 +604,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get statusCreated => 'Erstellt';
@override
String get statusPending => 'Wartend';
@override
String get statusAssigned => 'Zugewiesen';
@@ -537,6 +616,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get statusCompleted => 'Abgeschlossen';
@override
String get statusCancelled => 'Abgebrochen';
@override
String get statusFailed => 'Fehlgeschlagen';
@override
String get priorityLow => 'Niedrig';

View File

@@ -47,12 +47,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get unknown => 'Unknown';
@override
String get yesterday => 'Yesterday';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Jobs';
@override
String get availableJobs => 'Available Jobs';
String get availableJobs => 'Order List';
@override
String get chats => 'Chats';
@@ -88,9 +91,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
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
String get login => 'Login';
@@ -101,7 +125,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get forgotPassword => 'Forgot Password?';
@override
String get forgotPasswordMessage => 'Forgot password feature not yet implemented';
String get forgotPasswordMessage =>
'Forgot password feature not yet implemented';
@override
String get loginSuccess => 'Successfully logged out';
@@ -212,6 +237,34 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Tasks';
@@ -229,7 +282,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get confirmationRequired => 'Confirmation Required';
@override
String get confirmationDescription => 'Click the button to complete the task.';
String get confirmationDescription =>
'Click the button to complete the task.';
@override
String get checklist => 'Checklist';
@@ -241,7 +295,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get completeTask => 'Complete Task';
@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
String get completeTaskNote => 'Note (optional)';
@@ -280,7 +335,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get signatureError => 'Error saving signature';
@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
String get photoCapture => 'Take Photos';
@@ -371,7 +427,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get cameraNotAvailable => 'Camera not available';
@override
String get cameraNotSupportedMessage => 'The camera is not supported on this platform.';
String get cameraNotSupportedMessage =>
'The camera is not supported on this platform.';
@override
String get cameraNotSupportedOnPlatform => 'Not supported on this platform';
@@ -395,7 +452,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get addPhotos => 'Add photos';
@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
String get photoOf => 'of';
@@ -443,6 +501,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get selectPhoto => 'Select Photo';
@@ -528,6 +595,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get statusCreated => 'Created';
@override
String get statusPending => 'Pending';
@override
String get statusAssigned => 'Assigned';
@@ -537,6 +607,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get statusCompleted => 'Completed';
@override
String get statusCancelled => 'Cancelled';
@override
String get statusFailed => 'Failed';
@override
String get priorityLow => 'Low';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get unknown => 'Desconocido';
@override
String get yesterday => 'Ayer';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Trabajos';
@override
String get availableJobs => 'Trabajos Disponibles';
String get availableJobs => 'Lista de pedidos';
@override
String get chats => 'Chats';
@override
@@ -64,8 +67,33 @@ class AppLocalizationsEs extends AppLocalizations {
String get loginSubtitle => 'Inicie sesión en su cuenta';
@override
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
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
String get login => 'Iniciar sesión';
@override
@@ -73,15 +101,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get forgotPassword => '¿Olvidó su contraseña?';
@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
String get loginSuccess => 'Sesión cerrada correctamente';
@override
String get loginFailed => 'Error al iniciar sesión';
@override
String get connectionFailed => 'Error de conexión al servidor (Tiempo agotado).';
String get connectionFailed =>
'Error de conexión al servidor (Tiempo agotado).';
@override
String get connectionTimeout => 'Error de conexión al servidor (Tiempo agotado).';
String get connectionTimeout =>
'Error de conexión al servidor (Tiempo agotado).';
@override
String get connecting => 'Conectando al servidor...';
@override
@@ -149,6 +180,34 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Tareas';
@@ -161,7 +220,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get confirmationRequired => 'Confirmación requerida';
@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
String get checklist => 'Lista de verificación';
@override
@@ -195,7 +255,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get signatureError => 'Error al guardar la firma';
@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
String get photoCapture => 'Tomar fotos';
@override
@@ -243,11 +304,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get enterBarcode => 'Ingresar código de barras';
@override
String get barcodeEnterDescription => 'Por favor ingrese los códigos de barras:';
String get barcodeEnterDescription =>
'Por favor ingrese los códigos de barras:';
@override
String barcodeNumberRequired(int number) => 'Código de barras $number (requerido)';
String barcodeNumberRequired(int number) =>
'Código de barras $number (requerido)';
@override
String barcodeNumberOptional(int number) => 'Código de barras $number (opcional)';
String barcodeNumberOptional(int number) =>
'Código de barras $number (opcional)';
@override
String get barcodeError => 'Error al escanear el código de barras';
@override
@@ -257,7 +321,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get cameraNotAvailable => 'Cámara no disponible';
@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
String get cameraNotSupportedOnPlatform => 'No soportado en esta plataforma';
@override
@@ -269,11 +334,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get cameraInitializing => 'Inicializando cámara...';
@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
String get addPhotos => 'Añadir fotos';
@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
String get photoOf => 'de';
@@ -285,11 +352,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noSender => 'No hay remitente disponible';
@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
String get noRecipient => 'No hay destinatario configurado';
@override
String get noRecipientMessage => 'No hay destinatario configurado para este chat.';
String get noRecipientMessage =>
'No hay destinatario configurado para este chat.';
@override
String get messageSendError => 'El mensaje no pudo ser enviado.';
@override
@@ -306,6 +375,15 @@ class AppLocalizationsEs extends AppLocalizations {
String get jobNumber => 'Número de trabajo';
@override
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
String get selectPhoto => 'Seleccionar foto';
@override
@@ -327,7 +405,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noCargoItems => 'Sin artículos de carga';
@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
String get article => 'Artículo';
@@ -369,12 +448,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get statusCreated => 'Creado';
@override
String get statusPending => 'Pendiente';
@override
String get statusAssigned => 'Asignado';
@override
String get statusInProgress => 'En progreso';
@override
String get statusCompleted => 'Completado';
@override
String get statusCancelled => 'Cancelado';
@override
String get statusFailed => 'Fallido';
@override
String get priorityLow => 'Baja';
@override
String get priorityMedium => 'Media';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get unknown => 'Tundmatu';
@override
String get yesterday => 'Eile';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Tööd';
@override
String get availableJobs => 'Saadaolevad tööd';
String get availableJobs => 'Tellimuste loend';
@override
String get chats => 'Vestlused';
@override
@@ -64,8 +67,29 @@ class AppLocalizationsEt extends AppLocalizations {
String get loginSubtitle => 'Logige oma kontosse sisse';
@override
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
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
String get login => 'Logi sisse';
@override
@@ -73,15 +97,18 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get forgotPassword => 'Unustasid parooli?';
@override
String get forgotPasswordMessage => 'Unustatud parooli funktsioon pole veel rakendatud';
String get forgotPasswordMessage =>
'Unustatud parooli funktsioon pole veel rakendatud';
@override
String get loginSuccess => 'Edukalt välja logitud';
@override
String get loginFailed => 'Sisselogimine ebaõnnestus';
@override
String get connectionFailed => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
String get connectionFailed =>
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
@override
String get connectionTimeout => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
String get connectionTimeout =>
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
@override
String get connecting => 'Serveriga ühendamine...';
@override
@@ -149,6 +176,34 @@ class AppLocalizationsEt extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Ülesanded';
@@ -161,7 +216,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get confirmationRequired => 'Vajalik kinnitus';
@override
String get confirmationDescription => 'Ülesande lõpuleviimiseks klõpsake nuppu.';
String get confirmationDescription =>
'Ülesande lõpuleviimiseks klõpsake nuppu.';
@override
String get checklist => 'Kontrollnimekiri';
@override
@@ -169,7 +225,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get completeTask => 'Lõpeta ülesanne';
@override
String get completeTaskConfirm => 'Kas soovite selle ülesande lõpetatuks märgistada?';
String get completeTaskConfirm =>
'Kas soovite selle ülesande lõpetatuks märgistada?';
@override
String get completeTaskNote => 'Märkus (valikuline)';
@override
@@ -195,7 +252,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get signatureError => 'Viga allkirja salvestamisel';
@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
String get photoCapture => 'Tee pilte';
@override
@@ -257,7 +315,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get cameraNotAvailable => 'Kaamera pole saadaval';
@override
String get cameraNotSupportedMessage => 'Kaamerat ei toetata sellel platvormil.';
String get cameraNotSupportedMessage =>
'Kaamerat ei toetata sellel platvormil.';
@override
String get cameraNotSupportedOnPlatform => 'Sellel platvormil ei toetata';
@override
@@ -273,7 +332,8 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get addPhotos => 'Lisa fotod';
@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
String get photoOf => '/';
@@ -285,11 +345,13 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get noSender => 'Saatja pole saadaval';
@override
String get noSenderMessage => 'Saatja pole saadaval. Palun logige uuesti sisse.';
String get noSenderMessage =>
'Saatja pole saadaval. Palun logige uuesti sisse.';
@override
String get noRecipient => 'Vastuvõtjat pole konfigureeritud';
@override
String get noRecipientMessage => 'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.';
String get noRecipientMessage =>
'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.';
@override
String get messageSendError => 'Sõnumi saatmine ebaõnnestus.';
@override
@@ -306,6 +368,15 @@ class AppLocalizationsEt extends AppLocalizations {
String get jobNumber => 'Töö number';
@override
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
String get selectPhoto => 'Vali foto';
@override
@@ -369,12 +440,18 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get statusCreated => 'Loodud';
@override
String get statusPending => 'Ootel';
@override
String get statusAssigned => 'Määratud';
@override
String get statusInProgress => 'Töös';
@override
String get statusCompleted => 'Lõpetatud';
@override
String get statusCancelled => 'Tühistatud';
@override
String get statusFailed => 'Ebaõnnestunud';
@override
String get priorityLow => 'Madal';
@override
String get priorityMedium => 'Keskmine';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get unknown => 'Inconnu';
@override
String get yesterday => 'Hier';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Emplois';
@override
String get availableJobs => 'Emplois Disponibles';
String get availableJobs => 'Liste des commandes';
@override
String get chats => 'Discussions';
@override
@@ -64,8 +67,30 @@ class AppLocalizationsFr extends AppLocalizations {
String get loginSubtitle => 'Connectez-vous à votre compte';
@override
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
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
String get login => 'Connexion';
@override
@@ -73,15 +98,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get forgotPassword => 'Mot de passe oublié?';
@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
String get loginSuccess => 'Déconnexion réussie';
@override
String get loginFailed => 'Échec de la connexion';
@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
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
String get connecting => 'Connexion au serveur...';
@override
@@ -137,7 +165,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get jobsUpdated => 'Emplois actualisés';
@override
String get connectionRestored => 'Connexion restaurée. Chargement des emplois...';
String get connectionRestored =>
'Connexion restaurée. Chargement des emplois...';
@override
String get connectionLost => 'Connexion perdue. Hors ligne.';
@override
@@ -149,6 +178,34 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Tâches';
@@ -161,7 +218,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get confirmationRequired => 'Confirmation requise';
@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
String get checklist => 'Liste de contrôle';
@override
@@ -169,7 +227,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get completeTask => 'Terminer la tâche';
@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
String get completeTaskNote => 'Note (optionnelle)';
@override
@@ -193,9 +252,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get clear => 'Effacer';
@override
String get signatureError => 'Erreur lors de l\'enregistrement de la signature';
String get signatureError =>
'Erreur lors de l\'enregistrement de la signature';
@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
String get photoCapture => 'Prendre des photos';
@override
@@ -221,7 +282,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get deletePhoto => 'Supprimer la photo';
@override
String get deletePhotoConfirm => 'Voulez-vous vraiment supprimer cette photo?';
String get deletePhotoConfirm =>
'Voulez-vous vraiment supprimer cette photo?';
@override
String get barcode => 'Code-barres';
@override
@@ -257,9 +319,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cameraNotAvailable => 'Caméra non disponible';
@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
String get cameraNotSupportedOnPlatform => 'Non supporté sur cette plateforme';
String get cameraNotSupportedOnPlatform =>
'Non supporté sur cette plateforme';
@override
String get maxPhotosReached => 'Maximum atteint';
@override
@@ -269,11 +333,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cameraInitializing => 'Initialisation de la caméra...';
@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
String get addPhotos => 'Ajouter des photos';
@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
String get photoOf => 'sur';
@@ -285,11 +351,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get noSender => 'Aucun expéditeur disponible';
@override
String get noSenderMessage => 'Aucun expéditeur disponible. Veuillez vous reconnecter.';
String get noSenderMessage =>
'Aucun expéditeur disponible. Veuillez vous reconnecter.';
@override
String get noRecipient => 'Aucun destinataire configuré';
@override
String get noRecipientMessage => 'Aucun destinataire configuré pour cette discussion.';
String get noRecipientMessage =>
'Aucun destinataire configuré pour cette discussion.';
@override
String get messageSendError => 'Le message n\'a pas pu être envoyé.';
@override
@@ -306,6 +374,15 @@ class AppLocalizationsFr extends AppLocalizations {
String get jobNumber => 'Numéro d\'emploi';
@override
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
String get selectPhoto => 'Sélectionner une photo';
@override
@@ -327,7 +404,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get noCargoItems => 'Aucun article de cargaison';
@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
String get article => 'Article';
@@ -369,12 +447,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get statusCreated => 'Créé';
@override
String get statusPending => 'En attente';
@override
String get statusAssigned => 'Assigné';
@override
String get statusInProgress => 'En cours';
@override
String get statusCompleted => 'Terminé';
@override
String get statusCancelled => 'Annulé';
@override
String get statusFailed => 'Échoué';
@override
String get priorityLow => 'Basse';
@override
String get priorityMedium => 'Moyenne';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get unknown => 'Nežinoma';
@override
String get yesterday => 'Vakar';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Darbai';
@override
String get availableJobs => 'Galimi darbai';
String get availableJobs => 'Užsakymų sąrašas';
@override
String get chats => 'Pokalbiai';
@override
@@ -64,8 +67,30 @@ class AppLocalizationsLt extends AppLocalizations {
String get loginSubtitle => 'Prisijunkite prie savo paskyros';
@override
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
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
String get login => 'Prisijungti';
@override
@@ -73,15 +98,18 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get forgotPassword => 'Pamiršote slaptažodį?';
@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
String get loginSuccess => 'Sėkmingai atsijungta';
@override
String get loginFailed => 'Prisijungimas nepavyko';
@override
String get connectionFailed => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
String get connectionFailed =>
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
@override
String get connectionTimeout => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
String get connectionTimeout =>
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
@override
String get connecting => 'Jungiamasi prie serverio...';
@override
@@ -149,6 +177,34 @@ class AppLocalizationsLt extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Užduotys';
@@ -161,7 +217,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get confirmationRequired => 'Reikalingas patvirtinimas';
@override
String get confirmationDescription => 'Spustelėkite mygtuką, kad atliktumėte užduotį.';
String get confirmationDescription =>
'Spustelėkite mygtuką, kad atliktumėte užduotį.';
@override
String get checklist => 'Patikros sąrašas';
@override
@@ -169,7 +226,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get completeTask => 'Baigti užduotį';
@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
String get completeTaskNote => 'Pastaba (neprivaloma)';
@override
@@ -195,7 +253,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get signatureError => 'Klaida išsaugant parašą';
@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
String get photoCapture => 'Daryti nuotraukas';
@override
@@ -245,9 +304,11 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get barcodeEnterDescription => 'Prašome įvesti brūkšninius kodus:';
@override
String barcodeNumberRequired(int number) => 'Brūkšninis kodas $number (būtinas)';
String barcodeNumberRequired(int number) =>
'Brūkšninis kodas $number (būtinas)';
@override
String barcodeNumberOptional(int number) => 'Brūkšninis kodas $number (neprivalomas)';
String barcodeNumberOptional(int number) =>
'Brūkšninis kodas $number (neprivalomas)';
@override
String get barcodeError => 'Klaida skaitant brūkšninį kodą';
@override
@@ -257,7 +318,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get cameraNotAvailable => 'Kamera nepasiekiama';
@override
String get cameraNotSupportedMessage => 'Šioje platformoje kamera nepalaikoma.';
String get cameraNotSupportedMessage =>
'Šioje platformoje kamera nepalaikoma.';
@override
String get cameraNotSupportedOnPlatform => 'Nepalaikoma šioje platformoje';
@override
@@ -273,7 +335,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get addPhotos => 'Pridėti nuotraukas';
@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
String get photoOf => '';
@@ -285,7 +348,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get noSender => 'Siuntėjas nepasiekiamas';
@override
String get noSenderMessage => 'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.';
String get noSenderMessage =>
'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.';
@override
String get noRecipient => 'Gavėjas nesukonfigūruotas';
@override
@@ -306,6 +370,15 @@ class AppLocalizationsLt extends AppLocalizations {
String get jobNumber => 'Darbo numeris';
@override
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
String get selectPhoto => 'Pasirinkti nuotrauką';
@override
@@ -327,7 +400,8 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get noCargoItems => 'Nėra krovinių pozicijų';
@override
String get noCargoItemsMessage => 'Šiam darbui nėra apibrėžtų krovinių pozicijų.';
String get noCargoItemsMessage =>
'Šiam darbui nėra apibrėžtų krovinių pozicijų.';
@override
String get article => 'Pozicija';
@@ -369,12 +443,18 @@ class AppLocalizationsLt extends AppLocalizations {
@override
String get statusCreated => 'Sukurta';
@override
String get statusPending => 'Laukiama';
@override
String get statusAssigned => 'Priskirta';
@override
String get statusInProgress => 'Vykdoma';
@override
String get statusCompleted => 'Baigta';
@override
String get statusCancelled => 'Atšaukta';
@override
String get statusFailed => 'Nepavyko';
@override
String get priorityLow => 'Žemas';
@override
String get priorityMedium => 'Vidutinis';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get unknown => 'Nezināms';
@override
String get yesterday => 'Vakar';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Darbi';
@override
String get availableJobs => 'Pieejamie darbi';
String get availableJobs => 'Pasūtījumu saraksts';
@override
String get chats => 'Tērzēšanas';
@override
@@ -64,8 +67,29 @@ class AppLocalizationsLv extends AppLocalizations {
String get loginSubtitle => 'Pierakstieties savā kontā';
@override
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
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
String get login => 'Pierakstīties';
@override
@@ -73,7 +97,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get forgotPassword => 'Aizmirsāt paroli?';
@override
String get forgotPasswordMessage => 'Aizmirstās paroles funkcija vēl nav ieviesta';
String get forgotPasswordMessage =>
'Aizmirstās paroles funkcija vēl nav ieviesta';
@override
String get loginSuccess => 'Veiksmīgi izrakstījās';
@override
@@ -149,6 +174,34 @@ class AppLocalizationsLv extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Uzdevumi';
@@ -161,7 +214,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get confirmationRequired => 'Nepieciešams apstiprinājums';
@override
String get confirmationDescription => 'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.';
String get confirmationDescription =>
'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.';
@override
String get checklist => 'Pārbaudes saraksts';
@override
@@ -169,7 +223,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get completeTask => 'Pabeigt uzdevumu';
@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
String get completeTaskNote => 'Piezīme (neobligāta)';
@override
@@ -195,7 +250,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get signatureError => 'Kļūda saglabājot parakstu';
@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
String get photoCapture => 'Uzņemt fotogrāfijas';
@override
@@ -257,7 +313,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get cameraNotAvailable => 'Kamera nav pieejama';
@override
String get cameraNotSupportedMessage => 'Šajā platformā kamera netiek atbalstīta.';
String get cameraNotSupportedMessage =>
'Šajā platformā kamera netiek atbalstīta.';
@override
String get cameraNotSupportedOnPlatform => 'Šajā platformā netiek atbalstīts';
@override
@@ -269,11 +326,13 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get cameraInitializing => 'Kamera tiek inicializēta...';
@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
String get addPhotos => 'Pievienot fotogrāfijas';
@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
String get photoOf => 'no';
@@ -285,7 +344,8 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get noSender => 'Sūtītājs nav pieejams';
@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
String get noRecipient => 'Saņēmējs nav konfigurēts';
@override
@@ -306,6 +366,15 @@ class AppLocalizationsLv extends AppLocalizations {
String get jobNumber => 'Darba numurs';
@override
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
String get selectPhoto => 'Izvēlēties fotogrāfiju';
@override
@@ -369,12 +438,18 @@ class AppLocalizationsLv extends AppLocalizations {
@override
String get statusCreated => 'Izveidots';
@override
String get statusPending => 'Gaida';
@override
String get statusAssigned => 'Piešķirts';
@override
String get statusInProgress => 'Procesā';
@override
String get statusCompleted => 'Pabeigts';
@override
String get statusCancelled => 'Atcelts';
@override
String get statusFailed => 'Neizdevās';
@override
String get priorityLow => 'Zema';
@override
String get priorityMedium => 'Vidēja';

View File

@@ -35,11 +35,14 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get unknown => 'Nieznany';
@override
String get yesterday => 'Wczoraj';
// ==================== NAVIGATION ====================
@override
String get jobs => 'Zadania';
@override
String get availableJobs => 'Dostępne Zadania';
String get availableJobs => 'Lista zleceń';
@override
String get chats => 'Czaty';
@override
@@ -64,8 +67,29 @@ class AppLocalizationsPl extends AppLocalizations {
String get loginSubtitle => 'Zaloguj się do swojego konta';
@override
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
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
String get login => 'Zaloguj';
@override
@@ -73,7 +97,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get forgotPassword => 'Zapomniałeś hasła?';
@override
String get forgotPasswordMessage => 'Funkcja zapomnianego hasła jeszcze nie zaimplementowana';
String get forgotPasswordMessage =>
'Funkcja zapomnianego hasła jeszcze nie zaimplementowana';
@override
String get loginSuccess => 'Pomyślnie wylogowano';
@override
@@ -93,7 +118,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get noJobsAssigned => 'Brak przypisanych zadań';
@override
String get noJobsMessage => 'Twoje przypisane zadania będą wyświetlane tutaj.';
String get noJobsMessage =>
'Twoje przypisane zadania będą wyświetlane tutaj.';
@override
String get pullToRefresh => 'Przeciągnij w dół, aby odświeżyć';
@override
@@ -149,6 +175,34 @@ class AppLocalizationsPl extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Zadania';
@@ -161,7 +215,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get confirmationRequired => 'Wymagane potwierdzenie';
@override
String get confirmationDescription => 'Kliknij przycisk, aby ukończyć zadanie.';
String get confirmationDescription =>
'Kliknij przycisk, aby ukończyć zadanie.';
@override
String get checklist => 'Lista kontrolna';
@override
@@ -169,7 +224,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get completeTask => 'Ukończ zadanie';
@override
String get completeTaskConfirm => 'Czy chcesz oznaczyć to zadanie jako ukończone?';
String get completeTaskConfirm =>
'Czy chcesz oznaczyć to zadanie jako ukończone?';
@override
String get completeTaskNote => 'Notatka (opcjonalnie)';
@override
@@ -195,7 +251,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get signatureError => 'Błąd podczas zapisywania podpisu';
@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
String get photoCapture => 'Zrób zdjęcia';
@override
@@ -247,7 +304,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String barcodeNumberRequired(int number) => 'Kod kreskowy $number (wymagany)';
@override
String barcodeNumberOptional(int number) => 'Kod kreskowy $number (opcjonalny)';
String barcodeNumberOptional(int number) =>
'Kod kreskowy $number (opcjonalny)';
@override
String get barcodeError => 'Błąd podczas skanowania kodu kreskowego';
@override
@@ -257,7 +315,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get cameraNotAvailable => 'Kamera niedostępna';
@override
String get cameraNotSupportedMessage => 'Kamera nie jest obsługiwana na tej platformie.';
String get cameraNotSupportedMessage =>
'Kamera nie jest obsługiwana na tej platformie.';
@override
String get cameraNotSupportedOnPlatform => 'Nieobsługiwane na tej platformie';
@override
@@ -273,7 +332,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get addPhotos => 'Dodaj zdjęcia';
@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
String get photoOf => 'z';
@@ -285,11 +345,13 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get noSender => 'Brak dostępnego nadawcy';
@override
String get noSenderMessage => 'Brak dostępnego nadawcy. Proszę zalogować się ponownie.';
String get noSenderMessage =>
'Brak dostępnego nadawcy. Proszę zalogować się ponownie.';
@override
String get noRecipient => 'Brak skonfigurowanego odbiorcy';
@override
String get noRecipientMessage => 'Brak skonfigurowanego odbiorcy dla tego czatu.';
String get noRecipientMessage =>
'Brak skonfigurowanego odbiorcy dla tego czatu.';
@override
String get messageSendError => 'Wiadomość nie mogła zostać wysłana.';
@override
@@ -306,6 +368,15 @@ class AppLocalizationsPl extends AppLocalizations {
String get jobNumber => 'Numer zadania';
@override
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
String get selectPhoto => 'Wybierz zdjęcie';
@override
@@ -327,7 +398,8 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get noCargoItems => 'Brak pozycji ładunku';
@override
String get noCargoItemsMessage => 'Brak pozycji ładunku zdefiniowanych dla tego zadania.';
String get noCargoItemsMessage =>
'Brak pozycji ładunku zdefiniowanych dla tego zadania.';
@override
String get article => 'Pozycja';
@@ -369,12 +441,18 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get statusCreated => 'Utworzono';
@override
String get statusPending => 'Oczekujące';
@override
String get statusAssigned => 'Przypisano';
@override
String get statusInProgress => 'W trakcie';
@override
String get statusCompleted => 'Ukończono';
@override
String get statusCancelled => 'Anulowano';
@override
String get statusFailed => 'Nieudane';
@override
String get priorityLow => 'Niski';
@override
String get priorityMedium => 'Średni';

View File

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

View File

@@ -35,11 +35,14 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get unknown => 'Bilinmiyor';
@override
String get yesterday => 'Dün';
// ==================== NAVIGATION ====================
@override
String get jobs => 'İşler';
@override
String get availableJobs => 'Mevcut İşler';
String get availableJobs => 'Sipariş Listesi';
@override
String get chats => 'Sohbetler';
@override
@@ -64,8 +67,29 @@ class AppLocalizationsTr extends AppLocalizations {
String get loginSubtitle => 'Hesabınıza giriş yapın';
@override
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
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
String get login => 'Giriş';
@override
@@ -73,7 +97,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get forgotPassword => 'Şifrenizi mi unuttunuz?';
@override
String get forgotPasswordMessage => 'Şifremi unuttum özelliği henüz uygulanmadı';
String get forgotPasswordMessage =>
'Şifremi unuttum özelliği henüz uygulanmadı';
@override
String get loginSuccess => 'Başarıyla çıkış yapıldı';
@override
@@ -137,7 +162,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get jobsUpdated => 'İşler güncellendi';
@override
String get connectionRestored => 'Bağlantı geri yüklendi. İşler yükleniyor...';
String get connectionRestored =>
'Bağlantı geri yüklendi. İşler yükleniyor...';
@override
String get connectionLost => 'Bağlantı kesildi. Çevrimdışı.';
@override
@@ -149,6 +175,34 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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 ====================
@override
String get tasks => 'Görevler';
@@ -161,7 +215,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get confirmationRequired => 'Onay gerekli';
@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
String get checklist => 'Kontrol listesi';
@override
@@ -169,7 +224,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get completeTask => 'Görevi tamamla';
@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
String get completeTaskNote => 'Not (isteğe bağlı)';
@override
@@ -195,7 +251,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get signatureError => 'İmza kaydedilirken hata oluştu';
@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
String get photoCapture => 'Fotoğraf çek';
@override
@@ -221,7 +278,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get deletePhoto => 'Fotoğrafı sil';
@override
String get deletePhotoConfirm => 'Bu fotoğrafı gerçekten silmek istiyor musunuz?';
String get deletePhotoConfirm =>
'Bu fotoğrafı gerçekten silmek istiyor musunuz?';
@override
String get barcode => 'Barkod';
@override
@@ -257,7 +315,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get cameraNotAvailable => 'Kamera kullanılamıyor';
@override
String get cameraNotSupportedMessage => 'Bu platformda kamera desteklenmiyor.';
String get cameraNotSupportedMessage =>
'Bu platformda kamera desteklenmiyor.';
@override
String get cameraNotSupportedOnPlatform => 'Bu platformda desteklenmiyor';
@override
@@ -273,7 +332,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get addPhotos => 'Fotoğraf ekle';
@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
String get photoOf => '/';
@@ -285,7 +345,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get noSender => 'Gönderen mevcut değil';
@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
String get noRecipient => 'Alıcı yapılandırılmamış';
@override
@@ -306,6 +367,15 @@ class AppLocalizationsTr extends AppLocalizations {
String get jobNumber => 'İş numarası';
@override
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
String get selectPhoto => 'Fotoğraf seç';
@override
@@ -369,12 +439,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get statusCreated => 'Oluşturuldu';
@override
String get statusPending => 'Beklemede';
@override
String get statusAssigned => 'Atandı';
@override
String get statusInProgress => 'Devam ediyor';
@override
String get statusCompleted => 'Tamamlandı';
@override
String get statusCancelled => 'İptal edildi';
@override
String get statusFailed => 'Başarısız';
@override
String get priorityLow => 'Düşük';
@override
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/database_service.dart';
import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
class LoginView extends StatefulWidget {
@@ -34,6 +35,8 @@ class _LoginViewState extends State<LoginView> {
bool _logoutNoticeShown = false;
bool _hasNavigatedToJobs = false;
String _appVersion = '';
String? _pendingLoginEmail;
String? _pendingLoginPassword;
@override
void initState() {
@@ -52,7 +55,13 @@ class _LoginViewState extends State<LoginView> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_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
// Note: Don't reset _isLoggingIn here - the login flow in _handleLogin
// manages button state through its own error/success handling.
_connectionStatusSubscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) {
if (mounted) {
setState(() {});
}
});
_connectionStatusSubscription = DartMQ().subscribe<bool>(
MQTopics.connectionStatus,
(isConnected) {
if (mounted) {
setState(() {});
}
},
);
// Listen to authentication responses via dart_mq
_authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(MQTopics.authResponse, (response) {
final responseTime = DateTime.now();
developer.log('=== AUTHENTICATION RESPONSE RECEIVED ===', name: 'LoginView');
developer.log('Timestamp: ${responseTime.toIso8601String()}', name: 'LoginView');
developer.log('Response data: $response', name: 'LoginView');
_authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(
MQTopics.authResponse,
(response) {
final responseTime = DateTime.now();
developer.log(
'=== AUTHENTICATION RESPONSE RECEIVED ===',
name: 'LoginView',
);
developer.log(
'Timestamp: ${responseTime.toIso8601String()}',
name: 'LoginView',
);
developer.log('Response data: $response', name: 'LoginView');
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_isLoggingIn = false;
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleAuthResponse(response);
});
} else {
developer.log(
'Widget not mounted - skipping UI updates for auth response',
name: 'LoginView',
);
}
if (response['success'] == true) {
// Prevent duplicate navigation from multiple auth responses
if (_hasNavigatedToJobs) {
developer.log('Already navigated to jobs view - ignoring duplicate auth response', name: 'LoginView');
return;
}
_hasNavigatedToJobs = true;
developer.log(
'Authentication response processing completed',
name: 'LoginView',
);
},
);
}
final message = response['message'] ?? 'Anmeldung erfolgreich';
final email = _emailController.text.trim();
final password = _passwordController.text;
void _clearPendingLoginCredentials() {
_pendingLoginEmail = null;
_pendingLoginPassword = null;
}
developer.log('=== LOGIN SUCCESS ===', name: 'LoginView');
developer.log('Email: $email', name: 'LoginView');
developer.log('Message: $message', name: 'LoginView');
Future<void> _handleAuthResponse(Map<String, dynamic> response) async {
if (!mounted) return;
// Store email as login identifier
_appState.setLoggedInEmail(email);
final pendingEmail = _pendingLoginEmail?.trim();
final pendingPassword = _pendingLoginPassword;
final hadPendingLogin =
pendingEmail != null &&
pendingEmail.isNotEmpty &&
pendingPassword != null &&
pendingPassword.isNotEmpty;
_clearPendingLoginCredentials();
// Save credentials for auto-login on app restart
DatabaseService().saveCredentials(email, password);
setState(() {
_isLoggingIn = false;
});
// 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');
} else {
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');
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');
if (response['success'] == true) {
// Prevent duplicate navigation from multiple auth responses
if (_hasNavigatedToJobs) {
developer.log(
'Already navigated to jobs view - ignoring duplicate auth response',
name: 'LoginView',
);
return;
}
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 {
@@ -158,34 +241,62 @@ class _LoginViewState extends State<LoginView> {
developer.log('=== LOGIN ATTEMPT STARTED ===', 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()) {
developer.log('Login validation failed - form is invalid', name: 'LoginView');
developer.log(
'Login validation failed - form is invalid',
name: 'LoginView',
);
return;
}
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;
}
String email = _emailController.text.trim();
String password = _passwordController.text;
_pendingLoginEmail = email;
_pendingLoginPassword = password;
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
final scaffoldMessenger = ScaffoldMessenger.of(context);
final localizations = AppLocalizations.of(context);
if (!_stompService.isConnected) {
developer.log('Not connected to STOMP server - establishing connection first', name: 'LoginView');
developer.log('STOMP service connection state: ${_stompService.isConnected}', name: 'LoginView');
developer.log(
'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)
// Show connecting message
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
@@ -202,20 +313,29 @@ class _LoginViewState extends State<LoginView> {
// Wait for connection to be established with a timeout
try {
final completer = Completer<bool>();
final subscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) {
if (isConnected && !completer.isCompleted) {
completer.complete(true);
}
});
final subscription = DartMQ().subscribe<bool>(
MQTopics.connectionStatus,
(isConnected) {
if (isConnected && !completer.isCompleted) {
completer.complete(true);
}
},
);
await completer.future.timeout(const Duration(seconds: 12));
subscription.cancel();
developer.log('STOMP connection established - proceeding with login', name: 'LoginView');
developer.log(
'STOMP connection established - proceeding with login',
name: 'LoginView',
);
} on TimeoutException {
developer.log('STOMP connection timed out', name: 'LoginView');
}
} 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
@@ -223,43 +343,74 @@ class _LoginViewState extends State<LoginView> {
setState(() {
_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;
}
} catch (e, stackTrace) {
setState(() {
_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');
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;
}
}
developer.log('Pre-login checks passed - initiating login request', name: 'LoginView');
developer.log('Connection status: connected=${_stompService.isConnected}', name: 'LoginView');
developer.log(
'Pre-login checks passed - initiating login request',
name: 'LoginView',
);
developer.log(
'Connection status: connected=${_stompService.isConnected}',
name: 'LoginView',
);
setState(() {
_isLoggingIn = true;
});
String password = _passwordController.text;
developer.log('Sending login request via STOMP service...', name: 'LoginView');
developer.log(
'Sending login request via STOMP service...',
name: 'LoginView',
);
try {
// Send login request via STOMP
await _stompService.login(email, password);
final requestSentTime = DateTime.now();
final requestDuration = requestSentTime.difference(loginStartTime).inMilliseconds;
developer.log('Login request sent successfully after ${requestDuration}ms', name: 'LoginView');
final requestDuration =
requestSentTime.difference(loginStartTime).inMilliseconds;
developer.log(
'Login request sent successfully after ${requestDuration}ms',
name: 'LoginView',
);
} catch (e, stackTrace) {
final errorTime = DateTime.now();
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('Stack trace: $stackTrace', name: 'LoginView');
@@ -267,116 +418,230 @@ class _LoginViewState extends State<LoginView> {
_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
// _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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Colors.grey[50],
body: Column(
children: [
Expanded(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo oder App-Name
Icon(Icons.account_circle, size: 100, color: Colors.deepPurple),
const SizedBox(height: 32),
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppGradients.shellBackground),
child: Column(
children: [
Expanded(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.account_circle,
size: 100,
color: AppColors.primary,
),
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),
const SizedBox(height: 8),
Text(AppLocalizations.of(context).loginSubtitle, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]), textAlign: TextAlign.center),
const SizedBox(height: 32),
// E-Mail-Feld
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(labelText: '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;
});
},
Text(
l10n.welcomeBack,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textStrong,
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.white,
textAlign: TextAlign.center,
),
validator: (value) {
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),
const SizedBox(height: 8),
// 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(AppLocalizations.of(context).forgotPasswordMessage), duration: const Duration(seconds: 1)));
Text(
l10n.loginSubtitle,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppColors.textMuted),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// E-Mail-Feld
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailAddress,
hintText: l10n.emailAddressHint,
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
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: 24),
const SizedBox(height: 16),
// Verbindungsstatus
// Anmelden Button
ElevatedButton(onPressed: _isLoggingIn ? null : _handleLogin, style: ElevatedButton.styleFrom(backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 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))),
const SizedBox(height: 24),
],
// Passwort-Feld
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: l10n.password,
hintText: l10n.passwordHint,
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: AppColors.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.passwordRequired;
}
if (value.length < 6) {
return l10n.passwordMinLength;
}
return null;
},
),
const SizedBox(height: 24),
// Passwort vergessen Link
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.forgotPasswordMessage),
duration: const Duration(seconds: 1),
),
);
},
child: Text(
l10n.forgotPassword,
style: const TextStyle(
color: AppColors.primaryStrong,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 24),
// 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),
],
),
),
),
),
),
),
),
// Version number at the bottom
if (_appVersion.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16.0), child: Text('Version $_appVersion', style: TextStyle(fontSize: 12, color: Colors.grey[500]), textAlign: TextAlign.center)),
],
if (_appVersion.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Version $_appVersion',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
textAlign: TextAlign.center,
),
),
],
),
),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'app_theme.dart';
import 'login_view.dart';
import 'jobs_view.dart';
import 'cargo_items_view.dart';
@@ -13,6 +14,7 @@ import 'services/chat_service.dart';
import 'app_state.dart';
import 'navigation_observer.dart';
import 'services/notification_service.dart';
import 'services/websocket_service.dart';
import 'l10n/app_localizations.dart';
void main() async {
@@ -43,14 +45,59 @@ void main() async {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
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
Widget build(BuildContext context) {
// 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>(
valueListenable: localeNotifier,
@@ -58,11 +105,17 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: 'VotianLT App',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true),
theme: buildAppTheme(),
// Localization configuration
locale: locale,
localizationsDelegates: const [AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate],
supportedLocales: supportedLanguageCodes.map((code) => Locale(code)).toList(),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales:
supportedLanguageCodes.map((code) => Locale(code)).toList(),
navigatorObservers: [routeObserver],
initialRoute: initialRoute,
onGenerateRoute: (settings) {
@@ -70,21 +123,30 @@ class MyApp extends StatelessWidget {
case '/login':
final arg = settings.arguments;
final suppress = (arg is bool) ? arg : false;
return MaterialPageRoute(builder: (_) => LoginView(suppressConnectionSnack: suppress));
return MaterialPageRoute(
builder: (_) => LoginView(suppressConnectionSnack: suppress),
);
case '/jobs':
return MaterialPageRoute(builder: (_) => const JobsView());
case '/cargo_items':
final job = settings.arguments as Job;
return MaterialPageRoute(builder: (_) => CargoItemsView(job: job));
return MaterialPageRoute(
builder: (_) => CargoItemsView(job: job),
);
case '/chats':
return MaterialPageRoute(builder: (_) => const ChatsView());
case '/chat_details':
final chat = settings.arguments as Chat;
return MaterialPageRoute(builder: (_) => ChatDetailsView(chat: chat));
return MaterialPageRoute(
builder: (_) => ChatDetailsView(chat: chat),
);
case '/settings':
return MaterialPageRoute(builder: (_) => const SettingsView());
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
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(backgroundColor: Theme.of(context).colorScheme.inversePrimary, 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)])),
floatingActionButton: FloatingActionButton(onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add)), // This trailing comma makes auto-formatting nicer for build methods.
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,
),
],
),
),
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
class SignatureTask extends Task {
final String? note;
SignatureTask({
required super.id,
required super.jobId,
@@ -14,11 +16,19 @@ class SignatureTask extends Task {
super.title,
super.description,
super.displayName,
this.note,
});
factory SignatureTask.fromJson(Map<String, dynamic> json) {
final commonProps = Task.parseCommonProperties(json);
String? note;
final taskSpecificData = json['taskSpecificData'];
if (taskSpecificData is Map<String, dynamic>) {
final n = taskSpecificData['note'];
if (n is String) note = n;
}
return SignatureTask(
id: commonProps['id'],
jobId: commonProps['jobId'],
@@ -31,6 +41,7 @@ class SignatureTask extends Task {
title: commonProps['title'],
description: commonProps['description'],
displayName: commonProps['displayName'],
note: note,
);
}
@@ -47,7 +58,11 @@ class SignatureTask extends Task {
'taskOrder': taskOrder,
'description': description,
'displayName': displayName,
'taskSpecificData': {'taskType': 'SIGNATURE', 'title': title},
'taskSpecificData': {
'taskType': 'SIGNATURE',
'title': title,
'note': note,
},
};
}
@@ -64,6 +79,7 @@ class SignatureTask extends Task {
String? title,
String? description,
String? displayName,
String? note,
}) {
return SignatureTask(
id: id ?? this.id,
@@ -77,6 +93,7 @@ class SignatureTask extends Task {
title: title ?? this.title,
description: description ?? this.description,
displayName: displayName ?? this.displayName,
note: note ?? this.note,
);
}
}

View File

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

View File

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

View File

@@ -15,6 +15,8 @@ class ChatService {
static const _jobIdPrefix = 'job:';
static const _jobNumberPrefix = 'job_number:';
static const _generalPrefix = 'general:';
static const _defaultGeneralConversationKey =
'general:allgemeine-nachrichten';
final DatabaseService _databaseService = DatabaseService();
final AppState _appState = AppState();
@@ -103,9 +105,11 @@ class ChatService {
_chats.removeWhere((chat) {
final matchesKey = conversationKeys.contains(chat.id);
final matchesId = trimmedJobId.isNotEmpty &&
final matchesId =
trimmedJobId.isNotEmpty &&
(chat.jobId?.trim().toLowerCase() == lowerJobId);
final matchesNumber = trimmedJobNumber.isNotEmpty &&
final matchesNumber =
trimmedJobNumber.isNotEmpty &&
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
return matchesKey || matchesId || matchesNumber;
});
@@ -129,18 +133,11 @@ class ChatService {
// Messages with GENERAL messageType should always go to the default general chat
if (message.messageType == ChatMessageType.general) {
final localId = _primaryLocalIdentifier();
if (localId != null && localId.isNotEmpty) {
final key = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
developer.log(
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $key (localId=$localId, receiver=${_appState.loggedInEmail})',
name: 'ChatService',
);
return key;
}
developer.log(
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $_defaultGeneralConversationKey',
name: 'ChatService',
);
return _defaultGeneralConversationKey;
}
// Job-related messages go to job-specific chats
@@ -165,30 +162,11 @@ class ChatService {
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(
'[DEBUG_LOG] No local identifier available for fallback routing',
'[DEBUG_LOG] No job context available, routing to default general chat',
name: 'ChatService',
);
return '$_generalPrefix${_appState.loggedInEmail!}';
}
String _conversationKeyForParticipants(String a, String b) {
final participants = <String>[a.toLowerCase(), b.toLowerCase()]..sort();
return '$_generalPrefix${participants.join('|')}';
return _defaultGeneralConversationKey;
}
Future<void> saveIncomingMessage(ChatMessage message) async {
@@ -205,6 +183,20 @@ class ChatService {
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 {
final conversationKey = conversationKeyForMessage(message);
@@ -239,7 +231,7 @@ class ChatService {
Future<void> _loadChatsFromDatabase() async {
await _databaseService.ensureInitialized();
final grouped = await _databaseService.loadAllChatMessagesGrouped();
final grouped = await _loadNormalizedChatGroups();
_chats.clear();
grouped.forEach((conversationKey, messages) {
final chat = _buildChat(conversationKey, messages);
@@ -254,6 +246,14 @@ class ChatService {
}
Future<void> _refreshConversation(String conversationKey) async {
if (_isLegacyGeneralConversationKey(conversationKey)) {
await _databaseService.migrateConversationKey(
conversationKey,
_defaultGeneralConversationKey,
);
conversationKey = _defaultGeneralConversationKey;
}
final messages = await _databaseService.loadChatMessages(
conversationKey: conversationKey,
);
@@ -317,15 +317,13 @@ class ChatService {
final counterpartNormalized =
counterpart != null &&
counterpart.toLowerCase() == _appState.loggedInEmail!.toLowerCase()
counterpart.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase()
? _appState.loggedInEmail!
: counterpart;
final bool isDefaultGeneral =
!isJobChat &&
conversationKey.startsWith(_generalPrefix) &&
(counterpartNormalized?.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase());
!isJobChat && conversationKey == _defaultGeneralConversationKey;
final title =
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() {
final localId = _primaryLocalIdentifier();
if (localId == null || localId.isEmpty) {
final receiver = _appState.loggedInEmail;
if (receiver == null || receiver.isEmpty) {
developer.log(
'[DEBUG_LOG] _ensureDefaultGeneralChat: No local identifier available, skipping',
'[DEBUG_LOG] _ensureDefaultGeneralChat: No receiver available, skipping',
name: 'ChatService',
);
return;
}
final conversationKey = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
const conversationKey = _defaultGeneralConversationKey;
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',
);
@@ -431,8 +452,7 @@ class ChatService {
chat.id != conversationKey &&
chat.type == ChatType.general &&
chat.receiver != null &&
chat.receiver!.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase() &&
chat.receiver!.toLowerCase() == receiver.toLowerCase() &&
chat.messages.isEmpty,
);
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
@@ -446,7 +466,7 @@ class ChatService {
Chat(
id: conversationKey,
title: 'Allgemeine Nachrichten',
receiver: _appState.loggedInEmail!,
receiver: receiver,
type: ChatType.general,
jobId: null,
jobNumber: null,
@@ -463,8 +483,7 @@ class ChatService {
final existing = _chats[index];
if (existing.type != ChatType.general ||
existing.receiver == null ||
existing.receiver!.toLowerCase() !=
_appState.loggedInEmail!.toLowerCase() ||
existing.receiver!.toLowerCase() != receiver.toLowerCase() ||
(existing.messages.isEmpty &&
existing.title != 'Allgemeine Nachrichten')) {
developer.log(
@@ -477,7 +496,7 @@ class ChatService {
existing.messages.isEmpty
? 'Allgemeine Nachrichten'
: existing.title,
receiver: _appState.loggedInEmail!,
receiver: receiver,
type: ChatType.general,
jobId: existing.jobId,
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>();
_initializingCompleter = completer;
try {
developer.log('Initializing ObjectBox database...', name: 'DatabaseService');
developer.log(
'Initializing ObjectBox database...',
name: 'DatabaseService',
);
// Get database path
final docsDir = await getApplicationDocumentsDirectory();
@@ -75,8 +78,6 @@ class DatabaseService {
await initialize();
}
/// Log database statistics
Future<void> _logDatabaseStats() async {
try {
@@ -164,7 +165,10 @@ class DatabaseService {
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 query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
@@ -173,9 +177,15 @@ class DatabaseService {
if (entities.isNotEmpty) {
jobBox.remove(entities.first.id);
developer.log('Job $jobId deleted successfully', name: 'DatabaseService');
developer.log(
'Job $jobId deleted successfully',
name: 'DatabaseService',
);
} else {
developer.log('Job $jobId not found in database', name: 'DatabaseService');
developer.log(
'Job $jobId not found in database',
name: 'DatabaseService',
);
}
} catch (e, stackTrace) {
developer.log('Error deleting job: $e', name: 'DatabaseService');
@@ -220,9 +230,13 @@ class DatabaseService {
if (jobs.isNotEmpty) {
try {
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(
(ChatMessageEntity_.jobId.notNull() | ChatMessageEntity_.jobNumber.notNull())
).build();
final query =
chatBox
.query(
(ChatMessageEntity_.jobId.notNull() |
ChatMessageEntity_.jobNumber.notNull()),
)
.build();
final messagesWithJobs = query.find();
query.close();
@@ -282,7 +296,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>();
// 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();
query.close();
@@ -321,7 +336,8 @@ class DatabaseService {
}
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();
query.close();
@@ -449,7 +465,8 @@ class DatabaseService {
final keys = jobIds.map((id) => 'job_seen:$id').toList();
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();
query.close();
@@ -545,7 +562,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>();
// 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();
jobQuery.close();
@@ -568,7 +586,10 @@ class DatabaseService {
final taskIds = normalized.tasks.map((t) => t.id).toList();
if (taskIds.isNotEmpty) {
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();
query.close();
for (final entity in entities) {
@@ -617,7 +638,8 @@ class DatabaseService {
if (trimmedJobId.isNotEmpty) {
// Delete job
final jobQuery = jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobQuery =
jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobEntities = jobQuery.find();
jobQuery.close();
for (final entity in jobEntities) {
@@ -625,7 +647,10 @@ class DatabaseService {
}
// 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();
seenQuery.close();
for (final entity in seenEntities) {
@@ -633,15 +658,19 @@ class DatabaseService {
}
}
final taskIds = job.tasks
.map((task) => task.id.trim())
.where((id) => id.isNotEmpty)
.toList();
final taskIds =
job.tasks
.map((task) => task.id.trim())
.where((id) => id.isNotEmpty)
.toList();
if (taskIds.isNotEmpty) {
for (final taskId in taskIds) {
// 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();
taskQuery.close();
for (final entity in taskEntities) {
@@ -649,7 +678,8 @@ class DatabaseService {
}
// Delete photos
final photoQuery = photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoQuery =
photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoEntities = photoQuery.find();
photoQuery.close();
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
Future<String?> loadTaskSignature(String taskId) async {
try {
@@ -960,9 +1026,21 @@ class DatabaseService {
/// Save login credentials for auto-login on app restart
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);
developer.log('Credentials saved for $email', name: 'DatabaseService');
developer.log(
'Credentials saved for $normalizedEmail',
name: 'DatabaseService',
);
}
/// Load saved login credentials
@@ -970,11 +1048,29 @@ class DatabaseService {
Future<({String email, String password})?> loadCredentials() async {
final email = await loadKeyValue('auth_email');
final password = await loadKeyValue('auth_password');
if (email != null && password != null) {
developer.log('Credentials loaded for $email', name: 'DatabaseService');
return (email: email, password: password);
final normalizedEmail = email?.trim();
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;
}
@@ -1008,7 +1104,10 @@ class DatabaseService {
final chatBox = _store!.box<ChatMessageEntity>();
// 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();
query.close();
@@ -1060,7 +1159,10 @@ class DatabaseService {
return;
}
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();
query.close();
@@ -1089,13 +1191,18 @@ class DatabaseService {
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(
ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.content.equals(message.content) &
ChatMessageEntity_.contentType.equals(chatContentTypeToString(message.contentType)) &
ChatMessageEntity_.messageId.notEquals(message.id)
).build();
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.content.equals(message.content) &
ChatMessageEntity_.contentType.equals(
chatContentTypeToString(message.contentType),
) &
ChatMessageEntity_.messageId.notEquals(message.id),
)
.build();
final entities = query.find();
query.close();
@@ -1123,9 +1230,13 @@ class DatabaseService {
List<ChatMessageEntity> entities;
if (conversationKey != null) {
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
.order(ChatMessageEntity_.createdAt)
.build();
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey),
)
.order(ChatMessageEntity_.createdAt)
.build();
entities = query.find();
query.close();
} else {
@@ -1186,7 +1297,10 @@ class DatabaseService {
}
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();
query.close();
@@ -1211,7 +1325,8 @@ class DatabaseService {
}
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();
query.close();
@@ -1241,15 +1356,18 @@ class DatabaseService {
final trimmedJobId = jobId?.trim() ?? '';
final trimmedJobNumber = jobNumber?.trim() ?? '';
final keysList = conversationKeys == null
? <String>[]
: conversationKeys
.map((key) => key.trim())
.where((key) => key.isNotEmpty)
.toSet()
.toList();
final keysList =
conversationKeys == null
? <String>[]
: conversationKeys
.map((key) => key.trim())
.where((key) => key.isNotEmpty)
.toSet()
.toList();
if (trimmedJobId.isEmpty && trimmedJobNumber.isEmpty && keysList.isEmpty) {
if (trimmedJobId.isEmpty &&
trimmedJobNumber.isEmpty &&
keysList.isEmpty) {
developer.log(
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
name: 'DatabaseService',
@@ -1261,20 +1379,29 @@ class DatabaseService {
final entitiesToDelete = <ChatMessageEntity>[];
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());
query.close();
}
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());
query.close();
}
if (keysList.isNotEmpty) {
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());
query.close();
}
@@ -1309,7 +1436,8 @@ class DatabaseService {
}
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();
query.close();
@@ -1349,6 +1477,7 @@ class DatabaseService {
/// Save a failed message to the queue
Future<void> queueMessage(QueuedMessage message) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
@@ -1357,7 +1486,8 @@ class DatabaseService {
final box = _store!.box<QueuedMessageEntity>();
// 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();
query.close();
@@ -1391,6 +1521,7 @@ class DatabaseService {
/// Get all queued messages
Future<List<QueuedMessage>> getQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
@@ -1424,13 +1555,15 @@ class DatabaseService {
/// Remove a successfully sent message from the queue
Future<void> removeQueuedMessage(String messageId) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
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();
query.close();
@@ -1452,18 +1585,17 @@ class DatabaseService {
}
/// Update retry count for a message
Future<void> updateMessageRetryCount(
String messageId,
int retryCount,
) async {
Future<void> updateMessageRetryCount(String messageId, int retryCount) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
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();
query.close();
@@ -1488,16 +1620,14 @@ class DatabaseService {
/// Clear all queued messages (for cleanup)
Future<void> clearQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
_store!.box<QueuedMessageEntity>().removeAll();
developer.log(
'Cleared all queued messages',
name: 'DatabaseService',
);
developer.log('Cleared all queued messages', name: 'DatabaseService');
} catch (e, st) {
developer.log(
'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 ----------------------------------------------------
/// Save language preference
Future<void> saveLanguagePreference(String languageCode) async {
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
@@ -1520,7 +1687,10 @@ class DatabaseService {
Future<String?> loadLanguagePreference() async {
final languageCode = await loadKeyValue('language_preference');
if (languageCode != null) {
developer.log('Language preference loaded: $languageCode', name: 'DatabaseService');
developer.log(
'Language preference loaded: $languageCode',
name: 'DatabaseService',
);
return languageCode;
}
developer.log('No language preference found', name: 'DatabaseService');

View File

@@ -13,6 +13,7 @@ import 'location_service.dart';
import '../app_state.dart';
import '../models/chat_message.dart';
import '../models/job.dart';
import '../models/queued_message.dart';
import 'dart_mq.dart';
class WebSocketService {
@@ -193,6 +194,73 @@ class WebSocketService {
_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
// ---------------------------------------------------------------------------
@@ -290,6 +358,8 @@ class WebSocketService {
_handleJobDeletedMessage(data);
} else if (topic.endsWith('/job_created')) {
_handleJobCreatedMessage(data);
} else if (topic.endsWith('/message_ack')) {
await _handleChatMessageAck(data);
} else if (topic.endsWith('/message')) {
await _handleChatMessage(topic, data);
} 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) {
final type = data['type'];
if (topic.contains('/tasks/') || type == 'task') {
@@ -731,6 +815,7 @@ class WebSocketService {
/// Clears all local jobs and related data, then notifies the server.
Future<void> _flushMessageBuffer() async {
final initialBufferSize = _messageBuffer.length;
final sentQueuedChatCount = await _flushQueuedChatMessages();
if (initialBufferSize > 0) {
developer.log(
@@ -766,7 +851,8 @@ class WebSocketService {
await _databaseService.clearAllJobsAndRelatedData();
// Notify server that buffer flush is complete
final sentCount = initialBufferSize - _messageBuffer.length;
final sentCount =
(initialBufferSize - _messageBuffer.length) + sentQueuedChatCount;
final bufferFlushedPayload = jsonEncode({
'timestamp': DateTime.now().toIso8601String(),
'messageCount': sentCount,
@@ -774,9 +860,51 @@ class WebSocketService {
_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.
/// Returns the locally constructed message so callers can persist it locally.
/// Messages are buffered if offline and sent automatically when reconnected.
/// The message is stored locally and remains queued until the server confirms it.
Future<ChatMessage?> sendChatMessage({
required String sender,
required String receiver,
@@ -790,6 +918,9 @@ class WebSocketService {
final trimmedContent = content.trim();
final normalizedJobId = jobId?.trim();
final normalizedJobNumber = jobNumber?.trim();
final hasJobContext =
(normalizedJobId?.isNotEmpty ?? false) ||
(normalizedJobNumber?.isNotEmpty ?? false);
if (trimmedSender.isEmpty ||
trimmedReceiver.isEmpty ||
@@ -816,6 +947,9 @@ class WebSocketService {
'receiver': trimmedReceiver,
'content': trimmedContent,
};
final now = DateTime.now();
final clientMessageId = 'local-${now.microsecondsSinceEpoch}';
payload['messageId'] = clientMessageId;
if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
payload['jobId'] = normalizedJobId;
@@ -828,18 +962,13 @@ class WebSocketService {
const topic = '/server/message';
try {
final jsonPayload = jsonEncode(payload);
// sendMessage buffers automatically if not connected/authenticated
sendMessage(topic, jsonPayload);
final now = DateTime.now();
final message = ChatMessage(
id: 'local-${now.microsecondsSinceEpoch}',
id: clientMessageId,
content: trimmedContent,
createdAt: now,
direction: ChatDirection.outgoing,
messageType:
normalizedJobId != null && normalizedJobId.isNotEmpty
hasJobContext
? ChatMessageType.jobRelated
: ChatMessageType.general,
contentType: contentType,
@@ -849,13 +978,26 @@ class WebSocketService {
read: false,
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;
} catch (e, st) {
developer.log(
'Error encoding chat message payload: $e',
name: 'WebSocketService',
);
developer.log('Error sending chat message: $e', name: 'WebSocketService');
developer.log('Stack: $st', name: 'WebSocketService');
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
void dispose() {
_stopReconnectTimer();

View File

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

View File

@@ -5,7 +5,9 @@ import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/job.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
@@ -39,7 +41,6 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final Set<String> _skippedTasks = {};
final DatabaseService _databaseService = DatabaseService();
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@@ -60,7 +61,7 @@ class _TaskViewState extends State<TaskView> {
.toList();
}
/// Load task completion statuses from database and merge with JSON task states
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses();
setState(() {
@@ -89,7 +90,6 @@ class _TaskViewState extends State<TaskView> {
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
),
backgroundColor: Colors.deepPurple[100],
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -116,14 +116,14 @@ class _TaskViewState extends State<TaskView> {
constraints: const BoxConstraints(maxHeight: 150),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
border: Border.all(color: Colors.grey[300]!, width: 1),
color: AppColors.surfaceMuted,
border: Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Text(
_getRemark(),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
style: const TextStyle(fontSize: 14, color: AppColors.text),
),
),
),
@@ -132,7 +132,13 @@ class _TaskViewState extends State<TaskView> {
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Expanded(child: _buildTasksStepper())],
children: [
Expanded(child: _buildTasksStepper()),
if (_visibleTasks.isNotEmpty) ...[
const SizedBox(height: 12),
_buildCompleteStationButton(),
],
],
),
),
),
@@ -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() {
if (_visibleTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_outlined, size: 64, color: Colors.grey[400]),
const Icon(
Icons.task_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context).noTasks,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).noTasksMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
textAlign: TextAlign.center,
),
],
@@ -169,36 +264,30 @@ class _TaskViewState extends State<TaskView> {
itemBuilder: (context, index) {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final isSkipped = _skippedTasks.contains(task.id);
final canBeCompletedNow =
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index);
!isCompleted && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
// (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.)
final Color cardColor =
isCompleted
? const Color(0xFFE8F5E9) // hellgrün
: isSkipped
? const Color(0xFFFFF8E1) // hellgelb
? AppColors.successSoft
: canBeCompletedNow
? Colors.white
: const Color(0xFFF5F5F5); // hellgrau
? AppColors.surface
: AppColors.surfaceMuted;
final Color borderColor =
isCompleted
? Colors.green[300]!
: isSkipped
? Colors.amber[300]!
? AppColors.success.withValues(alpha: 0.35)
: canBeCompletedNow
? Colors.grey[300]!
: Colors.grey[200]!;
? AppColors.border
: AppColors.border.withValues(alpha: 0.7);
final Color circleColor =
isCompleted
? Colors.green[600]!
: isSkipped
? Colors.amber[600]!
? AppColors.success
: canBeCompletedNow
? Colors.deepPurple[400]!
: Colors.grey[400]!;
? AppColors.primary
: AppColors.textMuted;
return Card(
margin: const EdgeInsets.only(bottom: 12),
@@ -219,61 +308,93 @@ class _TaskViewState extends State<TaskView> {
borderRadius: BorderRadius.circular(12),
color: cardColor,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: Stack(
children: [
// Task number circle
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Task number circle
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Padding(
padding: EdgeInsets.only(
right: task.optional ? 72 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(task, isCompleted, index),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
const Icon(
Icons.check_circle,
color: AppColors.success,
),
],
],
),
if (task.optional)
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
),
),
child: const Text(
'Optional',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
),
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(
task,
isCompleted || isSkipped,
index,
),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
Icon(Icons.check_circle, color: Colors.green[600]),
],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
],
],
),
),
@@ -501,10 +622,11 @@ class _TaskViewState extends State<TaskView> {
builder:
(context) => SignatureCaptureScreen(
task: task,
onSignatureCompleted: (String svg) async {
onSignatureCompleted: (String svg, String note) async {
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
await _databaseService.saveTaskSignatureNote(task.id, note);
} catch (e, stackTrace) {
developer.log(
'Error saving task signature: $e',
@@ -528,6 +650,7 @@ class _TaskViewState extends State<TaskView> {
'signatureSvg': svg,
'svgLength': svg.length,
'hasSignature': true,
'signatureNote': note,
},
);
},
@@ -611,6 +734,7 @@ class _TaskViewState extends State<TaskView> {
String? taskType,
Map<String, dynamic>? extraData,
}) {
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() {
_completedTasks.add(taskId);
});
@@ -627,15 +751,60 @@ class _TaskViewState extends State<TaskView> {
} catch (e) {
developer.log('Error sending task completion: $e', name: 'TaskView');
}
// Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde,
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showLastMandatoryCompletedDialog();
}
});
}
}
bool get _hasOpenMandatoryTasks {
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return true;
}
}
return false;
}
void _showLastMandatoryCompletedDialog() {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Alle Pflichtaufgaben erledigt'),
content: const Text(
'Alle nicht optionalen Aufgaben dieser Station sind erledigt. '
'Möchten Sie die Station jetzt abschließen?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Später'),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_onCompleteStationPressed();
},
child: const Text('Station abschließen'),
),
],
);
},
);
}
bool _arePreviousTasksCompleted(int index) {
if (index <= 0) return true;
for (int i = 0; i < index; i++) {
final t = _visibleTasks[i];
if (!t.optional &&
!_completedTasks.contains(t.id) &&
!_skippedTasks.contains(t.id)) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
}
}
@@ -721,8 +890,18 @@ class _TaskViewState extends State<TaskView> {
decoration: isCompleted ? TextDecoration.lineThrough : null,
);
final displayName = task.displayName;
final description = task.description;
final displayName =
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) {
return Column(
@@ -733,14 +912,39 @@ class _TaskViewState extends State<TaskView> {
const SizedBox(height: 2),
Text(description!, style: subtitleStyle),
],
if (signatureNote != null) ...[
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
],
);
}
if (description?.isNotEmpty == true) {
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description!, style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
return Text(description!, style: titleStyle);
}
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_getStandardTaskDisplayText(task), style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
// Fall back to standard text based on task type
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
}
@@ -785,12 +989,10 @@ class _TaskViewState extends State<TaskView> {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
return suffix.isNotEmpty
? 'Station ${stationOrder + 1}: $suffix'
: 'Station ${stationOrder + 1}';
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
}
}
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 Function(List<String>) onBarcodesCompleted;
const BarcodeCaptureScreen({super.key, required this.task, required this.onBarcodesCompleted});
const BarcodeCaptureScreen({
super.key,
required this.task,
required this.onBarcodesCompleted,
});
@override
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
@@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${AppLocalizations.of(context).cameraError}: $e'),
),
);
}
}
}
@@ -142,7 +150,28 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(appBar: AppBar(title: Text(AppLocalizations.of(context).barcodeScan), backgroundColor: Colors.deepPurple[100], leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop())), body: Column(children: [OfflineBanner(), Expanded(child: _isScannerInitialized ? (_isMobilePlatform ? _buildMobileView() : _buildDesktopView()) : const Center(child: CircularProgressIndicator()))]));
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).barcodeScan),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
body: Column(
children: [
const OfflineBanner(),
Expanded(
child:
_isScannerInitialized
? (_isMobilePlatform
? _buildMobileView()
: _buildDesktopView())
: const Center(child: CircularProgressIndicator()),
),
],
),
);
}
Widget _buildMobileView() {
@@ -153,9 +182,33 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
flex: 3,
child: Stack(
children: [
MobileScanner(controller: _scannerController, onDetect: _onBarcodeDetected),
MobileScanner(
controller: _scannerController,
onDetect: _onBarcodeDetected,
),
// Overlay with scanning frame
Container(decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: Center(child: Container(width: 250, height: 250, decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2), borderRadius: BorderRadius.circular(12)), child: Container(margin: const EdgeInsets.all(20), decoration: BoxDecoration(border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8)))))),
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
),
child: Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: Colors.green, width: 2),
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
],
),
),
@@ -167,20 +220,47 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
'${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
Text(
'${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: _scannedBarcodes.length,
itemBuilder: (context, index) {
return Card(child: ListTile(leading: const Icon(Icons.qr_code), title: Text(_scannedBarcodes[index]), trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeBarcode(index))));
return Card(
child: ListTile(
leading: const Icon(Icons.qr_code),
title: Text(_scannedBarcodes[index]),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeBarcode(index),
),
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canFinish() ? _finishTask : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
),
@@ -195,9 +275,15 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).enterBarcode, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(
AppLocalizations.of(context).enterBarcode,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
Text(
'${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 24),
Expanded(
child: ListView.builder(
@@ -207,7 +293,18 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: _textControllers[index],
decoration: InputDecoration(labelText: index < widget.task.minBarcodeCount ? AppLocalizations.of(context).barcodeNumberRequired(index + 1) : AppLocalizations.of(context).barcodeNumberOptional(index + 1), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.qr_code)),
decoration: InputDecoration(
labelText:
index < widget.task.minBarcodeCount
? AppLocalizations.of(
context,
).barcodeNumberRequired(index + 1)
: AppLocalizations.of(
context,
).barcodeNumberOptional(index + 1),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.qr_code),
),
onChanged: (value) {
setState(() {
// Trigger rebuild to update button state
@@ -219,7 +316,16 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
),
),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canFinish() ? _finishTask : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.9.12+1
version: 0.9.16+1
environment:
sdk: ^3.7.0

View File

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

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.14</revision>
<revision>0.9.16</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,13 @@ public class Message {
@Field("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
*/

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,11 @@ import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.dnd.DragSource;
import com.vaadin.flow.component.dnd.DropEffect;
import com.vaadin.flow.component.dnd.DropTarget;
import com.vaadin.flow.component.dnd.EffectAllowed;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
@@ -53,10 +56,28 @@ public class DeliveryStationDialog extends Dialog {
private String zip;
private String city;
private boolean saveAddress;
private boolean addressDiffersFromCustomer;
private org.bson.types.ObjectId customerId;
public boolean isAddressDiffersFromCustomer() {
return addressDiffersFromCustomer;
}
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
}
private List<BaseTask> tasks = new ArrayList<>();
private boolean addressValidatedByGoogle;
private AddressValidationResult addressValidationResult;
public org.bson.types.ObjectId getCustomerId() {
return customerId;
}
public void setCustomerId(org.bson.types.ObjectId customerId) {
this.customerId = customerId;
}
public boolean isAddressValidatedByGoogle() {
return addressValidatedByGoogle;
}
@@ -204,12 +225,14 @@ public class DeliveryStationDialog extends Dialog {
private final List<BaseTask> tasksState = new ArrayList<>();
private VerticalLayout tasksList;
private VerticalLayout draggedTaskContainer;
private Span addressTabError;
private Span tasksTabError;
private final DeliveryStationTile.TranslationHelper translationHelper;
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
private org.bson.types.ObjectId selectedCustomerId;
public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
@@ -441,25 +464,21 @@ public class DeliveryStationDialog extends Dialog {
close();
} else {
// Adresse nicht gefunden: Benutzer fragen
ConfirmDialog confirmDialog = new ConfirmDialog();
confirmDialog.setHeader(
translationHelper.getTranslation("addjob.validation.address.not.found.title"));
confirmDialog.setText(
translationHelper.getTranslation("addjob.validation.address.not.found.message"));
confirmDialog.setConfirmText(
translationHelper.getTranslation("addjob.validation.address.save.anyway"));
confirmDialog.setConfirmButtonTheme("primary");
confirmDialog.setCancelable(true);
confirmDialog.setCancelText(
translationHelper.getTranslation("addjob.validation.address.correct"));
confirmDialog.addConfirmListener(ev -> {
data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) {
saveListener.onSave(data);
}
close();
});
Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
translationHelper.getTranslation("addjob.validation.address.not.found.title"),
translationHelper.getTranslation("addjob.validation.address.not.found.message"),
"560px",
translationHelper.getTranslation("addjob.validation.address.correct"),
translationHelper.getTranslation("addjob.validation.address.save.anyway"),
() -> {
data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) {
saveListener.onSave(data);
}
close();
},
ButtonVariant.LUMO_PRIMARY);
confirmDialog.open();
}
}));
@@ -512,6 +531,13 @@ public class DeliveryStationDialog extends Dialog {
zip.setValue(data.getZip());
if (data.getCity() != null)
city.setValue(data.getCity());
selectedCustomerId = data.getCustomerId();
if (selectedCustomerId == null && customerSelectedFromOptions) {
Customer matched = companyAddressOptions.get(companyOption);
if (matched != null) {
selectedCustomerId = matched.getId();
}
}
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState();
@@ -548,10 +574,43 @@ public class DeliveryStationDialog extends Dialog {
data.setZip(zip.getValue());
data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setTasks(new ArrayList<>(tasksState));
return data;
}
private boolean computeAddressDiffers() {
boolean hasAnyValue = !isBlank(resolveCompanyValue(company.getValue())) || !isBlank(firstName.getValue())
|| !isBlank(lastName.getValue()) || !isBlank(phone.getValue()) || !isBlank(mail.getValue())
|| !isBlank(street.getValue()) || !isBlank(houseNumber.getValue())
|| !isBlank(addressAddition.getValue()) || !isBlank(zip.getValue()) || !isBlank(city.getValue());
if (!hasAnyValue) {
return false;
}
if (selectedCustomerId == null) {
return true;
}
Customer linked = findCustomerById(selectedCustomerId);
return linked == null || !matchesCurrentCustomer(linked);
}
private Customer findCustomerById(org.bson.types.ObjectId id) {
if (id == null) {
return null;
}
for (Customer c : companyAddressOptions.values()) {
if (c != null && id.equals(c.getId())) {
return c;
}
}
return null;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private boolean validateRequiredFields() {
// Address tab validation
boolean addressValid = true;
@@ -601,11 +660,9 @@ public class DeliveryStationDialog extends Dialog {
String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
applyErrorStyling(mail, invalid);
return !invalid;
}
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) {
@@ -648,10 +705,12 @@ public class DeliveryStationDialog extends Dialog {
companyField.addValueChangeListener(event -> {
Customer customer = companyAddressOptions.get(event.getValue());
if (customer == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -680,27 +739,34 @@ public class DeliveryStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState();
});
}
private void updateSaveAddressState() {
Customer selectedCustomer = companyAddressOptions.get(company.getValue());
boolean customerSelectedFromOptions = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
boolean customerDataMatches = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
if (customerSelectedFromOptions) {
if (customerDataMatches) {
saveAddress.setValue(false);
saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement();
return;
}
saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement();
}
private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
mail.setRequiredIndicatorVisible(false);
}
private String buildCompanyAddressLabel(Customer customer) {
@@ -871,6 +937,15 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card");
// Drag handle
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dragHandle.addClassName("dialog-task-drag-handle");
// Compact summary shown during drag
HorizontalLayout summaryRow = createDragSummary("", "");
summaryRow.addClassName("dialog-task-summary");
// Task type selection
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values());
@@ -882,6 +957,7 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false);
configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
// Red X button positioned in top-right corner
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
@@ -889,8 +965,14 @@ public class DeliveryStationDialog extends Dialog {
deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton);
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
headerRow.setAlignItems(FlexComponent.Alignment.START);
headerRow.setWidthFull();
headerRow.setFlexGrow(1, taskTypeCombo);
taskContainer.add(headerRow, configContainer);
setupDragAndDrop(taskContainer);
// Create Task and add to state with correct order
BaseTask task = new ConfirmationTask("");
@@ -901,6 +983,7 @@ public class DeliveryStationDialog extends Dialog {
taskTypeCombo.setValue(TaskType.CONFIRMATION);
updateTaskConfiguration(configContainer, currentTask[0]);
updateDragSummary(summaryRow, TaskType.CONFIRMATION, task);
taskTypeCombo.addValueChangeListener(ev -> {
TaskType selectedType = ev.getValue();
@@ -945,6 +1028,7 @@ public class DeliveryStationDialog extends Dialog {
}
updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
}
});
@@ -958,6 +1042,18 @@ public class DeliveryStationDialog extends Dialog {
taskContainer.setSpacing(true);
taskContainer.addClassName("dialog-task-card");
// Drag handle
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dragHandle.addClassName("dialog-task-drag-handle");
// Compact summary shown during drag
TaskType initialTaskType = getTaskTypeFromTask(task);
HorizontalLayout summaryRow = createDragSummary(
initialTaskType != null ? initialTaskType.getDisplayName() : "",
task.getDescription() != null ? task.getDescription() : "");
summaryRow.addClassName("dialog-task-summary");
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
taskTypeCombo.setItems(TaskType.values());
taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName);
@@ -967,21 +1063,27 @@ public class DeliveryStationDialog extends Dialog {
VerticalLayout configContainer = new VerticalLayout();
configContainer.setPadding(false);
configContainer.setSpacing(true);
configContainer.addClassName("dialog-task-config");
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton);
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
headerRow.setAlignItems(FlexComponent.Alignment.START);
headerRow.setWidthFull();
headerRow.setFlexGrow(1, taskTypeCombo);
taskContainer.add(headerRow, configContainer);
setupDragAndDrop(taskContainer);
final BaseTask[] currentTask = { task };
// Set the combo value BEFORE registering the listener
TaskType taskType = getTaskTypeFromTask(task);
if (taskType != null) {
taskTypeCombo.setValue(taskType);
if (initialTaskType != null) {
taskTypeCombo.setValue(initialTaskType);
}
// Register the listener for user-initiated type changes only
@@ -1026,11 +1128,13 @@ public class DeliveryStationDialog extends Dialog {
}
updateTaskConfiguration(configContainer, newTask);
updateDragSummary(summaryRow, selectedType, newTask);
}
});
// Render the UI with the loaded task
updateTaskConfiguration(configContainer, task);
updateDragSummary(summaryRow, initialTaskType, task);
tasksList.add(taskContainer);
updateTaskDeleteAvailability();
@@ -1047,6 +1151,160 @@ public class DeliveryStationDialog extends Dialog {
};
}
private HorizontalLayout createDragSummary(String typeName, String description) {
Span typeLabel = new Span(typeName);
typeLabel.addClassName("task-type-label");
Span descLabel = new Span(description != null && !description.isBlank() ? "" + description : "");
descLabel.addClassName("task-desc-label");
HorizontalLayout layout = new HorizontalLayout(typeLabel, descLabel);
layout.setSpacing(false);
layout.setPadding(false);
layout.setAlignItems(FlexComponent.Alignment.CENTER);
return layout;
}
private void updateDragSummary(HorizontalLayout summaryRow, TaskType taskType, BaseTask task) {
summaryRow.getChildren()
.filter(Span.class::isInstance)
.map(Span.class::cast)
.forEach(span -> {
if (span.getClassNames().contains("task-type-label")) {
span.setText(taskType != null ? taskType.getDisplayName() : "");
} else if (span.getClassNames().contains("task-desc-label")) {
String desc = task.getDescription();
span.setText(desc != null && !desc.isBlank() ? "" + desc : "");
}
});
}
private void clearAllDropIndicators() {
if (tasksList == null) {
return;
}
tasksList.removeClassName("tasks-reordering");
tasksList.getChildren()
.filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast)
.forEach(c -> {
c.removeClassName("drag-over-top");
c.removeClassName("drag-over-bottom");
c.removeClassName("dragging");
});
}
private void setupDragAndDrop(VerticalLayout taskContainer) {
DragSource<VerticalLayout> dragSource = DragSource.create(taskContainer);
dragSource.setEffectAllowed(EffectAllowed.MOVE);
dragSource.addDragStartListener(e -> {
draggedTaskContainer = taskContainer;
taskContainer.addClassName("dragging");
if (tasksList != null) {
// Update all summaries with latest description values before compressing
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
for (int i = 0; i < rows.size() && i < tasksState.size(); i++) {
if (rows.get(i) instanceof VerticalLayout row) {
row.getChildren()
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.findFirst()
.ifPresent(headerRow -> headerRow.getChildren()
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.filter(c -> c.getClassNames().contains("dialog-task-summary"))
.findFirst()
.ifPresent(summary -> {
int idx = rows.indexOf(row);
if (idx >= 0 && idx < tasksState.size()) {
BaseTask t = tasksState.get(idx);
TaskType tt = getTaskTypeFromTask(t);
updateDragSummary(summary, tt, t);
}
}));
}
}
tasksList.addClassName("tasks-reordering");
}
});
dragSource.addDragEndListener(e -> {
draggedTaskContainer = null;
clearAllDropIndicators();
});
DropTarget<VerticalLayout> dropTarget = DropTarget.create(taskContainer);
dropTarget.setDropEffect(DropEffect.MOVE);
dropTarget.addDropListener(e -> {
if (draggedTaskContainer != null && draggedTaskContainer != taskContainer) {
moveTaskRow(draggedTaskContainer, taskContainer);
}
clearAllDropIndicators();
});
// Client-side drag events with position detection and no-op suppression
taskContainer.getElement().executeJs(
"const el = this;"
+ "function clearIndicators() {"
+ " var p = el.parentElement;"
+ " if (p) p.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach("
+ " function(c) { c.classList.remove('drag-over-top', 'drag-over-bottom'); });"
+ "}"
+ "function getSiblings() {"
+ " return Array.from(el.parentElement.children).filter("
+ " function(c) { return c.classList.contains('dialog-task-card'); });"
+ "}"
+ "el.addEventListener('dragstart', function() {"
+ " el.parentElement.__draggedEl = el;"
+ "});"
+ "el.addEventListener('dragend', function() {"
+ " el.parentElement.__draggedEl = null;"
+ "});"
+ "el.addEventListener('dragover', function(e) {"
+ " e.preventDefault();"
+ " var dragged = el.parentElement.__draggedEl;"
+ " if (!dragged || dragged === el) { clearIndicators(); return; }"
+ " var cards = getSiblings();"
+ " var dragIdx = cards.indexOf(dragged);"
+ " var myIdx = cards.indexOf(el);"
+ " clearIndicators();"
+ " var rect = el.getBoundingClientRect();"
+ " var midY = rect.top + rect.height / 2;"
+ " if (e.clientY < midY) {"
+ " if (myIdx !== dragIdx + 1) el.classList.add('drag-over-top');"
+ " } else {"
+ " if (myIdx !== dragIdx - 1) el.classList.add('drag-over-bottom');"
+ " }"
+ "});"
+ "el.addEventListener('dragleave', function() {"
+ " el.classList.remove('drag-over-top', 'drag-over-bottom');"
+ "});"
+ "el.addEventListener('drop', function() {"
+ " clearIndicators();"
+ "});");
}
private void moveTaskRow(VerticalLayout source, VerticalLayout target) {
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
int fromIndex = rows.indexOf(source);
int toIndex = rows.indexOf(target);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) {
return;
}
// Reorder tasksState
BaseTask movedTask = tasksState.remove(fromIndex);
tasksState.add(toIndex, movedTask);
// Reorder UI: remove all, re-add in new order
List<com.vaadin.flow.component.Component> rowList = new ArrayList<>(rows);
com.vaadin.flow.component.Component movedRow = rowList.remove(fromIndex);
rowList.add(toIndex, movedRow);
tasksList.removeAll();
rowList.forEach(tasksList::add);
// Update taskOrder values
reorderTasksAfterDeletion();
}
private void reorderTasksAfterDeletion() {
for (int i = 0; i < tasksState.size(); i++) {
BaseTask task = tasksState.get(i);
@@ -1099,11 +1357,15 @@ public class DeliveryStationDialog extends Dialog {
.filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast)
.forEach(taskContainer -> taskContainer.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.filter(HorizontalLayout.class::isInstance)
.map(HorizontalLayout.class::cast)
.findFirst()
.ifPresent(button -> button.setEnabled(deletable)));
.ifPresent(headerRow -> headerRow.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst()
.ifPresent(button -> button.setEnabled(deletable))));
}
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
@@ -1147,10 +1409,14 @@ public class DeliveryStationDialog extends Dialog {
break;
case SIGNATURE:
Span info = new Span(translationHelper.getTranslation("addjob.tasks.signature.noconfig"));
info.getStyle().set("color", "var(--lumo-secondary-text-color)");
info.getStyle().set("font-style", "italic");
configContainer.add(info);
TextField signatureNoteField = new TextField(
translationHelper.getTranslation("addjob.tasks.signature.notelabel"));
signatureNoteField.setPlaceholder(
translationHelper.getTranslation("addjob.tasks.signature.notelabel.placeholder"));
signatureNoteField.setWidthFull();
signatureNoteField.setValue(task.getDescription() != null ? task.getDescription() : "");
signatureNoteField.addValueChangeListener(ev -> task.setDescription(ev.getValue()));
configContainer.add(signatureNoteField);
break;
case TODOLIST:

View File

@@ -1,8 +1,11 @@
package de.assecutor.votianlt.pages.base.ui.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.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
@@ -20,6 +23,33 @@ public final class DialogStylingHelper {
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) {
if (title != null && !title.isBlank()) {
dialog.setHeaderTitle(title);
@@ -32,16 +62,40 @@ public final class DialogStylingHelper {
}
public static Component wrapContent(Component content) {
return wrapContent(content, false);
}
public static Component wrapContent(Component content, boolean fillHeight) {
Div frame = new Div();
frame.getStyle().set("border", "10px solid transparent");
frame.getStyle().set("border-radius", "0");
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();
Div whiteCard = new Div();
whiteCard.getStyle().set("background", "white");
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.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.timepicker.TimePicker;
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.progressbar.ProgressBar;
import de.assecutor.votianlt.model.AddressValidationResult;
@@ -229,6 +228,25 @@ public class PickupStationDialog extends Dialog {
public void setCargoItems(List<CargoItem> cargoItems) {
this.cargoItems = cargoItems != null ? cargoItems : new ArrayList<>();
}
private org.bson.types.ObjectId customerId;
private boolean addressDiffersFromCustomer;
public org.bson.types.ObjectId getCustomerId() {
return customerId;
}
public void setCustomerId(org.bson.types.ObjectId customerId) {
this.customerId = customerId;
}
public boolean isAddressDiffersFromCustomer() {
return addressDiffersFromCustomer;
}
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
}
}
public interface SaveListener {
@@ -251,6 +269,7 @@ public class PickupStationDialog extends Dialog {
private final ComboBox<String> customerComboBox;
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
private org.bson.types.ObjectId selectedCustomerId;
private DatePicker appointmentDatePicker;
private TimePicker appointmentTimePicker;
private Checkbox digitalProcessingCheckbox;
@@ -432,14 +451,17 @@ public class PickupStationDialog extends Dialog {
customerComboBox.addValueChangeListener(ev -> {
String selected = ev.getValue();
if (selected == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
Customer c = customerLabelMap.get(selected);
if (c == null) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
selectedCustomerId = c.getId();
if (c.getCompanyName() != null)
company.setValue(c.getCompanyName());
else
@@ -577,25 +599,21 @@ public class PickupStationDialog extends Dialog {
close();
} else {
// Adresse nicht gefunden: Benutzer fragen
ConfirmDialog confirmDialog = new ConfirmDialog();
confirmDialog.setHeader(
translationHelper.getTranslation("addjob.validation.address.not.found.title"));
confirmDialog.setText(
translationHelper.getTranslation("addjob.validation.address.not.found.message"));
confirmDialog.setConfirmText(
translationHelper.getTranslation("addjob.validation.address.save.anyway"));
confirmDialog.setConfirmButtonTheme("primary");
confirmDialog.setCancelable(true);
confirmDialog.setCancelText(
translationHelper.getTranslation("addjob.validation.address.correct"));
confirmDialog.addConfirmListener(ev -> {
data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) {
saveListener.onSave(data);
}
close();
});
Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
translationHelper.getTranslation("addjob.validation.address.not.found.title"),
translationHelper.getTranslation("addjob.validation.address.not.found.message"),
"560px",
translationHelper.getTranslation("addjob.validation.address.correct"),
translationHelper.getTranslation("addjob.validation.address.save.anyway"),
() -> {
data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) {
saveListener.onSave(data);
}
close();
},
ButtonVariant.LUMO_PRIMARY);
confirmDialog.open();
}
}));
@@ -668,6 +686,15 @@ public class PickupStationDialog extends Dialog {
}
}
if (data.getCustomerId() != null) {
selectedCustomerId = data.getCustomerId();
} else {
Customer matched = customerLabelMap.get(data.getCustomerSelection());
if (matched == null) {
matched = companyCustomerMap.get(normalizeValue(data.getCompany()));
}
selectedCustomerId = matched != null ? matched.getId() : null;
}
saveAddress.setValue(data.isSaveAddress());
updateSaveAddressState();
}
@@ -686,6 +713,8 @@ public class PickupStationDialog extends Dialog {
data.setZip(zip.getValue());
data.setCity(city.getValue());
data.setSaveAddress(saveAddress.getValue());
data.setCustomerId(selectedCustomerId);
data.setAddressDiffersFromCustomer(computeAddressDiffers());
data.setCustomerSelection(customerComboBox.getValue());
if (appointmentDatePicker != null) {
data.setAppointmentDate(appointmentDatePicker.getValue());
@@ -825,6 +854,7 @@ public class PickupStationDialog extends Dialog {
companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
selectedCustomerId = null;
updateSaveAddressState();
return;
}
@@ -834,6 +864,7 @@ public class PickupStationDialog extends Dialog {
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -864,6 +895,7 @@ public class PickupStationDialog extends Dialog {
companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail());
selectedCustomerId = null;
updateSaveAddressState();
});
}
@@ -871,17 +903,23 @@ public class PickupStationDialog extends Dialog {
private void updateSaveAddressState() {
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue()));
boolean existingCustomerSelected = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean existingCompanySelected = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
if (existingCustomerSelected || existingCompanySelected) {
if (customerDataMatches || companyDataMatches) {
saveAddress.setValue(false);
saveAddress.setEnabled(false);
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
updateMailRequirement();
return;
}
saveAddress.setEnabled(true);
if (selectedCustomerId != null) {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
} else {
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
}
updateMailRequirement();
}
@@ -911,6 +949,40 @@ public class PickupStationDialog extends Dialog {
return value == null ? "" : value.trim();
}
private boolean computeAddressDiffers() {
boolean hasAnyValue = !normalizeValue(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;
}
// ============================================
// 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.router.Layout;
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.security.SecurityService;
@@ -136,7 +137,7 @@ public final class AdminLayout extends AppLayout {
// Profile display with navigation
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout());
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
// Update function for username and avatar
Runnable updateUserInfo = () -> {
@@ -151,4 +152,17 @@ public final class AdminLayout extends AppLayout {
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 de.assecutor.votianlt.model.User;
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.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService;
@@ -327,8 +328,7 @@ public final class MainLayout extends AppLayout {
// Profil anzeigen mit Navigation
userMenuItem.getSubMenu().addItem(getTranslation("nav.showprofile"),
e -> UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem(getTranslation("nav.settings"));
userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> securityService.logout());
userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> openLogoutConfirmDialog());
// Update-Funktion für Benutzername und Avatar
Runnable updateUserInfo = () -> {
@@ -344,6 +344,19 @@ public final class MainLayout extends AppLayout {
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
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);

View File

@@ -13,4 +13,6 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
Slice<Customer> findAllBy(Pageable pageable);
List<Customer> findByOwner(ObjectId owner);
List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
}

View File

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

View File

@@ -32,7 +32,7 @@ public class CustomerService {
public List<Customer> findAllForCurrentOwner() {
ObjectId ownerId = securityService.getCurrentUserId();
return todoRepository.findByOwner(ownerId);
return todoRepository.findByOwnerAndInternalFalse(ownerId);
}
public Customer save(Customer customer) {

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

@@ -81,9 +81,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
fax = new TextField(getTranslation("profile.fax"));
fax.setWidthFull();
// E-Mail (Pflichtfeld)
// E-Mail (optional)
mail = new TextField(getTranslation("profile.email"));
mail.setRequiredIndicatorVisible(true);
mail.setWidthFull();
mail.addBlurListener(e -> validateEmail());
@@ -179,8 +178,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
binder.forField(fax).bind(Customer::getFax, Customer::setFax);
binder.forField(mail).asRequired(getTranslation("profile.validation.email.required"))
.withValidator(email -> email.contains("@"), getTranslation("profile.validation.email.invalid"))
binder.forField(mail)
.withValidator(email -> email == null || email.isBlank() || email.contains("@"),
getTranslation("profile.validation.email.invalid"))
.bind(Customer::getMail, Customer::setMail);
binder.forField(street).asRequired(getTranslation("profile.validation.street.required"))
@@ -247,10 +247,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void validateEmail() {
String value = mail.getValue();
if (value == null || value.trim().isEmpty()) {
mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.email.required"));
} else if (!value.contains("@")) {
if (value != null && !value.trim().isEmpty() && !value.contains("@")) {
mail.setInvalid(true);
mail.setErrorMessage(getTranslation("profile.validation.email.invalid"));
} else {

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.checkbox.Checkbox;
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.UI;
@@ -138,12 +137,16 @@ public class AddJobView extends Main implements HasDynamicTitle {
private TextField pickupZip;
private TextField pickupCity;
private Checkbox savePickupAddress;
private org.bson.types.ObjectId pickupCustomerId;
private boolean pickupAddressDiffers;
// Delivery stations as tiles in a 3x3 grid (max 7 delivery + 1 pickup + 1 plus
// = 9)
private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
private final List<org.bson.types.ObjectId> deliveryStationsCustomerId = new ArrayList<>();
private final List<Boolean> deliveryStationsAddressDiffers = new ArrayList<>();
private final List<String> deliveryStationsMailState = new ArrayList<>();
private final List<Div> deliveryStationSlotList = new ArrayList<>();
private final List<Span> deliveryStationDistanceChips = new ArrayList<>();
@@ -722,6 +725,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Add empty state for this station
deliveryStationsState.add(new DeliveryStation());
deliveryStationsSaveAddress.add(true);
deliveryStationsCustomerId.add(null);
deliveryStationsAddressDiffers.add(false);
deliveryStationsMailState.add(null);
deliveryStationsValidatedByGoogle.add(false);
@@ -756,86 +761,91 @@ public class AddJobView extends Main implements HasDynamicTitle {
if (idx < 0)
return;
ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader(getTranslation("addjob.station.remove.confirm", idx + 1));
dialog.setCancelable(true);
dialog.setCancelText(getTranslation("dialog.cancel"));
dialog.setConfirmText(getTranslation("dialog.confirm"));
dialog.addConfirmListener(e -> {
int removeIdx = deliveryStationTilesList.indexOf(tile);
if (removeIdx < 0)
return;
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
getTranslation("addjob.station.remove.confirm", idx + 1),
null,
"460px",
getTranslation("dialog.cancel"),
getTranslation("dialog.confirm"),
() -> {
int removeIdx = deliveryStationTilesList.indexOf(tile);
if (removeIdx < 0)
return;
deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx);
Div removedSlot = deliveryStationSlotList.remove(removeIdx);
deliveryStationDistanceChips.remove(removeIdx);
pickupToDeliveryRouteResults.remove(removeIdx);
// Re-index tasks state for remaining stations
Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
reindexed.put(newIdx, entry.getValue());
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexed);
deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsCustomerId.remove(removeIdx);
deliveryStationsAddressDiffers.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx);
Div removedSlot = deliveryStationSlotList.remove(removeIdx);
deliveryStationDistanceChips.remove(removeIdx);
pickupToDeliveryRouteResults.remove(removeIdx);
// Re-index tasks state for remaining stations
Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
reindexed.put(newIdx, entry.getValue());
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexed);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
reindexedRoutes.put(newIdx, entry.getValue());
}
pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
reindexedRoutes.put(newIdx, entry.getValue());
}
pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) {
continue;
}
if (stationOrder == removeIdx) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else if (stationOrder > removeIdx) {
selectedService.setDeliveryStationOrder(stationOrder - 1);
}
}
stationsGridContainer.remove(removedSlot);
for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) {
continue;
}
if (stationOrder == removeIdx) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else if (stationOrder > removeIdx) {
selectedService.setDeliveryStationOrder(stationOrder - 1);
}
}
stationsGridContainer.remove(removedSlot);
// Renumber remaining tiles and update click listeners
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));
// Update click listener to use correct index
final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
// First station should not be removable
if (i == 0) {
t.setDeleteListener(null);
}
}
// Renumber remaining tiles and update click listeners
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));
// Update click listener to use correct index
final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
// First station should not be removable
if (i == 0) {
t.setDeleteListener(null);
}
}
// Ensure "+" button is visible if under max
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS && addStationButtonSlot.getParent().isEmpty()) {
stationsGridContainer.add(addStationButtonSlot);
}
// Ensure "+" button is visible if under max
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
&& addStationButtonSlot.getParent().isEmpty()) {
stationsGridContainer.add(addStationButtonSlot);
}
resetRouteInformation();
resetStationsAppliedState();
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
});
resetRouteInformation();
resetStationsAppliedState();
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
},
ButtonVariant.LUMO_PRIMARY);
dialog.open();
}
@@ -865,6 +875,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupZip.setValue(data.getZip() != null ? data.getZip() : "");
pickupCity.setValue(data.getCity() != null ? data.getCity() : "");
savePickupAddress.setValue(data.isSaveAddress());
pickupCustomerId = data.getCustomerId();
pickupAddressDiffers = data.isAddressDiffersFromCustomer();
// Sync appointment fields for binder/submit
pickupDate.setValue(data.getAppointmentDate());
@@ -911,6 +923,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(pickupZip.getValue());
currentData.setCity(pickupCity.getValue());
currentData.setSaveAddress(savePickupAddress.getValue());
currentData.setCustomerId(pickupCustomerId);
currentData.setCustomerSelection(customerSelection.getValue());
// Pre-fill pickup-specific fields
currentData.setAppointmentDate(pickupDate.getValue());
@@ -1135,6 +1148,14 @@ public class AddJobView extends Main implements HasDynamicTitle {
station.setCity(data.getCity());
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
while (deliveryStationsCustomerId.size() <= idx) {
deliveryStationsCustomerId.add(null);
}
deliveryStationsCustomerId.set(idx, data.getCustomerId());
while (deliveryStationsAddressDiffers.size() <= idx) {
deliveryStationsAddressDiffers.add(false);
}
deliveryStationsAddressDiffers.set(idx, data.isAddressDiffersFromCustomer());
deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
// Store tasks for this delivery station
@@ -1180,6 +1201,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(station.getZip());
currentData.setCity(station.getCity());
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
if (actualIndex < deliveryStationsCustomerId.size()) {
currentData.setCustomerId(deliveryStationsCustomerId.get(actualIndex));
}
if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
}
@@ -1815,39 +1839,60 @@ public class AddJobView extends Main implements HasDynamicTitle {
return;
}
// NEU: Kunden anlegen, wenn Checkboxen aktiviert
// Kunden anlegen/aktualisieren bzw. intern sichern
Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue());
pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
if (savePickupAddress.getValue()) {
Customer pickupCustomer = new Customer();
pickupCustomer.setCompanyName(pickupCompany.getValue());
pickupCustomer.setTitle(pickupSalutation.getValue());
pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
pickupCustomer.setZip(pickupZip.getValue());
pickupCustomer.setCity(pickupCity.getValue());
addCustomerService.addCustomer(pickupCustomer);
if (pickupCustomerId != null) {
pickupCustomer.setId(pickupCustomerId);
addCustomerService.updateCustomer(pickupCustomer);
} else {
addCustomerService.addCustomer(pickupCustomer);
}
} else if (pickupAddressDiffers) {
addCustomerService.addInternalCustomer(pickupCustomer);
}
// Save delivery station addresses as customers if checkbox is checked
// Delivery-Stationen: anlegen, aktualisieren oder als intern sichern
for (int i = 0; i < deliveryStationsState.size(); i++) {
if (i < deliveryStationsSaveAddress.size() && deliveryStationsSaveAddress.get(i)) {
DeliveryStation ds = deliveryStationsState.get(i);
Customer deliveryCustomer = new Customer();
deliveryCustomer.setCompanyName(ds.getCompany());
deliveryCustomer.setTitle(ds.getSalutation());
deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
deliveryCustomer.setZip(ds.getZip());
deliveryCustomer.setCity(ds.getCity());
addCustomerService.addCustomer(deliveryCustomer);
DeliveryStation ds = deliveryStationsState.get(i);
Customer deliveryCustomer = new Customer();
deliveryCustomer.setCompanyName(ds.getCompany());
deliveryCustomer.setTitle(ds.getSalutation());
deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
deliveryCustomer.setZip(ds.getZip());
deliveryCustomer.setCity(ds.getCity());
boolean saveRequested = i < deliveryStationsSaveAddress.size()
&& deliveryStationsSaveAddress.get(i);
org.bson.types.ObjectId existingId = i < deliveryStationsCustomerId.size()
? deliveryStationsCustomerId.get(i)
: null;
boolean addressDiffers = i < deliveryStationsAddressDiffers.size()
&& deliveryStationsAddressDiffers.get(i);
if (saveRequested) {
if (existingId != null) {
deliveryCustomer.setId(existingId);
addCustomerService.updateCustomer(deliveryCustomer);
} else {
addCustomerService.addCustomer(deliveryCustomer);
}
} else if (addressDiffers) {
addCustomerService.addInternalCustomer(deliveryCustomer);
}
}
@@ -2091,7 +2136,105 @@ public class AddJobView extends Main implements HasDynamicTitle {
return null;
}
/**
* Entfernt alle leeren (nicht editierten) Lieferstationen, sofern mindestens
* eine valide Lieferstation übrig bleibt. Wird aufgerufen bevor die Stationen
* übernommen werden.
*/
private void removeEmptyDeliveryStations() {
// Indizes der leeren Stationen sammeln (absteigend, damit beim Entfernen die Indizes stabil bleiben)
List<Integer> emptyIndices = new ArrayList<>();
for (int i = 0; i < deliveryStationsState.size(); i++) {
if (hasDeliveryStationValidationErrors(deliveryStationsState.get(i))) {
emptyIndices.add(i);
}
}
// Mindestens eine valide Station muss übrig bleiben
if (emptyIndices.size() >= deliveryStationsState.size()) {
return;
}
// Von hinten nach vorne entfernen, damit Indizes stabil bleiben
for (int k = emptyIndices.size() - 1; k >= 0; k--) {
int idx = emptyIndices.get(k);
deliveryStationTilesList.remove(idx);
deliveryStationsState.remove(idx);
deliveryStationsSaveAddress.remove(idx);
deliveryStationsCustomerId.remove(idx);
deliveryStationsAddressDiffers.remove(idx);
deliveryStationsMailState.remove(idx);
deliveryStationsValidatedByGoogle.remove(idx);
deliveryStationTasksState.remove(idx);
Div removedSlot = deliveryStationSlotList.remove(idx);
deliveryStationDistanceChips.remove(idx);
pickupToDeliveryRouteResults.remove(idx);
stationsGridContainer.remove(removedSlot);
}
// Tasks und Routen-Maps re-indizieren
Map<Integer, List<BaseTask>> reindexedTasks = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedTasks.put(newIdx, entry.getValue());
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexedTasks);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedRoutes.put(newIdx, entry.getValue());
}
pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
// Service-Zuordnungen anpassen
for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) {
continue;
}
if (emptyIndices.contains(stationOrder)) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else {
int newOrder = stationOrder - (int) emptyIndices.stream().filter(ei -> ei < stationOrder).count();
selectedService.setDeliveryStationOrder(newOrder);
}
}
// Tiles neu nummerieren und Click-Listener aktualisieren
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
StationTile t = deliveryStationTilesList.get(i);
int newNumber = i + 1;
t.updateStationNumber(newNumber);
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
if (i == 0) {
t.setDeleteListener(null);
}
}
// "+" Button wieder anzeigen falls unter Maximum
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
&& addStationButtonSlot.getParent().isEmpty()) {
stationsGridContainer.add(addStationButtonSlot);
}
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
}
private void handleApplyStations() {
removeEmptyDeliveryStations();
revealPriceAndDetailsSection();
if (!areAllStationsValidatedByGoogle()) {

View File

@@ -11,6 +11,7 @@ import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
@@ -23,6 +24,7 @@ import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
@@ -45,7 +47,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame;
@@ -68,6 +69,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private List<ServiceRow> gridRows = new ArrayList<>();
private Grid<ServiceRow> servicesGrid;
private Div servicesSection;
private Div summarySection;
private NumberField vatField;
/**
* Helper class to represent a row in the services grid
@@ -176,6 +179,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return;
}
currentUser = securityService.getAuthenticatedUser()
.flatMap(auth -> userRepository.findByEmail(auth.getUsername())).orElse(null);
createInvoiceView();
}
@@ -203,8 +209,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Div servicesSection = createServicesSelectionSection();
add(servicesSection);
// VAT Section (must exist before summary so effectiveVatRate() can read the field)
Div vatSection = createVatSection();
add(vatSection);
// Summary Section
Div summarySection = createSummarySection();
summarySection = createSummarySection();
add(summarySection);
// Create Invoice Button
@@ -336,13 +346,16 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
section.setWidthFull();
section.addClassName("invoice-section-card");
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
populateSummarySection(section);
return section;
}
private void populateSummarySection(Div section) {
H3 sectionTitle = new H3(getTranslation("createinvoice.section.summary"));
section.add(sectionTitle);
// Calculate totals
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -355,9 +368,40 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
totalAmount.setScale(2, RoundingMode.HALF_UP) + "", true));
section.add(priceTable);
}
private Div createVatSection() {
Div section = new Div();
section.setWidthFull();
section.addClassName("invoice-section-card");
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
H3 sectionTitle = new H3(getTranslation("createinvoice.section.vat"));
section.add(sectionTitle);
vatField = new NumberField();
vatField.setLabel(getTranslation("createinvoice.field.vatrate"));
vatField.setSuffixComponent(new Span("%"));
vatField.setStep(0.01);
vatField.setMin(0);
BigDecimal initialRate = currentUser != null && currentUser.getVatRate() != null
? currentUser.getVatRate()
: Service.FIXED_VAT_RATE;
vatField.setValue(initialRate.multiply(new BigDecimal("100")).doubleValue());
vatField.addValueChangeListener(e -> refreshSummarySection());
section.add(vatField);
return section;
}
private void refreshSummarySection() {
if (summarySection == null) {
return;
}
summarySection.removeAll();
populateSummarySection(summarySection);
}
private Div createPriceRow(String label, String value, boolean bold) {
Div row = new Div();
row.addClassName("price-row");
@@ -427,6 +471,17 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return units;
}
private BigDecimal effectiveVatRate() {
if (vatField != null && vatField.getValue() != null) {
return new BigDecimal(Double.toString(vatField.getValue()))
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
}
if (currentUser != null && currentUser.getVatRate() != null) {
return currentUser.getVatRate();
}
return Service.FIXED_VAT_RATE;
}
private BigDecimal calculateNetAmount() {
BigDecimal total = BigDecimal.ZERO;
@@ -514,7 +569,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -553,7 +608,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
throws Exception {
// Calculate totals
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = Service.FIXED_VAT_RATE;
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -659,9 +714,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private void showPdfPreviewDialog(byte[] pdfBytes, String templateData, User user) {
String title = getTranslation("createinvoice.preview.title");
Dialog pdfDialog = new Dialog();
pdfDialog.setHeaderTitle(title);
pdfDialog.setWidth("90vw");
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
pdfDialog.setHeight("90vh");
IFrame pdfFrame = new IFrame();
@@ -676,19 +729,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader(getTranslation("createinvoice.confirm.save.title"));
confirm.setText(getTranslation("createinvoice.confirm.save.message"));
confirm.setConfirmText(getTranslation("createinvoice.confirm.save.confirm"));
confirm.setConfirmButtonTheme("primary");
confirm.setCancelText(getTranslation("button.cancel"));
confirm.setCancelable(true);
confirm.addConfirmListener(ev -> saveInvoice(templateData, user, pdfDialog));
Dialog confirm = DialogStylingHelper.createConfirmationDialog(
getTranslation("createinvoice.confirm.save.title"),
getTranslation("createinvoice.confirm.save.message"),
"560px",
getTranslation("button.cancel"),
getTranslation("createinvoice.confirm.save.confirm"),
() -> saveInvoice(templateData, user, pdfDialog),
ButtonVariant.LUMO_PRIMARY);
confirm.open();
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
pdfDialog.add(pdfFrame);
pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
pdfDialog.getFooter().add(closeButton, saveButton);
pdfDialog.open();
}
@@ -696,9 +749,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public static void showSavedInvoiceDialog(byte[] pdfBytes, String invoiceNumber,
com.vaadin.flow.component.Component parent) {
String title = "Rechnung " + invoiceNumber;
Dialog pdfDialog = new Dialog();
pdfDialog.setHeaderTitle(title);
pdfDialog.setWidth("90vw");
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
pdfDialog.setHeight("90vh");
IFrame pdfFrame = new IFrame();
@@ -720,7 +771,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Button closeButton = new Button("Schließen", e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
pdfDialog.add(pdfFrame);
pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open();
}

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
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.H5;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
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.Route;
import jakarta.annotation.security.PermitAll;
@@ -25,29 +28,82 @@ public class ImprintView extends VerticalLayout implements HasDynamicTitle {
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint"));
content.add(toolbar);
try {
// Load HTML content from resources
ClassPathResource resource = new ClassPathResource("html/imprint.html");
String htmlContent = new String(resource.getInputStream().readAllBytes());
Div imprintCard = new Div();
imprintCard.addClassNames("form-card", "form-shell");
imprintCard.getStyle().set("max-width", "800px").set("margin", "0 auto");
// Create a Div to hold the HTML content
Div imprintDiv = new Div();
imprintDiv.addClassNames("form-card", "form-shell");
imprintDiv.getElement().setProperty("innerHTML", htmlContent);
VerticalLayout imprintContent = new VerticalLayout();
imprintContent.setPadding(false);
imprintContent.setSpacing(false);
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) {
// Fallback content in case of error
Div errorDiv = new Div();
errorDiv.addClassNames("form-card", "form-shell");
errorDiv.setText(getTranslation("imprint.error", e.getMessage()));
content.add(errorDiv);
}
imprintCard.add(imprintContent);
content.add(imprintCard);
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
public String getPageTitle() {
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.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.server.StreamResource;
import elemental.json.JsonValue;
import elemental.json.JsonType;
import com.vaadin.flow.component.upload.Upload;
@@ -306,15 +305,8 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
}
private void showPdfInDialog(byte[] pdfBytes) {
// Create a stream resource for the PDF
StreamResource resource = new StreamResource("preview.pdf", () -> new java.io.ByteArrayInputStream(pdfBytes));
resource.setContentType("application/pdf");
resource.setCacheTime(0);
// Create dialog
Dialog pdfDialog = new Dialog();
pdfDialog.setHeaderTitle(getTranslation("invoicegenerator.pdf.preview.title"));
pdfDialog.setWidth("90vw");
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
"90vw");
pdfDialog.setHeight("90vh");
// Create a Div to hold the PDF viewer
@@ -347,7 +339,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
});
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
pdfDialog.add(pdfContainer);
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open();
}

View File

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

View File

@@ -0,0 +1,152 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
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.Job;
import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
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.time.LocalDateTime;
@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 final JobRepository jobRepository;
private final JobHistoryService jobHistoryService;
private final SecurityService securityService;
private final VerticalLayout content;
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService) {
this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService;
this.securityService = securityService;
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();
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 job = jobRepository.findById(jobId).orElse(null);
if (job == null) {
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
return;
}
render(job);
}
private void render(Job job) {
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);
HorizontalLayout buttonBar = new HorizontalLayout();
buttonBar.setWidthFull();
buttonBar.setJustifyContentMode(HorizontalLayout.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 -> {
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();
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);
}
});
buttonBar.add(cancelButton, confirmButton);
content.add(buttonBar);
}
}

View File

@@ -60,7 +60,6 @@ import de.assecutor.votianlt.service.LocationService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.service.TaskAssignmentService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId;
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()) {
content.removeAll();
removeAll();
add(new ViewToolbar("Zusammenfassung"));
content.add(new Span("Fehler: Keine Job-ID angegeben"));
add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span(getTranslation("jobsummary.error.noid")));
add(content);
return;
}
@@ -146,8 +145,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
} catch (Exception e) {
content.removeAll();
removeAll();
add(new ViewToolbar("Zusammenfassung"));
content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter));
add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span(getTranslation("jobsummary.error.invalidid", parameter)));
add(content);
return;
}
@@ -186,8 +185,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
Job job = jobRepository.findById(currentJobId).orElse(null);
if (job == null) {
add(new ViewToolbar("Zusammenfassung"));
content.add(new Span("Fehler: Job mit ID " + currentJobId.toHexString() + " nicht gefunden"));
add(new ViewToolbar(getTranslation("jobsummary.title")));
content.add(new Span(getTranslation("jobsummary.error.notfound", currentJobId.toHexString())));
add(content);
return;
}
@@ -213,6 +212,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId));
});
// Create Manual Completion Button for app jobs (digital processing)
Button manualCompleteButton = null;
if (job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED) {
manualCompleteButton = new Button(getTranslation("jobsummary.button.manualcomplete"));
manualCompleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
manualCompleteButton.addClickListener(e -> getUI()
.ifPresent(ui -> ui.navigate("job_manual_complete/" + job.getId().toHexString())));
}
// Create Job History Button for toolbar
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -220,8 +229,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString()));
});
// Add toolbar with both buttons in top right (Send Message button on the left)
add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton));
// Add toolbar with buttons
if (manualCompleteButton != null) {
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
jobHistoryButton));
} else {
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
}
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
@@ -325,33 +339,33 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
new Icon(VaadinIcon.CHECK_CIRCLE));
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
completeButton.addClickListener(e -> {
ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader(getTranslation("jobsummary.dialog.complete.title"));
dialog.setText(getTranslation("jobsummary.dialog.complete.text", job.getJobNumber()));
dialog.setCancelable(true);
dialog.setCancelText(getTranslation("jobsummary.dialog.complete.cancel"));
dialog.setConfirmText(getTranslation("jobsummary.dialog.complete.confirm"));
dialog.setConfirmButtonTheme("primary");
dialog.addConfirmListener(ev -> {
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
// Re-render the page
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);
}
});
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
getTranslation("jobsummary.dialog.complete.title"),
getTranslation("jobsummary.dialog.complete.text", job.getJobNumber()),
"560px",
getTranslation("jobsummary.dialog.complete.cancel"),
getTranslation("jobsummary.dialog.complete.confirm"),
() -> {
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()),
3000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
// Re-render the page
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);
}
},
ButtonVariant.LUMO_PRIMARY);
dialog.open();
});
@@ -896,8 +910,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
// Gespeicherte Dauer formatieren
int hours = savedDuration / 3600;
int minutes = (savedDuration % 3600) / 60;
String savedDurationText = hours > 0 ? String.format("%d Std. %d Min.", hours, minutes)
: String.format("%d Min.", minutes);
String savedDurationText = formatDurationShort(hours, minutes);
String plannedRouteLabel = escapeJs(getTranslation("jobsummary.route.planned"));
String durationLabel = escapeJs(getTranslation("createinvoice.route.duration"));
// Build waypoints JS array
StringBuilder waypointsJs = new StringBuilder("[");
@@ -925,6 +940,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
var hasSavedRouteData = %s;
var savedDistance = %s;
var savedDurationText = '%s';
var plannedRouteLabel = '%s';
var durationLabel = '%s';
var waypoints = %s;
var appUserMarker = null;
@@ -968,7 +985,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
savedRouteDiv.style.backgroundColor = '#e3f2fd';
savedRouteDiv.style.borderRadius = '4px';
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);
}
@@ -1075,7 +1092,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate),
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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.server.auth.AnonymousAllowed;
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.SecurityService;
import de.assecutor.votianlt.service.DemoModeService;
@@ -241,17 +242,15 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
String currentUser = securityService.getCurrentUsername();
ComboBox<String> userCombo = new ComboBox<>();
userCombo.setPlaceholder(currentUser);
userCombo.setItems("Profil anzeigen", "Einstellungen", "Abmelden");
userCombo.setItems("Profil anzeigen", "Abmelden");
userCombo.addValueChangeListener(event -> {
String value = event.getValue();
if (value != null) {
switch (value) {
case "Profil anzeigen":
break;
case "Einstellungen":
break;
case "Abmelden":
securityService.logout();
openLogoutConfirmDialog();
break;
}
userCombo.clear(); // Reset selection
@@ -450,6 +449,19 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
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() {
UI.getCurrent().navigate("register");
}

View File

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

View File

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

View File

@@ -258,6 +258,13 @@ public class CustomerInvoiceService {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, null);
}
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate) throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
@@ -458,7 +465,7 @@ public class CustomerInvoiceService {
}
} else if ("services.list".equals(variable)) {
// Render services list as a table
htmlBuilder.append(generateServicesTableHtml(mmWidth));
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
} else if (text.contains("<br>")) {
// Multi-line text: render without nowrap so <br> tags work
htmlBuilder.append("<span>").append(text).append("</span>");
@@ -484,16 +491,23 @@ public class CustomerInvoiceService {
/**
* Generate HTML table for services list with summary section below.
*/
private String generateServicesTableHtml(double widthMm) {
private String generateServicesTableHtml(double widthMm, BigDecimal vatRate) {
StringBuilder html = new StringBuilder();
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, java.math.RoundingMode.HALF_UP)
.stripTrailingZeros();
if (pct.scale() < 0) {
pct = pct.setScale(0);
}
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
// Sample data for preview (will be replaced with actual job data later)
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", "19%", "450,00 €" },
{ "Entsorgung Möbel", "19%", "85,00 €" }, { "Montage/De-Montage", "19%", "120,00 €" } };
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", vatLabel, "450,00 €" },
{ "Entsorgung Möbel", vatLabel, "85,00 €" }, { "Montage/De-Montage", vatLabel, "120,00 €" } };
// Calculate totals
double netTotal = 655.00;
double grossTotal = 779.45;
double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
// Wrapper div
html.append("<div style='width:100%;box-sizing:border-box;'>");
@@ -797,7 +811,9 @@ public class CustomerInvoiceService {
// Get invoice data from variables
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
// Parse services JSON from variables
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
@@ -822,7 +838,9 @@ public class CustomerInvoiceService {
// Header row
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
html.append(
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:75%;white-space:nowrap;'>Name</th>");
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
html.append("</tr>");
@@ -832,7 +850,7 @@ public class CustomerInvoiceService {
// Fallback: show a single row with no data
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append(
"<td colspan='2' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
html.append("</tr>");
} else {
for (int i = 0; i < servicesData.size(); i++) {
@@ -843,8 +861,10 @@ public class CustomerInvoiceService {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>")
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(vatRateLabel)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(netAmount).append(" €</td>");
html.append("</tr>");
@@ -865,6 +885,15 @@ public class CustomerInvoiceService {
.append(netTotal).append("</td>");
html.append("</tr>");
// Umsatzsteuer
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
.append(escapeHtml(vatRateLabel)).append(" USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(vatTotal).append("</td>");
html.append("</tr>");
// Gesamtsumme
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");

View File

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

View File

@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;
@@ -100,6 +101,16 @@ public class MessageService {
* Inbound message payload where receiver = AppUser ID (clientId)
*/
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
String clientMessageId = payload.messageId();
if (clientMessageId != null && !clientMessageId.isBlank()) {
Optional<Message> existingMessage = messageRepository.findFirstByReceiverAndOriginAndClientMessageId(
payload.receiver(), MessageOrigin.CLIENT, clientMessageId);
if (existingMessage.isPresent()) {
sendClientMessageAck(payload.receiver(), clientMessageId, existingMessage.get());
return existingMessage.get();
}
}
Message message;
MessageContentType contentType = payload.contentType();
if (payload.hasJobContext()) {
@@ -109,11 +120,36 @@ public class MessageService {
} else {
message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType);
}
message.setClientMessageId(clientMessageId);
message = saveMessage(message);
sendClientMessageAck(payload.receiver(), clientMessageId, message);
eventPublisher.publishEvent(new MessageReceivedEvent(this, message));
return message;
}
private void sendClientMessageAck(String receiver, String clientMessageId, Message message) {
if (receiver == null || receiver.isBlank() || clientMessageId == null || clientMessageId.isBlank()
|| message == null) {
return;
}
try {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("clientMessageId", clientMessageId);
payload.put("messageId", message.getIdAsString());
payload.put("createdAt", message.getCreatedAt());
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
webSocketService.sendToClient(receiver, "message_ack", data).exceptionally(ex -> {
log.debug("[Messaging] Failed to send ACK for client message {} to {}: {}", clientMessageId, receiver,
ex.getMessage());
return null;
});
} catch (Exception e) {
log.error("[Messaging] Error sending ACK for client message {}: {}", clientMessageId, e.getMessage());
}
}
/**
* Publish message to topic for the receiver. Only sends if client is connected,
* otherwise keeps NOTSEND status.

View File

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

View File

@@ -5,7 +5,7 @@ dialog.confirm=Bestätigen
# Navigation and Main Layout
nav.jobs=Aufträge
nav.job.create=Auftragserstellung
nav.customers=Kunden
nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
@@ -18,6 +18,8 @@ nav.users=Benutzer
nav.showprofile=Profil anzeigen
nav.settings=Einstellungen
nav.logout=Abmelden
logout.confirm.title=Abmelden bestätigen
logout.confirm.message=Möchten Sie sich wirklich abmelden?
# Profile View
profile.title=Profil bearbeiten
@@ -29,7 +31,7 @@ profile.lastname=Nachname
profile.phone=Telefonnummer
profile.fax=Telefon (Fax)
profile.mobile=Telefon (Mobil)
profile.email=E-Mail-Adresse (Login)*
profile.email=E-Mail-Adresse
profile.street=Straße
profile.housenr=Hausnr
profile.addressadd=Adresszusatz
@@ -44,6 +46,7 @@ profile.settings.digitalprocess=Digitale Abwicklung
profile.settings.digitalprocess.info=Aufträge werden digital über die App abgewickelt
profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -200,6 +203,10 @@ common.error=Fehler
common.success=Erfolg
common.required=Pflichtfeld
# Duration
duration.hours.short=Std.
duration.minutes.short=Min.
# Validation
validation.required=Feld ist erforderlich
validation.email=Ungültige E-Mail-Adresse
@@ -441,7 +448,8 @@ addjob.address.city.placeholder.pickup=Ort (Abholung)
addjob.address.city.placeholder.delivery=Ort (Lieferung)
addjob.address.delivery.street.placeholder=Straße (Lieferung)
addjob.address.delivery.addition.placeholder=Adresszusatz (Lieferung)
addjob.address.save=Adresse speichern
addjob.address.save=Adresse in Adressbuch übernehmen
addjob.address.update=Adresse im Adressbuch aktualisieren
addjob.section.pickup=Abholung
addjob.section.delivery=Lieferung
addjob.stations.apply=Stationen \u00fcbernehmen
@@ -507,7 +515,8 @@ addjob.tasks.photo.min=Min. Fotos
addjob.tasks.photo.max=Max. Fotos
addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. Barcodes
addjob.tasks.signature.noconfig=Keine Konfiguration erforderlich
addjob.tasks.signature.notelabel=Bemerkung (optional)
addjob.tasks.signature.notelabel.placeholder=Hinweistext für die Bemerkung eingeben
addjob.tasks.todolist.title=To-Do Liste
addjob.tasks.todolist.item.placeholder=To-Do eingeben
addjob.tasks.todolist.add=To-Do hinzufügen
@@ -603,6 +612,15 @@ jobsummary.task.photo.maxonly=Maximal {0} Fotos erlaubt
jobsummary.task.photo.taken=Aufgenommene Fotos ({0})
jobsummary.task.button.text=Button-Text
jobsummary.button.schliessen=Schließen
jobsummary.route.planned=Geplante Route
jobsummary.button.manualcomplete=Manuell beenden
jobsummary.dialog.manualcomplete.title=Auftrag manuell beenden
jobsummary.dialog.manualcomplete.text=Der Auftrag {0} wird jetzt manuell abgeschlossen. Danach kann er nicht mehr per App weiter bearbeitet werden.
jobsummary.dialog.manualcomplete.reason=Begründung
jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründung ein
jobsummary.dialog.manualcomplete.cancel=Abbrechen
jobsummary.dialog.manualcomplete.confirm=Akzeptiert
jobsummary.history.manualcomplete.reason=Manuell beendet
# Jobs
jobs.title=Aufträge
@@ -645,6 +663,8 @@ createinvoice.section.job=Auftragsdetails
createinvoice.section.route=Streckeninfo
createinvoice.section.services=Leistungen
createinvoice.section.summary=Zusammenfassung
createinvoice.section.vat=Umsatzsteuer
createinvoice.field.vatrate=USt-Satz
createinvoice.field.jobnumber=Auftragsnummer
createinvoice.field.customer=Kunde
createinvoice.field.status=Status
@@ -730,6 +750,8 @@ statistics.loading=Berechne...
# Job Status
jobstatus.IN_PROGRESS=In Bearbeitung
jobstatus.COMPLETED=Abgeschlossen
jobstatus.CREATED=Erstellt
jobstatus.CANCELLED=Storniert
# Task Types
tasktype.CONFIRMATION=Bestätigung
@@ -832,6 +854,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Telefon
imprint.email=E-Mail
imprint.country=Deutschland
imprint.management=Geschäftsführung
imprint.registeredoffice=Firmensitz
imprint.commercialregister=Handelsregister
imprint.vatid=Umsatzsteuer-ID
imprint.imagecredits=Quellenangaben für die verwendeten Bilder und Grafiken
imprint.backgroundimage=Hintergrundbild Startseite
start.cta.text=Registrieren Sie sich noch heute und nutzen den kostenfreien Probemonat, um das System auf Herz und Nieren zu testen.
start.slogan=Betreiben Sie Ihr Geschäft smart … mit votianLT!
start.version=Version
@@ -914,6 +945,8 @@ jobhistory.info.createdat=Erstellt am: {0}
jobhistory.info.status=Status: {0}
jobhistory.count={0} Einträge in der Historie
jobhistory.changedby=Geändert von: {0}
jobhistory.entry.create.reason=Job erstellt
jobhistory.entry.create.description=Neuer Job wurde erstellt: {0}
# Version
version.label=Version

View File

@@ -3,7 +3,7 @@ dialog.cancel=T\u00fchista
dialog.confirm=Kinnita
nav.jobs=Tellimused
nav.job.create=Tellimuse loomine
nav.customers=Kliendid
nav.customers=Aadressiraamat
nav.appusers=\u00c4pikasutajad
nav.statistics=Statistika
nav.invoices=Arved
@@ -16,6 +16,8 @@ nav.users=Kasutajad
nav.showprofile=Kuva profiil
nav.settings=Seaded
nav.logout=Logi v\u00e4lja
logout.confirm.title=Kinnita v\u00e4ljalogimine
logout.confirm.message=Kas soovite t\u00f5esti v\u00e4lja logida?
profile.title=Profiili muutmine
profile.language=Keel
profile.company=Ettev\u00f5te
@@ -25,7 +27,7 @@ profile.lastname=Perekonnanimi
profile.phone=Telefoninumber
profile.fax=Telefon (faks)
profile.mobile=Telefon (mobiil)
profile.email=E-posti aadress (sisselogimine)*
profile.email=E-posti aadress
profile.street=T\u00e4nav
profile.housenr=Majanumber
profile.addressadd=Aadressi t\u00e4iend
@@ -181,6 +183,11 @@ common.loading=Laadimine...
common.error=Viga
common.success=Edukas
common.required=Kohustuslik v\u00e4li
# Duration
duration.hours.short=t
duration.minutes.short=min
validation.required=V\u00e4li on kohustuslik
validation.email=Vigane e-posti aadress
validation.error=Valideerimise viga
@@ -389,7 +396,8 @@ addjob.address.city.placeholder.pickup=Asukoht (pealekorje)
addjob.address.city.placeholder.delivery=Asukoht (kohaletoimetamine)
addjob.address.delivery.street.placeholder=T\u00e4nav (kohaletoimetamine)
addjob.address.delivery.addition.placeholder=Aadressi t\u00e4iend (kohaletoimetamine)
addjob.address.save=Salvesta aadress
addjob.address.save=Lisa aadress aadressiraamatusse
addjob.address.update=Uuenda aadressi aadressiraamatus
addjob.section.pickup=Pealekorje
addjob.section.delivery=Kohaletoimetamine
addjob.stations.apply=Rakenda jaamad
@@ -455,7 +463,8 @@ addjob.tasks.photo.min=Min. fotosid
addjob.tasks.photo.max=Max. fotosid
addjob.tasks.barcode.min=Min. v\u00f6\u00f6tkoode
addjob.tasks.barcode.max=Max. v\u00f6\u00f6tkoode
addjob.tasks.signature.noconfig=Seadistamine pole vajalik
addjob.tasks.signature.notelabel=Märkus (valikuline)
addjob.tasks.signature.notelabel.placeholder=Sisestage vihje tekst märkusele
addjob.tasks.todolist.title=\u00dclesannete nimekiri
addjob.tasks.todolist.item.placeholder=Sisestage \u00fclesanne
addjob.tasks.todolist.add=Lisa \u00fclesanne
@@ -549,6 +558,15 @@ jobsummary.task.photo.maxonly=Maksimaalselt {0} fotot lubatud
jobsummary.task.photo.taken=Tehtud fotod ({0})
jobsummary.task.button.text=Nupu tekst
jobsummary.button.schliessen=Sulge
jobsummary.route.planned=Planeeritud marsruut
jobsummary.button.manualcomplete=Lõpeta käsitsi
jobsummary.dialog.manualcomplete.title=Lõpeta tellimus käsitsi
jobsummary.dialog.manualcomplete.text=Tellimus {0} lõpetatakse nüüd käsitsi. Pärast seda ei saa seda enam rakenduse kaudu töödelda.
jobsummary.dialog.manualcomplete.reason=Põhjendus
jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus
jobsummary.dialog.manualcomplete.cancel=Tühista
jobsummary.dialog.manualcomplete.confirm=Nõustu
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
jobs.title=Tellimused
jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...
@@ -662,6 +680,8 @@ statistics.data.fetched=Andmed on k\u00e4tte saadud
statistics.loading=Arvutamine...
jobstatus.IN_PROGRESS=T\u00f6\u00f6s
jobstatus.COMPLETED=L\u00f5petatud
jobstatus.CREATED=Loodud
jobstatus.CANCELLED=T\u00fchistatud
tasktype.CONFIRMATION=Kinnitus
tasktype.SIGNATURE=Allkiri
tasktype.TODOLIST=\u00dclesannete nimekiri
@@ -754,6 +774,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-post: ahoi@assecutor.de
imprint.phone=Telefon
imprint.email=E-post
imprint.country=Saksamaa
imprint.management=Juhtkond
imprint.registeredoffice=Registrijärgne asukoht
imprint.commercialregister=Äriregister
imprint.vatid=KMKR number
imprint.imagecredits=Kasutatud piltide ja graafika allikad
imprint.backgroundimage=Avalehe taustapilt
start.cta.text=Registreeruge juba t\u00e4na ja kasutage tasuta proovikuud, et s\u00fcsteemi p\u00f5hjalikult testida.
start.slogan=Ajage oma \u00e4ri targalt \u2026 votianLT-ga!
start.version=Versioon
@@ -824,6 +853,8 @@ jobhistory.info.createdat=Loodud: {0}
jobhistory.info.status=Olek: {0}
jobhistory.count={0} kirjet ajaloos
jobhistory.changedby=Muutnud: {0}
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
version.label=Versioon
management.placeholder=Haldus
management.customers=Kliendid

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirm
# Navigation and Main Layout
nav.jobs=Jobs
nav.job.create=Create Job
nav.customers=Customers
nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
@@ -18,6 +18,8 @@ nav.users=Users
nav.showprofile=Show Profile
nav.settings=Settings
nav.logout=Log Out
logout.confirm.title=Confirm logout
logout.confirm.message=Do you really want to log out?
# Profile View
profile.title=Edit Profile
@@ -29,7 +31,7 @@ profile.lastname=Last Name
profile.phone=Phone Number
profile.fax=Phone (Fax)
profile.mobile=Phone (Mobile)
profile.email=Email Address (Login)*
profile.email=Email Address
profile.street=Street
profile.housenr=House No.
profile.addressadd=Address Suffix
@@ -44,6 +46,7 @@ profile.settings.digitalprocess=Digital Processing
profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -200,6 +203,10 @@ common.error=Error
common.success=Success
common.required=Required
# Duration
duration.hours.short=hr
duration.minutes.short=min
# Validation
validation.required=Field is required
validation.email=Invalid email address
@@ -441,7 +448,8 @@ addjob.address.city.placeholder.pickup=City (Pickup)
addjob.address.city.placeholder.delivery=City (Delivery)
addjob.address.delivery.street.placeholder=Street (Delivery)
addjob.address.delivery.addition.placeholder=Address suffix (Delivery)
addjob.address.save=Save Address
addjob.address.save=Add address to address book
addjob.address.update=Update address in address book
addjob.section.pickup=Pickup
addjob.section.delivery=Delivery
addjob.stations.apply=Apply Stations
@@ -507,7 +515,8 @@ addjob.tasks.photo.min=Min. Photos
addjob.tasks.photo.max=Max. Photos
addjob.tasks.barcode.min=Min. Barcodes
addjob.tasks.barcode.max=Max. Barcodes
addjob.tasks.signature.noconfig=No configuration required
addjob.tasks.signature.notelabel=Note (optional)
addjob.tasks.signature.notelabel.placeholder=Enter hint text for the note
addjob.tasks.todolist.title=To-Do List
addjob.tasks.todolist.item.placeholder=Enter to-do
addjob.tasks.todolist.add=Add To-Do
@@ -603,6 +612,15 @@ jobsummary.task.photo.maxonly=Maximum {0} photos allowed
jobsummary.task.photo.taken=Photos taken ({0})
jobsummary.task.button.text=Button Text
jobsummary.button.schliessen=Close
jobsummary.route.planned=Planned Route
jobsummary.button.manualcomplete=Complete manually
jobsummary.dialog.manualcomplete.title=Complete job manually
jobsummary.dialog.manualcomplete.text=Job {0} will now be completed manually. It can no longer be processed via the app afterwards.
jobsummary.dialog.manualcomplete.reason=Reason
jobsummary.dialog.manualcomplete.reason.required=Please enter a reason
jobsummary.dialog.manualcomplete.cancel=Cancel
jobsummary.dialog.manualcomplete.confirm=Accept
jobsummary.history.manualcomplete.reason=Manually completed
# Jobs
jobs.title=Jobs
@@ -645,6 +663,8 @@ createinvoice.section.job=Job Details
createinvoice.section.route=Route Info
createinvoice.section.services=Services
createinvoice.section.summary=Summary
createinvoice.section.vat=VAT
createinvoice.field.vatrate=VAT rate
createinvoice.field.jobnumber=Job Number
createinvoice.field.customer=Customer
createinvoice.field.status=Status
@@ -730,6 +750,8 @@ statistics.loading=Calculating...
# Job Status
jobstatus.IN_PROGRESS=In Progress
jobstatus.COMPLETED=Completed
jobstatus.CREATED=Created
jobstatus.CANCELLED=Cancelled
# Task Types
tasktype.CONFIRMATION=Confirmation
@@ -832,6 +854,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Phone: +49 40 18 123 771 0
start.imprint.email=Email: ahoi@assecutor.de
imprint.phone=Phone
imprint.email=Email
imprint.country=Germany
imprint.management=Management
imprint.registeredoffice=Registered Office
imprint.commercialregister=Commercial Register
imprint.vatid=VAT ID
imprint.imagecredits=Image Credits for Pictures and Graphics Used
imprint.backgroundimage=Homepage Background Image
start.cta.text=Register today and use the free trial month to put the system through its paces.
start.slogan=Run your business smart ... with votianLT!
start.version=Version
@@ -914,6 +945,8 @@ jobhistory.info.createdat=Created on: {0}
jobhistory.info.status=Status: {0}
jobhistory.count={0} entries in history
jobhistory.changedby=Changed by: {0}
jobhistory.entry.create.reason=Job Created
jobhistory.entry.create.description=New job was created: {0}
# Version
version.label=Version

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmar
# Navigation and Main Layout
nav.jobs=Pedidos
nav.job.create=Crear pedido
nav.customers=Clientes
nav.customers=Libreta de direcciones
nav.appusers=Usuarios de la app
nav.statistics=Estad\u00edsticas
nav.invoices=Facturas
@@ -18,6 +18,8 @@ nav.users=Usuarios
nav.showprofile=Ver perfil
nav.settings=Configuraci\u00f3n
nav.logout=Cerrar sesi\u00f3n
logout.confirm.title=Confirmar cierre de sesi\u00f3n
logout.confirm.message=\u00bfRealmente desea cerrar sesi\u00f3n?
# Profile View
profile.title=Editar perfil
@@ -29,7 +31,7 @@ profile.lastname=Apellido
profile.phone=N\u00famero de tel\u00e9fono
profile.fax=Tel\u00e9fono (Fax)
profile.mobile=Tel\u00e9fono (M\u00f3vil)
profile.email=Direcci\u00f3n de correo electr\u00f3nico (Login)*
profile.email=Direcci\u00f3n de correo electr\u00f3nico
profile.street=Calle
profile.housenr=N\u00famero
profile.addressadd=Complemento de direcci\u00f3n
@@ -200,6 +202,10 @@ common.error=Error
common.success=\u00c9xito
common.required=Campo obligatorio
# Duration
duration.hours.short=h
duration.minutes.short=min
# Validation
validation.required=El campo es obligatorio
validation.email=Direcci\u00f3n de correo electr\u00f3nico no v\u00e1lida
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Localidad (Recogida)
addjob.address.city.placeholder.delivery=Localidad (Entrega)
addjob.address.delivery.street.placeholder=Calle (Entrega)
addjob.address.delivery.addition.placeholder=Complemento de direcci\u00f3n (Entrega)
addjob.address.save=Guardar direcci\u00f3n
addjob.address.save=A\u00f1adir direcci\u00f3n a la libreta de direcciones
addjob.address.update=Actualizar direcci\u00f3n en la libreta de direcciones
addjob.section.pickup=Recogida
addjob.section.delivery=Entrega
addjob.stations.apply=Aplicar estaciones
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=M\u00edn. fotos
addjob.tasks.photo.max=M\u00e1x. fotos
addjob.tasks.barcode.min=M\u00edn. c\u00f3digos de barras
addjob.tasks.barcode.max=M\u00e1x. c\u00f3digos de barras
addjob.tasks.signature.noconfig=No se requiere configuraci\u00f3n
addjob.tasks.signature.notelabel=Nota (opcional)
addjob.tasks.signature.notelabel.placeholder=Introducir texto de sugerencia para la nota
addjob.tasks.todolist.title=Lista de tareas pendientes
addjob.tasks.todolist.item.placeholder=Introducir tarea pendiente
addjob.tasks.todolist.add=A\u00f1adir tarea pendiente
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Se permiten como m\u00e1ximo {0} fotos
jobsummary.task.photo.taken=Fotos tomadas ({0})
jobsummary.task.button.text=Texto del bot\u00f3n
jobsummary.button.schliessen=Cerrar
jobsummary.route.planned=Ruta planificada
jobsummary.button.manualcomplete=Finalizar manualmente
jobsummary.dialog.manualcomplete.title=Finalizar pedido manualmente
jobsummary.dialog.manualcomplete.text=El pedido {0} se completar\u00e1 manualmente. Despu\u00e9s ya no podr\u00e1 ser procesado a trav\u00e9s de la aplicaci\u00f3n.
jobsummary.dialog.manualcomplete.reason=Motivo
jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo
jobsummary.dialog.manualcomplete.cancel=Cancelar
jobsummary.dialog.manualcomplete.confirm=Aceptar
jobsummary.history.manualcomplete.reason=Finalizado manualmente
# Jobs
jobs.title=Pedidos
@@ -730,6 +747,8 @@ statistics.loading=Calculando...
# Job Status
jobstatus.IN_PROGRESS=En proceso
jobstatus.COMPLETED=Completado
jobstatus.CREATED=Creado
jobstatus.CANCELLED=Cancelado
# Task Types
tasktype.CONFIRMATION=Confirmaci\u00f3n
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Tel\u00e9fono: +49 40 18 123 771 0
start.imprint.email=Correo electr\u00f3nico: ahoi@assecutor.de
imprint.phone=Tel\u00e9fono
imprint.email=Correo electr\u00f3nico
imprint.country=Alemania
imprint.management=Direcci\u00f3n
imprint.registeredoffice=Domicilio social
imprint.commercialregister=Registro mercantil
imprint.vatid=ID de IVA
imprint.imagecredits=Fuentes de las im\u00e1genes y gr\u00e1ficos utilizados
imprint.backgroundimage=Imagen de fondo de la p\u00e1gina de inicio
start.cta.text=Reg\u00edstrese hoy mismo y utilice el mes de prueba gratuito para probar el sistema a fondo.
start.slogan=\u00a1Gestione su negocio de forma inteligente... con votianLT!
start.version=Versi\u00f3n
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Creado el: {0}
jobhistory.info.status=Estado: {0}
jobhistory.count={0} entradas en el historial
jobhistory.changedby=Modificado por: {0}
jobhistory.entry.create.reason=Pedido creado
jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0}
# Version
version.label=Versi\u00f3n

View File

@@ -5,7 +5,7 @@ dialog.confirm=Confirmer
# Navigation and Main Layout
nav.jobs=Missions
nav.job.create=Cr\u00e9ation de mission
nav.customers=Clients
nav.customers=Carnet d'adresses
nav.appusers=Utilisateurs d'app
nav.statistics=Statistiques
nav.invoices=Factures
@@ -18,6 +18,8 @@ nav.users=Utilisateurs
nav.showprofile=Afficher le profil
nav.settings=Param\u00e8tres
nav.logout=D\u00e9connexion
logout.confirm.title=Confirmer la d\u00e9connexion
logout.confirm.message=Voulez-vous vraiment vous d\u00e9connecter ?
# Profile View
profile.title=Modifier le profil
@@ -29,7 +31,7 @@ profile.lastname=Nom
profile.phone=Num\u00e9ro de t\u00e9l\u00e9phone
profile.fax=T\u00e9l\u00e9phone (fax)
profile.mobile=T\u00e9l\u00e9phone (mobile)
profile.email=Adresse e-mail (connexion)*
profile.email=Adresse e-mail
profile.street=Rue
profile.housenr=N\u00b0
profile.addressadd=Compl\u00e9ment d'adresse
@@ -200,6 +202,10 @@ common.error=Erreur
common.success=Succ\u00e8s
common.required=Champ obligatoire
# Duration
duration.hours.short=h
duration.minutes.short=min
# Validation
validation.required=Le champ est obligatoire
validation.email=Adresse e-mail invalide
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Ville (enl\u00e8vement)
addjob.address.city.placeholder.delivery=Ville (livraison)
addjob.address.delivery.street.placeholder=Rue (livraison)
addjob.address.delivery.addition.placeholder=Compl\u00e9ment d'adresse (livraison)
addjob.address.save=Enregistrer l'adresse
addjob.address.save=Ajouter l'adresse au carnet d'adresses
addjob.address.update=Mettre \u00e0 jour l'adresse dans le carnet d'adresses
addjob.section.pickup=Enl\u00e8vement
addjob.section.delivery=Livraison
addjob.stations.apply=Appliquer les stations
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Min. photos
addjob.tasks.photo.max=Max. photos
addjob.tasks.barcode.min=Min. codes-barres
addjob.tasks.barcode.max=Max. codes-barres
addjob.tasks.signature.noconfig=Aucune configuration n\u00e9cessaire
addjob.tasks.signature.notelabel=Note (optionnelle)
addjob.tasks.signature.notelabel.placeholder=Saisir le texte d'indication pour la note
addjob.tasks.todolist.title=Liste de t\u00e2ches
addjob.tasks.todolist.item.placeholder=Saisir la t\u00e2che
addjob.tasks.todolist.add=Ajouter une t\u00e2che
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Maximum {0} photos autoris\u00e9es
jobsummary.task.photo.taken=Photos prises ({0})
jobsummary.task.button.text=Texte du bouton
jobsummary.button.schliessen=Fermer
jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu
jobsummary.button.manualcomplete=Terminer manuellement
jobsummary.dialog.manualcomplete.title=Terminer la commande manuellement
jobsummary.dialog.manualcomplete.text=La commande {0} va maintenant \u00eatre termin\u00e9e manuellement. Elle ne pourra plus \u00eatre trait\u00e9e via l\u2019application par la suite.
jobsummary.dialog.manualcomplete.reason=Motif
jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif
jobsummary.dialog.manualcomplete.cancel=Annuler
jobsummary.dialog.manualcomplete.confirm=Accepter
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
# Jobs
jobs.title=Missions
@@ -730,6 +747,8 @@ statistics.loading=Calcul en cours...
# Job Status
jobstatus.IN_PROGRESS=En cours
jobstatus.COMPLETED=Termin\u00e9e
jobstatus.CREATED=Cr\u00e9\u00e9e
jobstatus.CANCELLED=Annul\u00e9e
# Task Types
tasktype.CONFIRMATION=Confirmation
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=T\u00e9l\u00e9phone
imprint.email=E-mail
imprint.country=Allemagne
imprint.management=Direction
imprint.registeredoffice=Si\u00e8ge social
imprint.commercialregister=Registre du commerce
imprint.vatid=ID TVA
imprint.imagecredits=Cr\u00e9dits des images et graphiques utilis\u00e9s
imprint.backgroundimage=Image d'arri\u00e8re-plan de la page d'accueil
start.cta.text=Inscrivez-vous d\u00e8s aujourd'hui et profitez du mois d'essai gratuit pour tester le syst\u00e8me en profondeur.
start.slogan=G\u00e9rez votre activit\u00e9 intelligemment ... avec votianLT !
start.version=Version
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0}
jobhistory.info.status=Statut : {0}
jobhistory.count={0} entr\u00e9es dans l'historique
jobhistory.changedby=Modifi\u00e9 par : {0}
jobhistory.entry.create.reason=Mission cr\u00e9\u00e9e
jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0}
# Version
version.label=Version

View File

@@ -5,7 +5,7 @@ dialog.confirm=Patvirtinti
# Navigation and Main Layout
nav.jobs=Užsakymai
nav.job.create=Užsakymo kūrimas
nav.customers=Klientai
nav.customers=Adres\u0173 knyga
nav.appusers=Programėlės naudotojai
nav.statistics=Statistika
nav.invoices=Sąskaitos faktūros
@@ -18,6 +18,8 @@ nav.users=Naudotojai
nav.showprofile=Rodyti profilį
nav.settings=Nustatymai
nav.logout=Atsijungti
logout.confirm.title=Patvirtinti atsijungimą
logout.confirm.message=Ar tikrai norite atsijungti?
# Profile View
profile.title=Redaguoti profilį
@@ -29,7 +31,7 @@ profile.lastname=Pavardė
profile.phone=Telefono numeris
profile.fax=Telefonas (faksas)
profile.mobile=Telefonas (mob.)
profile.email=El. pašto adresas (prisijungimas)*
profile.email=El. pašto adresas*
profile.street=Gatvė
profile.housenr=Namo nr.
profile.addressadd=Adreso priedas
@@ -200,6 +202,10 @@ common.error=Klaida
common.success=Sėkmė
common.required=Privalomas laukas
# Duration
duration.hours.short=val.
duration.minutes.short=min.
# Validation
validation.required=Laukas yra privalomas
validation.email=Neteisingas el. pašto adresas
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Vietovė (atsiėmimas)
addjob.address.city.placeholder.delivery=Vietovė (pristatymas)
addjob.address.delivery.street.placeholder=Gatvė (pristatymas)
addjob.address.delivery.addition.placeholder=Adreso priedas (pristatymas)
addjob.address.save=Išsaugoti adresą
addjob.address.save=Pridėti adresą į adresų knygą
addjob.address.update=Atnaujinti adresą adresų knygoje
addjob.section.pickup=Atsiėmimas
addjob.section.delivery=Pristatymas
addjob.stations.apply=Pritaikyti stotis
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Min. nuotraukų
addjob.tasks.photo.max=Maks. nuotraukų
addjob.tasks.barcode.min=Min. brūkšninių kodų
addjob.tasks.barcode.max=Maks. brūkšninių kodų
addjob.tasks.signature.noconfig=Konfigūracija nereikalinga
addjob.tasks.signature.notelabel=Pastaba (neprivaloma)
addjob.tasks.signature.notelabel.placeholder=Įveskite patarimo tekstą pastabai
addjob.tasks.todolist.title=Užduočių sąrašas
addjob.tasks.todolist.item.placeholder=Įveskite užduotį
addjob.tasks.todolist.add=Pridėti užduotį
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Daugiausiai {0} nuotraukų
jobsummary.task.photo.taken=Padarytos nuotraukos ({0})
jobsummary.task.button.text=Mygtuko tekstas
jobsummary.button.schliessen=Uždaryti
jobsummary.route.planned=Planuotas maršrutas
jobsummary.button.manualcomplete=Užbaigti rankiniu būdu
jobsummary.dialog.manualcomplete.title=Užbaigti užsakymą rankiniu būdu
jobsummary.dialog.manualcomplete.text=Užsakymas {0} dabar bus užbaigtas rankiniu būdu. Po to jo nebebus galima apdoroti per programėlę.
jobsummary.dialog.manualcomplete.reason=Priežastis
jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį
jobsummary.dialog.manualcomplete.cancel=Atšaukti
jobsummary.dialog.manualcomplete.confirm=Priimti
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
# Jobs
jobs.title=Užsakymai
@@ -645,6 +662,8 @@ createinvoice.section.job=Užsakymo informacija
createinvoice.section.route=Maršruto informacija
createinvoice.section.services=Paslaugos
createinvoice.section.summary=Santrauka
createinvoice.section.vat=PVM
createinvoice.field.vatrate=PVM tarifas
createinvoice.field.jobnumber=Užsakymo numeris
createinvoice.field.customer=Klientas
createinvoice.field.status=Būsena
@@ -730,6 +749,8 @@ statistics.loading=Skaičiuojama...
# Job Status
jobstatus.IN_PROGRESS=Vykdomas
jobstatus.COMPLETED=Užbaigtas
jobstatus.CREATED=Sukurtas
jobstatus.CANCELLED=Atšauktas
# Task Types
tasktype.CONFIRMATION=Patvirtinimas
@@ -832,6 +853,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Telefonas
imprint.email=El. paštas
imprint.country=Vokietija
imprint.management=Vadovybė
imprint.registeredoffice=Buveinė
imprint.commercialregister=Komercinis registras
imprint.vatid=PVM kodas
imprint.imagecredits=Naudotų vaizdų ir grafikos šaltiniai
imprint.backgroundimage=Pradžios puslapio fono paveikslėlis
start.cta.text=Užsiregistruokite šiandien ir pasinaudokite nemokamu bandomuoju mėnesiu, kad galėtumėte išbandyti sistemą.
start.slogan=Valdykite savo verslą protingai … su votianLT!
start.version=Versija
@@ -914,6 +944,8 @@ jobhistory.info.createdat=Sukurta: {0}
jobhistory.info.status=Būsena: {0}
jobhistory.count={0} įrašų istorijoje
jobhistory.changedby=Pakeitė: {0}
jobhistory.entry.create.reason=Užsakymas sukurtas
jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0}
# Version
version.label=Versija

View File

@@ -5,7 +5,7 @@ dialog.confirm=Apstiprināt
# Navigation and Main Layout
nav.jobs=Uzdevumi
nav.job.create=Izveidot uzdevumu
nav.customers=Klienti
nav.customers=Adrešu gr\u0101mata
nav.appusers=Lietotnes lietotāji
nav.statistics=Statistika
nav.invoices=Rēķini
@@ -18,6 +18,8 @@ nav.users=Lietotāji
nav.showprofile=Rādīt profilu
nav.settings=Iestatījumi
nav.logout=Izrakstīties
logout.confirm.title=Apstiprināt izrakstīšanos
logout.confirm.message=Vai tiešām vēlaties izrakstīties?
# Profile View
profile.title=Rediģēt profilu
@@ -29,7 +31,7 @@ profile.lastname=Uzvārds
profile.phone=Tālruņa numurs
profile.fax=Tālrunis (fakss)
profile.mobile=Tālrunis (mobilais)
profile.email=E-pasta adrese (pieteikšanās)*
profile.email=E-pasta adrese
profile.street=Iela
profile.housenr=Mājas nr.
profile.addressadd=Adreses papildinājums
@@ -200,6 +202,10 @@ common.error=Kļūda
common.success=Veiksmīgi
common.required=Obligāts lauks
# Duration
duration.hours.short=st.
duration.minutes.short=min.
# Validation
validation.required=Lauks ir obligāts
validation.email=Nederīga e-pasta adrese
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Vieta (saņemšana)
addjob.address.city.placeholder.delivery=Vieta (piegāde)
addjob.address.delivery.street.placeholder=Iela (piegāde)
addjob.address.delivery.addition.placeholder=Adreses papildinājums (piegāde)
addjob.address.save=Saglabāt adresi
addjob.address.save=Pievienot adresi adrešu grāmatai
addjob.address.update=Atjaunin\u0101t adresi adrešu gr\u0101mat\u0101
addjob.section.pickup=Saņemšana
addjob.section.delivery=Piegāde
addjob.stations.apply=Pārņemt stacijas
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Min. fotogrāfijas
addjob.tasks.photo.max=Maks. fotogrāfijas
addjob.tasks.barcode.min=Min. svītrkodi
addjob.tasks.barcode.max=Maks. svītrkodi
addjob.tasks.signature.noconfig=Konfigurācija nav nepieciešama
addjob.tasks.signature.notelabel=Piezīme (neobligāta)
addjob.tasks.signature.notelabel.placeholder=Ievadiet padoma tekstu piezīmei
addjob.tasks.todolist.title=Uzdevumu saraksts
addjob.tasks.todolist.item.placeholder=Ievadiet uzdevumu
addjob.tasks.todolist.add=Pievienot uzdevumu
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Atļautas ne vairāk kā {0} fotogrāfijas
jobsummary.task.photo.taken=Uzņemtās fotogrāfijas ({0})
jobsummary.task.button.text=Pogas teksts
jobsummary.button.schliessen=Aizvērt
jobsummary.route.planned=Plānotais maršruts
jobsummary.button.manualcomplete=Pabeigt manuāli
jobsummary.dialog.manualcomplete.title=Pabeigt pasūtījumu manuāli
jobsummary.dialog.manualcomplete.text=Pasūtījums {0} tagad tiks pabeigts manuāli. Pēc tam to vairs nevarēs apstrādāt, izmantojot lietotni.
jobsummary.dialog.manualcomplete.reason=Pamatojums
jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu
jobsummary.dialog.manualcomplete.cancel=Atcelt
jobsummary.dialog.manualcomplete.confirm=Apstiprināt
jobsummary.history.manualcomplete.reason=Pabeigts manuāli
# Jobs
jobs.title=Uzdevumi
@@ -730,6 +747,8 @@ statistics.loading=Aprēķina...
# Job Status
jobstatus.IN_PROGRESS=Izpildē
jobstatus.COMPLETED=Pabeigts
jobstatus.CREATED=Izveidots
jobstatus.CANCELLED=Atcelts
# Task Types
tasktype.CONFIRMATION=Apstiprinājums
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Tālrunis
imprint.email=E-pasts
imprint.country=Vācija
imprint.management=Vadība
imprint.registeredoffice=Juridiskā adrese
imprint.commercialregister=Komercreģistrs
imprint.vatid=PVN ID
imprint.imagecredits=Izmantoto attēlu un grafiku avoti
imprint.backgroundimage=Sākumlapas fona attēls
start.cta.text=Reģistrējieties jau šodien un izmantojiet bezmaksas izmēģinājuma mēnesi, lai rūpīgi pārbaudītu sistēmu.
start.slogan=Vadiet savu biznesu gudri ... ar votianLT!
start.version=Versija
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Izveidots: {0}
jobhistory.info.status=Statuss: {0}
jobhistory.count={0} ieraksti vēsturē
jobhistory.changedby=Mainīja: {0}
jobhistory.entry.create.reason=Uzdevums izveidots
jobhistory.entry.create.description=Izveidots jauns uzdevums: {0}
# Version
version.label=Versija

View File

@@ -5,7 +5,7 @@ dialog.confirm=Potwierd\u017a
# Navigation and Main Layout
nav.jobs=Zlecenia
nav.job.create=Tworzenie zlecenia
nav.customers=Klienci
nav.customers=Ksi\u0105\u017cka adresowa
nav.appusers=U\u017cytkownicy aplikacji
nav.statistics=Statystyki
nav.invoices=Faktury
@@ -18,6 +18,8 @@ nav.users=U\u017cytkownicy
nav.showprofile=Poka\u017c profil
nav.settings=Ustawienia
nav.logout=Wyloguj si\u0119
logout.confirm.title=Potwierd\u017a wylogowanie
logout.confirm.message=Czy na pewno chcesz si\u0119 wylogowa\u0107?
# Profile View
profile.title=Edytuj profil
@@ -29,7 +31,7 @@ profile.lastname=Nazwisko
profile.phone=Numer telefonu
profile.fax=Telefon (faks)
profile.mobile=Telefon (kom\u00f3rkowy)
profile.email=Adres e-mail (login)*
profile.email=Adres e-mail
profile.street=Ulica
profile.housenr=Nr domu
profile.addressadd=Dodatek do adresu
@@ -200,6 +202,10 @@ common.error=B\u0142\u0105d
common.success=Sukces
common.required=Pole wymagane
# Duration
duration.hours.short=godz.
duration.minutes.short=min.
# Validation
validation.required=Pole jest wymagane
validation.email=Nieprawid\u0142owy adres e-mail
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Miejscowo\u015b\u0107 (odbi\u00f3r)
addjob.address.city.placeholder.delivery=Miejscowo\u015b\u0107 (dostawa)
addjob.address.delivery.street.placeholder=Ulica (dostawa)
addjob.address.delivery.addition.placeholder=Dodatek do adresu (dostawa)
addjob.address.save=Zapisz adres
addjob.address.save=Dodaj adres do ksi\u0105\u017cki adresowej
addjob.address.update=Zaktualizuj adres w ksi\u0105\u017cce adresowej
addjob.section.pickup=Odbi\u00f3r
addjob.section.delivery=Dostawa
addjob.stations.apply=Zastosuj stacje
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Min. zdj\u0119\u0107
addjob.tasks.photo.max=Maks. zdj\u0119\u0107
addjob.tasks.barcode.min=Min. kod\u00f3w kreskowych
addjob.tasks.barcode.max=Maks. kod\u00f3w kreskowych
addjob.tasks.signature.noconfig=Konfiguracja nie jest wymagana
addjob.tasks.signature.notelabel=Notatka (opcjonalnie)
addjob.tasks.signature.notelabel.placeholder=Wprowadź tekst podpowiedzi dla notatki
addjob.tasks.todolist.title=Lista zada\u0144
addjob.tasks.todolist.item.placeholder=Wprowad\u017a zadanie
addjob.tasks.todolist.add=Dodaj zadanie
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Dozwolone maksymalnie {0} zdj\u0119\u0107
jobsummary.task.photo.taken=Wykonane zdj\u0119cia ({0})
jobsummary.task.button.text=Tekst przycisku
jobsummary.button.schliessen=Zamknij
jobsummary.route.planned=Planowana trasa
jobsummary.button.manualcomplete=Zako\u0144cz r\u0119cznie
jobsummary.dialog.manualcomplete.title=Zako\u0144cz zlecenie r\u0119cznie
jobsummary.dialog.manualcomplete.text=Zlecenie {0} zostanie teraz zako\u0144czone r\u0119cznie. Po tym nie b\u0119dzie mo\u017cna go dalej obs\u0142ugiwa\u0107 przez aplikacj\u0119.
jobsummary.dialog.manualcomplete.reason=Uzasadnienie
jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadnienie
jobsummary.dialog.manualcomplete.cancel=Anuluj
jobsummary.dialog.manualcomplete.confirm=Akceptuj
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
# Jobs
jobs.title=Zlecenia
@@ -730,6 +747,8 @@ statistics.loading=Obliczanie...
# Job Status
jobstatus.IN_PROGRESS=W trakcie realizacji
jobstatus.COMPLETED=Zako\u0144czone
jobstatus.CREATED=Utworzone
jobstatus.CANCELLED=Anulowane
# Task Types
tasktype.CONFIRMATION=Potwierdzenie
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Telefon
imprint.email=E-mail
imprint.country=Niemcy
imprint.management=Zarz\u0105d
imprint.registeredoffice=Siedziba firmy
imprint.commercialregister=Rejestr handlowy
imprint.vatid=Numer VAT
imprint.imagecredits=\u0179r\u00f3d\u0142a wykorzystanych zdj\u0119\u0107 i grafik
imprint.backgroundimage=Obraz t\u0142a strony startowej
start.cta.text=Zarejestruj si\u0119 ju\u017c dzi\u015b i skorzystaj z bezp\u0142atnego miesi\u0105ca pr\u00f3bnego, aby dok\u0142adnie przetestowa\u0107 system.
start.slogan=Prowad\u017a sw\u00f3j biznes m\u0105drze \u2026 z votianLT!
start.version=Wersja
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Utworzono dnia: {0}
jobhistory.info.status=Status: {0}
jobhistory.count={0} wpis\u00f3w w historii
jobhistory.changedby=Zmienione przez: {0}
jobhistory.entry.create.reason=Zlecenie utworzone
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
# Version
version.label=Wersja

View File

@@ -5,7 +5,7 @@ dialog.confirm=Подтвердить
# Navigation and Main Layout
nav.jobs=Заказы
nav.job.create=Создание заказа
nav.customers=Клиенты
nav.customers=Адресная книга
nav.appusers=Пользователи приложения
nav.statistics=Статистика
nav.invoices=Счета
@@ -18,6 +18,8 @@ nav.users=Пользователи
nav.showprofile=Показать профиль
nav.settings=Настройки
nav.logout=Выйти
logout.confirm.title=Подтвердите выход
logout.confirm.message=Вы действительно хотите выйти?
# Profile View
profile.title=Редактирование профиля
@@ -29,7 +31,7 @@ profile.lastname=Фамилия
profile.phone=Номер телефона
profile.fax=Телефон (факс)
profile.mobile=Телефон (мобильный)
profile.email=Адрес электронной почты (логин)*
profile.email=Адрес электронной почты
profile.street=Улица
profile.housenr=Дом
profile.addressadd=Дополнение к адресу
@@ -200,6 +202,10 @@ common.error=Ошибка
common.success=Успех
common.required=Обязательное поле
# Duration
duration.hours.short=ч
duration.minutes.short=мин.
# Validation
validation.required=Поле обязательно для заполнения
validation.email=Недействительный адрес электронной почты
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=Город (забор)
addjob.address.city.placeholder.delivery=Город (доставка)
addjob.address.delivery.street.placeholder=Улица (доставка)
addjob.address.delivery.addition.placeholder=Дополнение к адресу (доставка)
addjob.address.save=Сохранить адрес
addjob.address.save=Добавить адрес в адресную книгу
addjob.address.update=Обновить адрес в адресной книге
addjob.section.pickup=Забор
addjob.section.delivery=Доставка
addjob.stations.apply=Применить станции
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Мин. фото
addjob.tasks.photo.max=Макс. фото
addjob.tasks.barcode.min=Мин. штрих-кодов
addjob.tasks.barcode.max=Макс. штрих-кодов
addjob.tasks.signature.noconfig=Настройка не требуется
addjob.tasks.signature.notelabel=Примечание (необязательно)
addjob.tasks.signature.notelabel.placeholder=Введите текст подсказки для примечания
addjob.tasks.todolist.title=Список дел
addjob.tasks.todolist.item.placeholder=Введите задачу
addjob.tasks.todolist.add=Добавить задачу
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=Максимум {0} фото разрешено
jobsummary.task.photo.taken=Сделанные фотографии ({0})
jobsummary.task.button.text=Текст кнопки
jobsummary.button.schliessen=Закрыть
jobsummary.route.planned=Запланированный маршрут
jobsummary.button.manualcomplete=Завершить вручную
jobsummary.dialog.manualcomplete.title=Завершить заказ вручную
jobsummary.dialog.manualcomplete.text=Заказ {0} будет завершён вручную. После этого его больше нельзя будет обрабатывать через приложение.
jobsummary.dialog.manualcomplete.reason=Обоснование
jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укажите обоснование
jobsummary.dialog.manualcomplete.cancel=Отмена
jobsummary.dialog.manualcomplete.confirm=Принять
jobsummary.history.manualcomplete.reason=Завершено вручную
# Jobs
jobs.title=Заказы
@@ -730,6 +747,8 @@ statistics.loading=Вычисление...
# Job Status
jobstatus.IN_PROGRESS=В обработке
jobstatus.COMPLETED=Завершено
jobstatus.CREATED=Создано
jobstatus.CANCELLED=Отменено
# Task Types
tasktype.CONFIRMATION=Подтверждение
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Телефон
imprint.email=Эл. почта
imprint.country=Германия
imprint.management=Руководство
imprint.registeredoffice=Юридический адрес
imprint.commercialregister=Торговый реестр
imprint.vatid=Номер НДС
imprint.imagecredits=Источники использованных изображений и графики
imprint.backgroundimage=Фоновое изображение главной страницы
start.cta.text=Зарегистрируйтесь сегодня и воспользуйтесь бесплатным пробным месяцем, чтобы тщательно протестировать систему.
start.slogan=Ведите свой бизнес умно ... с votianLT!
start.version=Версия
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Создано: {0}
jobhistory.info.status=Статус: {0}
jobhistory.count={0} записей в истории
jobhistory.changedby=Изменено: {0}
jobhistory.entry.create.reason=Заказ создан
jobhistory.entry.create.description=Создан новый заказ: {0}
# Version
version.label=Версия

View File

@@ -5,7 +5,7 @@ dialog.confirm=Onayla
# Navigation and Main Layout
nav.jobs=\u0130\u015fler
nav.job.create=\u0130\u015f Olu\u015ftur
nav.customers=M\u00fc\u015fteriler
nav.customers=Adres Defteri
nav.appusers=Uygulama Kullan\u0131c\u0131lar\u0131
nav.statistics=\u0130statistikler
nav.invoices=Faturalar
@@ -18,6 +18,8 @@ nav.users=Kullan\u0131c\u0131lar
nav.showprofile=Profili G\u00f6ster
nav.settings=Ayarlar
nav.logout=\u00c7\u0131k\u0131\u015f
logout.confirm.title=\u00c7\u0131k\u0131\u015f\u0131 onayla
logout.confirm.message=Ger\u00e7ekten \u00e7\u0131k\u0131\u015f yapmak istiyor musunuz?
# Profile View
profile.title=Profili D\u00fczenle
@@ -29,7 +31,7 @@ profile.lastname=Soyad
profile.phone=Telefon Numaras\u0131
profile.fax=Telefon (Faks)
profile.mobile=Telefon (Mobil)
profile.email=E-Posta Adresi (Giri\u015f)*
profile.email=E-Posta Adresi*
profile.street=Sokak
profile.housenr=Kap\u0131 No
profile.addressadd=Adres Eki
@@ -200,6 +202,10 @@ common.error=Hata
common.success=Ba\u015far\u0131l\u0131
common.required=Zorunlu Alan
# Duration
duration.hours.short=sa.
duration.minutes.short=dk.
# Validation
validation.required=Alan gereklidir
validation.email=Ge\u00e7ersiz e-posta adresi
@@ -441,7 +447,8 @@ addjob.address.city.placeholder.pickup=\u015eehir (Al\u0131m)
addjob.address.city.placeholder.delivery=\u015eehir (Teslimat)
addjob.address.delivery.street.placeholder=Sokak (Teslimat)
addjob.address.delivery.addition.placeholder=Adres eki (Teslimat)
addjob.address.save=Adresi Kaydet
addjob.address.save=Adresi adres defterine ekle
addjob.address.update=Adres defterindeki adresi g\u00fcncelle
addjob.section.pickup=Al\u0131m
addjob.section.delivery=Teslimat
addjob.stations.apply=\u0130stasyonlar\u0131 \u00fcbernehmennehmen
@@ -507,7 +514,8 @@ addjob.tasks.photo.min=Min. Foto\u011fraf
addjob.tasks.photo.max=Maks. Foto\u011fraf
addjob.tasks.barcode.min=Min. Barkod
addjob.tasks.barcode.max=Maks. Barkod
addjob.tasks.signature.noconfig=Yap\u0131land\u0131rma gerekli de\u011fil
addjob.tasks.signature.notelabel=Not (iste\u011fe ba\u011fl\u0131)
addjob.tasks.signature.notelabel.placeholder=Not i\u00e7in ipucu metnini girin
addjob.tasks.todolist.title=Yap\u0131lacaklar Listesi
addjob.tasks.todolist.item.placeholder=Yap\u0131lacak \u00f6\u011feyi girin
addjob.tasks.todolist.add=Yap\u0131lacak \u00d6\u011fe Ekle
@@ -603,6 +611,15 @@ jobsummary.task.photo.maxonly=En fazla {0} foto\u011fraf izin verilir
jobsummary.task.photo.taken=\u00c7ekilen Foto\u011fraflar ({0})
jobsummary.task.button.text=Buton Metni
jobsummary.button.schliessen=Kapat
jobsummary.route.planned=Planlanan Rota
jobsummary.button.manualcomplete=Manuel olarak tamamla
jobsummary.dialog.manualcomplete.title=Siparişi manuel olarak tamamla
jobsummary.dialog.manualcomplete.text=Sipariş {0} şimdi manuel olarak tamamlanacak. Bundan sonra uygulama üzerinden işlenemez.
jobsummary.dialog.manualcomplete.reason=Gerekçe
jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin
jobsummary.dialog.manualcomplete.cancel=İptal
jobsummary.dialog.manualcomplete.confirm=Kabul et
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
# Jobs
jobs.title=\u0130\u015fler
@@ -730,6 +747,8 @@ statistics.loading=Hesaplan\u0131yor...
# Job Status
jobstatus.IN_PROGRESS=Devam Ediyor
jobstatus.COMPLETED=Tamamland\u0131
jobstatus.CREATED=Olu\u015fturuldu
jobstatus.CANCELLED=\u0130ptal Edildi
# Task Types
tasktype.CONFIRMATION=Onay
@@ -832,6 +851,15 @@ start.imprint.company=Assecutor Data Service GmbH
start.imprint.address=Ottensener Str. 8, 22525 Hamburg
start.imprint.phone=Telefon: +49 40 18 123 771 0
start.imprint.email=E-Mail: ahoi@assecutor.de
imprint.phone=Telefon
imprint.email=E-posta
imprint.country=Almanya
imprint.management=Y\u00f6netim
imprint.registeredoffice=\u015eirket merkezi
imprint.commercialregister=Ticaret sicili
imprint.vatid=KDV kimli\u011fi
imprint.imagecredits=Kullan\u0131lan g\u00f6rsel ve grafik kaynaklar\u0131
imprint.backgroundimage=Ana sayfa arka plan g\u00f6rseli
start.cta.text=Bug\u00fcn kay\u0131t olun ve sistemi ba\u015ftan sona test etmek i\u00e7in \u00fccretsiz deneme ay\u0131n\u0131 kullan\u0131n.
start.slogan=\u0130\u015finizi ak\u0131ll\u0131ca y\u00f6netin ... votianLT ile!
start.version=S\u00fcr\u00fcm
@@ -914,6 +942,8 @@ jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0}
jobhistory.info.status=Durum: {0}
jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t
jobhistory.changedby=De\u011fi\u015ftiren: {0}
jobhistory.entry.create.reason=\u0130\u015f olu\u015fturuldu
jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0}
# Version
version.label=S\u00fcr\u00fcm

View File

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

16
upload.sh Executable file
View File

@@ -0,0 +1,16 @@
find . \
-type f \
\( -name "*.java" -o -name "*.dart" -o -name "*.md" \
-o -name "*.yaml" -o -name "*.yml" -o -name "*.properties" \
-o -name "*.xml" -o -name "*.gradle" \) \
! -path "*/build/*" \
! -path "*/.gradle/*" \
! -path "*/.dart_tool/*" \
! -path "*/node_modules/*" \
! -path "*/.git/*" \
| while read file; do
echo "Upload: $file"
curl -s -X POST "http://192.168.180.16:3001/api/v1/document/upload/votianlt" \
-H "Authorization: Bearer W25V4A8-5E24XSH-KKNJBX3-WN07B7P" \
-F "file=@$file"
done