Compare commits
8 Commits
d6132fabe1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31b18e1f52 | |||
| d699609aa1 | |||
| 5ac629c23d | |||
| 069b829294 | |||
| 704d1e7378 | |||
| 6e8bedd9b4 | |||
| 1ac755bcbd | |||
| bba5733783 |
175
app/lib/app_theme.dart
Normal file
175
app/lib/app_theme.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'models/delivery_station.dart';
|
import 'models/delivery_station.dart';
|
||||||
import 'models/job.dart';
|
import 'models/job.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
@@ -19,7 +21,7 @@ Color? deliveryStationCardBackgroundColor(
|
|||||||
final isCompleted = station.tasks.every(
|
final isCompleted = station.tasks.every(
|
||||||
(task) => taskStatuses[task.id] ?? task.completed,
|
(task) => taskStatuses[task.id] ?? task.completed,
|
||||||
);
|
);
|
||||||
return isCompleted ? Colors.green[50] : null;
|
return isCompleted ? AppColors.successSoft : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CargoItemsView extends StatefulWidget {
|
class CargoItemsView extends StatefulWidget {
|
||||||
@@ -51,10 +53,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.job.jobNumber),
|
title: Text(widget.job.jobNumber),
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -93,7 +96,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Text(
|
Text(
|
||||||
widget.job.jobNumber.isNotEmpty
|
widget.job.jobNumber.isNotEmpty
|
||||||
? widget.job.jobNumber
|
? widget.job.jobNumber
|
||||||
: widget.job.title,
|
: localizeKnownText(context, widget.job.title),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -136,7 +139,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_downward,
|
Icons.arrow_downward,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.blue[600],
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -160,11 +163,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.local_shipping_outlined,
|
Icons.local_shipping_outlined,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: Colors.deepPurple[600],
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Lieferstationen (${_deliveryStations.length})',
|
l10n.deliveryStationsCount(_deliveryStations.length),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -221,12 +224,12 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Keine Lieferstationen',
|
AppLocalizations.of(context).noDeliveryStations,
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Dieser Job enthält aktuell keine Lieferstationen.',
|
AppLocalizations.of(context).noDeliveryStationsMessage,
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -255,6 +258,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
station.company.isNotEmpty && station.company != title
|
station.company.isNotEmpty && station.company != title
|
||||||
? station.company
|
? station.company
|
||||||
: null;
|
: null;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final addressLines =
|
final addressLines =
|
||||||
<String>[
|
<String>[
|
||||||
[
|
[
|
||||||
@@ -289,7 +293,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
stationTitle:
|
stationTitle:
|
||||||
station.displayName.isNotEmpty
|
station.displayName.isNotEmpty
|
||||||
? station.displayName
|
? station.displayName
|
||||||
: 'Station ${station.stationOrder + 1}',
|
: l10n.stationNumber(station.stationOrder + 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -309,15 +313,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.deepPurple[100],
|
color: AppColors.primarySoft,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Station ${station.stationOrder + 1}',
|
l10n.stationNumber(station.stationOrder + 1),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.deepPurple[700],
|
color: AppColors.primaryStrong,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,7 +331,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title.isNotEmpty ? title : 'Unbenannte Station',
|
title.isNotEmpty
|
||||||
|
? localizeKnownText(context, title)
|
||||||
|
: l10n.unnamedStation,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -353,15 +359,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Icons.location_on_outlined,
|
Icons.location_on_outlined,
|
||||||
AppLocalizations.of(context).location,
|
AppLocalizations.of(context).location,
|
||||||
addressLines.join('\n'),
|
addressLines.join('\n'),
|
||||||
Colors.blue,
|
AppColors.primary,
|
||||||
),
|
),
|
||||||
if (station.phone.trim().isNotEmpty) ...[
|
if (station.phone.trim().isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildDetailItem(
|
_buildDetailItem(
|
||||||
Icons.phone_outlined,
|
Icons.phone_outlined,
|
||||||
'Telefon',
|
l10n.phone,
|
||||||
station.phone,
|
station.phone,
|
||||||
Colors.green,
|
AppColors.success,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (station.deliveryDate.trim().isNotEmpty ||
|
if (station.deliveryDate.trim().isNotEmpty ||
|
||||||
@@ -374,7 +380,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
station.deliveryDate,
|
station.deliveryDate,
|
||||||
station.deliveryTime,
|
station.deliveryTime,
|
||||||
].where((part) => part.trim().isNotEmpty).join(' '),
|
].where((part) => part.trim().isNotEmpty).join(' '),
|
||||||
Colors.orange,
|
AppColors.warning,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -382,7 +388,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Icons.task_alt,
|
Icons.task_alt,
|
||||||
AppLocalizations.of(context).tasks,
|
AppLocalizations.of(context).tasks,
|
||||||
'${station.tasks.length}',
|
'${station.tasks.length}',
|
||||||
Colors.deepPurple,
|
AppColors.primaryStrong,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
import 'models/chat.dart';
|
import 'models/chat.dart';
|
||||||
import 'models/chat_message.dart';
|
import 'models/chat_message.dart';
|
||||||
@@ -195,9 +197,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
if (sender == null || sender.isEmpty) {
|
if (sender == null || sender.isEmpty) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
|
||||||
content: Text(AppLocalizations.of(context).noSenderMessage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -233,7 +233,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _chatService.saveOutgoingMessage(result);
|
|
||||||
_syncActiveChatFromService();
|
_syncActiveChatFromService();
|
||||||
|
|
||||||
_messageController.clear();
|
_messageController.clear();
|
||||||
@@ -250,19 +249,21 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(_activeChat.title, style: const TextStyle(fontSize: 16)),
|
Text(
|
||||||
|
localizedChatTitle(context, _activeChat),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
if (isJobChat && _activeChat.jobNumber != null)
|
if (isJobChat && _activeChat.jobNumber != null)
|
||||||
Text(
|
Text(
|
||||||
'Job-Nr: ${_activeChat.jobNumber}',
|
'Job-Nr: ${_activeChat.jobNumber}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[600],
|
color: AppColors.textMuted,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(isJobChat ? Icons.work : Icons.support_agent),
|
icon: Icon(isJobChat ? Icons.work : Icons.support_agent),
|
||||||
@@ -280,7 +281,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
// Messages list
|
// Messages list
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(color: Colors.grey[50]),
|
decoration: const BoxDecoration(color: AppColors.surfaceMuted),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
|
||||||
@@ -324,7 +325,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
vertical: isImage ? 6 : 8,
|
vertical: isImage ? 6 : 8,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isOwn ? Colors.deepPurple[100] : Colors.white,
|
color: isOwn ? AppColors.primarySoft : AppColors.surface,
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(12),
|
topLeft: const Radius.circular(12),
|
||||||
topRight: const Radius.circular(12),
|
topRight: const Radius.circular(12),
|
||||||
@@ -350,7 +351,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatMessageTime(message.createdAt),
|
_formatMessageTime(message.createdAt),
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isOwn) ...[
|
if (isOwn) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -361,10 +365,10 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
size: 14,
|
size: 14,
|
||||||
color:
|
color:
|
||||||
message.pendingSync
|
message.pendingSync
|
||||||
? Colors.orange[700]
|
? AppColors.warning
|
||||||
: (message.read
|
: (message.read
|
||||||
? Colors.deepPurple[400]
|
? AppColors.primary
|
||||||
: Colors.grey[600]),
|
: AppColors.textMuted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -383,7 +387,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
return Text(
|
return Text(
|
||||||
message.content,
|
message.content,
|
||||||
style: TextStyle(fontSize: 15, color: Colors.grey[800]),
|
style: const TextStyle(fontSize: 15, color: AppColors.textStrong),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,8 +458,8 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppColors.surface,
|
||||||
border: Border(top: BorderSide(color: Colors.grey[300]!)),
|
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -465,12 +469,12 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[200],
|
color: AppColors.surfaceMuted,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.attach_file,
|
Icons.attach_file,
|
||||||
color: Colors.black87,
|
color: AppColors.text,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -479,7 +483,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
color: AppColors.surfaceMuted,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -507,7 +511,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.deepPurple,
|
color: AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.send, color: Colors.white, size: 20),
|
child: const Icon(Icons.send, color: Colors.white, size: 20),
|
||||||
@@ -540,9 +544,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
if (sender == null || sender.isEmpty) {
|
if (sender == null || sender.isEmpty) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text(AppLocalizations.of(context).noSenderMessage)),
|
||||||
content: Text(AppLocalizations.of(context).noSenderMessage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -589,7 +591,6 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _chatService.saveOutgoingMessage(result);
|
|
||||||
_syncActiveChatFromService();
|
_syncActiveChatFromService();
|
||||||
|
|
||||||
if (prepared.bytes.isNotEmpty) {
|
if (prepared.bytes.isNotEmpty) {
|
||||||
@@ -645,7 +646,7 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
||||||
// Yesterday
|
// Yesterday
|
||||||
return 'Gestern ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
return '${AppLocalizations.of(context).yesterday} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
} else {
|
} else {
|
||||||
// Older - show date and time
|
// Older - show date and time
|
||||||
return '${dateTime.day.toString().padLeft(2, '0')}.${dateTime.month.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
return '${dateTime.day.toString().padLeft(2, '0')}.${dateTime.month.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
@@ -659,21 +660,27 @@ class _ChatDetailsViewState extends State<ChatDetailsView> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(_activeChat.title),
|
title: Text(localizedChatTitle(context, _activeChat)),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${AppLocalizations.of(context).status}: ${isJobChat ? AppLocalizations.of(context).chatTypeJob : AppLocalizations.of(context).chatTypeGeneral}'),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).status}: ${isJobChat ? AppLocalizations.of(context).chatTypeJob : AppLocalizations.of(context).chatTypeGeneral}',
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (isJobChat && _activeChat.jobNumber != null) ...[
|
if (isJobChat && _activeChat.jobNumber != null) ...[
|
||||||
Text('${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}'),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}',
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Text('${AppLocalizations.of(context).messages}: ${_messages.length}'),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).messages}: ${_messages.length}',
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Erstellt: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}',
|
'${AppLocalizations.of(context).created}: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'models/chat.dart';
|
import 'models/chat.dart';
|
||||||
import 'services/chat_service.dart';
|
import 'services/chat_service.dart';
|
||||||
import 'widgets/offline_banner.dart';
|
import 'widgets/offline_banner.dart';
|
||||||
@@ -51,15 +53,9 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(AppLocalizations.of(context).chats)),
|
||||||
title: Text(AppLocalizations.of(context).chats),
|
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [const OfflineBanner(), Expanded(child: _buildBody())],
|
||||||
const OfflineBanner(),
|
|
||||||
Expanded(child: _buildBody()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,15 +66,19 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_chats.isEmpty) {
|
if (_chats.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.chat_outlined, size: 64, color: Colors.grey),
|
const Icon(
|
||||||
SizedBox(height: 16),
|
Icons.chat_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Keine Chats verfügbar',
|
AppLocalizations.of(context).noChatsAvailable,
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -98,7 +98,9 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
final isJobChat = chat.type == ChatType.jobSpecific;
|
final isJobChat = chat.type == ChatType.jobSpecific;
|
||||||
final hasMessages = chat.messages.isNotEmpty;
|
final hasMessages = chat.messages.isNotEmpty;
|
||||||
final previewText =
|
final previewText =
|
||||||
hasMessages ? chat.lastMessagePreview : 'Noch keine Nachrichten';
|
hasMessages
|
||||||
|
? chat.lastMessagePreview
|
||||||
|
: AppLocalizations.of(context).noMessagesYet;
|
||||||
final timeLabel = hasMessages ? _formatTime(chat.lastMessageTime) : '--';
|
final timeLabel = hasMessages ? _formatTime(chat.lastMessageTime) : '--';
|
||||||
final jobId = chat.jobId?.trim();
|
final jobId = chat.jobId?.trim();
|
||||||
final jobNumber = chat.jobNumber?.trim();
|
final jobNumber = chat.jobNumber?.trim();
|
||||||
@@ -108,10 +110,11 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: isJobChat ? Colors.blue[100] : Colors.green[100],
|
backgroundColor:
|
||||||
|
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isJobChat ? Icons.work : Icons.support_agent,
|
isJobChat ? Icons.work : Icons.support_agent,
|
||||||
color: isJobChat ? Colors.blue[700] : Colors.green[700],
|
color: isJobChat ? AppColors.primaryStrong : AppColors.secondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(() {
|
title: Text(() {
|
||||||
@@ -123,15 +126,13 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
return 'Job $jobId';
|
return 'Job $jobId';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chat.type == ChatType.general
|
return localizedChatTitle(context, chat);
|
||||||
? 'Allgemeine Nachrichten'
|
|
||||||
: chat.title;
|
|
||||||
}(), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
}(), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
previewText,
|
previewText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||||
),
|
),
|
||||||
trailing: Column(
|
trailing: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -139,16 +140,17 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
timeLabel,
|
timeLabel,
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isJobChat ? Colors.blue[50] : Colors.green[50],
|
color:
|
||||||
|
isJobChat ? AppColors.primarySoft : AppColors.secondarySoft,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isJobChat ? Colors.blue[200]! : Colors.green[200]!,
|
color: isJobChat ? AppColors.primary : AppColors.secondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -156,7 +158,8 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isJobChat ? Colors.blue[700] : Colors.green[700],
|
color:
|
||||||
|
isJobChat ? AppColors.primaryStrong : AppColors.secondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'services/websocket_service.dart';
|
import 'services/websocket_service.dart';
|
||||||
import 'services/dart_mq.dart';
|
import 'services/dart_mq.dart';
|
||||||
import 'services/chat_service.dart';
|
import 'services/chat_service.dart';
|
||||||
@@ -97,7 +99,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (isConnected && !_wasConnected) {
|
if (isConnected && !_wasConnected) {
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).connectionRestored,
|
AppLocalizations.of(context).connectionRestored,
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: AppColors.success,
|
||||||
);
|
);
|
||||||
if (_appState.isLoggedIn) {
|
if (_appState.isLoggedIn) {
|
||||||
_loadJobs();
|
_loadJobs();
|
||||||
@@ -114,7 +116,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (_appState.isLoggedIn && !_isLoggingOut) {
|
if (_appState.isLoggedIn && !_isLoggingOut) {
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).connectionLost,
|
AppLocalizations.of(context).connectionLost,
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: AppColors.danger,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +147,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
jobNumber != null
|
jobNumber != null
|
||||||
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||||
: AppLocalizations.of(context).jobRemoved;
|
: AppLocalizations.of(context).jobRemoved;
|
||||||
_showSnack(message, backgroundColor: Colors.orange);
|
_showSnack(message, backgroundColor: AppColors.warning);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
jobNumber.isNotEmpty
|
jobNumber.isNotEmpty
|
||||||
? '${AppLocalizations.of(context).newJobReceived}: $jobNumber'
|
? '${AppLocalizations.of(context).newJobReceived}: $jobNumber'
|
||||||
: AppLocalizations.of(context).newJobReceived;
|
: AppLocalizations.of(context).newJobReceived;
|
||||||
_showSnack(message, backgroundColor: Colors.green);
|
_showSnack(message, backgroundColor: AppColors.success);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error handling job_created event: $e', name: 'JobsView');
|
developer.log('Error handling job_created event: $e', name: 'JobsView');
|
||||||
@@ -203,7 +205,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
});
|
});
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).jobsUpdated,
|
AppLocalizations.of(context).jobsUpdated,
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: AppColors.success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -559,7 +561,6 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
title: Text(AppLocalizations.of(context).availableJobs),
|
title: Text(AppLocalizations.of(context).availableJobs),
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -693,7 +694,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: AppColors.danger,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: Text(AppLocalizations.of(context).logout),
|
child: Text(AppLocalizations.of(context).logout),
|
||||||
@@ -765,8 +766,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: Text(AppLocalizations.of(context).refresh),
|
label: Text(AppLocalizations.of(context).refresh),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.deepPurple[100],
|
backgroundColor: AppColors.primarySoft,
|
||||||
foregroundColor: Colors.deepPurple[700],
|
foregroundColor: AppColors.primaryStrong,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -791,7 +792,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
} else {
|
} else {
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).offline,
|
AppLocalizations.of(context).offline,
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: AppColors.danger,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -907,7 +908,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).jobDeleted,
|
AppLocalizations.of(context).jobDeleted,
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: AppColors.danger,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -916,7 +917,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnack(
|
_showSnack(
|
||||||
AppLocalizations.of(context).jobDeleteError,
|
AppLocalizations.of(context).jobDeleteError,
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: AppColors.danger,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -934,19 +935,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
Color statusColor;
|
Color statusColor;
|
||||||
switch (job.statusColor) {
|
switch (job.statusColor) {
|
||||||
case 'green':
|
case 'green':
|
||||||
statusColor = Colors.green;
|
statusColor = AppColors.success;
|
||||||
break;
|
break;
|
||||||
case 'blue':
|
case 'blue':
|
||||||
statusColor = Colors.blue;
|
statusColor = AppColors.primary;
|
||||||
break;
|
break;
|
||||||
case 'orange':
|
case 'orange':
|
||||||
statusColor = Colors.orange;
|
statusColor = AppColors.warning;
|
||||||
break;
|
break;
|
||||||
case 'red':
|
case 'red':
|
||||||
statusColor = Colors.red;
|
statusColor = AppColors.danger;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
statusColor = Colors.grey;
|
statusColor = AppColors.textMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine card background color based on task completion
|
// Determine card background color based on task completion
|
||||||
@@ -964,9 +965,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (totalTasks == 0 || completedTasks == 0) {
|
if (totalTasks == 0 || completedTasks == 0) {
|
||||||
cardBg = null; // unchanged (default)
|
cardBg = null; // unchanged (default)
|
||||||
} else if (completedTasks > 0 && completedTasks < totalTasks) {
|
} else if (completedTasks > 0 && completedTasks < totalTasks) {
|
||||||
cardBg = Colors.yellow[50];
|
cardBg = AppColors.warningSoft;
|
||||||
} else if (completedTasks == totalTasks) {
|
} else if (completedTasks == totalTasks) {
|
||||||
cardBg = Colors.green[50];
|
cardBg = AppColors.successSoft;
|
||||||
}
|
}
|
||||||
// Build robust display strings with fallbacks
|
// Build robust display strings with fallbacks
|
||||||
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
||||||
@@ -996,7 +997,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
: job.deliveryCompany));
|
: job.deliveryCompany));
|
||||||
final deliveryAddress =
|
final deliveryAddress =
|
||||||
hasMultipleDeliveryStations
|
hasMultipleDeliveryStations
|
||||||
? '${job.deliveryStations.length} Stationen'
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).deliveryStationsCount(job.deliveryStations.length)
|
||||||
: (firstDeliveryStation?.formattedAddress.isNotEmpty == true
|
: (firstDeliveryStation?.formattedAddress.isNotEmpty == true
|
||||||
? firstDeliveryStation!.formattedAddress
|
? firstDeliveryStation!.formattedAddress
|
||||||
: _joinNonEmpty([
|
: _joinNonEmpty([
|
||||||
@@ -1030,7 +1033,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
iconSize: 28,
|
iconSize: 28,
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
splashRadius: 24,
|
splashRadius: 24,
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
icon: const Icon(Icons.delete, color: AppColors.danger),
|
||||||
tooltip: AppLocalizations.of(context).deleteJob,
|
tooltip: AppLocalizations.of(context).deleteJob,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (isDeleting) {
|
if (isDeleting) {
|
||||||
@@ -1116,7 +1119,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
Text(
|
Text(
|
||||||
job.jobNumber.isNotEmpty
|
job.jobNumber.isNotEmpty
|
||||||
? job.jobNumber
|
? job.jobNumber
|
||||||
: job.title,
|
: localizeKnownText(context, job.title),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -1230,13 +1233,13 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
? 0
|
? 0
|
||||||
: completedTasks / totalTasks,
|
: completedTasks / totalTasks,
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
backgroundColor: Colors.grey[200],
|
backgroundColor: AppColors.border,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
completedTasks >= totalTasks
|
completedTasks >= totalTasks
|
||||||
? Colors.green
|
? AppColors.success
|
||||||
: (completedTasks > 0
|
: (completedTasks > 0
|
||||||
? Colors.amber
|
? AppColors.warning
|
||||||
: Colors.deepPurpleAccent),
|
: AppColors.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1333,7 +1336,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_downward,
|
Icons.arrow_downward,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.blue[600],
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -1372,7 +1375,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
tooltip: 'Route planen',
|
tooltip: 'Route planen',
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.route,
|
Icons.route,
|
||||||
color: Colors.blueAccent,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_routeActionInProgress) return;
|
if (_routeActionInProgress) return;
|
||||||
@@ -1571,19 +1574,19 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(job.title),
|
title: Text(localizeKnownText(context, job.title)),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context).status}: ${job.statusDisplayText}',
|
'${AppLocalizations.of(context).status}: ${_localizedStatusText(job.status)}',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context).priority}: ${job.priorityDisplayText}',
|
'${AppLocalizations.of(context).priority}: ${_localizedPriorityText(job.priority)}',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -1612,7 +1615,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(job.description),
|
Text(localizeKnownText(context, job.description)),
|
||||||
],
|
],
|
||||||
// CargoItems section
|
// CargoItems section
|
||||||
if (job.cargoItems.isNotEmpty) ...[
|
if (job.cargoItems.isNotEmpty) ...[
|
||||||
@@ -1657,7 +1660,9 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (job.deliveryStations.isNotEmpty) ...[
|
if (job.deliveryStations.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context).delivery} (${job.deliveryStations.length})',
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).deliveryStationsCount(job.deliveryStations.length),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -1674,7 +1679,11 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Station ${station.stationOrder + 1}: ${station.displayName}',
|
localizedStationLabel(
|
||||||
|
context,
|
||||||
|
station.stationOrder + 1,
|
||||||
|
suffix: station.displayName,
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
if (station.formattedAddress.isNotEmpty) ...[
|
if (station.formattedAddress.isNotEmpty) ...[
|
||||||
@@ -1855,12 +1864,49 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (station.stationOrder == stationOrder) {
|
if (station.stationOrder == stationOrder) {
|
||||||
final suffix =
|
final suffix =
|
||||||
station.displayName.isNotEmpty ? station.displayName : station.city;
|
station.displayName.isNotEmpty ? station.displayName : station.city;
|
||||||
return suffix.isNotEmpty
|
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
|
||||||
? 'Station ${stationOrder + 1}: $suffix'
|
|
||||||
: 'Station ${stationOrder + 1}';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Station ${stationOrder + 1}';
|
return AppLocalizations.of(context).stationNumber(stationOrder + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedStatusText(String status) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'created':
|
||||||
|
return l10n.statusCreated;
|
||||||
|
case 'pending':
|
||||||
|
return l10n.statusPending;
|
||||||
|
case 'assigned':
|
||||||
|
return l10n.statusAssigned;
|
||||||
|
case 'in_progress':
|
||||||
|
case 'started':
|
||||||
|
return l10n.statusInProgress;
|
||||||
|
case 'completed':
|
||||||
|
case 'done':
|
||||||
|
return l10n.statusCompleted;
|
||||||
|
case 'cancelled':
|
||||||
|
return l10n.statusCancelled;
|
||||||
|
case 'failed':
|
||||||
|
return l10n.statusFailed;
|
||||||
|
default:
|
||||||
|
return localizeKnownText(context, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedPriorityText(String priority) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
switch (priority.toLowerCase()) {
|
||||||
|
case 'low':
|
||||||
|
return l10n.priorityLow;
|
||||||
|
case 'high':
|
||||||
|
return l10n.priorityHigh;
|
||||||
|
case 'urgent':
|
||||||
|
return l10n.priorityUrgent;
|
||||||
|
case 'normal':
|
||||||
|
default:
|
||||||
|
return l10n.priorityMedium;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,28 @@ import 'app_localizations_lv.dart';
|
|||||||
import 'app_localizations_lt.dart';
|
import 'app_localizations_lt.dart';
|
||||||
|
|
||||||
/// Supported language codes
|
/// Supported language codes
|
||||||
const List<String> supportedLanguageCodes = ['de', 'en', 'es', 'fr', 'pl', 'ru', 'tr', 'et', 'lv', 'lt'];
|
const List<String> supportedLanguageCodes = [
|
||||||
|
'de',
|
||||||
|
'en',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'pl',
|
||||||
|
'ru',
|
||||||
|
'tr',
|
||||||
|
'et',
|
||||||
|
'lv',
|
||||||
|
'lt',
|
||||||
|
];
|
||||||
|
|
||||||
/// AppLocalizations provides localized strings for the app
|
/// AppLocalizations provides localized strings for the app
|
||||||
abstract class AppLocalizations {
|
abstract class AppLocalizations {
|
||||||
static AppLocalizations of(BuildContext context) {
|
static AppLocalizations of(BuildContext context) {
|
||||||
return Localizations.of<AppLocalizations>(context, AppLocalizations) ?? AppLocalizationsDe();
|
return Localizations.of<AppLocalizations>(context, AppLocalizations) ??
|
||||||
|
AppLocalizationsDe();
|
||||||
}
|
}
|
||||||
|
|
||||||
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
|
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||||
|
_AppLocalizationsDelegate();
|
||||||
|
|
||||||
/// Language name
|
/// Language name
|
||||||
String get languageName;
|
String get languageName;
|
||||||
@@ -41,6 +54,7 @@ abstract class AppLocalizations {
|
|||||||
String get refresh;
|
String get refresh;
|
||||||
String get version;
|
String get version;
|
||||||
String get unknown;
|
String get unknown;
|
||||||
|
String get yesterday;
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
String get jobs;
|
String get jobs;
|
||||||
@@ -58,7 +72,14 @@ abstract class AppLocalizations {
|
|||||||
String get welcomeBack;
|
String get welcomeBack;
|
||||||
String get loginSubtitle;
|
String get loginSubtitle;
|
||||||
String get email;
|
String get email;
|
||||||
|
String get emailAddress;
|
||||||
|
String get emailAddressHint;
|
||||||
|
String get emailAddressRequired;
|
||||||
|
String get emailAddressInvalid;
|
||||||
String get password;
|
String get password;
|
||||||
|
String get passwordHint;
|
||||||
|
String get passwordRequired;
|
||||||
|
String get passwordMinLength;
|
||||||
String get login;
|
String get login;
|
||||||
String get loggingIn;
|
String get loggingIn;
|
||||||
String get forgotPassword;
|
String get forgotPassword;
|
||||||
@@ -101,6 +122,15 @@ abstract class AppLocalizations {
|
|||||||
String get deleteJob;
|
String get deleteJob;
|
||||||
String get jobRemoved;
|
String get jobRemoved;
|
||||||
String get newJobReceived;
|
String get newJobReceived;
|
||||||
|
String get jobDetails;
|
||||||
|
String get jobTasks;
|
||||||
|
String get deliveryStations;
|
||||||
|
String deliveryStationsCount(int count);
|
||||||
|
String get noDeliveryStations;
|
||||||
|
String get noDeliveryStationsMessage;
|
||||||
|
String get phone;
|
||||||
|
String get unnamedStation;
|
||||||
|
String stationNumber(int number);
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
String get tasks;
|
String get tasks;
|
||||||
@@ -182,6 +212,9 @@ abstract class AppLocalizations {
|
|||||||
String get chatTypeGeneral;
|
String get chatTypeGeneral;
|
||||||
String get jobNumber;
|
String get jobNumber;
|
||||||
String get messages;
|
String get messages;
|
||||||
|
String get generalMessages;
|
||||||
|
String get noMessagesYet;
|
||||||
|
String get noChatsAvailable;
|
||||||
String get selectPhoto;
|
String get selectPhoto;
|
||||||
String get unreadMessages;
|
String get unreadMessages;
|
||||||
|
|
||||||
@@ -217,16 +250,20 @@ abstract class AppLocalizations {
|
|||||||
|
|
||||||
// ==================== STATUS ====================
|
// ==================== STATUS ====================
|
||||||
String get statusCreated;
|
String get statusCreated;
|
||||||
|
String get statusPending;
|
||||||
String get statusAssigned;
|
String get statusAssigned;
|
||||||
String get statusInProgress;
|
String get statusInProgress;
|
||||||
String get statusCompleted;
|
String get statusCompleted;
|
||||||
|
String get statusCancelled;
|
||||||
|
String get statusFailed;
|
||||||
String get priorityLow;
|
String get priorityLow;
|
||||||
String get priorityMedium;
|
String get priorityMedium;
|
||||||
String get priorityHigh;
|
String get priorityHigh;
|
||||||
String get priorityUrgent;
|
String get priorityUrgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
class _AppLocalizationsDelegate
|
||||||
|
extends LocalizationsDelegate<AppLocalizations> {
|
||||||
const _AppLocalizationsDelegate();
|
const _AppLocalizationsDelegate();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -47,12 +47,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Unbekannt';
|
String get unknown => 'Unbekannt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Gestern';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Jobs';
|
String get jobs => 'Jobs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Verfügbare Jobs';
|
String get availableJobs => 'Auftragsliste';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chats => 'Chats';
|
String get chats => 'Chats';
|
||||||
@@ -88,9 +91,32 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get email => 'E-Mail';
|
String get email => 'E-Mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'E-Mail-Adresse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Geben Sie Ihre E-Mail-Adresse ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Bitte geben Sie Ihre E-Mail-Adresse ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid =>
|
||||||
|
'Bitte geben Sie eine gültige E-Mail-Adresse ein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get password => 'Passwort';
|
String get password => 'Passwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Geben Sie Ihr Passwort ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Bitte geben Sie Ihr Passwort ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength =>
|
||||||
|
'Das Passwort muss mindestens 6 Zeichen lang sein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get login => 'Anmelden';
|
String get login => 'Anmelden';
|
||||||
|
|
||||||
@@ -101,7 +127,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get forgotPassword => 'Passwort vergessen?';
|
String get forgotPassword => 'Passwort vergessen?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Passwort vergessen Funktion noch nicht implementiert';
|
String get forgotPasswordMessage =>
|
||||||
|
'Passwort vergessen Funktion noch nicht implementiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Erfolgreich abgemeldet';
|
String get loginSuccess => 'Erfolgreich abgemeldet';
|
||||||
@@ -110,10 +137,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get loginFailed => 'Anmeldung fehlgeschlagen';
|
String get loginFailed => 'Anmeldung fehlgeschlagen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen (Timeout).';
|
String get connectionFailed =>
|
||||||
|
'Verbindung zum Server fehlgeschlagen (Timeout).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectionTimeout => 'Verbindung zum Server fehlgeschlagen (Timeout).';
|
String get connectionTimeout =>
|
||||||
|
'Verbindung zum Server fehlgeschlagen (Timeout).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connecting => 'Verbindung zum Server wird hergestellt...';
|
String get connecting => 'Verbindung zum Server wird hergestellt...';
|
||||||
@@ -212,6 +241,34 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Neuer Job erhalten';
|
String get newJobReceived => 'Neuer Job erhalten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Auftragsdetails';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Aufgaben eines Auftrags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Lieferstationen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Lieferstationen ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Keine Lieferstationen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Dieser Job enthält aktuell keine Lieferstationen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Telefon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Unbenannte Station';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Station $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Aufgaben';
|
String get tasks => 'Aufgaben';
|
||||||
@@ -229,7 +286,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get confirmationRequired => 'Bestätigung erforderlich';
|
String get confirmationRequired => 'Bestätigung erforderlich';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Klicken Sie auf den Button um die Aufgabe zu erledigen.';
|
String get confirmationDescription =>
|
||||||
|
'Klicken Sie auf den Button um die Aufgabe zu erledigen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Checkliste';
|
String get checklist => 'Checkliste';
|
||||||
@@ -241,7 +299,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get completeTask => 'Aufgabe abschließen';
|
String get completeTask => 'Aufgabe abschließen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Möchten Sie diese Aufgabe als erledigt markieren?';
|
String get completeTaskConfirm =>
|
||||||
|
'Möchten Sie diese Aufgabe als erledigt markieren?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Notiz (optional)';
|
String get completeTaskNote => 'Notiz (optional)';
|
||||||
@@ -280,7 +339,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get signatureError => 'Fehler beim Speichern der Unterschrift';
|
String get signatureError => 'Fehler beim Speichern der Unterschrift';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Bitte unterschreiben Sie im Feld unten (Maus oder Finger).';
|
String get signatureInstruction =>
|
||||||
|
'Bitte unterschreiben Sie im Feld unten (Maus oder Finger).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Fotos aufnehmen';
|
String get photoCapture => 'Fotos aufnehmen';
|
||||||
@@ -371,10 +431,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get cameraNotAvailable => 'Kamera nicht verfügbar';
|
String get cameraNotAvailable => 'Kamera nicht verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Auf dieser Plattform wird die Kamera nicht unterstützt.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Auf dieser Plattform wird die Kamera nicht unterstützt.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Nicht unterstützt auf dieser Plattform';
|
String get cameraNotSupportedOnPlatform =>
|
||||||
|
'Nicht unterstützt auf dieser Plattform';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get maxPhotosReached => 'Maximum erreicht';
|
String get maxPhotosReached => 'Maximum erreicht';
|
||||||
@@ -389,13 +451,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get cameraInitializing => 'Kamera wird initialisiert...';
|
String get cameraInitializing => 'Kamera wird initialisiert...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cameraLoadingMessage => 'Bitte warten Sie, während die Kamera geladen wird';
|
String get cameraLoadingMessage =>
|
||||||
|
'Bitte warten Sie, während die Kamera geladen wird';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addPhotos => 'Fotos hinzufügen';
|
String get addPhotos => 'Fotos hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Verwenden Sie den Button „Foto auswählen", um Bilder von Ihrer Kamera oder Festplatte hinzuzufügen.';
|
String get addPhotosInstruction =>
|
||||||
|
'Verwenden Sie den Button „Foto auswählen", um Bilder von Ihrer Kamera oder Festplatte hinzuzufügen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'von';
|
String get photoOf => 'von';
|
||||||
@@ -411,13 +475,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get noSender => 'Kein Absender verfügbar';
|
String get noSender => 'Kein Absender verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Kein Absender verfügbar. Bitte erneut anmelden.';
|
String get noSenderMessage =>
|
||||||
|
'Kein Absender verfügbar. Bitte erneut anmelden.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Kein Empfänger konfiguriert';
|
String get noRecipient => 'Kein Empfänger konfiguriert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientMessage => 'Kein Empfänger für diesen Chat konfiguriert.';
|
String get noRecipientMessage =>
|
||||||
|
'Kein Empfänger für diesen Chat konfiguriert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get messageSendError => 'Nachricht konnte nicht gesendet werden.';
|
String get messageSendError => 'Nachricht konnte nicht gesendet werden.';
|
||||||
@@ -443,6 +509,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get messages => 'Nachrichten';
|
String get messages => 'Nachrichten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Allgemeine Nachrichten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Noch keine Nachrichten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Keine Chats verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Foto auswählen';
|
String get selectPhoto => 'Foto auswählen';
|
||||||
|
|
||||||
@@ -482,7 +557,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get noCargoItems => 'Keine Frachtgüter';
|
String get noCargoItems => 'Keine Frachtgüter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'Für diesen Job sind keine Frachtgüter definiert.';
|
String get noCargoItemsMessage =>
|
||||||
|
'Für diesen Job sind keine Frachtgüter definiert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get article => 'Artikel';
|
String get article => 'Artikel';
|
||||||
@@ -528,6 +604,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Erstellt';
|
String get statusCreated => 'Erstellt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusPending => 'Wartend';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusAssigned => 'Zugewiesen';
|
String get statusAssigned => 'Zugewiesen';
|
||||||
|
|
||||||
@@ -537,6 +616,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Abgeschlossen';
|
String get statusCompleted => 'Abgeschlossen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusCancelled => 'Abgebrochen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Fehlgeschlagen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get priorityLow => 'Niedrig';
|
String get priorityLow => 'Niedrig';
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Unknown';
|
String get unknown => 'Unknown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Yesterday';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Jobs';
|
String get jobs => 'Jobs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Available Jobs';
|
String get availableJobs => 'Order List';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chats => 'Chats';
|
String get chats => 'Chats';
|
||||||
@@ -88,9 +91,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get email => 'Email';
|
String get email => 'Email';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'Email Address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Enter your email address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Please enter your email address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Please enter a valid email address';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get password => 'Password';
|
String get password => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Enter your password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Please enter your password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Password must be at least 6 characters long';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get login => 'Login';
|
String get login => 'Login';
|
||||||
|
|
||||||
@@ -101,7 +125,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get forgotPassword => 'Forgot Password?';
|
String get forgotPassword => 'Forgot Password?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Forgot password feature not yet implemented';
|
String get forgotPasswordMessage =>
|
||||||
|
'Forgot password feature not yet implemented';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Successfully logged out';
|
String get loginSuccess => 'Successfully logged out';
|
||||||
@@ -212,6 +237,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'New job received';
|
String get newJobReceived => 'New job received';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Job Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Job Tasks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Delivery Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Delivery Stations ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'No Delivery Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'This job currently contains no delivery stations.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Phone';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Unnamed Station';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Station $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Tasks';
|
String get tasks => 'Tasks';
|
||||||
@@ -229,7 +282,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get confirmationRequired => 'Confirmation Required';
|
String get confirmationRequired => 'Confirmation Required';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Click the button to complete the task.';
|
String get confirmationDescription =>
|
||||||
|
'Click the button to complete the task.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Checklist';
|
String get checklist => 'Checklist';
|
||||||
@@ -241,7 +295,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get completeTask => 'Complete Task';
|
String get completeTask => 'Complete Task';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Do you want to mark this task as completed?';
|
String get completeTaskConfirm =>
|
||||||
|
'Do you want to mark this task as completed?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Note (optional)';
|
String get completeTaskNote => 'Note (optional)';
|
||||||
@@ -280,7 +335,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get signatureError => 'Error saving signature';
|
String get signatureError => 'Error saving signature';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Please sign in the field below (mouse or finger).';
|
String get signatureInstruction =>
|
||||||
|
'Please sign in the field below (mouse or finger).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Take Photos';
|
String get photoCapture => 'Take Photos';
|
||||||
@@ -371,7 +427,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get cameraNotAvailable => 'Camera not available';
|
String get cameraNotAvailable => 'Camera not available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'The camera is not supported on this platform.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'The camera is not supported on this platform.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Not supported on this platform';
|
String get cameraNotSupportedOnPlatform => 'Not supported on this platform';
|
||||||
@@ -395,7 +452,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get addPhotos => 'Add photos';
|
String get addPhotos => 'Add photos';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Use the "Select photo" button to add images from your camera or hard drive.';
|
String get addPhotosInstruction =>
|
||||||
|
'Use the "Select photo" button to add images from your camera or hard drive.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'of';
|
String get photoOf => 'of';
|
||||||
@@ -443,6 +501,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get messages => 'Messages';
|
String get messages => 'Messages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'General Messages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'No messages yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'No chats available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Select Photo';
|
String get selectPhoto => 'Select Photo';
|
||||||
|
|
||||||
@@ -528,6 +595,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Created';
|
String get statusCreated => 'Created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusPending => 'Pending';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusAssigned => 'Assigned';
|
String get statusAssigned => 'Assigned';
|
||||||
|
|
||||||
@@ -537,6 +607,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Completed';
|
String get statusCompleted => 'Completed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusCancelled => 'Cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Failed';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get priorityLow => 'Low';
|
String get priorityLow => 'Low';
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Desconocido';
|
String get unknown => 'Desconocido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Ayer';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Trabajos';
|
String get jobs => 'Trabajos';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Trabajos Disponibles';
|
String get availableJobs => 'Lista de pedidos';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Chats';
|
String get chats => 'Chats';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,33 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Inicie sesión en su cuenta';
|
String get loginSubtitle => 'Inicie sesión en su cuenta';
|
||||||
@override
|
@override
|
||||||
String get email => 'Correo electrónico';
|
String get email => 'Correo electrónico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'Dirección de correo electrónico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint =>
|
||||||
|
'Introduzca su dirección de correo electrónico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired =>
|
||||||
|
'Por favor, introduzca su dirección de correo electrónico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid =>
|
||||||
|
'Por favor, introduzca una dirección de correo electrónico válida';
|
||||||
@override
|
@override
|
||||||
String get password => 'Contraseña';
|
String get password => 'Contraseña';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Introduzca su contraseña';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Por favor, introduzca su contraseña';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength =>
|
||||||
|
'La contraseña debe tener al menos 6 caracteres';
|
||||||
@override
|
@override
|
||||||
String get login => 'Iniciar sesión';
|
String get login => 'Iniciar sesión';
|
||||||
@override
|
@override
|
||||||
@@ -73,15 +101,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => '¿Olvidó su contraseña?';
|
String get forgotPassword => '¿Olvidó su contraseña?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Función de contraseña olvidada aún no implementada';
|
String get forgotPasswordMessage =>
|
||||||
|
'Función de contraseña olvidada aún no implementada';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Sesión cerrada correctamente';
|
String get loginSuccess => 'Sesión cerrada correctamente';
|
||||||
@override
|
@override
|
||||||
String get loginFailed => 'Error al iniciar sesión';
|
String get loginFailed => 'Error al iniciar sesión';
|
||||||
@override
|
@override
|
||||||
String get connectionFailed => 'Error de conexión al servidor (Tiempo agotado).';
|
String get connectionFailed =>
|
||||||
|
'Error de conexión al servidor (Tiempo agotado).';
|
||||||
@override
|
@override
|
||||||
String get connectionTimeout => 'Error de conexión al servidor (Tiempo agotado).';
|
String get connectionTimeout =>
|
||||||
|
'Error de conexión al servidor (Tiempo agotado).';
|
||||||
@override
|
@override
|
||||||
String get connecting => 'Conectando al servidor...';
|
String get connecting => 'Conectando al servidor...';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +180,34 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Nuevo trabajo recibido';
|
String get newJobReceived => 'Nuevo trabajo recibido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Detalles del pedido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Tareas del pedido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Estaciones de entrega';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Estaciones de entrega ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'No hay estaciones de entrega';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Este trabajo no contiene estaciones de entrega actualmente.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Teléfono';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Estación sin nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Estación $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Tareas';
|
String get tasks => 'Tareas';
|
||||||
@@ -161,7 +220,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Confirmación requerida';
|
String get confirmationRequired => 'Confirmación requerida';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Haga clic en el botón para completar la tarea.';
|
String get confirmationDescription =>
|
||||||
|
'Haga clic en el botón para completar la tarea.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Lista de verificación';
|
String get checklist => 'Lista de verificación';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +255,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Error al guardar la firma';
|
String get signatureError => 'Error al guardar la firma';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Por favor, firme en el campo de abajo (ratón o dedo).';
|
String get signatureInstruction =>
|
||||||
|
'Por favor, firme en el campo de abajo (ratón o dedo).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Tomar fotos';
|
String get photoCapture => 'Tomar fotos';
|
||||||
@override
|
@override
|
||||||
@@ -243,11 +304,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get enterBarcode => 'Ingresar código de barras';
|
String get enterBarcode => 'Ingresar código de barras';
|
||||||
@override
|
@override
|
||||||
String get barcodeEnterDescription => 'Por favor ingrese los códigos de barras:';
|
String get barcodeEnterDescription =>
|
||||||
|
'Por favor ingrese los códigos de barras:';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberRequired(int number) => 'Código de barras $number (requerido)';
|
String barcodeNumberRequired(int number) =>
|
||||||
|
'Código de barras $number (requerido)';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberOptional(int number) => 'Código de barras $number (opcional)';
|
String barcodeNumberOptional(int number) =>
|
||||||
|
'Código de barras $number (opcional)';
|
||||||
@override
|
@override
|
||||||
String get barcodeError => 'Error al escanear el código de barras';
|
String get barcodeError => 'Error al escanear el código de barras';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +321,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Cámara no disponible';
|
String get cameraNotAvailable => 'Cámara no disponible';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'La cámara no es compatible con esta plataforma.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'La cámara no es compatible con esta plataforma.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'No soportado en esta plataforma';
|
String get cameraNotSupportedOnPlatform => 'No soportado en esta plataforma';
|
||||||
@override
|
@override
|
||||||
@@ -269,11 +334,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraInitializing => 'Inicializando cámara...';
|
String get cameraInitializing => 'Inicializando cámara...';
|
||||||
@override
|
@override
|
||||||
String get cameraLoadingMessage => 'Por favor espere mientras se carga la cámara';
|
String get cameraLoadingMessage =>
|
||||||
|
'Por favor espere mientras se carga la cámara';
|
||||||
@override
|
@override
|
||||||
String get addPhotos => 'Añadir fotos';
|
String get addPhotos => 'Añadir fotos';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Use el botón "Seleccionar foto" para añadir imágenes de su cámara o disco duro.';
|
String get addPhotosInstruction =>
|
||||||
|
'Use el botón "Seleccionar foto" para añadir imágenes de su cámara o disco duro.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'de';
|
String get photoOf => 'de';
|
||||||
|
|
||||||
@@ -285,11 +352,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'No hay remitente disponible';
|
String get noSender => 'No hay remitente disponible';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'No hay remitente disponible. Por favor inicie sesión de nuevo.';
|
String get noSenderMessage =>
|
||||||
|
'No hay remitente disponible. Por favor inicie sesión de nuevo.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'No hay destinatario configurado';
|
String get noRecipient => 'No hay destinatario configurado';
|
||||||
@override
|
@override
|
||||||
String get noRecipientMessage => 'No hay destinatario configurado para este chat.';
|
String get noRecipientMessage =>
|
||||||
|
'No hay destinatario configurado para este chat.';
|
||||||
@override
|
@override
|
||||||
String get messageSendError => 'El mensaje no pudo ser enviado.';
|
String get messageSendError => 'El mensaje no pudo ser enviado.';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +375,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get jobNumber => 'Número de trabajo';
|
String get jobNumber => 'Número de trabajo';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Mensajes';
|
String get messages => 'Mensajes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Mensajes generales';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Todavía no hay mensajes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'No hay chats disponibles';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Seleccionar foto';
|
String get selectPhoto => 'Seleccionar foto';
|
||||||
@override
|
@override
|
||||||
@@ -327,7 +405,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCargoItems => 'Sin artículos de carga';
|
String get noCargoItems => 'Sin artículos de carga';
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'No hay artículos de carga definidos para este trabajo.';
|
String get noCargoItemsMessage =>
|
||||||
|
'No hay artículos de carga definidos para este trabajo.';
|
||||||
@override
|
@override
|
||||||
String get article => 'Artículo';
|
String get article => 'Artículo';
|
||||||
|
|
||||||
@@ -369,12 +448,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Creado';
|
String get statusCreated => 'Creado';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Pendiente';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Asignado';
|
String get statusAssigned => 'Asignado';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'En progreso';
|
String get statusInProgress => 'En progreso';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Completado';
|
String get statusCompleted => 'Completado';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Cancelado';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Fallido';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Baja';
|
String get priorityLow => 'Baja';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Media';
|
String get priorityMedium => 'Media';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Tundmatu';
|
String get unknown => 'Tundmatu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Eile';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Tööd';
|
String get jobs => 'Tööd';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Saadaolevad tööd';
|
String get availableJobs => 'Tellimuste loend';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Vestlused';
|
String get chats => 'Vestlused';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,29 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Logige oma kontosse sisse';
|
String get loginSubtitle => 'Logige oma kontosse sisse';
|
||||||
@override
|
@override
|
||||||
String get email => 'E-post';
|
String get email => 'E-post';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'E-posti aadress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Sisestage oma e-posti aadress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Palun sisestage oma e-posti aadress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Palun sisestage kehtiv e-posti aadress';
|
||||||
@override
|
@override
|
||||||
String get password => 'Parool';
|
String get password => 'Parool';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Sisestage oma parool';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Palun sisestage oma parool';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Parool peab olema vähemalt 6 tähemärki pikk';
|
||||||
@override
|
@override
|
||||||
String get login => 'Logi sisse';
|
String get login => 'Logi sisse';
|
||||||
@override
|
@override
|
||||||
@@ -73,15 +97,18 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Unustasid parooli?';
|
String get forgotPassword => 'Unustasid parooli?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Unustatud parooli funktsioon pole veel rakendatud';
|
String get forgotPasswordMessage =>
|
||||||
|
'Unustatud parooli funktsioon pole veel rakendatud';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Edukalt välja logitud';
|
String get loginSuccess => 'Edukalt välja logitud';
|
||||||
@override
|
@override
|
||||||
String get loginFailed => 'Sisselogimine ebaõnnestus';
|
String get loginFailed => 'Sisselogimine ebaõnnestus';
|
||||||
@override
|
@override
|
||||||
String get connectionFailed => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
|
String get connectionFailed =>
|
||||||
|
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
|
||||||
@override
|
@override
|
||||||
String get connectionTimeout => 'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
|
String get connectionTimeout =>
|
||||||
|
'Serveriga ühenduse loomine ebaõnnestus (Aegunud).';
|
||||||
@override
|
@override
|
||||||
String get connecting => 'Serveriga ühendamine...';
|
String get connecting => 'Serveriga ühendamine...';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +176,34 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Uus töö saadud';
|
String get newJobReceived => 'Uus töö saadud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Töö üksikasjad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Töö ülesanded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Tarnejaamad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Tarnejaamad ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Tarnejaamu pole';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Sellel tööl ei ole praegu tarnejaamu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Telefon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Nimetu jaam';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Jaam $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Ülesanded';
|
String get tasks => 'Ülesanded';
|
||||||
@@ -161,7 +216,8 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Vajalik kinnitus';
|
String get confirmationRequired => 'Vajalik kinnitus';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Ülesande lõpuleviimiseks klõpsake nuppu.';
|
String get confirmationDescription =>
|
||||||
|
'Ülesande lõpuleviimiseks klõpsake nuppu.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Kontrollnimekiri';
|
String get checklist => 'Kontrollnimekiri';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +225,8 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Lõpeta ülesanne';
|
String get completeTask => 'Lõpeta ülesanne';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Kas soovite selle ülesande lõpetatuks märgistada?';
|
String get completeTaskConfirm =>
|
||||||
|
'Kas soovite selle ülesande lõpetatuks märgistada?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Märkus (valikuline)';
|
String get completeTaskNote => 'Märkus (valikuline)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +252,8 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Viga allkirja salvestamisel';
|
String get signatureError => 'Viga allkirja salvestamisel';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Palun allkirjastage allolevas väljas (hiir või sõrm).';
|
String get signatureInstruction =>
|
||||||
|
'Palun allkirjastage allolevas väljas (hiir või sõrm).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Tee pilte';
|
String get photoCapture => 'Tee pilte';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +315,8 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Kaamera pole saadaval';
|
String get cameraNotAvailable => 'Kaamera pole saadaval';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Kaamerat ei toetata sellel platvormil.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Kaamerat ei toetata sellel platvormil.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Sellel platvormil ei toetata';
|
String get cameraNotSupportedOnPlatform => 'Sellel platvormil ei toetata';
|
||||||
@override
|
@override
|
||||||
@@ -273,7 +332,8 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addPhotos => 'Lisa fotod';
|
String get addPhotos => 'Lisa fotod';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Kasutage nuppu "Vali foto", et lisada pilte kaamerast või kõvakettalt.';
|
String get addPhotosInstruction =>
|
||||||
|
'Kasutage nuppu "Vali foto", et lisada pilte kaamerast või kõvakettalt.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => '/';
|
String get photoOf => '/';
|
||||||
|
|
||||||
@@ -285,11 +345,13 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Saatja pole saadaval';
|
String get noSender => 'Saatja pole saadaval';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Saatja pole saadaval. Palun logige uuesti sisse.';
|
String get noSenderMessage =>
|
||||||
|
'Saatja pole saadaval. Palun logige uuesti sisse.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Vastuvõtjat pole konfigureeritud';
|
String get noRecipient => 'Vastuvõtjat pole konfigureeritud';
|
||||||
@override
|
@override
|
||||||
String get noRecipientMessage => 'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.';
|
String get noRecipientMessage =>
|
||||||
|
'Selle vestluse jaoks pole vastuvõtjat konfigureeritud.';
|
||||||
@override
|
@override
|
||||||
String get messageSendError => 'Sõnumi saatmine ebaõnnestus.';
|
String get messageSendError => 'Sõnumi saatmine ebaõnnestus.';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +368,15 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
String get jobNumber => 'Töö number';
|
String get jobNumber => 'Töö number';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Sõnumid';
|
String get messages => 'Sõnumid';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Üldised sõnumid';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Sõnumeid veel pole';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Vestlusi pole saadaval';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Vali foto';
|
String get selectPhoto => 'Vali foto';
|
||||||
@override
|
@override
|
||||||
@@ -369,12 +440,18 @@ class AppLocalizationsEt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Loodud';
|
String get statusCreated => 'Loodud';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Ootel';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Määratud';
|
String get statusAssigned => 'Määratud';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'Töös';
|
String get statusInProgress => 'Töös';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Lõpetatud';
|
String get statusCompleted => 'Lõpetatud';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Tühistatud';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Ebaõnnestunud';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Madal';
|
String get priorityLow => 'Madal';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Keskmine';
|
String get priorityMedium => 'Keskmine';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Inconnu';
|
String get unknown => 'Inconnu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Hier';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Emplois';
|
String get jobs => 'Emplois';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Emplois Disponibles';
|
String get availableJobs => 'Liste des commandes';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Discussions';
|
String get chats => 'Discussions';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Connectez-vous à votre compte';
|
String get loginSubtitle => 'Connectez-vous à votre compte';
|
||||||
@override
|
@override
|
||||||
String get email => 'E-mail';
|
String get email => 'E-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'Adresse e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Saisissez votre adresse e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Veuillez saisir votre adresse e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Veuillez saisir une adresse e-mail valide';
|
||||||
@override
|
@override
|
||||||
String get password => 'Mot de passe';
|
String get password => 'Mot de passe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Saisissez votre mot de passe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Veuillez saisir votre mot de passe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength =>
|
||||||
|
'Le mot de passe doit contenir au moins 6 caractères';
|
||||||
@override
|
@override
|
||||||
String get login => 'Connexion';
|
String get login => 'Connexion';
|
||||||
@override
|
@override
|
||||||
@@ -73,15 +98,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Mot de passe oublié?';
|
String get forgotPassword => 'Mot de passe oublié?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Fonction mot de passe oublié pas encore implémentée';
|
String get forgotPasswordMessage =>
|
||||||
|
'Fonction mot de passe oublié pas encore implémentée';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Déconnexion réussie';
|
String get loginSuccess => 'Déconnexion réussie';
|
||||||
@override
|
@override
|
||||||
String get loginFailed => 'Échec de la connexion';
|
String get loginFailed => 'Échec de la connexion';
|
||||||
@override
|
@override
|
||||||
String get connectionFailed => 'Échec de la connexion au serveur (Délai dépassé).';
|
String get connectionFailed =>
|
||||||
|
'Échec de la connexion au serveur (Délai dépassé).';
|
||||||
@override
|
@override
|
||||||
String get connectionTimeout => 'Échec de la connexion au serveur (Délai dépassé).';
|
String get connectionTimeout =>
|
||||||
|
'Échec de la connexion au serveur (Délai dépassé).';
|
||||||
@override
|
@override
|
||||||
String get connecting => 'Connexion au serveur...';
|
String get connecting => 'Connexion au serveur...';
|
||||||
@override
|
@override
|
||||||
@@ -137,7 +165,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get jobsUpdated => 'Emplois actualisés';
|
String get jobsUpdated => 'Emplois actualisés';
|
||||||
@override
|
@override
|
||||||
String get connectionRestored => 'Connexion restaurée. Chargement des emplois...';
|
String get connectionRestored =>
|
||||||
|
'Connexion restaurée. Chargement des emplois...';
|
||||||
@override
|
@override
|
||||||
String get connectionLost => 'Connexion perdue. Hors ligne.';
|
String get connectionLost => 'Connexion perdue. Hors ligne.';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +178,34 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Nouvel emploi reçu';
|
String get newJobReceived => 'Nouvel emploi reçu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Détails de la commande';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Tâches de la commande';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Stations de livraison';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Stations de livraison ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Aucune station de livraison';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Cette mission ne contient actuellement aucune station de livraison.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Téléphone';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Station sans nom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Station $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Tâches';
|
String get tasks => 'Tâches';
|
||||||
@@ -161,7 +218,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Confirmation requise';
|
String get confirmationRequired => 'Confirmation requise';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Cliquez sur le bouton pour terminer la tâche.';
|
String get confirmationDescription =>
|
||||||
|
'Cliquez sur le bouton pour terminer la tâche.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Liste de contrôle';
|
String get checklist => 'Liste de contrôle';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +227,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Terminer la tâche';
|
String get completeTask => 'Terminer la tâche';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Voulez-vous marquer cette tâche comme terminée?';
|
String get completeTaskConfirm =>
|
||||||
|
'Voulez-vous marquer cette tâche comme terminée?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Note (optionnelle)';
|
String get completeTaskNote => 'Note (optionnelle)';
|
||||||
@override
|
@override
|
||||||
@@ -193,9 +252,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clear => 'Effacer';
|
String get clear => 'Effacer';
|
||||||
@override
|
@override
|
||||||
String get signatureError => 'Erreur lors de l\'enregistrement de la signature';
|
String get signatureError =>
|
||||||
|
'Erreur lors de l\'enregistrement de la signature';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Veuillez signer dans le champ ci-dessous (souris ou doigt).';
|
String get signatureInstruction =>
|
||||||
|
'Veuillez signer dans le champ ci-dessous (souris ou doigt).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Prendre des photos';
|
String get photoCapture => 'Prendre des photos';
|
||||||
@override
|
@override
|
||||||
@@ -221,7 +282,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get deletePhoto => 'Supprimer la photo';
|
String get deletePhoto => 'Supprimer la photo';
|
||||||
@override
|
@override
|
||||||
String get deletePhotoConfirm => 'Voulez-vous vraiment supprimer cette photo?';
|
String get deletePhotoConfirm =>
|
||||||
|
'Voulez-vous vraiment supprimer cette photo?';
|
||||||
@override
|
@override
|
||||||
String get barcode => 'Code-barres';
|
String get barcode => 'Code-barres';
|
||||||
@override
|
@override
|
||||||
@@ -257,9 +319,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Caméra non disponible';
|
String get cameraNotAvailable => 'Caméra non disponible';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'La caméra n\'est pas prise en charge sur cette plateforme.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'La caméra n\'est pas prise en charge sur cette plateforme.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Non supporté sur cette plateforme';
|
String get cameraNotSupportedOnPlatform =>
|
||||||
|
'Non supporté sur cette plateforme';
|
||||||
@override
|
@override
|
||||||
String get maxPhotosReached => 'Maximum atteint';
|
String get maxPhotosReached => 'Maximum atteint';
|
||||||
@override
|
@override
|
||||||
@@ -269,11 +333,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraInitializing => 'Initialisation de la caméra...';
|
String get cameraInitializing => 'Initialisation de la caméra...';
|
||||||
@override
|
@override
|
||||||
String get cameraLoadingMessage => 'Veuillez patienter pendant le chargement de la caméra';
|
String get cameraLoadingMessage =>
|
||||||
|
'Veuillez patienter pendant le chargement de la caméra';
|
||||||
@override
|
@override
|
||||||
String get addPhotos => 'Ajouter des photos';
|
String get addPhotos => 'Ajouter des photos';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Utilisez le bouton "Sélectionner une photo" pour ajouter des images depuis votre appareil photo ou disque dur.';
|
String get addPhotosInstruction =>
|
||||||
|
'Utilisez le bouton "Sélectionner une photo" pour ajouter des images depuis votre appareil photo ou disque dur.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'sur';
|
String get photoOf => 'sur';
|
||||||
|
|
||||||
@@ -285,11 +351,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Aucun expéditeur disponible';
|
String get noSender => 'Aucun expéditeur disponible';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Aucun expéditeur disponible. Veuillez vous reconnecter.';
|
String get noSenderMessage =>
|
||||||
|
'Aucun expéditeur disponible. Veuillez vous reconnecter.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Aucun destinataire configuré';
|
String get noRecipient => 'Aucun destinataire configuré';
|
||||||
@override
|
@override
|
||||||
String get noRecipientMessage => 'Aucun destinataire configuré pour cette discussion.';
|
String get noRecipientMessage =>
|
||||||
|
'Aucun destinataire configuré pour cette discussion.';
|
||||||
@override
|
@override
|
||||||
String get messageSendError => 'Le message n\'a pas pu être envoyé.';
|
String get messageSendError => 'Le message n\'a pas pu être envoyé.';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +374,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get jobNumber => 'Numéro d\'emploi';
|
String get jobNumber => 'Numéro d\'emploi';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Messages';
|
String get messages => 'Messages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Messages généraux';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Pas encore de messages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Aucune discussion disponible';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Sélectionner une photo';
|
String get selectPhoto => 'Sélectionner une photo';
|
||||||
@override
|
@override
|
||||||
@@ -327,7 +404,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCargoItems => 'Aucun article de cargaison';
|
String get noCargoItems => 'Aucun article de cargaison';
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'Aucun article de cargaison défini pour cet emploi.';
|
String get noCargoItemsMessage =>
|
||||||
|
'Aucun article de cargaison défini pour cet emploi.';
|
||||||
@override
|
@override
|
||||||
String get article => 'Article';
|
String get article => 'Article';
|
||||||
|
|
||||||
@@ -369,12 +447,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Créé';
|
String get statusCreated => 'Créé';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'En attente';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Assigné';
|
String get statusAssigned => 'Assigné';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'En cours';
|
String get statusInProgress => 'En cours';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Terminé';
|
String get statusCompleted => 'Terminé';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Annulé';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Échoué';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Basse';
|
String get priorityLow => 'Basse';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Moyenne';
|
String get priorityMedium => 'Moyenne';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Nežinoma';
|
String get unknown => 'Nežinoma';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Vakar';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Darbai';
|
String get jobs => 'Darbai';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Galimi darbai';
|
String get availableJobs => 'Užsakymų sąrašas';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Pokalbiai';
|
String get chats => 'Pokalbiai';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,30 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Prisijunkite prie savo paskyros';
|
String get loginSubtitle => 'Prisijunkite prie savo paskyros';
|
||||||
@override
|
@override
|
||||||
String get email => 'El. paštas';
|
String get email => 'El. paštas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'El. pašto adresas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Įveskite savo el. pašto adresą';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Prašome įvesti savo el. pašto adresą';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid =>
|
||||||
|
'Prašome įvesti galiojantį el. pašto adresą';
|
||||||
@override
|
@override
|
||||||
String get password => 'Slaptažodis';
|
String get password => 'Slaptažodis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Įveskite savo slaptažodį';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Prašome įvesti savo slaptažodį';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Slaptažodis turi būti bent 6 simbolių ilgio';
|
||||||
@override
|
@override
|
||||||
String get login => 'Prisijungti';
|
String get login => 'Prisijungti';
|
||||||
@override
|
@override
|
||||||
@@ -73,15 +98,18 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Pamiršote slaptažodį?';
|
String get forgotPassword => 'Pamiršote slaptažodį?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Pamiršto slaptažodžio funkcija dar neįdiegta';
|
String get forgotPasswordMessage =>
|
||||||
|
'Pamiršto slaptažodžio funkcija dar neįdiegta';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Sėkmingai atsijungta';
|
String get loginSuccess => 'Sėkmingai atsijungta';
|
||||||
@override
|
@override
|
||||||
String get loginFailed => 'Prisijungimas nepavyko';
|
String get loginFailed => 'Prisijungimas nepavyko';
|
||||||
@override
|
@override
|
||||||
String get connectionFailed => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
|
String get connectionFailed =>
|
||||||
|
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
|
||||||
@override
|
@override
|
||||||
String get connectionTimeout => 'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
|
String get connectionTimeout =>
|
||||||
|
'Nepavyko prisijungti prie serverio (Laikas baigėsi).';
|
||||||
@override
|
@override
|
||||||
String get connecting => 'Jungiamasi prie serverio...';
|
String get connecting => 'Jungiamasi prie serverio...';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +177,34 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Gautas naujas darbas';
|
String get newJobReceived => 'Gautas naujas darbas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Užsakymo detalės';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Užsakymo užduotys';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Pristatymo stotelės';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Pristatymo stotelės ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Nėra pristatymo stotelių';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Ši užduotis šiuo metu neturi pristatymo stotelių.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Telefonas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Neįvardyta stotelė';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Stotelė $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Užduotys';
|
String get tasks => 'Užduotys';
|
||||||
@@ -161,7 +217,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Reikalingas patvirtinimas';
|
String get confirmationRequired => 'Reikalingas patvirtinimas';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Spustelėkite mygtuką, kad atliktumėte užduotį.';
|
String get confirmationDescription =>
|
||||||
|
'Spustelėkite mygtuką, kad atliktumėte užduotį.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Patikros sąrašas';
|
String get checklist => 'Patikros sąrašas';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +226,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Baigti užduotį';
|
String get completeTask => 'Baigti užduotį';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Ar norite pažymėti šią užduotį kaip baigtą?';
|
String get completeTaskConfirm =>
|
||||||
|
'Ar norite pažymėti šią užduotį kaip baigtą?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Pastaba (neprivaloma)';
|
String get completeTaskNote => 'Pastaba (neprivaloma)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +253,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Klaida išsaugant parašą';
|
String get signatureError => 'Klaida išsaugant parašą';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Prašome pasirašyti laukelyje žemiau (pele arba pirštu).';
|
String get signatureInstruction =>
|
||||||
|
'Prašome pasirašyti laukelyje žemiau (pele arba pirštu).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Daryti nuotraukas';
|
String get photoCapture => 'Daryti nuotraukas';
|
||||||
@override
|
@override
|
||||||
@@ -245,9 +304,11 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get barcodeEnterDescription => 'Prašome įvesti brūkšninius kodus:';
|
String get barcodeEnterDescription => 'Prašome įvesti brūkšninius kodus:';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberRequired(int number) => 'Brūkšninis kodas $number (būtinas)';
|
String barcodeNumberRequired(int number) =>
|
||||||
|
'Brūkšninis kodas $number (būtinas)';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberOptional(int number) => 'Brūkšninis kodas $number (neprivalomas)';
|
String barcodeNumberOptional(int number) =>
|
||||||
|
'Brūkšninis kodas $number (neprivalomas)';
|
||||||
@override
|
@override
|
||||||
String get barcodeError => 'Klaida skaitant brūkšninį kodą';
|
String get barcodeError => 'Klaida skaitant brūkšninį kodą';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +318,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Kamera nepasiekiama';
|
String get cameraNotAvailable => 'Kamera nepasiekiama';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Šioje platformoje kamera nepalaikoma.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Šioje platformoje kamera nepalaikoma.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Nepalaikoma šioje platformoje';
|
String get cameraNotSupportedOnPlatform => 'Nepalaikoma šioje platformoje';
|
||||||
@override
|
@override
|
||||||
@@ -273,7 +335,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addPhotos => 'Pridėti nuotraukas';
|
String get addPhotos => 'Pridėti nuotraukas';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Naudokite mygtuką "Pasirinkti nuotrauką", norėdami pridėti vaizdų iš fotoaparato ar standžiojo disko.';
|
String get addPhotosInstruction =>
|
||||||
|
'Naudokite mygtuką "Pasirinkti nuotrauką", norėdami pridėti vaizdų iš fotoaparato ar standžiojo disko.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'iš';
|
String get photoOf => 'iš';
|
||||||
|
|
||||||
@@ -285,7 +348,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Siuntėjas nepasiekiamas';
|
String get noSender => 'Siuntėjas nepasiekiamas';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.';
|
String get noSenderMessage =>
|
||||||
|
'Siuntėjas nepasiekiamas. Prašome prisijungti dar kartą.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Gavėjas nesukonfigūruotas';
|
String get noRecipient => 'Gavėjas nesukonfigūruotas';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +370,15 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
String get jobNumber => 'Darbo numeris';
|
String get jobNumber => 'Darbo numeris';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Žinutės';
|
String get messages => 'Žinutės';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Bendri pranešimai';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Pranešimų dar nėra';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Nėra galimų pokalbių';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Pasirinkti nuotrauką';
|
String get selectPhoto => 'Pasirinkti nuotrauką';
|
||||||
@override
|
@override
|
||||||
@@ -327,7 +400,8 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCargoItems => 'Nėra krovinių pozicijų';
|
String get noCargoItems => 'Nėra krovinių pozicijų';
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'Šiam darbui nėra apibrėžtų krovinių pozicijų.';
|
String get noCargoItemsMessage =>
|
||||||
|
'Šiam darbui nėra apibrėžtų krovinių pozicijų.';
|
||||||
@override
|
@override
|
||||||
String get article => 'Pozicija';
|
String get article => 'Pozicija';
|
||||||
|
|
||||||
@@ -369,12 +443,18 @@ class AppLocalizationsLt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Sukurta';
|
String get statusCreated => 'Sukurta';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Laukiama';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Priskirta';
|
String get statusAssigned => 'Priskirta';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'Vykdoma';
|
String get statusInProgress => 'Vykdoma';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Baigta';
|
String get statusCompleted => 'Baigta';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Atšaukta';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Nepavyko';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Žemas';
|
String get priorityLow => 'Žemas';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Vidutinis';
|
String get priorityMedium => 'Vidutinis';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Nezināms';
|
String get unknown => 'Nezināms';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Vakar';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Darbi';
|
String get jobs => 'Darbi';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Pieejamie darbi';
|
String get availableJobs => 'Pasūtījumu saraksts';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Tērzēšanas';
|
String get chats => 'Tērzēšanas';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,29 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Pierakstieties savā kontā';
|
String get loginSubtitle => 'Pierakstieties savā kontā';
|
||||||
@override
|
@override
|
||||||
String get email => 'E-pasts';
|
String get email => 'E-pasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'E-pasta adrese';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Ievadiet savu e-pasta adresi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Lūdzu, ievadiet savu e-pasta adresi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Lūdzu, ievadiet derīgu e-pasta adresi';
|
||||||
@override
|
@override
|
||||||
String get password => 'Parole';
|
String get password => 'Parole';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Ievadiet savu paroli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Lūdzu, ievadiet savu paroli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Parolei jābūt vismaz 6 rakstzīmes garai';
|
||||||
@override
|
@override
|
||||||
String get login => 'Pierakstīties';
|
String get login => 'Pierakstīties';
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +97,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Aizmirsāt paroli?';
|
String get forgotPassword => 'Aizmirsāt paroli?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Aizmirstās paroles funkcija vēl nav ieviesta';
|
String get forgotPasswordMessage =>
|
||||||
|
'Aizmirstās paroles funkcija vēl nav ieviesta';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Veiksmīgi izrakstījās';
|
String get loginSuccess => 'Veiksmīgi izrakstījās';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +174,34 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Saņemts jauns darbs';
|
String get newJobReceived => 'Saņemts jauns darbs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Darba detaļas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Darba uzdevumi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Piegādes stacijas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Piegādes stacijas ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Nav piegādes staciju';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Šajā darbā pašlaik nav piegādes staciju.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Tālrunis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Nenosaukta stacija';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Stacija $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Uzdevumi';
|
String get tasks => 'Uzdevumi';
|
||||||
@@ -161,7 +214,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Nepieciešams apstiprinājums';
|
String get confirmationRequired => 'Nepieciešams apstiprinājums';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.';
|
String get confirmationDescription =>
|
||||||
|
'Noklikšķiniet uz pogas, lai pabeigtu uzdevumu.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Pārbaudes saraksts';
|
String get checklist => 'Pārbaudes saraksts';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +223,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Pabeigt uzdevumu';
|
String get completeTask => 'Pabeigt uzdevumu';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Vai vēlaties atzīmēt šo uzdevumu kā pabeigtu?';
|
String get completeTaskConfirm =>
|
||||||
|
'Vai vēlaties atzīmēt šo uzdevumu kā pabeigtu?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Piezīme (neobligāta)';
|
String get completeTaskNote => 'Piezīme (neobligāta)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +250,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Kļūda saglabājot parakstu';
|
String get signatureError => 'Kļūda saglabājot parakstu';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Lūdzu parakstieties zemāk esošajā laukā (pele vai pirksts).';
|
String get signatureInstruction =>
|
||||||
|
'Lūdzu parakstieties zemāk esošajā laukā (pele vai pirksts).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Uzņemt fotogrāfijas';
|
String get photoCapture => 'Uzņemt fotogrāfijas';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +313,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Kamera nav pieejama';
|
String get cameraNotAvailable => 'Kamera nav pieejama';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Šajā platformā kamera netiek atbalstīta.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Šajā platformā kamera netiek atbalstīta.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Šajā platformā netiek atbalstīts';
|
String get cameraNotSupportedOnPlatform => 'Šajā platformā netiek atbalstīts';
|
||||||
@override
|
@override
|
||||||
@@ -269,11 +326,13 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraInitializing => 'Kamera tiek inicializēta...';
|
String get cameraInitializing => 'Kamera tiek inicializēta...';
|
||||||
@override
|
@override
|
||||||
String get cameraLoadingMessage => 'Lūdzu, uzgaidiet, kamēr kamera tiek ielādēta';
|
String get cameraLoadingMessage =>
|
||||||
|
'Lūdzu, uzgaidiet, kamēr kamera tiek ielādēta';
|
||||||
@override
|
@override
|
||||||
String get addPhotos => 'Pievienot fotogrāfijas';
|
String get addPhotos => 'Pievienot fotogrāfijas';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Izmantojiet pogu "Izvēlēties fotogrāfiju", lai pievienotu attēlus no kameras vai cietā diska.';
|
String get addPhotosInstruction =>
|
||||||
|
'Izmantojiet pogu "Izvēlēties fotogrāfiju", lai pievienotu attēlus no kameras vai cietā diska.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'no';
|
String get photoOf => 'no';
|
||||||
|
|
||||||
@@ -285,7 +344,8 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Sūtītājs nav pieejams';
|
String get noSender => 'Sūtītājs nav pieejams';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Sūtītājs nav pieejams. Lūdzu, piesakieties vēlreiz.';
|
String get noSenderMessage =>
|
||||||
|
'Sūtītājs nav pieejams. Lūdzu, piesakieties vēlreiz.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Saņēmējs nav konfigurēts';
|
String get noRecipient => 'Saņēmējs nav konfigurēts';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +366,15 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
String get jobNumber => 'Darba numurs';
|
String get jobNumber => 'Darba numurs';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Ziņojumi';
|
String get messages => 'Ziņojumi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Vispārīgi ziņojumi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Ziņojumu vēl nav';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Nav pieejamu tērzēšanu';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Izvēlēties fotogrāfiju';
|
String get selectPhoto => 'Izvēlēties fotogrāfiju';
|
||||||
@override
|
@override
|
||||||
@@ -369,12 +438,18 @@ class AppLocalizationsLv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Izveidots';
|
String get statusCreated => 'Izveidots';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Gaida';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Piešķirts';
|
String get statusAssigned => 'Piešķirts';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'Procesā';
|
String get statusInProgress => 'Procesā';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Pabeigts';
|
String get statusCompleted => 'Pabeigts';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Atcelts';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Neizdevās';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Zema';
|
String get priorityLow => 'Zema';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Vidēja';
|
String get priorityMedium => 'Vidēja';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Nieznany';
|
String get unknown => 'Nieznany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Wczoraj';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Zadania';
|
String get jobs => 'Zadania';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Dostępne Zadania';
|
String get availableJobs => 'Lista zleceń';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Czaty';
|
String get chats => 'Czaty';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,29 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Zaloguj się do swojego konta';
|
String get loginSubtitle => 'Zaloguj się do swojego konta';
|
||||||
@override
|
@override
|
||||||
String get email => 'E-mail';
|
String get email => 'E-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'Adres e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Wpisz adres e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Proszę wpisać adres e-mail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Proszę wpisać prawidłowy adres e-mail';
|
||||||
@override
|
@override
|
||||||
String get password => 'Hasło';
|
String get password => 'Hasło';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Wpisz hasło';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Proszę wpisać hasło';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Hasło musi mieć co najmniej 6 znaków';
|
||||||
@override
|
@override
|
||||||
String get login => 'Zaloguj';
|
String get login => 'Zaloguj';
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +97,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Zapomniałeś hasła?';
|
String get forgotPassword => 'Zapomniałeś hasła?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Funkcja zapomnianego hasła jeszcze nie zaimplementowana';
|
String get forgotPasswordMessage =>
|
||||||
|
'Funkcja zapomnianego hasła jeszcze nie zaimplementowana';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Pomyślnie wylogowano';
|
String get loginSuccess => 'Pomyślnie wylogowano';
|
||||||
@override
|
@override
|
||||||
@@ -93,7 +118,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noJobsAssigned => 'Brak przypisanych zadań';
|
String get noJobsAssigned => 'Brak przypisanych zadań';
|
||||||
@override
|
@override
|
||||||
String get noJobsMessage => 'Twoje przypisane zadania będą wyświetlane tutaj.';
|
String get noJobsMessage =>
|
||||||
|
'Twoje przypisane zadania będą wyświetlane tutaj.';
|
||||||
@override
|
@override
|
||||||
String get pullToRefresh => 'Przeciągnij w dół, aby odświeżyć';
|
String get pullToRefresh => 'Przeciągnij w dół, aby odświeżyć';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +175,34 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Otrzymano nowe zadanie';
|
String get newJobReceived => 'Otrzymano nowe zadanie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Szczegóły zlecenia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Zadania zlecenia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Stacje dostawy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Stacje dostawy ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Brak stacji dostawy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'To zlecenie nie zawiera obecnie żadnych stacji dostawy.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Telefon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Nienazwana stacja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Stacja $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Zadania';
|
String get tasks => 'Zadania';
|
||||||
@@ -161,7 +215,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Wymagane potwierdzenie';
|
String get confirmationRequired => 'Wymagane potwierdzenie';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Kliknij przycisk, aby ukończyć zadanie.';
|
String get confirmationDescription =>
|
||||||
|
'Kliknij przycisk, aby ukończyć zadanie.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Lista kontrolna';
|
String get checklist => 'Lista kontrolna';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +224,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Ukończ zadanie';
|
String get completeTask => 'Ukończ zadanie';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Czy chcesz oznaczyć to zadanie jako ukończone?';
|
String get completeTaskConfirm =>
|
||||||
|
'Czy chcesz oznaczyć to zadanie jako ukończone?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Notatka (opcjonalnie)';
|
String get completeTaskNote => 'Notatka (opcjonalnie)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +251,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Błąd podczas zapisywania podpisu';
|
String get signatureError => 'Błąd podczas zapisywania podpisu';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Proszę podpisać się w polu poniżej (mysz lub palec).';
|
String get signatureInstruction =>
|
||||||
|
'Proszę podpisać się w polu poniżej (mysz lub palec).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Zrób zdjęcia';
|
String get photoCapture => 'Zrób zdjęcia';
|
||||||
@override
|
@override
|
||||||
@@ -247,7 +304,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String barcodeNumberRequired(int number) => 'Kod kreskowy $number (wymagany)';
|
String barcodeNumberRequired(int number) => 'Kod kreskowy $number (wymagany)';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberOptional(int number) => 'Kod kreskowy $number (opcjonalny)';
|
String barcodeNumberOptional(int number) =>
|
||||||
|
'Kod kreskowy $number (opcjonalny)';
|
||||||
@override
|
@override
|
||||||
String get barcodeError => 'Błąd podczas skanowania kodu kreskowego';
|
String get barcodeError => 'Błąd podczas skanowania kodu kreskowego';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +315,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Kamera niedostępna';
|
String get cameraNotAvailable => 'Kamera niedostępna';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Kamera nie jest obsługiwana na tej platformie.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Kamera nie jest obsługiwana na tej platformie.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Nieobsługiwane na tej platformie';
|
String get cameraNotSupportedOnPlatform => 'Nieobsługiwane na tej platformie';
|
||||||
@override
|
@override
|
||||||
@@ -273,7 +332,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addPhotos => 'Dodaj zdjęcia';
|
String get addPhotos => 'Dodaj zdjęcia';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Użyj przycisku "Wybierz zdjęcie", aby dodać obrazy z kamery lub dysku twardego.';
|
String get addPhotosInstruction =>
|
||||||
|
'Użyj przycisku "Wybierz zdjęcie", aby dodać obrazy z kamery lub dysku twardego.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'z';
|
String get photoOf => 'z';
|
||||||
|
|
||||||
@@ -285,11 +345,13 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Brak dostępnego nadawcy';
|
String get noSender => 'Brak dostępnego nadawcy';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Brak dostępnego nadawcy. Proszę zalogować się ponownie.';
|
String get noSenderMessage =>
|
||||||
|
'Brak dostępnego nadawcy. Proszę zalogować się ponownie.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Brak skonfigurowanego odbiorcy';
|
String get noRecipient => 'Brak skonfigurowanego odbiorcy';
|
||||||
@override
|
@override
|
||||||
String get noRecipientMessage => 'Brak skonfigurowanego odbiorcy dla tego czatu.';
|
String get noRecipientMessage =>
|
||||||
|
'Brak skonfigurowanego odbiorcy dla tego czatu.';
|
||||||
@override
|
@override
|
||||||
String get messageSendError => 'Wiadomość nie mogła zostać wysłana.';
|
String get messageSendError => 'Wiadomość nie mogła zostać wysłana.';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +368,15 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
String get jobNumber => 'Numer zadania';
|
String get jobNumber => 'Numer zadania';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Wiadomości';
|
String get messages => 'Wiadomości';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Wiadomości ogólne';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Brak wiadomości';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Brak dostępnych czatów';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Wybierz zdjęcie';
|
String get selectPhoto => 'Wybierz zdjęcie';
|
||||||
@override
|
@override
|
||||||
@@ -327,7 +398,8 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCargoItems => 'Brak pozycji ładunku';
|
String get noCargoItems => 'Brak pozycji ładunku';
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'Brak pozycji ładunku zdefiniowanych dla tego zadania.';
|
String get noCargoItemsMessage =>
|
||||||
|
'Brak pozycji ładunku zdefiniowanych dla tego zadania.';
|
||||||
@override
|
@override
|
||||||
String get article => 'Pozycja';
|
String get article => 'Pozycja';
|
||||||
|
|
||||||
@@ -369,12 +441,18 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Utworzono';
|
String get statusCreated => 'Utworzono';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Oczekujące';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Przypisano';
|
String get statusAssigned => 'Przypisano';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'W trakcie';
|
String get statusInProgress => 'W trakcie';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Ukończono';
|
String get statusCompleted => 'Ukończono';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Anulowano';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Nieudane';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Niski';
|
String get priorityLow => 'Niski';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Średni';
|
String get priorityMedium => 'Średni';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Неизвестно';
|
String get unknown => 'Неизвестно';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Вчера';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Задания';
|
String get jobs => 'Задания';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Доступные задания';
|
String get availableJobs => 'Список заказов';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Чаты';
|
String get chats => 'Чаты';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Войдите в свою учетную запись';
|
String get loginSubtitle => 'Войдите в свою учетную запись';
|
||||||
@override
|
@override
|
||||||
String get email => 'Эл. почта';
|
String get email => 'Эл. почта';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'Адрес эл. почты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'Введите адрес эл. почты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Пожалуйста, введите адрес эл. почты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid =>
|
||||||
|
'Пожалуйста, введите корректный адрес эл. почты';
|
||||||
@override
|
@override
|
||||||
String get password => 'Пароль';
|
String get password => 'Пароль';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Введите пароль';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Пожалуйста, введите пароль';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Пароль должен содержать не менее 6 символов';
|
||||||
@override
|
@override
|
||||||
String get login => 'Войти';
|
String get login => 'Войти';
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +98,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Забыли пароль?';
|
String get forgotPassword => 'Забыли пароль?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Функция восстановления пароля еще не реализована';
|
String get forgotPasswordMessage =>
|
||||||
|
'Функция восстановления пароля еще не реализована';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Успешный выход из системы';
|
String get loginSuccess => 'Успешный выход из системы';
|
||||||
@override
|
@override
|
||||||
@@ -93,7 +119,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noJobsAssigned => 'Нет назначенных заданий';
|
String get noJobsAssigned => 'Нет назначенных заданий';
|
||||||
@override
|
@override
|
||||||
String get noJobsMessage => 'Ваши назначенные задания будут отображаться здесь.';
|
String get noJobsMessage =>
|
||||||
|
'Ваши назначенные задания будут отображаться здесь.';
|
||||||
@override
|
@override
|
||||||
String get pullToRefresh => 'Потяните вниз, чтобы обновить';
|
String get pullToRefresh => 'Потяните вниз, чтобы обновить';
|
||||||
@override
|
@override
|
||||||
@@ -137,7 +164,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get jobsUpdated => 'Задания обновлены';
|
String get jobsUpdated => 'Задания обновлены';
|
||||||
@override
|
@override
|
||||||
String get connectionRestored => 'Соединение восстановлено. Загрузка заданий...';
|
String get connectionRestored =>
|
||||||
|
'Соединение восстановлено. Загрузка заданий...';
|
||||||
@override
|
@override
|
||||||
String get connectionLost => 'Соединение потеряно. Офлайн.';
|
String get connectionLost => 'Соединение потеряно. Офлайн.';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +177,34 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Получено новое задание';
|
String get newJobReceived => 'Получено новое задание';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'Детали заказа';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'Задачи заказа';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Точки доставки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Точки доставки ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Нет точек доставки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Для этого заказа сейчас нет точек доставки.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Телефон';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Станция без названия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Станция $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Задачи';
|
String get tasks => 'Задачи';
|
||||||
@@ -161,7 +217,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Требуется подтверждение';
|
String get confirmationRequired => 'Требуется подтверждение';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Нажмите кнопку, чтобы выполнить задачу.';
|
String get confirmationDescription =>
|
||||||
|
'Нажмите кнопку, чтобы выполнить задачу.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Контрольный список';
|
String get checklist => 'Контрольный список';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +226,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Завершить задачу';
|
String get completeTask => 'Завершить задачу';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Хотите отметить эту задачу как выполненную?';
|
String get completeTaskConfirm =>
|
||||||
|
'Хотите отметить эту задачу как выполненную?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Примечание (необязательно)';
|
String get completeTaskNote => 'Примечание (необязательно)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +253,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'Ошибка при сохранении подписи';
|
String get signatureError => 'Ошибка при сохранении подписи';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Пожалуйста, подпишитесь в поле ниже (мышь или палец).';
|
String get signatureInstruction =>
|
||||||
|
'Пожалуйста, подпишитесь в поле ниже (мышь или палец).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Сделать фото';
|
String get photoCapture => 'Сделать фото';
|
||||||
@override
|
@override
|
||||||
@@ -247,7 +306,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String barcodeNumberRequired(int number) => 'Штрих-код $number (обязательно)';
|
String barcodeNumberRequired(int number) => 'Штрих-код $number (обязательно)';
|
||||||
@override
|
@override
|
||||||
String barcodeNumberOptional(int number) => 'Штрих-код $number (необязательно)';
|
String barcodeNumberOptional(int number) =>
|
||||||
|
'Штрих-код $number (необязательно)';
|
||||||
@override
|
@override
|
||||||
String get barcodeError => 'Ошибка при сканировании штрих-кода';
|
String get barcodeError => 'Ошибка при сканировании штрих-кода';
|
||||||
@override
|
@override
|
||||||
@@ -257,9 +317,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Камера недоступна';
|
String get cameraNotAvailable => 'Камера недоступна';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Камера не поддерживается на этой платформе.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Камера не поддерживается на этой платформе.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Не поддерживается на этой платформе';
|
String get cameraNotSupportedOnPlatform =>
|
||||||
|
'Не поддерживается на этой платформе';
|
||||||
@override
|
@override
|
||||||
String get maxPhotosReached => 'Максимум достигнут';
|
String get maxPhotosReached => 'Максимум достигнут';
|
||||||
@override
|
@override
|
||||||
@@ -269,11 +331,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraInitializing => 'Инициализация камеры...';
|
String get cameraInitializing => 'Инициализация камеры...';
|
||||||
@override
|
@override
|
||||||
String get cameraLoadingMessage => 'Пожалуйста, подождите, пока загружается камера';
|
String get cameraLoadingMessage =>
|
||||||
|
'Пожалуйста, подождите, пока загружается камера';
|
||||||
@override
|
@override
|
||||||
String get addPhotos => 'Добавить фото';
|
String get addPhotos => 'Добавить фото';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Используйте кнопку "Выбрать фото", чтобы добавить изображения с камеры или жёсткого диска.';
|
String get addPhotosInstruction =>
|
||||||
|
'Используйте кнопку "Выбрать фото", чтобы добавить изображения с камеры или жёсткого диска.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => 'из';
|
String get photoOf => 'из';
|
||||||
|
|
||||||
@@ -285,7 +349,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Отправитель недоступен';
|
String get noSender => 'Отправитель недоступен';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Отправитель недоступен. Пожалуйста, войдите снова.';
|
String get noSenderMessage =>
|
||||||
|
'Отправитель недоступен. Пожалуйста, войдите снова.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Получатель не настроен';
|
String get noRecipient => 'Получатель не настроен';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +371,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get jobNumber => 'Номер задания';
|
String get jobNumber => 'Номер задания';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Сообщения';
|
String get messages => 'Сообщения';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Общие сообщения';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Сообщений пока нет';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Нет доступных чатов';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Выбрать фото';
|
String get selectPhoto => 'Выбрать фото';
|
||||||
@override
|
@override
|
||||||
@@ -327,7 +401,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCargoItems => 'Нет позиций груза';
|
String get noCargoItems => 'Нет позиций груза';
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage => 'Для этого задания не определены позиции груза.';
|
String get noCargoItemsMessage =>
|
||||||
|
'Для этого задания не определены позиции груза.';
|
||||||
@override
|
@override
|
||||||
String get article => 'Позиция';
|
String get article => 'Позиция';
|
||||||
|
|
||||||
@@ -369,12 +444,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Создано';
|
String get statusCreated => 'Создано';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'В ожидании';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Назначено';
|
String get statusAssigned => 'Назначено';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'В процессе';
|
String get statusInProgress => 'В процессе';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Завершено';
|
String get statusCompleted => 'Завершено';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'Отменено';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Не удалось';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Низкий';
|
String get priorityLow => 'Низкий';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Средний';
|
String get priorityMedium => 'Средний';
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get unknown => 'Bilinmiyor';
|
String get unknown => 'Bilinmiyor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Dün';
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'İşler';
|
String get jobs => 'İşler';
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Mevcut İşler';
|
String get availableJobs => 'Sipariş Listesi';
|
||||||
@override
|
@override
|
||||||
String get chats => 'Sohbetler';
|
String get chats => 'Sohbetler';
|
||||||
@override
|
@override
|
||||||
@@ -64,8 +67,29 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get loginSubtitle => 'Hesabınıza giriş yapın';
|
String get loginSubtitle => 'Hesabınıza giriş yapın';
|
||||||
@override
|
@override
|
||||||
String get email => 'E-posta';
|
String get email => 'E-posta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddress => 'E-posta adresi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressHint => 'E-posta adresinizi girin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressRequired => 'Lütfen e-posta adresinizi girin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emailAddressInvalid => 'Lütfen geçerli bir e-posta adresi girin';
|
||||||
@override
|
@override
|
||||||
String get password => 'Şifre';
|
String get password => 'Şifre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordHint => 'Şifrenizi girin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordRequired => 'Lütfen şifrenizi girin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordMinLength => 'Şifre en az 6 karakter olmalıdır';
|
||||||
@override
|
@override
|
||||||
String get login => 'Giriş';
|
String get login => 'Giriş';
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +97,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forgotPassword => 'Şifrenizi mi unuttunuz?';
|
String get forgotPassword => 'Şifrenizi mi unuttunuz?';
|
||||||
@override
|
@override
|
||||||
String get forgotPasswordMessage => 'Şifremi unuttum özelliği henüz uygulanmadı';
|
String get forgotPasswordMessage =>
|
||||||
|
'Şifremi unuttum özelliği henüz uygulanmadı';
|
||||||
@override
|
@override
|
||||||
String get loginSuccess => 'Başarıyla çıkış yapıldı';
|
String get loginSuccess => 'Başarıyla çıkış yapıldı';
|
||||||
@override
|
@override
|
||||||
@@ -137,7 +162,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get jobsUpdated => 'İşler güncellendi';
|
String get jobsUpdated => 'İşler güncellendi';
|
||||||
@override
|
@override
|
||||||
String get connectionRestored => 'Bağlantı geri yüklendi. İşler yükleniyor...';
|
String get connectionRestored =>
|
||||||
|
'Bağlantı geri yüklendi. İşler yükleniyor...';
|
||||||
@override
|
@override
|
||||||
String get connectionLost => 'Bağlantı kesildi. Çevrimdışı.';
|
String get connectionLost => 'Bağlantı kesildi. Çevrimdışı.';
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +175,34 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Yeni iş alındı';
|
String get newJobReceived => 'Yeni iş alındı';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobDetails => 'İş detayları';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jobTasks => 'İş görevleri';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deliveryStations => 'Teslimat durakları';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deliveryStationsCount(int count) => 'Teslimat durakları ($count)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStations => 'Teslimat durağı yok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeliveryStationsMessage =>
|
||||||
|
'Bu iş şu anda hiçbir teslimat durağı içermiyor.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get phone => 'Telefon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unnamedStation => 'Adsız durak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String stationNumber(int number) => 'Durak $number';
|
||||||
|
|
||||||
// ==================== TASKS ====================
|
// ==================== TASKS ====================
|
||||||
@override
|
@override
|
||||||
String get tasks => 'Görevler';
|
String get tasks => 'Görevler';
|
||||||
@@ -161,7 +215,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get confirmationRequired => 'Onay gerekli';
|
String get confirmationRequired => 'Onay gerekli';
|
||||||
@override
|
@override
|
||||||
String get confirmationDescription => 'Görevi tamamlamak için butona tıklayın.';
|
String get confirmationDescription =>
|
||||||
|
'Görevi tamamlamak için butona tıklayın.';
|
||||||
@override
|
@override
|
||||||
String get checklist => 'Kontrol listesi';
|
String get checklist => 'Kontrol listesi';
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +224,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get completeTask => 'Görevi tamamla';
|
String get completeTask => 'Görevi tamamla';
|
||||||
@override
|
@override
|
||||||
String get completeTaskConfirm => 'Bu görevi tamamlandı olarak işaretlemek istiyor musunuz?';
|
String get completeTaskConfirm =>
|
||||||
|
'Bu görevi tamamlandı olarak işaretlemek istiyor musunuz?';
|
||||||
@override
|
@override
|
||||||
String get completeTaskNote => 'Not (isteğe bağlı)';
|
String get completeTaskNote => 'Not (isteğe bağlı)';
|
||||||
@override
|
@override
|
||||||
@@ -195,7 +251,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get signatureError => 'İmza kaydedilirken hata oluştu';
|
String get signatureError => 'İmza kaydedilirken hata oluştu';
|
||||||
@override
|
@override
|
||||||
String get signatureInstruction => 'Lütfen aşağıdaki alana imzanızı atın (fare veya parmak).';
|
String get signatureInstruction =>
|
||||||
|
'Lütfen aşağıdaki alana imzanızı atın (fare veya parmak).';
|
||||||
@override
|
@override
|
||||||
String get photoCapture => 'Fotoğraf çek';
|
String get photoCapture => 'Fotoğraf çek';
|
||||||
@override
|
@override
|
||||||
@@ -221,7 +278,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get deletePhoto => 'Fotoğrafı sil';
|
String get deletePhoto => 'Fotoğrafı sil';
|
||||||
@override
|
@override
|
||||||
String get deletePhotoConfirm => 'Bu fotoğrafı gerçekten silmek istiyor musunuz?';
|
String get deletePhotoConfirm =>
|
||||||
|
'Bu fotoğrafı gerçekten silmek istiyor musunuz?';
|
||||||
@override
|
@override
|
||||||
String get barcode => 'Barkod';
|
String get barcode => 'Barkod';
|
||||||
@override
|
@override
|
||||||
@@ -257,7 +315,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cameraNotAvailable => 'Kamera kullanılamıyor';
|
String get cameraNotAvailable => 'Kamera kullanılamıyor';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedMessage => 'Bu platformda kamera desteklenmiyor.';
|
String get cameraNotSupportedMessage =>
|
||||||
|
'Bu platformda kamera desteklenmiyor.';
|
||||||
@override
|
@override
|
||||||
String get cameraNotSupportedOnPlatform => 'Bu platformda desteklenmiyor';
|
String get cameraNotSupportedOnPlatform => 'Bu platformda desteklenmiyor';
|
||||||
@override
|
@override
|
||||||
@@ -273,7 +332,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addPhotos => 'Fotoğraf ekle';
|
String get addPhotos => 'Fotoğraf ekle';
|
||||||
@override
|
@override
|
||||||
String get addPhotosInstruction => 'Kamera veya sabit diskten görüntü eklemek için "Fotoğraf seç" düğmesini kullanın.';
|
String get addPhotosInstruction =>
|
||||||
|
'Kamera veya sabit diskten görüntü eklemek için "Fotoğraf seç" düğmesini kullanın.';
|
||||||
@override
|
@override
|
||||||
String get photoOf => '/';
|
String get photoOf => '/';
|
||||||
|
|
||||||
@@ -285,7 +345,8 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSender => 'Gönderen mevcut değil';
|
String get noSender => 'Gönderen mevcut değil';
|
||||||
@override
|
@override
|
||||||
String get noSenderMessage => 'Gönderen mevcut değil. Lütfen tekrar giriş yapın.';
|
String get noSenderMessage =>
|
||||||
|
'Gönderen mevcut değil. Lütfen tekrar giriş yapın.';
|
||||||
@override
|
@override
|
||||||
String get noRecipient => 'Alıcı yapılandırılmamış';
|
String get noRecipient => 'Alıcı yapılandırılmamış';
|
||||||
@override
|
@override
|
||||||
@@ -306,6 +367,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get jobNumber => 'İş numarası';
|
String get jobNumber => 'İş numarası';
|
||||||
@override
|
@override
|
||||||
String get messages => 'Mesajlar';
|
String get messages => 'Mesajlar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generalMessages => 'Genel mesajlar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noMessagesYet => 'Henüz mesaj yok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChatsAvailable => 'Kullanılabilir sohbet yok';
|
||||||
@override
|
@override
|
||||||
String get selectPhoto => 'Fotoğraf seç';
|
String get selectPhoto => 'Fotoğraf seç';
|
||||||
@override
|
@override
|
||||||
@@ -369,12 +439,18 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get statusCreated => 'Oluşturuldu';
|
String get statusCreated => 'Oluşturuldu';
|
||||||
@override
|
@override
|
||||||
|
String get statusPending => 'Beklemede';
|
||||||
|
@override
|
||||||
String get statusAssigned => 'Atandı';
|
String get statusAssigned => 'Atandı';
|
||||||
@override
|
@override
|
||||||
String get statusInProgress => 'Devam ediyor';
|
String get statusInProgress => 'Devam ediyor';
|
||||||
@override
|
@override
|
||||||
String get statusCompleted => 'Tamamlandı';
|
String get statusCompleted => 'Tamamlandı';
|
||||||
@override
|
@override
|
||||||
|
String get statusCancelled => 'İptal edildi';
|
||||||
|
@override
|
||||||
|
String get statusFailed => 'Başarısız';
|
||||||
|
@override
|
||||||
String get priorityLow => 'Düşük';
|
String get priorityLow => 'Düşük';
|
||||||
@override
|
@override
|
||||||
String get priorityMedium => 'Orta';
|
String get priorityMedium => 'Orta';
|
||||||
|
|||||||
50
app/lib/l10n/localization_helpers.dart
Normal file
50
app/lib/l10n/localization_helpers.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'services/websocket_service.dart';
|
|||||||
import 'services/dart_mq.dart';
|
import 'services/dart_mq.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LoginView extends StatefulWidget {
|
class LoginView extends StatefulWidget {
|
||||||
@@ -34,6 +35,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
bool _logoutNoticeShown = false;
|
bool _logoutNoticeShown = false;
|
||||||
bool _hasNavigatedToJobs = false;
|
bool _hasNavigatedToJobs = false;
|
||||||
String _appVersion = '';
|
String _appVersion = '';
|
||||||
|
String? _pendingLoginEmail;
|
||||||
|
String? _pendingLoginPassword;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -52,7 +55,13 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_logoutNoticeShown = true;
|
_logoutNoticeShown = true;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).loginSuccess), backgroundColor: Colors.green, duration: const Duration(seconds: 1)));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).loginSuccess),
|
||||||
|
backgroundColor: AppColors.success,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,70 +95,144 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// Listen to connection status changes via dart_mq
|
// Listen to connection status changes via dart_mq
|
||||||
// Note: Don't reset _isLoggingIn here - the login flow in _handleLogin
|
// Note: Don't reset _isLoggingIn here - the login flow in _handleLogin
|
||||||
// manages button state through its own error/success handling.
|
// manages button state through its own error/success handling.
|
||||||
_connectionStatusSubscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) {
|
_connectionStatusSubscription = DartMQ().subscribe<bool>(
|
||||||
if (mounted) {
|
MQTopics.connectionStatus,
|
||||||
setState(() {});
|
(isConnected) {
|
||||||
}
|
if (mounted) {
|
||||||
});
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Listen to authentication responses via dart_mq
|
// Listen to authentication responses via dart_mq
|
||||||
_authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(MQTopics.authResponse, (response) {
|
_authResponseSubscription = DartMQ().subscribe<Map<String, dynamic>>(
|
||||||
final responseTime = DateTime.now();
|
MQTopics.authResponse,
|
||||||
developer.log('=== AUTHENTICATION RESPONSE RECEIVED ===', name: 'LoginView');
|
(response) {
|
||||||
developer.log('Timestamp: ${responseTime.toIso8601String()}', name: 'LoginView');
|
final responseTime = DateTime.now();
|
||||||
developer.log('Response data: $response', name: 'LoginView');
|
developer.log(
|
||||||
|
'=== AUTHENTICATION RESPONSE RECEIVED ===',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
developer.log(
|
||||||
|
'Timestamp: ${responseTime.toIso8601String()}',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
developer.log('Response data: $response', name: 'LoginView');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
_handleAuthResponse(response);
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoggingIn = false;
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
developer.log(
|
||||||
|
'Widget not mounted - skipping UI updates for auth response',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (response['success'] == true) {
|
developer.log(
|
||||||
// Prevent duplicate navigation from multiple auth responses
|
'Authentication response processing completed',
|
||||||
if (_hasNavigatedToJobs) {
|
name: 'LoginView',
|
||||||
developer.log('Already navigated to jobs view - ignoring duplicate auth response', name: 'LoginView');
|
);
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
_hasNavigatedToJobs = true;
|
}
|
||||||
|
|
||||||
final message = response['message'] ?? 'Anmeldung erfolgreich';
|
void _clearPendingLoginCredentials() {
|
||||||
final email = _emailController.text.trim();
|
_pendingLoginEmail = null;
|
||||||
final password = _passwordController.text;
|
_pendingLoginPassword = null;
|
||||||
|
}
|
||||||
|
|
||||||
developer.log('=== LOGIN SUCCESS ===', name: 'LoginView');
|
Future<void> _handleAuthResponse(Map<String, dynamic> response) async {
|
||||||
developer.log('Email: $email', name: 'LoginView');
|
if (!mounted) return;
|
||||||
developer.log('Message: $message', name: 'LoginView');
|
|
||||||
|
|
||||||
// Store email as login identifier
|
final pendingEmail = _pendingLoginEmail?.trim();
|
||||||
_appState.setLoggedInEmail(email);
|
final pendingPassword = _pendingLoginPassword;
|
||||||
|
final hadPendingLogin =
|
||||||
|
pendingEmail != null &&
|
||||||
|
pendingEmail.isNotEmpty &&
|
||||||
|
pendingPassword != null &&
|
||||||
|
pendingPassword.isNotEmpty;
|
||||||
|
_clearPendingLoginCredentials();
|
||||||
|
|
||||||
// Save credentials for auto-login on app restart
|
setState(() {
|
||||||
DatabaseService().saveCredentials(email, password);
|
_isLoggingIn = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate directly to jobs view - jobs will be loaded there
|
if (response['success'] == true) {
|
||||||
developer.log('Navigating to jobs view - jobs will be loaded there...', name: 'LoginView');
|
// Prevent duplicate navigation from multiple auth responses
|
||||||
Navigator.of(context).pushReplacementNamed('/jobs');
|
if (_hasNavigatedToJobs) {
|
||||||
} else {
|
developer.log(
|
||||||
final errorMessage = response['message'] ?? 'Unbekannter Fehler';
|
'Already navigated to jobs view - ignoring duplicate auth response',
|
||||||
final errorCode = response['code'] ?? 'No code';
|
name: 'LoginView',
|
||||||
|
);
|
||||||
developer.log('=== LOGIN FAILURE ===', name: 'LoginView');
|
return;
|
||||||
developer.log('Error message: $errorMessage', name: 'LoginView');
|
|
||||||
developer.log('Error code: $errorCode', name: 'LoginView');
|
|
||||||
developer.log('Full error response: $response', name: 'LoginView');
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).loginFailed}: $errorMessage'), backgroundColor: Colors.red, duration: const Duration(seconds: 1)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
developer.log('Widget not mounted - skipping UI updates for auth response', name: 'LoginView');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Authentication response processing completed', name: 'LoginView');
|
final message = response['message'] ?? 'Anmeldung erfolgreich';
|
||||||
});
|
final savedCredentials = await DatabaseService().loadCredentials();
|
||||||
|
final effectiveEmail =
|
||||||
|
(pendingEmail != null && pendingEmail.isNotEmpty)
|
||||||
|
? pendingEmail
|
||||||
|
: (savedCredentials?.email ??
|
||||||
|
_appState.loggedInEmail ??
|
||||||
|
_emailController.text.trim());
|
||||||
|
final effectivePassword =
|
||||||
|
(pendingPassword != null && pendingPassword.isNotEmpty)
|
||||||
|
? pendingPassword
|
||||||
|
: (savedCredentials?.password ?? _passwordController.text);
|
||||||
|
|
||||||
|
developer.log('=== LOGIN SUCCESS ===', name: 'LoginView');
|
||||||
|
developer.log('Email: $effectiveEmail', name: 'LoginView');
|
||||||
|
developer.log('Message: $message', name: 'LoginView');
|
||||||
|
|
||||||
|
if (effectiveEmail.isNotEmpty) {
|
||||||
|
_appState.setLoggedInEmail(effectiveEmail);
|
||||||
|
}
|
||||||
|
if (effectiveEmail.isNotEmpty && effectivePassword.isNotEmpty) {
|
||||||
|
await DatabaseService().saveCredentials(
|
||||||
|
effectiveEmail,
|
||||||
|
effectivePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
_hasNavigatedToJobs = true;
|
||||||
|
|
||||||
|
// Navigate directly to jobs view - jobs will be loaded there
|
||||||
|
developer.log(
|
||||||
|
'Navigating to jobs view - jobs will be loaded there...',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
Navigator.of(context).pushReplacementNamed('/jobs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final errorMessage = response['message'] ?? 'Unbekannter Fehler';
|
||||||
|
final errorCode = response['code'] ?? 'No code';
|
||||||
|
|
||||||
|
developer.log('=== LOGIN FAILURE ===', name: 'LoginView');
|
||||||
|
developer.log('Error message: $errorMessage', name: 'LoginView');
|
||||||
|
developer.log('Error code: $errorCode', name: 'LoginView');
|
||||||
|
developer.log('Full error response: $response', name: 'LoginView');
|
||||||
|
|
||||||
|
if (!hadPendingLogin || !mounted) {
|
||||||
|
developer.log(
|
||||||
|
'Ignoring auth failure in LoginView because no manual login attempt is pending',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${AppLocalizations.of(context).loginFailed}: $errorMessage',
|
||||||
|
),
|
||||||
|
backgroundColor: AppColors.danger,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
@@ -158,34 +241,62 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
|
|
||||||
developer.log('=== LOGIN ATTEMPT STARTED ===', name: 'LoginView');
|
developer.log('=== LOGIN ATTEMPT STARTED ===', name: 'LoginView');
|
||||||
developer.log('Session ID: $sessionId', name: 'LoginView');
|
developer.log('Session ID: $sessionId', name: 'LoginView');
|
||||||
developer.log('Timestamp: ${loginStartTime.toIso8601String()}', name: 'LoginView');
|
developer.log(
|
||||||
|
'Timestamp: ${loginStartTime.toIso8601String()}',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
developer.log('Login validation failed - form is invalid', name: 'LoginView');
|
developer.log(
|
||||||
|
'Login validation failed - form is invalid',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isLoggingIn) {
|
if (_isLoggingIn) {
|
||||||
developer.log('Login already in progress - ignoring duplicate request', name: 'LoginView');
|
developer.log(
|
||||||
|
'Login already in progress - ignoring duplicate request',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String email = _emailController.text.trim();
|
String email = _emailController.text.trim();
|
||||||
|
String password = _passwordController.text;
|
||||||
|
_pendingLoginEmail = email;
|
||||||
|
_pendingLoginPassword = password;
|
||||||
|
|
||||||
developer.log('Login attempt for email: $email', name: 'LoginView');
|
developer.log('Login attempt for email: $email', name: 'LoginView');
|
||||||
developer.log('Password length: ${_passwordController.text.length} characters', name: 'LoginView');
|
developer.log(
|
||||||
|
'Password length: ${_passwordController.text.length} characters',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
|
||||||
// Capture ScaffoldMessenger and localizations before any async operations
|
// Capture ScaffoldMessenger and localizations before any async operations
|
||||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
final localizations = AppLocalizations.of(context);
|
final localizations = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (!_stompService.isConnected) {
|
if (!_stompService.isConnected) {
|
||||||
developer.log('Not connected to STOMP server - establishing connection first', name: 'LoginView');
|
developer.log(
|
||||||
developer.log('STOMP service connection state: ${_stompService.isConnected}', name: 'LoginView');
|
'Not connected to STOMP server - establishing connection first',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
developer.log(
|
||||||
|
'STOMP service connection state: ${_stompService.isConnected}',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
|
||||||
// Always attempt connection to fixed STOMP endpoint (no discovery gating)
|
// Always attempt connection to fixed STOMP endpoint (no discovery gating)
|
||||||
// Show connecting message
|
// Show connecting message
|
||||||
if (!widget.suppressConnectionSnack) {
|
if (!widget.suppressConnectionSnack) {
|
||||||
scaffoldMessenger.showSnackBar(SnackBar(content: Text(localizations.connecting), backgroundColor: Colors.blue, duration: const Duration(seconds: 1)));
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(localizations.connecting),
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set loading state
|
// Set loading state
|
||||||
@@ -202,20 +313,29 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// Wait for connection to be established with a timeout
|
// Wait for connection to be established with a timeout
|
||||||
try {
|
try {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
final subscription = DartMQ().subscribe<bool>(MQTopics.connectionStatus, (isConnected) {
|
final subscription = DartMQ().subscribe<bool>(
|
||||||
if (isConnected && !completer.isCompleted) {
|
MQTopics.connectionStatus,
|
||||||
completer.complete(true);
|
(isConnected) {
|
||||||
}
|
if (isConnected && !completer.isCompleted) {
|
||||||
});
|
completer.complete(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await completer.future.timeout(const Duration(seconds: 12));
|
await completer.future.timeout(const Duration(seconds: 12));
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
developer.log('STOMP connection established - proceeding with login', name: 'LoginView');
|
developer.log(
|
||||||
|
'STOMP connection established - proceeding with login',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
developer.log('STOMP connection timed out', name: 'LoginView');
|
developer.log('STOMP connection timed out', name: 'LoginView');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
developer.log('STOMP already connected after connect - proceeding with login', name: 'LoginView');
|
developer.log(
|
||||||
|
'STOMP already connected after connect - proceeding with login',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connection was successful
|
// Check if connection was successful
|
||||||
@@ -223,43 +343,74 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoggingIn = false;
|
_isLoggingIn = false;
|
||||||
});
|
});
|
||||||
scaffoldMessenger.showSnackBar(SnackBar(content: Text(localizations.connectionTimeout), backgroundColor: Colors.red, duration: const Duration(seconds: 2)));
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(localizations.connectionTimeout),
|
||||||
|
backgroundColor: AppColors.danger,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_clearPendingLoginCredentials();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggingIn = false;
|
_isLoggingIn = false;
|
||||||
});
|
});
|
||||||
developer.log('Error connecting to STOMP server: $e', name: 'LoginView');
|
developer.log(
|
||||||
|
'Error connecting to STOMP server: $e',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'LoginView');
|
developer.log('Stack trace: $stackTrace', name: 'LoginView');
|
||||||
scaffoldMessenger.showSnackBar(SnackBar(content: Text('${localizations.connectionError}: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 1)));
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${localizations.connectionError}: $e'),
|
||||||
|
backgroundColor: AppColors.danger,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_clearPendingLoginCredentials();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Pre-login checks passed - initiating login request', name: 'LoginView');
|
developer.log(
|
||||||
developer.log('Connection status: connected=${_stompService.isConnected}', name: 'LoginView');
|
'Pre-login checks passed - initiating login request',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
developer.log(
|
||||||
|
'Connection status: connected=${_stompService.isConnected}',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggingIn = true;
|
_isLoggingIn = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
String password = _passwordController.text;
|
developer.log(
|
||||||
|
'Sending login request via STOMP service...',
|
||||||
developer.log('Sending login request via STOMP service...', name: 'LoginView');
|
name: 'LoginView',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send login request via STOMP
|
// Send login request via STOMP
|
||||||
await _stompService.login(email, password);
|
await _stompService.login(email, password);
|
||||||
|
|
||||||
final requestSentTime = DateTime.now();
|
final requestSentTime = DateTime.now();
|
||||||
final requestDuration = requestSentTime.difference(loginStartTime).inMilliseconds;
|
final requestDuration =
|
||||||
developer.log('Login request sent successfully after ${requestDuration}ms', name: 'LoginView');
|
requestSentTime.difference(loginStartTime).inMilliseconds;
|
||||||
|
developer.log(
|
||||||
|
'Login request sent successfully after ${requestDuration}ms',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
final errorTime = DateTime.now();
|
final errorTime = DateTime.now();
|
||||||
final errorDuration = errorTime.difference(loginStartTime).inMilliseconds;
|
final errorDuration = errorTime.difference(loginStartTime).inMilliseconds;
|
||||||
|
|
||||||
developer.log('LOGIN ERROR: Exception during login request after ${errorDuration}ms', name: 'LoginView');
|
developer.log(
|
||||||
|
'LOGIN ERROR: Exception during login request after ${errorDuration}ms',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
developer.log('Error: $e', name: 'LoginView');
|
developer.log('Error: $e', name: 'LoginView');
|
||||||
developer.log('Stack trace: $stackTrace', name: 'LoginView');
|
developer.log('Stack trace: $stackTrace', name: 'LoginView');
|
||||||
|
|
||||||
@@ -267,116 +418,230 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
_isLoggingIn = false;
|
_isLoggingIn = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
scaffoldMessenger.showSnackBar(SnackBar(content: Text('${localizations.loginError}: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 1)));
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${localizations.loginError}: $e'),
|
||||||
|
backgroundColor: AppColors.danger,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_clearPendingLoginCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The auth response will be handled by the stream listener
|
// The auth response will be handled by the stream listener
|
||||||
// _isLoggingIn will be set to false in the listener
|
// _isLoggingIn will be set to false in the listener
|
||||||
developer.log('Login request phase completed - waiting for auth response', name: 'LoginView');
|
developer.log(
|
||||||
|
'Login request phase completed - waiting for auth response',
|
||||||
|
name: 'LoginView',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey[50],
|
body: DecoratedBox(
|
||||||
body: Column(
|
decoration: const BoxDecoration(gradient: AppGradients.shellBackground),
|
||||||
children: [
|
child: Column(
|
||||||
Expanded(
|
children: [
|
||||||
child: SafeArea(
|
Expanded(
|
||||||
child: Center(
|
child: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: Center(
|
||||||
padding: const EdgeInsets.all(24.0),
|
child: SingleChildScrollView(
|
||||||
child: Form(
|
padding: const EdgeInsets.all(24.0),
|
||||||
key: _formKey,
|
child: Form(
|
||||||
child: Column(
|
key: _formKey,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
// Logo oder App-Name
|
children: [
|
||||||
Icon(Icons.account_circle, size: 100, color: Colors.deepPurple),
|
Icon(
|
||||||
const SizedBox(height: 32),
|
Icons.account_circle,
|
||||||
|
size: 100,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
Text(AppLocalizations.of(context).welcomeBack, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[800]), textAlign: TextAlign.center),
|
Text(
|
||||||
const SizedBox(height: 8),
|
l10n.welcomeBack,
|
||||||
|
style: Theme.of(
|
||||||
Text(AppLocalizations.of(context).loginSubtitle, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]), textAlign: TextAlign.center),
|
context,
|
||||||
const SizedBox(height: 32),
|
).textTheme.headlineMedium?.copyWith(
|
||||||
// E-Mail-Feld
|
fontWeight: FontWeight.bold,
|
||||||
TextFormField(
|
color: AppColors.textStrong,
|
||||||
controller: _emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
decoration: InputDecoration(labelText: 'E-Mail-Adresse', hintText: 'Geben Sie Ihre E-Mail-Adresse ein', prefixIcon: const Icon(Icons.email_outlined), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), filled: true, fillColor: Colors.white),
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Bitte geben Sie Ihre E-Mail-Adresse ein';
|
|
||||||
}
|
|
||||||
if (!RegExp(r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$').hasMatch(value)) {
|
|
||||||
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Passwort-Feld
|
|
||||||
TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
obscureText: !_isPasswordVisible,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Passwort',
|
|
||||||
hintText: 'Geben Sie Ihr Passwort ein',
|
|
||||||
prefixIcon: const Icon(Icons.lock_outlined),
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: Icon(_isPasswordVisible ? Icons.visibility : Icons.visibility_off),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_isPasswordVisible = !_isPasswordVisible;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
textAlign: TextAlign.center,
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.white,
|
|
||||||
),
|
),
|
||||||
validator: (value) {
|
const SizedBox(height: 8),
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Bitte geben Sie Ihr Passwort ein';
|
|
||||||
}
|
|
||||||
if (value.length < 6) {
|
|
||||||
return 'Das Passwort muss mindestens 6 Zeichen lang sein';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Passwort vergessen Link
|
Text(
|
||||||
Align(
|
l10n.loginSubtitle,
|
||||||
alignment: Alignment.centerRight,
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
child: TextButton(
|
?.copyWith(color: AppColors.textMuted),
|
||||||
onPressed: () {
|
textAlign: TextAlign.center,
|
||||||
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).forgotPasswordMessage), duration: const Duration(seconds: 1)));
|
const SizedBox(height: 32),
|
||||||
|
// E-Mail-Feld
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.emailAddress,
|
||||||
|
hintText: l10n.emailAddressHint,
|
||||||
|
prefixIcon: const Icon(Icons.email_outlined),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return l10n.emailAddressRequired;
|
||||||
|
}
|
||||||
|
if (!RegExp(
|
||||||
|
r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$',
|
||||||
|
).hasMatch(value)) {
|
||||||
|
return l10n.emailAddressInvalid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
child: Text(AppLocalizations.of(context).forgotPassword, style: const TextStyle(color: Colors.deepPurple, fontWeight: FontWeight.w500)),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Verbindungsstatus
|
// Passwort-Feld
|
||||||
// Anmelden Button
|
TextFormField(
|
||||||
ElevatedButton(onPressed: _isLoggingIn ? null : _handleLogin, style: ElevatedButton.styleFrom(backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2), child: _isLoggingIn ? Row(mainAxisAlignment: MainAxisAlignment.center, children: const [SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.5, valueColor: AlwaysStoppedAnimation<Color>(Colors.white))), SizedBox(width: 12), Text('Verbinden…', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))]) : const Text('Anmelden', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))),
|
controller: _passwordController,
|
||||||
const SizedBox(height: 24),
|
obscureText: !_isPasswordVisible,
|
||||||
],
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.password,
|
||||||
|
hintText: l10n.passwordHint,
|
||||||
|
prefixIcon: const Icon(Icons.lock_outlined),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isPasswordVisible
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordVisible = !_isPasswordVisible;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return l10n.passwordRequired;
|
||||||
|
}
|
||||||
|
if (value.length < 6) {
|
||||||
|
return l10n.passwordMinLength;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Passwort vergessen Link
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Hier würde die "Passwort vergessen" Funktionalität implementiert werden
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.forgotPasswordMessage),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
l10n.forgotPassword,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.primaryStrong,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Verbindungsstatus
|
||||||
|
// Anmelden Button
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoggingIn ? null : _handleLogin,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoggingIn
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
valueColor:
|
||||||
|
AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
l10n.loggingIn,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
l10n.login,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_appVersion.isNotEmpty)
|
||||||
// Version number at the bottom
|
Padding(
|
||||||
if (_appVersion.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16.0), child: Text('Version $_appVersion', style: TextStyle(fontSize: 12, color: Colors.grey[500]), textAlign: TextAlign.center)),
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
],
|
child: Text(
|
||||||
|
'Version $_appVersion',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'login_view.dart';
|
import 'login_view.dart';
|
||||||
import 'jobs_view.dart';
|
import 'jobs_view.dart';
|
||||||
import 'cargo_items_view.dart';
|
import 'cargo_items_view.dart';
|
||||||
@@ -13,6 +14,7 @@ import 'services/chat_service.dart';
|
|||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
import 'navigation_observer.dart';
|
import 'navigation_observer.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
|
import 'services/websocket_service.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -43,14 +45,59 @@ void main() async {
|
|||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||||
|
static const Duration _resumeReconnectThreshold = Duration(seconds: 30);
|
||||||
|
final AppState _appState = AppState();
|
||||||
|
final WebSocketService _webSocketService = WebSocketService();
|
||||||
|
DateTime? _lastPausedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.paused) {
|
||||||
|
_lastPausedAt = DateTime.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
final pausedAt = _lastPausedAt;
|
||||||
|
_lastPausedAt = null;
|
||||||
|
if (pausedAt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final standbyDuration = DateTime.now().difference(pausedAt);
|
||||||
|
if (standbyDuration < _resumeReconnectThreshold ||
|
||||||
|
!_appState.isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_webSocketService.reconnectForAppResume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Check if user is already logged in
|
// Check if user is already logged in
|
||||||
final appState = AppState();
|
final initialRoute = _appState.isLoggedIn ? '/jobs' : '/login';
|
||||||
final initialRoute = appState.isLoggedIn ? '/jobs' : '/login';
|
|
||||||
|
|
||||||
return ValueListenableBuilder<Locale>(
|
return ValueListenableBuilder<Locale>(
|
||||||
valueListenable: localeNotifier,
|
valueListenable: localeNotifier,
|
||||||
@@ -58,11 +105,17 @@ class MyApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'VotianLT App',
|
title: 'VotianLT App',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true),
|
theme: buildAppTheme(),
|
||||||
// Localization configuration
|
// Localization configuration
|
||||||
locale: locale,
|
locale: locale,
|
||||||
localizationsDelegates: const [AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate],
|
localizationsDelegates: const [
|
||||||
supportedLocales: supportedLanguageCodes.map((code) => Locale(code)).toList(),
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales:
|
||||||
|
supportedLanguageCodes.map((code) => Locale(code)).toList(),
|
||||||
navigatorObservers: [routeObserver],
|
navigatorObservers: [routeObserver],
|
||||||
initialRoute: initialRoute,
|
initialRoute: initialRoute,
|
||||||
onGenerateRoute: (settings) {
|
onGenerateRoute: (settings) {
|
||||||
@@ -70,21 +123,30 @@ class MyApp extends StatelessWidget {
|
|||||||
case '/login':
|
case '/login':
|
||||||
final arg = settings.arguments;
|
final arg = settings.arguments;
|
||||||
final suppress = (arg is bool) ? arg : false;
|
final suppress = (arg is bool) ? arg : false;
|
||||||
return MaterialPageRoute(builder: (_) => LoginView(suppressConnectionSnack: suppress));
|
return MaterialPageRoute(
|
||||||
|
builder: (_) => LoginView(suppressConnectionSnack: suppress),
|
||||||
|
);
|
||||||
case '/jobs':
|
case '/jobs':
|
||||||
return MaterialPageRoute(builder: (_) => const JobsView());
|
return MaterialPageRoute(builder: (_) => const JobsView());
|
||||||
case '/cargo_items':
|
case '/cargo_items':
|
||||||
final job = settings.arguments as Job;
|
final job = settings.arguments as Job;
|
||||||
return MaterialPageRoute(builder: (_) => CargoItemsView(job: job));
|
return MaterialPageRoute(
|
||||||
|
builder: (_) => CargoItemsView(job: job),
|
||||||
|
);
|
||||||
case '/chats':
|
case '/chats':
|
||||||
return MaterialPageRoute(builder: (_) => const ChatsView());
|
return MaterialPageRoute(builder: (_) => const ChatsView());
|
||||||
case '/chat_details':
|
case '/chat_details':
|
||||||
final chat = settings.arguments as Chat;
|
final chat = settings.arguments as Chat;
|
||||||
return MaterialPageRoute(builder: (_) => ChatDetailsView(chat: chat));
|
return MaterialPageRoute(
|
||||||
|
builder: (_) => ChatDetailsView(chat: chat),
|
||||||
|
);
|
||||||
case '/settings':
|
case '/settings':
|
||||||
return MaterialPageRoute(builder: (_) => const SettingsView());
|
return MaterialPageRoute(builder: (_) => const SettingsView());
|
||||||
default:
|
default:
|
||||||
return MaterialPageRoute(builder: (_) => const LoginView(suppressConnectionSnack: false));
|
return MaterialPageRoute(
|
||||||
|
builder:
|
||||||
|
(_) => const LoginView(suppressConnectionSnack: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -114,9 +176,24 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title)),
|
appBar: AppBar(title: Text(widget.title)),
|
||||||
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[const Text('You have pushed the button this many times:'), Text('$_counter', style: Theme.of(context).textTheme.headlineMedium)])),
|
body: Center(
|
||||||
floatingActionButton: FloatingActionButton(onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add)), // This trailing comma makes auto-formatting nicer for build methods.
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
const Text('You have pushed the button this many times:'),
|
||||||
|
Text(
|
||||||
|
'$_counter',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _incrementCounter,
|
||||||
|
tooltip: 'Increment',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import '../task.dart';
|
|||||||
|
|
||||||
// Signature Task
|
// Signature Task
|
||||||
class SignatureTask extends Task {
|
class SignatureTask extends Task {
|
||||||
|
final String? note;
|
||||||
|
|
||||||
SignatureTask({
|
SignatureTask({
|
||||||
required super.id,
|
required super.id,
|
||||||
required super.jobId,
|
required super.jobId,
|
||||||
@@ -14,11 +16,19 @@ class SignatureTask extends Task {
|
|||||||
super.title,
|
super.title,
|
||||||
super.description,
|
super.description,
|
||||||
super.displayName,
|
super.displayName,
|
||||||
|
this.note,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SignatureTask.fromJson(Map<String, dynamic> json) {
|
factory SignatureTask.fromJson(Map<String, dynamic> json) {
|
||||||
final commonProps = Task.parseCommonProperties(json);
|
final commonProps = Task.parseCommonProperties(json);
|
||||||
|
|
||||||
|
String? note;
|
||||||
|
final taskSpecificData = json['taskSpecificData'];
|
||||||
|
if (taskSpecificData is Map<String, dynamic>) {
|
||||||
|
final n = taskSpecificData['note'];
|
||||||
|
if (n is String) note = n;
|
||||||
|
}
|
||||||
|
|
||||||
return SignatureTask(
|
return SignatureTask(
|
||||||
id: commonProps['id'],
|
id: commonProps['id'],
|
||||||
jobId: commonProps['jobId'],
|
jobId: commonProps['jobId'],
|
||||||
@@ -31,6 +41,7 @@ class SignatureTask extends Task {
|
|||||||
title: commonProps['title'],
|
title: commonProps['title'],
|
||||||
description: commonProps['description'],
|
description: commonProps['description'],
|
||||||
displayName: commonProps['displayName'],
|
displayName: commonProps['displayName'],
|
||||||
|
note: note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +58,11 @@ class SignatureTask extends Task {
|
|||||||
'taskOrder': taskOrder,
|
'taskOrder': taskOrder,
|
||||||
'description': description,
|
'description': description,
|
||||||
'displayName': displayName,
|
'displayName': displayName,
|
||||||
'taskSpecificData': {'taskType': 'SIGNATURE', 'title': title},
|
'taskSpecificData': {
|
||||||
|
'taskType': 'SIGNATURE',
|
||||||
|
'title': title,
|
||||||
|
'note': note,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +79,7 @@ class SignatureTask extends Task {
|
|||||||
String? title,
|
String? title,
|
||||||
String? description,
|
String? description,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
|
String? note,
|
||||||
}) {
|
}) {
|
||||||
return SignatureTask(
|
return SignatureTask(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -77,6 +93,7 @@ class SignatureTask extends Task {
|
|||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
displayName: displayName ?? this.displayName,
|
displayName: displayName ?? this.displayName,
|
||||||
|
note: note ?? this.note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "5:2194624907249454848",
|
"id": "5:2194624907249454848",
|
||||||
"lastPropertyId": "6:5035828038544573244",
|
"lastPropertyId": "7:5673785903451668117",
|
||||||
"name": "TaskStatusEntity",
|
"name": "TaskStatusEntity",
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
@@ -275,7 +275,9 @@
|
|||||||
"modelVersionParserMinimum": 5,
|
"modelVersionParserMinimum": 5,
|
||||||
"retiredEntityUids": [],
|
"retiredEntityUids": [],
|
||||||
"retiredIndexUids": [],
|
"retiredIndexUids": [],
|
||||||
"retiredPropertyUids": [],
|
"retiredPropertyUids": [
|
||||||
|
5673785903451668117
|
||||||
|
],
|
||||||
"retiredRelationUids": [],
|
"retiredRelationUids": [],
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ final _entities = <obx_int.ModelEntity>[
|
|||||||
obx_int.ModelEntity(
|
obx_int.ModelEntity(
|
||||||
id: const obx_int.IdUid(5, 2194624907249454848),
|
id: const obx_int.IdUid(5, 2194624907249454848),
|
||||||
name: 'TaskStatusEntity',
|
name: 'TaskStatusEntity',
|
||||||
lastPropertyId: const obx_int.IdUid(6, 5035828038544573244),
|
lastPropertyId: const obx_int.IdUid(7, 5673785903451668117),
|
||||||
flags: 0,
|
flags: 0,
|
||||||
properties: <obx_int.ModelProperty>[
|
properties: <obx_int.ModelProperty>[
|
||||||
obx_int.ModelProperty(
|
obx_int.ModelProperty(
|
||||||
@@ -371,7 +371,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
|
|||||||
lastSequenceId: const obx_int.IdUid(0, 0),
|
lastSequenceId: const obx_int.IdUid(0, 0),
|
||||||
retiredEntityUids: const [],
|
retiredEntityUids: const [],
|
||||||
retiredIndexUids: const [],
|
retiredIndexUids: const [],
|
||||||
retiredPropertyUids: const [],
|
retiredPropertyUids: const [5673785903451668117],
|
||||||
retiredRelationUids: const [],
|
retiredRelationUids: const [],
|
||||||
modelVersion: 5,
|
modelVersion: 5,
|
||||||
modelVersionParserMinimum: 5,
|
modelVersionParserMinimum: 5,
|
||||||
@@ -632,7 +632,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
|
|||||||
},
|
},
|
||||||
objectToFB: (TaskStatusEntity object, fb.Builder fbb) {
|
objectToFB: (TaskStatusEntity object, fb.Builder fbb) {
|
||||||
final taskIdOffset = fbb.writeString(object.taskId);
|
final taskIdOffset = fbb.writeString(object.taskId);
|
||||||
fbb.startTable(7);
|
fbb.startTable(8);
|
||||||
fbb.addInt64(0, object.id);
|
fbb.addInt64(0, object.id);
|
||||||
fbb.addOffset(1, taskIdOffset);
|
fbb.addOffset(1, taskIdOffset);
|
||||||
fbb.addBool(2, object.completed);
|
fbb.addBool(2, object.completed);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class ChatService {
|
|||||||
static const _jobIdPrefix = 'job:';
|
static const _jobIdPrefix = 'job:';
|
||||||
static const _jobNumberPrefix = 'job_number:';
|
static const _jobNumberPrefix = 'job_number:';
|
||||||
static const _generalPrefix = 'general:';
|
static const _generalPrefix = 'general:';
|
||||||
|
static const _defaultGeneralConversationKey =
|
||||||
|
'general:allgemeine-nachrichten';
|
||||||
|
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
final AppState _appState = AppState();
|
final AppState _appState = AppState();
|
||||||
@@ -103,9 +105,11 @@ class ChatService {
|
|||||||
|
|
||||||
_chats.removeWhere((chat) {
|
_chats.removeWhere((chat) {
|
||||||
final matchesKey = conversationKeys.contains(chat.id);
|
final matchesKey = conversationKeys.contains(chat.id);
|
||||||
final matchesId = trimmedJobId.isNotEmpty &&
|
final matchesId =
|
||||||
|
trimmedJobId.isNotEmpty &&
|
||||||
(chat.jobId?.trim().toLowerCase() == lowerJobId);
|
(chat.jobId?.trim().toLowerCase() == lowerJobId);
|
||||||
final matchesNumber = trimmedJobNumber.isNotEmpty &&
|
final matchesNumber =
|
||||||
|
trimmedJobNumber.isNotEmpty &&
|
||||||
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
|
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
|
||||||
return matchesKey || matchesId || matchesNumber;
|
return matchesKey || matchesId || matchesNumber;
|
||||||
});
|
});
|
||||||
@@ -129,18 +133,11 @@ class ChatService {
|
|||||||
|
|
||||||
// Messages with GENERAL messageType should always go to the default general chat
|
// Messages with GENERAL messageType should always go to the default general chat
|
||||||
if (message.messageType == ChatMessageType.general) {
|
if (message.messageType == ChatMessageType.general) {
|
||||||
final localId = _primaryLocalIdentifier();
|
developer.log(
|
||||||
if (localId != null && localId.isNotEmpty) {
|
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $_defaultGeneralConversationKey',
|
||||||
final key = _conversationKeyForParticipants(
|
name: 'ChatService',
|
||||||
localId,
|
);
|
||||||
_appState.loggedInEmail!,
|
return _defaultGeneralConversationKey;
|
||||||
);
|
|
||||||
developer.log(
|
|
||||||
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $key (localId=$localId, receiver=${_appState.loggedInEmail})',
|
|
||||||
name: 'ChatService',
|
|
||||||
);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Job-related messages go to job-specific chats
|
// Job-related messages go to job-specific chats
|
||||||
@@ -165,30 +162,11 @@ class ChatService {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: create conversation based on userId
|
|
||||||
final localId = _primaryLocalIdentifier();
|
|
||||||
if (localId != null && localId.isNotEmpty) {
|
|
||||||
final key = _conversationKeyForParticipants(
|
|
||||||
localId,
|
|
||||||
_appState.loggedInEmail!,
|
|
||||||
);
|
|
||||||
developer.log(
|
|
||||||
'[DEBUG_LOG] Using fallback routing, conversation key: $key',
|
|
||||||
name: 'ChatService',
|
|
||||||
);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'[DEBUG_LOG] No local identifier available for fallback routing',
|
'[DEBUG_LOG] No job context available, routing to default general chat',
|
||||||
name: 'ChatService',
|
name: 'ChatService',
|
||||||
);
|
);
|
||||||
return '$_generalPrefix${_appState.loggedInEmail!}';
|
return _defaultGeneralConversationKey;
|
||||||
}
|
|
||||||
|
|
||||||
String _conversationKeyForParticipants(String a, String b) {
|
|
||||||
final participants = <String>[a.toLowerCase(), b.toLowerCase()]..sort();
|
|
||||||
return '$_generalPrefix${participants.join('|')}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveIncomingMessage(ChatMessage message) async {
|
Future<void> saveIncomingMessage(ChatMessage message) async {
|
||||||
@@ -205,6 +183,20 @@ class ChatService {
|
|||||||
await _persistMessage(message);
|
await _persistMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> markOutgoingMessageSynced(String messageId) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
final conversationKey = await _databaseService.updateChatMessagePendingSync(
|
||||||
|
messageId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (conversationKey != null && conversationKey.isNotEmpty) {
|
||||||
|
await _refreshConversation(conversationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistMessage(ChatMessage message) async {
|
Future<void> _persistMessage(ChatMessage message) async {
|
||||||
final conversationKey = conversationKeyForMessage(message);
|
final conversationKey = conversationKeyForMessage(message);
|
||||||
|
|
||||||
@@ -239,7 +231,7 @@ class ChatService {
|
|||||||
|
|
||||||
Future<void> _loadChatsFromDatabase() async {
|
Future<void> _loadChatsFromDatabase() async {
|
||||||
await _databaseService.ensureInitialized();
|
await _databaseService.ensureInitialized();
|
||||||
final grouped = await _databaseService.loadAllChatMessagesGrouped();
|
final grouped = await _loadNormalizedChatGroups();
|
||||||
_chats.clear();
|
_chats.clear();
|
||||||
grouped.forEach((conversationKey, messages) {
|
grouped.forEach((conversationKey, messages) {
|
||||||
final chat = _buildChat(conversationKey, messages);
|
final chat = _buildChat(conversationKey, messages);
|
||||||
@@ -254,6 +246,14 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshConversation(String conversationKey) async {
|
Future<void> _refreshConversation(String conversationKey) async {
|
||||||
|
if (_isLegacyGeneralConversationKey(conversationKey)) {
|
||||||
|
await _databaseService.migrateConversationKey(
|
||||||
|
conversationKey,
|
||||||
|
_defaultGeneralConversationKey,
|
||||||
|
);
|
||||||
|
conversationKey = _defaultGeneralConversationKey;
|
||||||
|
}
|
||||||
|
|
||||||
final messages = await _databaseService.loadChatMessages(
|
final messages = await _databaseService.loadChatMessages(
|
||||||
conversationKey: conversationKey,
|
conversationKey: conversationKey,
|
||||||
);
|
);
|
||||||
@@ -317,15 +317,13 @@ class ChatService {
|
|||||||
|
|
||||||
final counterpartNormalized =
|
final counterpartNormalized =
|
||||||
counterpart != null &&
|
counterpart != null &&
|
||||||
counterpart.toLowerCase() == _appState.loggedInEmail!.toLowerCase()
|
counterpart.toLowerCase() ==
|
||||||
|
_appState.loggedInEmail!.toLowerCase()
|
||||||
? _appState.loggedInEmail!
|
? _appState.loggedInEmail!
|
||||||
: counterpart;
|
: counterpart;
|
||||||
|
|
||||||
final bool isDefaultGeneral =
|
final bool isDefaultGeneral =
|
||||||
!isJobChat &&
|
!isJobChat && conversationKey == _defaultGeneralConversationKey;
|
||||||
conversationKey.startsWith(_generalPrefix) &&
|
|
||||||
(counterpartNormalized?.toLowerCase() ==
|
|
||||||
_appState.loggedInEmail!.toLowerCase());
|
|
||||||
|
|
||||||
final title =
|
final title =
|
||||||
isJobChat
|
isJobChat
|
||||||
@@ -406,23 +404,46 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<ChatMessage>>> _loadNormalizedChatGroups() async {
|
||||||
|
var grouped = await _databaseService.loadAllChatMessagesGrouped();
|
||||||
|
final legacyGeneralKeys =
|
||||||
|
grouped.keys.where(_isLegacyGeneralConversationKey).toList();
|
||||||
|
if (legacyGeneralKeys.isEmpty) {
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in legacyGeneralKeys) {
|
||||||
|
await _databaseService.migrateConversationKey(
|
||||||
|
key,
|
||||||
|
_defaultGeneralConversationKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped = await _databaseService.loadAllChatMessagesGrouped();
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLegacyGeneralConversationKey(String conversationKey) {
|
||||||
|
return conversationKey != _defaultGeneralConversationKey &&
|
||||||
|
conversationKey.startsWith(_generalPrefix) &&
|
||||||
|
!conversationKey.startsWith(_jobIdPrefix) &&
|
||||||
|
!conversationKey.startsWith(_jobNumberPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
void _ensureDefaultGeneralChat() {
|
void _ensureDefaultGeneralChat() {
|
||||||
final localId = _primaryLocalIdentifier();
|
final receiver = _appState.loggedInEmail;
|
||||||
if (localId == null || localId.isEmpty) {
|
if (receiver == null || receiver.isEmpty) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'[DEBUG_LOG] _ensureDefaultGeneralChat: No local identifier available, skipping',
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: No receiver available, skipping',
|
||||||
name: 'ChatService',
|
name: 'ChatService',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final conversationKey = _conversationKeyForParticipants(
|
const conversationKey = _defaultGeneralConversationKey;
|
||||||
localId,
|
|
||||||
_appState.loggedInEmail!,
|
|
||||||
);
|
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (localId=$localId, receiver=${_appState.loggedInEmail})',
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (receiver=$receiver)',
|
||||||
name: 'ChatService',
|
name: 'ChatService',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -431,8 +452,7 @@ class ChatService {
|
|||||||
chat.id != conversationKey &&
|
chat.id != conversationKey &&
|
||||||
chat.type == ChatType.general &&
|
chat.type == ChatType.general &&
|
||||||
chat.receiver != null &&
|
chat.receiver != null &&
|
||||||
chat.receiver!.toLowerCase() ==
|
chat.receiver!.toLowerCase() == receiver.toLowerCase() &&
|
||||||
_appState.loggedInEmail!.toLowerCase() &&
|
|
||||||
chat.messages.isEmpty,
|
chat.messages.isEmpty,
|
||||||
);
|
);
|
||||||
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
|
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
|
||||||
@@ -446,7 +466,7 @@ class ChatService {
|
|||||||
Chat(
|
Chat(
|
||||||
id: conversationKey,
|
id: conversationKey,
|
||||||
title: 'Allgemeine Nachrichten',
|
title: 'Allgemeine Nachrichten',
|
||||||
receiver: _appState.loggedInEmail!,
|
receiver: receiver,
|
||||||
type: ChatType.general,
|
type: ChatType.general,
|
||||||
jobId: null,
|
jobId: null,
|
||||||
jobNumber: null,
|
jobNumber: null,
|
||||||
@@ -463,8 +483,7 @@ class ChatService {
|
|||||||
final existing = _chats[index];
|
final existing = _chats[index];
|
||||||
if (existing.type != ChatType.general ||
|
if (existing.type != ChatType.general ||
|
||||||
existing.receiver == null ||
|
existing.receiver == null ||
|
||||||
existing.receiver!.toLowerCase() !=
|
existing.receiver!.toLowerCase() != receiver.toLowerCase() ||
|
||||||
_appState.loggedInEmail!.toLowerCase() ||
|
|
||||||
(existing.messages.isEmpty &&
|
(existing.messages.isEmpty &&
|
||||||
existing.title != 'Allgemeine Nachrichten')) {
|
existing.title != 'Allgemeine Nachrichten')) {
|
||||||
developer.log(
|
developer.log(
|
||||||
@@ -477,7 +496,7 @@ class ChatService {
|
|||||||
existing.messages.isEmpty
|
existing.messages.isEmpty
|
||||||
? 'Allgemeine Nachrichten'
|
? 'Allgemeine Nachrichten'
|
||||||
: existing.title,
|
: existing.title,
|
||||||
receiver: _appState.loggedInEmail!,
|
receiver: receiver,
|
||||||
type: ChatType.general,
|
type: ChatType.general,
|
||||||
jobId: existing.jobId,
|
jobId: existing.jobId,
|
||||||
jobNumber: existing.jobNumber,
|
jobNumber: existing.jobNumber,
|
||||||
@@ -493,8 +512,4 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _primaryLocalIdentifier() {
|
|
||||||
return _appState.loggedInEmail;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ class DatabaseService {
|
|||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
_initializingCompleter = completer;
|
_initializingCompleter = completer;
|
||||||
try {
|
try {
|
||||||
developer.log('Initializing ObjectBox database...', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Initializing ObjectBox database...',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
|
||||||
// Get database path
|
// Get database path
|
||||||
final docsDir = await getApplicationDocumentsDirectory();
|
final docsDir = await getApplicationDocumentsDirectory();
|
||||||
@@ -75,8 +78,6 @@ class DatabaseService {
|
|||||||
await initialize();
|
await initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Log database statistics
|
/// Log database statistics
|
||||||
Future<void> _logDatabaseStats() async {
|
Future<void> _logDatabaseStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -164,7 +165,10 @@ class DatabaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Deleting job $jobId from database...', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Deleting job $jobId from database...',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
|
||||||
final jobBox = _store!.box<JobEntity>();
|
final jobBox = _store!.box<JobEntity>();
|
||||||
final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
|
final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
|
||||||
@@ -173,9 +177,15 @@ class DatabaseService {
|
|||||||
|
|
||||||
if (entities.isNotEmpty) {
|
if (entities.isNotEmpty) {
|
||||||
jobBox.remove(entities.first.id);
|
jobBox.remove(entities.first.id);
|
||||||
developer.log('Job $jobId deleted successfully', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Job $jobId deleted successfully',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
developer.log('Job $jobId not found in database', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Job $jobId not found in database',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log('Error deleting job: $e', name: 'DatabaseService');
|
developer.log('Error deleting job: $e', name: 'DatabaseService');
|
||||||
@@ -220,9 +230,13 @@ class DatabaseService {
|
|||||||
if (jobs.isNotEmpty) {
|
if (jobs.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(
|
final query =
|
||||||
(ChatMessageEntity_.jobId.notNull() | ChatMessageEntity_.jobNumber.notNull())
|
chatBox
|
||||||
).build();
|
.query(
|
||||||
|
(ChatMessageEntity_.jobId.notNull() |
|
||||||
|
ChatMessageEntity_.jobNumber.notNull()),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
final messagesWithJobs = query.find();
|
final messagesWithJobs = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -282,7 +296,8 @@ class DatabaseService {
|
|||||||
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
||||||
|
|
||||||
// Find existing entity by taskId
|
// Find existing entity by taskId
|
||||||
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
final query =
|
||||||
|
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
||||||
final existing = query.findFirst();
|
final existing = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -321,7 +336,8 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
||||||
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
final query =
|
||||||
|
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
||||||
final entity = query.findFirst();
|
final entity = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -449,7 +465,8 @@ class DatabaseService {
|
|||||||
final keys = jobIds.map((id) => 'job_seen:$id').toList();
|
final keys = jobIds.map((id) => 'job_seen:$id').toList();
|
||||||
|
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build();
|
final query =
|
||||||
|
userDataBox.query(UserDataEntity_.key.equals(key)).build();
|
||||||
final entity = query.findFirst();
|
final entity = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -545,7 +562,8 @@ class DatabaseService {
|
|||||||
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
||||||
|
|
||||||
// Find existing job entity by jobId
|
// Find existing job entity by jobId
|
||||||
final jobQuery = jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
|
final jobQuery =
|
||||||
|
jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
|
||||||
final existingJob = jobQuery.findFirst();
|
final existingJob = jobQuery.findFirst();
|
||||||
jobQuery.close();
|
jobQuery.close();
|
||||||
|
|
||||||
@@ -568,7 +586,10 @@ class DatabaseService {
|
|||||||
final taskIds = normalized.tasks.map((t) => t.id).toList();
|
final taskIds = normalized.tasks.map((t) => t.id).toList();
|
||||||
if (taskIds.isNotEmpty) {
|
if (taskIds.isNotEmpty) {
|
||||||
for (final taskId in taskIds) {
|
for (final taskId in taskIds) {
|
||||||
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
final query =
|
||||||
|
taskStatusBox
|
||||||
|
.query(TaskStatusEntity_.taskId.equals(taskId))
|
||||||
|
.build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
for (final entity in entities) {
|
for (final entity in entities) {
|
||||||
@@ -617,7 +638,8 @@ class DatabaseService {
|
|||||||
|
|
||||||
if (trimmedJobId.isNotEmpty) {
|
if (trimmedJobId.isNotEmpty) {
|
||||||
// Delete job
|
// Delete job
|
||||||
final jobQuery = jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
|
final jobQuery =
|
||||||
|
jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
|
||||||
final jobEntities = jobQuery.find();
|
final jobEntities = jobQuery.find();
|
||||||
jobQuery.close();
|
jobQuery.close();
|
||||||
for (final entity in jobEntities) {
|
for (final entity in jobEntities) {
|
||||||
@@ -625,7 +647,10 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete job_seen flag
|
// Delete job_seen flag
|
||||||
final seenQuery = userDataBox.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId')).build();
|
final seenQuery =
|
||||||
|
userDataBox
|
||||||
|
.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId'))
|
||||||
|
.build();
|
||||||
final seenEntities = seenQuery.find();
|
final seenEntities = seenQuery.find();
|
||||||
seenQuery.close();
|
seenQuery.close();
|
||||||
for (final entity in seenEntities) {
|
for (final entity in seenEntities) {
|
||||||
@@ -633,15 +658,19 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final taskIds = job.tasks
|
final taskIds =
|
||||||
.map((task) => task.id.trim())
|
job.tasks
|
||||||
.where((id) => id.isNotEmpty)
|
.map((task) => task.id.trim())
|
||||||
.toList();
|
.where((id) => id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (taskIds.isNotEmpty) {
|
if (taskIds.isNotEmpty) {
|
||||||
for (final taskId in taskIds) {
|
for (final taskId in taskIds) {
|
||||||
// Delete task status
|
// Delete task status
|
||||||
final taskQuery = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
|
final taskQuery =
|
||||||
|
taskStatusBox
|
||||||
|
.query(TaskStatusEntity_.taskId.equals(taskId))
|
||||||
|
.build();
|
||||||
final taskEntities = taskQuery.find();
|
final taskEntities = taskQuery.find();
|
||||||
taskQuery.close();
|
taskQuery.close();
|
||||||
for (final entity in taskEntities) {
|
for (final entity in taskEntities) {
|
||||||
@@ -649,7 +678,8 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete photos
|
// Delete photos
|
||||||
final photoQuery = photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
|
final photoQuery =
|
||||||
|
photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
|
||||||
final photoEntities = photoQuery.find();
|
final photoEntities = photoQuery.find();
|
||||||
photoQuery.close();
|
photoQuery.close();
|
||||||
for (final entity in photoEntities) {
|
for (final entity in photoEntities) {
|
||||||
@@ -801,6 +831,42 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save signature note (Bemerkung) for a task into user_data table
|
||||||
|
Future<void> saveTaskSignatureNote(String taskId, String note) async {
|
||||||
|
try {
|
||||||
|
if (_store == null) {
|
||||||
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final key = 'task_signature_note:$taskId';
|
||||||
|
await saveKeyValue(key, note);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error saving task signature note: $e',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
developer.log('Stack trace: $st', name: 'DatabaseService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load signature note (Bemerkung) for a task from user_data table
|
||||||
|
Future<String?> loadTaskSignatureNote(String taskId) async {
|
||||||
|
try {
|
||||||
|
if (_store == null) {
|
||||||
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await loadKeyValue('task_signature_note:$taskId');
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error loading task signature note: $e',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
developer.log('Stack trace: $st', name: 'DatabaseService');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load signature SVG for a task from user_data table
|
/// Load signature SVG for a task from user_data table
|
||||||
Future<String?> loadTaskSignature(String taskId) async {
|
Future<String?> loadTaskSignature(String taskId) async {
|
||||||
try {
|
try {
|
||||||
@@ -960,9 +1026,21 @@ class DatabaseService {
|
|||||||
|
|
||||||
/// Save login credentials for auto-login on app restart
|
/// Save login credentials for auto-login on app restart
|
||||||
Future<void> saveCredentials(String email, String password) async {
|
Future<void> saveCredentials(String email, String password) async {
|
||||||
await saveKeyValue('auth_email', email);
|
final normalizedEmail = email.trim();
|
||||||
|
if (normalizedEmail.isEmpty || password.isEmpty) {
|
||||||
|
developer.log(
|
||||||
|
'Skipping credential save because email or password is empty',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveKeyValue('auth_email', normalizedEmail);
|
||||||
await saveKeyValue('auth_password', password);
|
await saveKeyValue('auth_password', password);
|
||||||
developer.log('Credentials saved for $email', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Credentials saved for $normalizedEmail',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load saved login credentials
|
/// Load saved login credentials
|
||||||
@@ -970,11 +1048,29 @@ class DatabaseService {
|
|||||||
Future<({String email, String password})?> loadCredentials() async {
|
Future<({String email, String password})?> loadCredentials() async {
|
||||||
final email = await loadKeyValue('auth_email');
|
final email = await loadKeyValue('auth_email');
|
||||||
final password = await loadKeyValue('auth_password');
|
final password = await loadKeyValue('auth_password');
|
||||||
if (email != null && password != null) {
|
final normalizedEmail = email?.trim();
|
||||||
developer.log('Credentials loaded for $email', name: 'DatabaseService');
|
|
||||||
return (email: email, password: password);
|
if (normalizedEmail != null &&
|
||||||
|
normalizedEmail.isNotEmpty &&
|
||||||
|
password != null &&
|
||||||
|
password.isNotEmpty) {
|
||||||
|
developer.log(
|
||||||
|
'Credentials loaded for $normalizedEmail',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
return (email: normalizedEmail, password: password);
|
||||||
}
|
}
|
||||||
developer.log('No credentials found', name: 'DatabaseService');
|
|
||||||
|
if ((email != null && email.isNotEmpty) ||
|
||||||
|
(password != null && password.isNotEmpty)) {
|
||||||
|
developer.log(
|
||||||
|
'Stored credentials are incomplete or empty - removing them',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
await deleteCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log('No valid credentials found', name: 'DatabaseService');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1104,10 @@ class DatabaseService {
|
|||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
|
|
||||||
// Find existing entity by messageId
|
// Find existing entity by messageId
|
||||||
final query = chatBox.query(ChatMessageEntity_.messageId.equals(message.id)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.messageId.equals(message.id))
|
||||||
|
.build();
|
||||||
final existing = query.findFirst();
|
final existing = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1060,7 +1159,10 @@ class DatabaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(fromKey)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.conversationKey.equals(fromKey))
|
||||||
|
.build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1089,13 +1191,18 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(
|
final query =
|
||||||
ChatMessageEntity_.conversationKey.equals(conversationKey) &
|
chatBox
|
||||||
ChatMessageEntity_.pendingSync.equals(true) &
|
.query(
|
||||||
ChatMessageEntity_.content.equals(message.content) &
|
ChatMessageEntity_.conversationKey.equals(conversationKey) &
|
||||||
ChatMessageEntity_.contentType.equals(chatContentTypeToString(message.contentType)) &
|
ChatMessageEntity_.pendingSync.equals(true) &
|
||||||
ChatMessageEntity_.messageId.notEquals(message.id)
|
ChatMessageEntity_.content.equals(message.content) &
|
||||||
).build();
|
ChatMessageEntity_.contentType.equals(
|
||||||
|
chatContentTypeToString(message.contentType),
|
||||||
|
) &
|
||||||
|
ChatMessageEntity_.messageId.notEquals(message.id),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1123,9 +1230,13 @@ class DatabaseService {
|
|||||||
List<ChatMessageEntity> entities;
|
List<ChatMessageEntity> entities;
|
||||||
|
|
||||||
if (conversationKey != null) {
|
if (conversationKey != null) {
|
||||||
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
|
final query =
|
||||||
.order(ChatMessageEntity_.createdAt)
|
chatBox
|
||||||
.build();
|
.query(
|
||||||
|
ChatMessageEntity_.conversationKey.equals(conversationKey),
|
||||||
|
)
|
||||||
|
.order(ChatMessageEntity_.createdAt)
|
||||||
|
.build();
|
||||||
entities = query.find();
|
entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
} else {
|
} else {
|
||||||
@@ -1186,7 +1297,10 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
|
||||||
|
.build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1211,7 +1325,8 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
|
final query =
|
||||||
|
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1241,15 +1356,18 @@ class DatabaseService {
|
|||||||
|
|
||||||
final trimmedJobId = jobId?.trim() ?? '';
|
final trimmedJobId = jobId?.trim() ?? '';
|
||||||
final trimmedJobNumber = jobNumber?.trim() ?? '';
|
final trimmedJobNumber = jobNumber?.trim() ?? '';
|
||||||
final keysList = conversationKeys == null
|
final keysList =
|
||||||
? <String>[]
|
conversationKeys == null
|
||||||
: conversationKeys
|
? <String>[]
|
||||||
.map((key) => key.trim())
|
: conversationKeys
|
||||||
.where((key) => key.isNotEmpty)
|
.map((key) => key.trim())
|
||||||
.toSet()
|
.where((key) => key.isNotEmpty)
|
||||||
.toList();
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (trimmedJobId.isEmpty && trimmedJobNumber.isEmpty && keysList.isEmpty) {
|
if (trimmedJobId.isEmpty &&
|
||||||
|
trimmedJobNumber.isEmpty &&
|
||||||
|
keysList.isEmpty) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
|
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
|
||||||
name: 'DatabaseService',
|
name: 'DatabaseService',
|
||||||
@@ -1261,20 +1379,29 @@ class DatabaseService {
|
|||||||
final entitiesToDelete = <ChatMessageEntity>[];
|
final entitiesToDelete = <ChatMessageEntity>[];
|
||||||
|
|
||||||
if (trimmedJobId.isNotEmpty) {
|
if (trimmedJobId.isNotEmpty) {
|
||||||
final query = chatBox.query(ChatMessageEntity_.jobId.equals(trimmedJobId)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.jobId.equals(trimmedJobId))
|
||||||
|
.build();
|
||||||
entitiesToDelete.addAll(query.find());
|
entitiesToDelete.addAll(query.find());
|
||||||
query.close();
|
query.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedJobNumber.isNotEmpty) {
|
if (trimmedJobNumber.isNotEmpty) {
|
||||||
final query = chatBox.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber))
|
||||||
|
.build();
|
||||||
entitiesToDelete.addAll(query.find());
|
entitiesToDelete.addAll(query.find());
|
||||||
query.close();
|
query.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keysList.isNotEmpty) {
|
if (keysList.isNotEmpty) {
|
||||||
for (final key in keysList) {
|
for (final key in keysList) {
|
||||||
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(key)).build();
|
final query =
|
||||||
|
chatBox
|
||||||
|
.query(ChatMessageEntity_.conversationKey.equals(key))
|
||||||
|
.build();
|
||||||
entitiesToDelete.addAll(query.find());
|
entitiesToDelete.addAll(query.find());
|
||||||
query.close();
|
query.close();
|
||||||
}
|
}
|
||||||
@@ -1309,7 +1436,8 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final chatBox = _store!.box<ChatMessageEntity>();
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
final query = chatBox.query(ChatMessageEntity_.read.equals(false)).build();
|
final query =
|
||||||
|
chatBox.query(ChatMessageEntity_.read.equals(false)).build();
|
||||||
final count = query.count();
|
final count = query.count();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1349,6 +1477,7 @@ class DatabaseService {
|
|||||||
/// Save a failed message to the queue
|
/// Save a failed message to the queue
|
||||||
Future<void> queueMessage(QueuedMessage message) async {
|
Future<void> queueMessage(QueuedMessage message) async {
|
||||||
try {
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
if (_store == null) {
|
if (_store == null) {
|
||||||
developer.log('Database not initialized', name: 'DatabaseService');
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
return;
|
return;
|
||||||
@@ -1357,7 +1486,8 @@ class DatabaseService {
|
|||||||
final box = _store!.box<QueuedMessageEntity>();
|
final box = _store!.box<QueuedMessageEntity>();
|
||||||
|
|
||||||
// Find existing entity by messageId
|
// Find existing entity by messageId
|
||||||
final query = box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
|
final query =
|
||||||
|
box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
|
||||||
final existing = query.findFirst();
|
final existing = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1391,6 +1521,7 @@ class DatabaseService {
|
|||||||
/// Get all queued messages
|
/// Get all queued messages
|
||||||
Future<List<QueuedMessage>> getQueuedMessages() async {
|
Future<List<QueuedMessage>> getQueuedMessages() async {
|
||||||
try {
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
if (_store == null) {
|
if (_store == null) {
|
||||||
developer.log('Database not initialized', name: 'DatabaseService');
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
return [];
|
return [];
|
||||||
@@ -1424,13 +1555,15 @@ class DatabaseService {
|
|||||||
/// Remove a successfully sent message from the queue
|
/// Remove a successfully sent message from the queue
|
||||||
Future<void> removeQueuedMessage(String messageId) async {
|
Future<void> removeQueuedMessage(String messageId) async {
|
||||||
try {
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
if (_store == null) {
|
if (_store == null) {
|
||||||
developer.log('Database not initialized', name: 'DatabaseService');
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final box = _store!.box<QueuedMessageEntity>();
|
final box = _store!.box<QueuedMessageEntity>();
|
||||||
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
|
final query =
|
||||||
|
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
|
||||||
final entities = query.find();
|
final entities = query.find();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1452,18 +1585,17 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update retry count for a message
|
/// Update retry count for a message
|
||||||
Future<void> updateMessageRetryCount(
|
Future<void> updateMessageRetryCount(String messageId, int retryCount) async {
|
||||||
String messageId,
|
|
||||||
int retryCount,
|
|
||||||
) async {
|
|
||||||
try {
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
if (_store == null) {
|
if (_store == null) {
|
||||||
developer.log('Database not initialized', name: 'DatabaseService');
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final box = _store!.box<QueuedMessageEntity>();
|
final box = _store!.box<QueuedMessageEntity>();
|
||||||
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
|
final query =
|
||||||
|
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
|
||||||
final entity = query.findFirst();
|
final entity = query.findFirst();
|
||||||
query.close();
|
query.close();
|
||||||
|
|
||||||
@@ -1488,16 +1620,14 @@ class DatabaseService {
|
|||||||
/// Clear all queued messages (for cleanup)
|
/// Clear all queued messages (for cleanup)
|
||||||
Future<void> clearQueuedMessages() async {
|
Future<void> clearQueuedMessages() async {
|
||||||
try {
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
if (_store == null) {
|
if (_store == null) {
|
||||||
developer.log('Database not initialized', name: 'DatabaseService');
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_store!.box<QueuedMessageEntity>().removeAll();
|
_store!.box<QueuedMessageEntity>().removeAll();
|
||||||
developer.log(
|
developer.log('Cleared all queued messages', name: 'DatabaseService');
|
||||||
'Cleared all queued messages',
|
|
||||||
name: 'DatabaseService',
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'Error clearing queued messages: $e',
|
'Error clearing queued messages: $e',
|
||||||
@@ -1507,12 +1637,49 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> updateChatMessagePendingSync(
|
||||||
|
String messageId,
|
||||||
|
bool pendingSync,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
if (_store == null) {
|
||||||
|
developer.log('Database not initialized', name: 'DatabaseService');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final chatBox = _store!.box<ChatMessageEntity>();
|
||||||
|
final query =
|
||||||
|
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
|
||||||
|
final entity = query.findFirst();
|
||||||
|
query.close();
|
||||||
|
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.pendingSync = pendingSync;
|
||||||
|
chatBox.put(entity);
|
||||||
|
return entity.conversationKey;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error updating pendingSync for message $messageId: $e',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
|
developer.log('Stack trace: $st', name: 'DatabaseService');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Language preference persistence ----------------------------------------------------
|
// Language preference persistence ----------------------------------------------------
|
||||||
|
|
||||||
/// Save language preference
|
/// Save language preference
|
||||||
Future<void> saveLanguagePreference(String languageCode) async {
|
Future<void> saveLanguagePreference(String languageCode) async {
|
||||||
await saveKeyValue('language_preference', languageCode);
|
await saveKeyValue('language_preference', languageCode);
|
||||||
developer.log('Language preference saved: $languageCode', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Language preference saved: $languageCode',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load saved language preference
|
/// Load saved language preference
|
||||||
@@ -1520,7 +1687,10 @@ class DatabaseService {
|
|||||||
Future<String?> loadLanguagePreference() async {
|
Future<String?> loadLanguagePreference() async {
|
||||||
final languageCode = await loadKeyValue('language_preference');
|
final languageCode = await loadKeyValue('language_preference');
|
||||||
if (languageCode != null) {
|
if (languageCode != null) {
|
||||||
developer.log('Language preference loaded: $languageCode', name: 'DatabaseService');
|
developer.log(
|
||||||
|
'Language preference loaded: $languageCode',
|
||||||
|
name: 'DatabaseService',
|
||||||
|
);
|
||||||
return languageCode;
|
return languageCode;
|
||||||
}
|
}
|
||||||
developer.log('No language preference found', name: 'DatabaseService');
|
developer.log('No language preference found', name: 'DatabaseService');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'location_service.dart';
|
|||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../models/chat_message.dart';
|
import '../models/chat_message.dart';
|
||||||
import '../models/job.dart';
|
import '../models/job.dart';
|
||||||
|
import '../models/queued_message.dart';
|
||||||
import 'dart_mq.dart';
|
import 'dart_mq.dart';
|
||||||
|
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
@@ -193,6 +194,73 @@ class WebSocketService {
|
|||||||
_reconnectTimer = null;
|
_reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force a clean reconnect after the app resumes from standby.
|
||||||
|
/// Keeps buffered outbound messages intact and relies on saved credentials
|
||||||
|
/// for the subsequent auto-login inside [connect].
|
||||||
|
Future<void> reconnectForAppResume() async {
|
||||||
|
final credentials = await _databaseService.loadCredentials();
|
||||||
|
if (credentials == null) {
|
||||||
|
developer.log(
|
||||||
|
'Skipping reconnect after resume - no saved credentials',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isConnecting) {
|
||||||
|
developer.log(
|
||||||
|
'Skipping reconnect after resume - connection attempt already running',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log(
|
||||||
|
'Restarting WebSocket connection after app resume',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
|
||||||
|
_stopReconnectTimer();
|
||||||
|
|
||||||
|
final existingSubscription = _wsSubscription;
|
||||||
|
final existingChannel = _wsChannel;
|
||||||
|
|
||||||
|
_wsSubscription = null;
|
||||||
|
_wsChannel = null;
|
||||||
|
_disconnectCompleter = null;
|
||||||
|
_isConnected = false;
|
||||||
|
_isConnecting = false;
|
||||||
|
_isAuthenticated = false;
|
||||||
|
_authToken = null;
|
||||||
|
_lastAuthResponse = null;
|
||||||
|
|
||||||
|
Future.microtask(() {
|
||||||
|
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await existingSubscription?.cancel();
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error cancelling old WebSocket subscription on resume: $e',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
developer.log('Stack: $st', name: 'WebSocketService');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await existingChannel?.sink.close(ws_status.goingAway);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error closing old WebSocket channel on resume: $e',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
developer.log('Stack: $st', name: 'WebSocketService');
|
||||||
|
}
|
||||||
|
|
||||||
|
await connect();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WebSocket Send / Receive
|
// WebSocket Send / Receive
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -290,6 +358,8 @@ class WebSocketService {
|
|||||||
_handleJobDeletedMessage(data);
|
_handleJobDeletedMessage(data);
|
||||||
} else if (topic.endsWith('/job_created')) {
|
} else if (topic.endsWith('/job_created')) {
|
||||||
_handleJobCreatedMessage(data);
|
_handleJobCreatedMessage(data);
|
||||||
|
} else if (topic.endsWith('/message_ack')) {
|
||||||
|
await _handleChatMessageAck(data);
|
||||||
} else if (topic.endsWith('/message')) {
|
} else if (topic.endsWith('/message')) {
|
||||||
await _handleChatMessage(topic, data);
|
await _handleChatMessage(topic, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -598,6 +668,20 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleChatMessageAck(Map<String, dynamic> data) async {
|
||||||
|
final clientMessageId = data['clientMessageId']?.toString().trim() ?? '';
|
||||||
|
if (clientMessageId.isEmpty) {
|
||||||
|
developer.log(
|
||||||
|
'Received message ACK without clientMessageId',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _databaseService.removeQueuedMessage(clientMessageId);
|
||||||
|
await ChatService().markOutgoingMessageSynced(clientMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleOtherClientMessage(String topic, Map<String, dynamic> data) {
|
void _handleOtherClientMessage(String topic, Map<String, dynamic> data) {
|
||||||
final type = data['type'];
|
final type = data['type'];
|
||||||
if (topic.contains('/tasks/') || type == 'task') {
|
if (topic.contains('/tasks/') || type == 'task') {
|
||||||
@@ -731,6 +815,7 @@ class WebSocketService {
|
|||||||
/// Clears all local jobs and related data, then notifies the server.
|
/// Clears all local jobs and related data, then notifies the server.
|
||||||
Future<void> _flushMessageBuffer() async {
|
Future<void> _flushMessageBuffer() async {
|
||||||
final initialBufferSize = _messageBuffer.length;
|
final initialBufferSize = _messageBuffer.length;
|
||||||
|
final sentQueuedChatCount = await _flushQueuedChatMessages();
|
||||||
|
|
||||||
if (initialBufferSize > 0) {
|
if (initialBufferSize > 0) {
|
||||||
developer.log(
|
developer.log(
|
||||||
@@ -766,7 +851,8 @@ class WebSocketService {
|
|||||||
await _databaseService.clearAllJobsAndRelatedData();
|
await _databaseService.clearAllJobsAndRelatedData();
|
||||||
|
|
||||||
// Notify server that buffer flush is complete
|
// Notify server that buffer flush is complete
|
||||||
final sentCount = initialBufferSize - _messageBuffer.length;
|
final sentCount =
|
||||||
|
(initialBufferSize - _messageBuffer.length) + sentQueuedChatCount;
|
||||||
final bufferFlushedPayload = jsonEncode({
|
final bufferFlushedPayload = jsonEncode({
|
||||||
'timestamp': DateTime.now().toIso8601String(),
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
'messageCount': sentCount,
|
'messageCount': sentCount,
|
||||||
@@ -774,9 +860,51 @@ class WebSocketService {
|
|||||||
_sendWebSocket('/server/buffer_flushed', bufferFlushedPayload);
|
_sendWebSocket('/server/buffer_flushed', bufferFlushedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> _flushQueuedChatMessages() async {
|
||||||
|
final queuedMessages = await _databaseService.getQueuedMessages();
|
||||||
|
if (queuedMessages.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log(
|
||||||
|
'Flushing ${queuedMessages.length} queued chat messages',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
|
||||||
|
var sentCount = 0;
|
||||||
|
for (final message in queuedMessages) {
|
||||||
|
final success = await _trySendQueuedChatMessage(
|
||||||
|
message,
|
||||||
|
incrementRetryOnFailure: true,
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _trySendQueuedChatMessage(
|
||||||
|
QueuedMessage message, {
|
||||||
|
bool incrementRetryOnFailure = false,
|
||||||
|
}) async {
|
||||||
|
if (!_isConnected || !_isAuthenticated || _wsChannel == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = _sendWebSocket(message.topic, jsonEncode(message.payload));
|
||||||
|
if (!success && incrementRetryOnFailure) {
|
||||||
|
await _databaseService.updateMessageRetryCount(
|
||||||
|
message.id,
|
||||||
|
message.retryCount + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a chat message according to the backend contract.
|
/// Publish a chat message according to the backend contract.
|
||||||
/// Returns the locally constructed message so callers can persist it locally.
|
/// The message is stored locally and remains queued until the server confirms it.
|
||||||
/// Messages are buffered if offline and sent automatically when reconnected.
|
|
||||||
Future<ChatMessage?> sendChatMessage({
|
Future<ChatMessage?> sendChatMessage({
|
||||||
required String sender,
|
required String sender,
|
||||||
required String receiver,
|
required String receiver,
|
||||||
@@ -790,6 +918,9 @@ class WebSocketService {
|
|||||||
final trimmedContent = content.trim();
|
final trimmedContent = content.trim();
|
||||||
final normalizedJobId = jobId?.trim();
|
final normalizedJobId = jobId?.trim();
|
||||||
final normalizedJobNumber = jobNumber?.trim();
|
final normalizedJobNumber = jobNumber?.trim();
|
||||||
|
final hasJobContext =
|
||||||
|
(normalizedJobId?.isNotEmpty ?? false) ||
|
||||||
|
(normalizedJobNumber?.isNotEmpty ?? false);
|
||||||
|
|
||||||
if (trimmedSender.isEmpty ||
|
if (trimmedSender.isEmpty ||
|
||||||
trimmedReceiver.isEmpty ||
|
trimmedReceiver.isEmpty ||
|
||||||
@@ -816,6 +947,9 @@ class WebSocketService {
|
|||||||
'receiver': trimmedReceiver,
|
'receiver': trimmedReceiver,
|
||||||
'content': trimmedContent,
|
'content': trimmedContent,
|
||||||
};
|
};
|
||||||
|
final now = DateTime.now();
|
||||||
|
final clientMessageId = 'local-${now.microsecondsSinceEpoch}';
|
||||||
|
payload['messageId'] = clientMessageId;
|
||||||
|
|
||||||
if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
|
if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
|
||||||
payload['jobId'] = normalizedJobId;
|
payload['jobId'] = normalizedJobId;
|
||||||
@@ -828,18 +962,13 @@ class WebSocketService {
|
|||||||
const topic = '/server/message';
|
const topic = '/server/message';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonPayload = jsonEncode(payload);
|
|
||||||
// sendMessage buffers automatically if not connected/authenticated
|
|
||||||
sendMessage(topic, jsonPayload);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final message = ChatMessage(
|
final message = ChatMessage(
|
||||||
id: 'local-${now.microsecondsSinceEpoch}',
|
id: clientMessageId,
|
||||||
content: trimmedContent,
|
content: trimmedContent,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
direction: ChatDirection.outgoing,
|
direction: ChatDirection.outgoing,
|
||||||
messageType:
|
messageType:
|
||||||
normalizedJobId != null && normalizedJobId.isNotEmpty
|
hasJobContext
|
||||||
? ChatMessageType.jobRelated
|
? ChatMessageType.jobRelated
|
||||||
: ChatMessageType.general,
|
: ChatMessageType.general,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
@@ -849,13 +978,26 @@ class WebSocketService {
|
|||||||
read: false,
|
read: false,
|
||||||
pendingSync: true,
|
pendingSync: true,
|
||||||
);
|
);
|
||||||
|
final queuedMessage = QueuedMessage(
|
||||||
|
id: clientMessageId,
|
||||||
|
topic: topic,
|
||||||
|
payload: payload,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _databaseService.queueMessage(queuedMessage);
|
||||||
|
await ChatService().saveOutgoingMessage(message);
|
||||||
|
final sentImmediately = await _trySendQueuedChatMessage(queuedMessage);
|
||||||
|
if (!sentImmediately) {
|
||||||
|
developer.log(
|
||||||
|
'Chat message $clientMessageId queued for retry after reconnect',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
developer.log(
|
developer.log('Error sending chat message: $e', name: 'WebSocketService');
|
||||||
'Error encoding chat message payload: $e',
|
|
||||||
name: 'WebSocketService',
|
|
||||||
);
|
|
||||||
developer.log('Stack: $st', name: 'WebSocketService');
|
developer.log('Stack: $st', name: 'WebSocketService');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1047,6 +1189,36 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send station completion event to server.
|
||||||
|
/// Messages are buffered if offline and sent automatically when reconnected.
|
||||||
|
Future<void> sendStationCompleted({
|
||||||
|
required String jobId,
|
||||||
|
required String jobNumber,
|
||||||
|
required int stationOrder,
|
||||||
|
bool hasIncompleteOptionalTasks = false,
|
||||||
|
}) async {
|
||||||
|
const String destination = '/server/station_completed';
|
||||||
|
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'jobId': jobId,
|
||||||
|
'jobNumber': jobNumber,
|
||||||
|
'stationOrder': stationOrder,
|
||||||
|
'completedAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'hasIncompleteOptionalTasks': hasIncompleteOptionalTasks,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final jsonPayload = jsonEncode(payload);
|
||||||
|
sendMessage(destination, jsonPayload);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'Error sending station completion: $e',
|
||||||
|
name: 'WebSocketService',
|
||||||
|
);
|
||||||
|
developer.log('Stack: $st', name: 'WebSocketService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispose resources
|
/// Dispose resources
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopReconnectTimer();
|
_stopReconnectTimer();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
|
|
||||||
/// Supported languages with their display names and flag emojis
|
/// Supported languages with their display names and flag emojis
|
||||||
class LanguageOption {
|
class LanguageOption {
|
||||||
@@ -98,11 +99,9 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text('${l10n.languageChanged}: $flagEmoji $languageName'),
|
||||||
'${l10n.languageChanged}: $flagEmoji $languageName',
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: AppColors.success,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,10 +128,7 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
final languageOptions = _getLanguageOptions();
|
final languageOptions = _getLanguageOptions();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(l10n.settings)),
|
||||||
title: Text(l10n.settings),
|
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
),
|
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
// Language Selection Section
|
// Language Selection Section
|
||||||
@@ -143,7 +139,7 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.grey,
|
color: AppColors.textMuted,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -160,7 +156,7 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
color: AppColors.surfaceMuted,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -173,22 +169,27 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
title: Text(
|
title: Text(
|
||||||
language.name,
|
language.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
fontWeight:
|
||||||
color: isSelected ? Colors.deepPurple : Colors.black87,
|
isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color:
|
||||||
|
isSelected
|
||||||
|
? AppColors.primaryStrong
|
||||||
|
: AppColors.textStrong,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: isSelected
|
trailing:
|
||||||
? const Icon(
|
isSelected
|
||||||
Icons.check_circle,
|
? const Icon(
|
||||||
color: Colors.deepPurple,
|
Icons.check_circle,
|
||||||
)
|
color: AppColors.primary,
|
||||||
: const Icon(
|
)
|
||||||
Icons.circle_outlined,
|
: const Icon(
|
||||||
color: Colors.grey,
|
Icons.circle_outlined,
|
||||||
),
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
onTap: () => _onLanguageSelected(language.code),
|
onTap: () => _onLanguageSelected(language.code),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
selectedTileColor: Colors.deepPurple.withValues(alpha: 0.05),
|
selectedTileColor: AppColors.primarySoft,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, indent: 72),
|
const Divider(height: 1, indent: 72),
|
||||||
],
|
],
|
||||||
@@ -203,17 +204,14 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.grey,
|
color: AppColors.textMuted,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(Icons.info_outline, color: AppColors.textMuted),
|
||||||
Icons.info_outline,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
title: Text(l10n.version),
|
title: Text(l10n.version),
|
||||||
subtitle: const Text('0.9.2'),
|
subtitle: const Text('0.9.2'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import 'package:image/image.dart' as img;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'models/job.dart';
|
import 'models/job.dart';
|
||||||
import 'models/task.dart';
|
import 'models/task.dart';
|
||||||
import 'models/tasks/confirmation_task.dart';
|
import 'models/tasks/confirmation_task.dart';
|
||||||
@@ -39,7 +41,6 @@ class TaskView extends StatefulWidget {
|
|||||||
|
|
||||||
class _TaskViewState extends State<TaskView> {
|
class _TaskViewState extends State<TaskView> {
|
||||||
final Set<String> _completedTasks = {};
|
final Set<String> _completedTasks = {};
|
||||||
final Set<String> _skippedTasks = {};
|
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
// Store SVG representations of signatures per task for later use
|
// Store SVG representations of signatures per task for later use
|
||||||
final Map<String, String> _signatureSvgByTask = {};
|
final Map<String, String> _signatureSvgByTask = {};
|
||||||
@@ -60,7 +61,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load task completion statuses from database and merge with JSON task states
|
/// Load task completion and skipped statuses from database and merge with JSON task states
|
||||||
Future<void> _loadTaskStatuses() async {
|
Future<void> _loadTaskStatuses() async {
|
||||||
final statuses = await _databaseService.loadAllTaskStatuses();
|
final statuses = await _databaseService.loadAllTaskStatuses();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -89,7 +90,6 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
||||||
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
|
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -116,14 +116,14 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
constraints: const BoxConstraints(maxHeight: 150),
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8F9FA),
|
color: AppColors.surfaceMuted,
|
||||||
border: Border.all(color: Colors.grey[300]!, width: 1),
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Text(
|
child: Text(
|
||||||
_getRemark(),
|
_getRemark(),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
style: const TextStyle(fontSize: 14, color: AppColors.text),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -132,7 +132,13 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [Expanded(child: _buildTasksStepper())],
|
children: [
|
||||||
|
Expanded(child: _buildTasksStepper()),
|
||||||
|
if (_visibleTasks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildCompleteStationButton(),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -141,22 +147,111 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _canCompleteStation {
|
||||||
|
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
||||||
|
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
|
||||||
|
// immer aktiv.
|
||||||
|
for (final t in _visibleTasks) {
|
||||||
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasIncompleteOptionalTasks {
|
||||||
|
for (final t in _visibleTasks) {
|
||||||
|
if (t.optional && !_completedTasks.contains(t.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompleteStationButton() {
|
||||||
|
final bool enabled = _canCompleteStation;
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: enabled ? _onCompleteStationPressed : null,
|
||||||
|
icon: const Icon(Icons.flag),
|
||||||
|
label: const Text('Station abschließen'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCompleteStationPressed() {
|
||||||
|
if (_hasIncompleteOptionalTasks) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Offene optionale Aufgaben'),
|
||||||
|
content: const Text(
|
||||||
|
'Es gibt nicht erledigte optionale Aufgaben. Möchten Sie die Station trotzdem abschließen?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: Text(AppLocalizations.of(context).cancel),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_sendStationCompleted();
|
||||||
|
},
|
||||||
|
child: const Text('Trotzdem abschließen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_sendStationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendStationCompleted() {
|
||||||
|
final stationOrder = widget.stationOrder ?? 0;
|
||||||
|
try {
|
||||||
|
StompService().sendStationCompleted(
|
||||||
|
jobId: widget.job.id,
|
||||||
|
jobNumber: widget.job.jobNumber,
|
||||||
|
stationOrder: stationOrder,
|
||||||
|
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Station abgeschlossen')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error sending station completion: $e', name: 'TaskView');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTasksStepper() {
|
Widget _buildTasksStepper() {
|
||||||
if (_visibleTasks.isEmpty) {
|
if (_visibleTasks.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.task_outlined, size: 64, color: Colors.grey[400]),
|
const Icon(
|
||||||
|
Icons.task_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).noTasks,
|
AppLocalizations.of(context).noTasks,
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).noTasksMessage,
|
AppLocalizations.of(context).noTasksMessage,
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -169,36 +264,30 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final task = _visibleTasks[index];
|
final task = _visibleTasks[index];
|
||||||
final isCompleted = _completedTasks.contains(task.id);
|
final isCompleted = _completedTasks.contains(task.id);
|
||||||
final isSkipped = _skippedTasks.contains(task.id);
|
|
||||||
final canBeCompletedNow =
|
final canBeCompletedNow =
|
||||||
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index);
|
!isCompleted && _arePreviousTasksCompleted(index);
|
||||||
|
|
||||||
// Hintergrundfarbe je nach Status:
|
// Hintergrundfarbe je nach Status:
|
||||||
// abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau
|
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
|
||||||
|
// (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.)
|
||||||
final Color cardColor =
|
final Color cardColor =
|
||||||
isCompleted
|
isCompleted
|
||||||
? const Color(0xFFE8F5E9) // hellgrün
|
? AppColors.successSoft
|
||||||
: isSkipped
|
|
||||||
? const Color(0xFFFFF8E1) // hellgelb
|
|
||||||
: canBeCompletedNow
|
: canBeCompletedNow
|
||||||
? Colors.white
|
? AppColors.surface
|
||||||
: const Color(0xFFF5F5F5); // hellgrau
|
: AppColors.surfaceMuted;
|
||||||
final Color borderColor =
|
final Color borderColor =
|
||||||
isCompleted
|
isCompleted
|
||||||
? Colors.green[300]!
|
? AppColors.success.withValues(alpha: 0.35)
|
||||||
: isSkipped
|
|
||||||
? Colors.amber[300]!
|
|
||||||
: canBeCompletedNow
|
: canBeCompletedNow
|
||||||
? Colors.grey[300]!
|
? AppColors.border
|
||||||
: Colors.grey[200]!;
|
: AppColors.border.withValues(alpha: 0.7);
|
||||||
final Color circleColor =
|
final Color circleColor =
|
||||||
isCompleted
|
isCompleted
|
||||||
? Colors.green[600]!
|
? AppColors.success
|
||||||
: isSkipped
|
|
||||||
? Colors.amber[600]!
|
|
||||||
: canBeCompletedNow
|
: canBeCompletedNow
|
||||||
? Colors.deepPurple[400]!
|
? AppColors.primary
|
||||||
: Colors.grey[400]!;
|
: AppColors.textMuted;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@@ -219,61 +308,93 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: cardColor,
|
color: cardColor,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
// Task number circle
|
Row(
|
||||||
Container(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
width: 40,
|
children: [
|
||||||
height: 40,
|
// Task number circle
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
shape: BoxShape.circle,
|
width: 40,
|
||||||
color: circleColor,
|
height: 40,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Center(
|
shape: BoxShape.circle,
|
||||||
child: Text(
|
color: circleColor,
|
||||||
'${index + 1}',
|
),
|
||||||
style: TextStyle(
|
child: Center(
|
||||||
color: Colors.white,
|
child: Text(
|
||||||
fontWeight: FontWeight.bold,
|
'${index + 1}',
|
||||||
fontSize: 16,
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Task content
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
right: task.optional ? 72 : 0,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTaskDisplayText(task, isCompleted, index),
|
||||||
|
if (_getTaskStationLabel(task) != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_getTaskStationLabel(task)!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isCompleted) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: AppColors.success,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (task.optional)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warningSoft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.warning.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Optional',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
// Task content
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildTaskDisplayText(
|
|
||||||
task,
|
|
||||||
isCompleted || isSkipped,
|
|
||||||
index,
|
|
||||||
),
|
|
||||||
if (_getTaskStationLabel(task) != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_getTaskStationLabel(task)!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isCompleted) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(Icons.check_circle, color: Colors.green[600]),
|
|
||||||
],
|
|
||||||
if (isSkipped) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(Icons.skip_next, color: Colors.amber[600]),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -501,10 +622,11 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
builder:
|
builder:
|
||||||
(context) => SignatureCaptureScreen(
|
(context) => SignatureCaptureScreen(
|
||||||
task: task,
|
task: task,
|
||||||
onSignatureCompleted: (String svg) async {
|
onSignatureCompleted: (String svg, String note) async {
|
||||||
try {
|
try {
|
||||||
// Persist SVG only (no PNG)
|
// Persist SVG only (no PNG)
|
||||||
await _databaseService.saveTaskSignature(task.id, svg);
|
await _databaseService.saveTaskSignature(task.id, svg);
|
||||||
|
await _databaseService.saveTaskSignatureNote(task.id, note);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'Error saving task signature: $e',
|
'Error saving task signature: $e',
|
||||||
@@ -528,6 +650,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
'signatureSvg': svg,
|
'signatureSvg': svg,
|
||||||
'svgLength': svg.length,
|
'svgLength': svg.length,
|
||||||
'hasSignature': true,
|
'hasSignature': true,
|
||||||
|
'signatureNote': note,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -611,6 +734,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
String? taskType,
|
String? taskType,
|
||||||
Map<String, dynamic>? extraData,
|
Map<String, dynamic>? extraData,
|
||||||
}) {
|
}) {
|
||||||
|
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
||||||
setState(() {
|
setState(() {
|
||||||
_completedTasks.add(taskId);
|
_completedTasks.add(taskId);
|
||||||
});
|
});
|
||||||
@@ -627,15 +751,60 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error sending task completion: $e', name: 'TaskView');
|
developer.log('Error sending task completion: $e', name: 'TaskView');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde,
|
||||||
|
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
||||||
|
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_showLastMandatoryCompletedDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasOpenMandatoryTasks {
|
||||||
|
for (final t in _visibleTasks) {
|
||||||
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLastMandatoryCompletedDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Alle Pflichtaufgaben erledigt'),
|
||||||
|
content: const Text(
|
||||||
|
'Alle nicht optionalen Aufgaben dieser Station sind erledigt. '
|
||||||
|
'Möchten Sie die Station jetzt abschließen?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Später'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_onCompleteStationPressed();
|
||||||
|
},
|
||||||
|
child: const Text('Station abschließen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _arePreviousTasksCompleted(int index) {
|
bool _arePreviousTasksCompleted(int index) {
|
||||||
if (index <= 0) return true;
|
if (index <= 0) return true;
|
||||||
for (int i = 0; i < index; i++) {
|
for (int i = 0; i < index; i++) {
|
||||||
final t = _visibleTasks[i];
|
final t = _visibleTasks[i];
|
||||||
if (!t.optional &&
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||||
!_completedTasks.contains(t.id) &&
|
|
||||||
!_skippedTasks.contains(t.id)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,8 +890,18 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
final displayName = task.displayName;
|
final displayName =
|
||||||
final description = task.description;
|
task.displayName != null
|
||||||
|
? localizeKnownText(context, task.displayName!)
|
||||||
|
: null;
|
||||||
|
final description =
|
||||||
|
task.description != null
|
||||||
|
? localizeKnownText(context, task.description!)
|
||||||
|
: null;
|
||||||
|
final String? signatureNote =
|
||||||
|
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
|
||||||
|
? task.note!.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
if (displayName?.isNotEmpty == true) {
|
if (displayName?.isNotEmpty == true) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -733,14 +912,39 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(description!, style: subtitleStyle),
|
Text(description!, style: subtitleStyle),
|
||||||
],
|
],
|
||||||
|
if (signatureNote != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(signatureNote, style: subtitleStyle),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (description?.isNotEmpty == true) {
|
if (description?.isNotEmpty == true) {
|
||||||
|
if (signatureNote != null) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(description!, style: titleStyle),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(signatureNote, style: subtitleStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return Text(description!, style: titleStyle);
|
return Text(description!, style: titleStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signatureNote != null) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(_getStandardTaskDisplayText(task), style: titleStyle),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(signatureNote, style: subtitleStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to standard text based on task type
|
// Fall back to standard text based on task type
|
||||||
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
|
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
|
||||||
}
|
}
|
||||||
@@ -785,12 +989,10 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
if (station.stationOrder == stationOrder) {
|
if (station.stationOrder == stationOrder) {
|
||||||
final suffix =
|
final suffix =
|
||||||
station.displayName.isNotEmpty ? station.displayName : station.city;
|
station.displayName.isNotEmpty ? station.displayName : station.city;
|
||||||
return suffix.isNotEmpty
|
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
|
||||||
? 'Station ${stationOrder + 1}: $suffix'
|
|
||||||
: 'Station ${stationOrder + 1}';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Station ${stationOrder + 1}';
|
return AppLocalizations.of(context).stationNumber(stationOrder + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ class BarcodeCaptureScreen extends StatefulWidget {
|
|||||||
final BarcodeTask task;
|
final BarcodeTask task;
|
||||||
final Function(List<String>) onBarcodesCompleted;
|
final Function(List<String>) onBarcodesCompleted;
|
||||||
|
|
||||||
const BarcodeCaptureScreen({super.key, required this.task, required this.onBarcodesCompleted});
|
const BarcodeCaptureScreen({
|
||||||
|
super.key,
|
||||||
|
required this.task,
|
||||||
|
required this.onBarcodesCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
|
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
|
||||||
@@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${AppLocalizations.of(context).cameraError}: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +150,28 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(appBar: AppBar(title: Text(AppLocalizations.of(context).barcodeScan), backgroundColor: Colors.deepPurple[100], leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop())), body: Column(children: [OfflineBanner(), Expanded(child: _isScannerInitialized ? (_isMobilePlatform ? _buildMobileView() : _buildDesktopView()) : const Center(child: CircularProgressIndicator()))]));
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(AppLocalizations.of(context).barcodeScan),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
_isScannerInitialized
|
||||||
|
? (_isMobilePlatform
|
||||||
|
? _buildMobileView()
|
||||||
|
: _buildDesktopView())
|
||||||
|
: const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileView() {
|
Widget _buildMobileView() {
|
||||||
@@ -153,9 +182,33 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
flex: 3,
|
flex: 3,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
MobileScanner(controller: _scannerController, onDetect: _onBarcodeDetected),
|
MobileScanner(
|
||||||
|
controller: _scannerController,
|
||||||
|
onDetect: _onBarcodeDetected,
|
||||||
|
),
|
||||||
// Overlay with scanning frame
|
// Overlay with scanning frame
|
||||||
Container(decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: Center(child: Container(width: 250, height: 250, decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2), borderRadius: BorderRadius.circular(12)), child: Container(margin: const EdgeInsets.all(20), decoration: BoxDecoration(border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8)))))),
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.green, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -167,20 +220,47 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _scannedBarcodes.length,
|
itemCount: _scannedBarcodes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return Card(child: ListTile(leading: const Icon(Icons.qr_code), title: Text(_scannedBarcodes[index]), trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeBarcode(index))));
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code),
|
||||||
|
title: Text(_scannedBarcodes[index]),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _removeBarcode(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _canFinish() ? _finishTask : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: Text(AppLocalizations.of(context).finish),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -195,9 +275,15 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(AppLocalizations.of(context).enterBarcode, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
Text(
|
||||||
|
AppLocalizations.of(context).enterBarcode,
|
||||||
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
Text(
|
||||||
|
'${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -207,7 +293,18 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _textControllers[index],
|
controller: _textControllers[index],
|
||||||
decoration: InputDecoration(labelText: index < widget.task.minBarcodeCount ? AppLocalizations.of(context).barcodeNumberRequired(index + 1) : AppLocalizations.of(context).barcodeNumberOptional(index + 1), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.qr_code)),
|
decoration: InputDecoration(
|
||||||
|
labelText:
|
||||||
|
index < widget.task.minBarcodeCount
|
||||||
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).barcodeNumberRequired(index + 1)
|
||||||
|
: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).barcodeNumberOptional(index + 1),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(Icons.qr_code),
|
||||||
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
// Trigger rebuild to update button state
|
// Trigger rebuild to update button state
|
||||||
@@ -219,7 +316,16 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))),
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _canFinish() ? _finishTask : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: Text(AppLocalizations.of(context).finish),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:camera/camera.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:file_selector/file_selector.dart' as fsel;
|
import 'package:file_selector/file_selector.dart' as fsel;
|
||||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||||
|
import '../app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/tasks/photo_task.dart';
|
import '../models/tasks/photo_task.dart';
|
||||||
import '../widgets/offline_banner.dart';
|
import '../widgets/offline_banner.dart';
|
||||||
@@ -91,11 +92,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log('Error initializing camera: $e', name: 'PhotoCaptureScreen');
|
developer.log(
|
||||||
|
'Error initializing camera: $e',
|
||||||
|
name: 'PhotoCaptureScreen',
|
||||||
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')),
|
SnackBar(
|
||||||
|
content: Text('${AppLocalizations.of(context).cameraError}: $e'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +124,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(AppLocalizations.of(context).cameraNotReady)),
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).cameraNotReady),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +135,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
|
SnackBar(
|
||||||
|
content: Text('${AppLocalizations.of(context).photoError}: $e'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +146,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
Future<void> _pickPhotoFromFile() async {
|
Future<void> _pickPhotoFromFile() async {
|
||||||
try {
|
try {
|
||||||
// Use file_selector for desktop and web for robust platform support
|
// Use file_selector for desktop and web for robust platform support
|
||||||
final bool useFileSelector = kIsWeb ||
|
final bool useFileSelector =
|
||||||
|
kIsWeb ||
|
||||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||||
defaultTargetPlatform == TargetPlatform.windows ||
|
defaultTargetPlatform == TargetPlatform.windows ||
|
||||||
defaultTargetPlatform == TargetPlatform.linux;
|
defaultTargetPlatform == TargetPlatform.linux;
|
||||||
@@ -146,7 +157,9 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
label: 'images',
|
label: 'images',
|
||||||
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
|
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
|
||||||
);
|
);
|
||||||
final fsel.XFile? picked = await fsel.openFile(acceptedTypeGroups: [typeGroup]);
|
final fsel.XFile? picked = await fsel.openFile(
|
||||||
|
acceptedTypeGroups: [typeGroup],
|
||||||
|
);
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
final data = await picked.readAsBytes();
|
final data = await picked.readAsBytes();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -187,11 +200,16 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log('Error picking photo from file: $e', name: 'PhotoCaptureScreen');
|
developer.log(
|
||||||
|
'Error picking photo from file: $e',
|
||||||
|
name: 'PhotoCaptureScreen',
|
||||||
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
|
SnackBar(
|
||||||
|
content: Text('${AppLocalizations.of(context).photoError}: $e'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +248,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
child: Text(AppLocalizations.of(context).delete, style: const TextStyle(color: Colors.white)),
|
child: Text(
|
||||||
|
AppLocalizations.of(context).delete,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -240,7 +261,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
|
|
||||||
bool get _canComplete {
|
bool get _canComplete {
|
||||||
return _capturedPhotos.length >= widget.task.minPhotoCount &&
|
return _capturedPhotos.length >= widget.task.minPhotoCount &&
|
||||||
_capturedPhotos.length <= widget.task.maxPhotoCount;
|
_capturedPhotos.length <= widget.task.maxPhotoCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _canTakeMore {
|
bool get _canTakeMore {
|
||||||
@@ -276,7 +297,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log('Error animating to page: $e', name: 'PhotoCaptureScreen');
|
developer.log(
|
||||||
|
'Error animating to page: $e',
|
||||||
|
name: 'PhotoCaptureScreen',
|
||||||
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
|
||||||
_pageController.jumpToPage(clamped);
|
_pageController.jumpToPage(clamped);
|
||||||
}
|
}
|
||||||
@@ -304,7 +328,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context).photoCapture),
|
title: Text(AppLocalizations.of(context).photoCapture),
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: AppColors.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
actions: [
|
actions: [
|
||||||
if (_canComplete)
|
if (_canComplete)
|
||||||
@@ -315,7 +339,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).finish,
|
AppLocalizations.of(context).finish,
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -327,7 +354,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
color: Colors.grey[100],
|
color: AppColors.surfaceMuted,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -337,7 +364,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
|
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -345,19 +375,20 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
|
|
||||||
// Camera preview, photo gallery or empty state
|
// Camera preview, photo gallery or empty state
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _capturedPhotos.isEmpty
|
child:
|
||||||
? _buildCameraOrEmptyState()
|
_capturedPhotos.isEmpty
|
||||||
: _buildPhotoGallery(),
|
? _buildCameraOrEmptyState()
|
||||||
|
: _buildPhotoGallery(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom controls
|
// Bottom controls
|
||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppColors.surface,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
color: AppColors.textStrong.withValues(alpha: 0.12),
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
blurRadius: 5,
|
blurRadius: 5,
|
||||||
offset: Offset(0, -3),
|
offset: Offset(0, -3),
|
||||||
@@ -372,23 +403,50 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
// Camera or file select button
|
// Camera or file select button
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _canTakeMore && _isCameraSupportedOnThisPlatform
|
onPressed:
|
||||||
? (_useFilePickerMode
|
_canTakeMore && _isCameraSupportedOnThisPlatform
|
||||||
? _pickPhotoFromFile
|
? (_useFilePickerMode
|
||||||
: (_isCameraInitialized ? _capturePhoto : null))
|
? _pickPhotoFromFile
|
||||||
: null,
|
: (_isCameraInitialized
|
||||||
icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt),
|
? _capturePhoto
|
||||||
|
: null))
|
||||||
|
: null,
|
||||||
|
icon: Icon(
|
||||||
|
_useFilePickerMode
|
||||||
|
? Icons.photo_library
|
||||||
|
: Icons.camera_alt,
|
||||||
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
!_isCameraSupportedOnThisPlatform
|
!_isCameraSupportedOnThisPlatform
|
||||||
? AppLocalizations.of(context).cameraNotSupportedOnPlatform
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).cameraNotSupportedOnPlatform
|
||||||
: (!_canTakeMore
|
: (!_canTakeMore
|
||||||
? AppLocalizations.of(context).maxPhotosReached
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).maxPhotosReached
|
||||||
: (_useFilePickerMode
|
: (_useFilePickerMode
|
||||||
? AppLocalizations.of(context).selectPhoto
|
? AppLocalizations.of(context).selectPhoto
|
||||||
: (_isCameraInitialized ? AppLocalizations.of(context).takePhoto : (defaultTargetPlatform == TargetPlatform.macOS ? AppLocalizations.of(context).cameraReadyNoPreview : AppLocalizations.of(context).cameraLoading)))),
|
: (_isCameraInitialized
|
||||||
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).takePhoto
|
||||||
|
: (defaultTargetPlatform ==
|
||||||
|
TargetPlatform.macOS
|
||||||
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).cameraReadyNoPreview
|
||||||
|
: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).cameraLoading)))),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canTakeMore && (_useFilePickerMode || _isCameraInitialized) ? Colors.blue : Colors.grey,
|
backgroundColor:
|
||||||
|
_canTakeMore &&
|
||||||
|
(_useFilePickerMode ||
|
||||||
|
_isCameraInitialized)
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.borderStrong,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
),
|
),
|
||||||
@@ -405,7 +463,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -416,18 +477,23 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _canComplete
|
onPressed:
|
||||||
? () {
|
_canComplete
|
||||||
widget.onPhotosCompleted(_capturedPhotos);
|
? () {
|
||||||
Navigator.of(context).pop();
|
widget.onPhotosCompleted(_capturedPhotos);
|
||||||
}
|
Navigator.of(context).pop();
|
||||||
: null,
|
}
|
||||||
|
: null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canComplete ? Colors.green : Colors.grey,
|
backgroundColor:
|
||||||
|
_canComplete ? Colors.green : Colors.grey,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: EdgeInsets.symmetric(vertical: 14),
|
padding: EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
),
|
||||||
child: Text(AppLocalizations.of(context).finish, style: const TextStyle(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
AppLocalizations.of(context).finish,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -451,7 +517,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).cameraNotAvailable,
|
AppLocalizations.of(context).cameraNotAvailable,
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -477,7 +547,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).addPhotos,
|
AppLocalizations.of(context).addPhotos,
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -518,11 +592,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.camera_alt, size: 80, color: Colors.grey[400]),
|
||||||
Icons.camera_alt,
|
|
||||||
size: 80,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).cameraInitializing,
|
AppLocalizations.of(context).cameraInitializing,
|
||||||
@@ -535,10 +605,7 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).cameraLoadingMessage,
|
AppLocalizations.of(context).cameraLoadingMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -601,7 +668,11 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error, size: 50, color: Colors.grey[600]),
|
Icon(
|
||||||
|
Icons.error,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(AppLocalizations.of(context).photoError),
|
Text(AppLocalizations.of(context).photoError),
|
||||||
],
|
],
|
||||||
@@ -621,9 +692,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: _currentPhotoIndex > 0
|
onPressed:
|
||||||
? _goToPreviousPhoto
|
_currentPhotoIndex > 0 ? _goToPreviousPhoto : null,
|
||||||
: null,
|
|
||||||
icon: Icon(Icons.chevron_left, size: 36),
|
icon: Icon(Icons.chevron_left, size: 36),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.7),
|
backgroundColor: Colors.white.withValues(alpha: 0.7),
|
||||||
@@ -638,9 +708,10 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: _currentPhotoIndex < _capturedPhotos.length - 1
|
onPressed:
|
||||||
? _goToNextPhoto
|
_currentPhotoIndex < _capturedPhotos.length - 1
|
||||||
: null,
|
? _goToNextPhoto
|
||||||
|
: null,
|
||||||
icon: Icon(Icons.chevron_right, size: 36),
|
icon: Icon(Icons.chevron_right, size: 36),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.7),
|
backgroundColor: Colors.white.withValues(alpha: 0.7),
|
||||||
@@ -658,19 +729,21 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
|||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: _capturedPhotos.asMap().entries.map((entry) {
|
children:
|
||||||
return Container(
|
_capturedPhotos.asMap().entries.map((entry) {
|
||||||
width: 8,
|
return Container(
|
||||||
height: 8,
|
width: 8,
|
||||||
margin: EdgeInsets.symmetric(horizontal: 4),
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
margin: EdgeInsets.symmetric(horizontal: 4),
|
||||||
shape: BoxShape.circle,
|
decoration: BoxDecoration(
|
||||||
color: _currentPhotoIndex == entry.key
|
shape: BoxShape.circle,
|
||||||
? Colors.blue
|
color:
|
||||||
: Colors.grey[400],
|
_currentPhotoIndex == entry.key
|
||||||
),
|
? AppColors.primary
|
||||||
);
|
: Colors.grey[400],
|
||||||
}).toList(),
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:signature/signature.dart';
|
import 'package:signature/signature.dart';
|
||||||
|
import '../app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/tasks/signature_task.dart';
|
import '../models/tasks/signature_task.dart';
|
||||||
import '../widgets/offline_banner.dart';
|
import '../widgets/offline_banner.dart';
|
||||||
|
|
||||||
class SignatureCaptureScreen extends StatefulWidget {
|
class SignatureCaptureScreen extends StatefulWidget {
|
||||||
final SignatureTask task;
|
final SignatureTask task;
|
||||||
final void Function(String svg) onSignatureCompleted;
|
final void Function(String svg, String note) onSignatureCompleted;
|
||||||
|
|
||||||
const SignatureCaptureScreen({
|
const SignatureCaptureScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -23,6 +23,7 @@ class SignatureCaptureScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
||||||
late final SignatureController _controller;
|
late final SignatureController _controller;
|
||||||
|
final TextEditingController _noteController = TextEditingController();
|
||||||
bool _hasSignature = false;
|
bool _hasSignature = false;
|
||||||
bool _isMobilePlatform = false;
|
bool _isMobilePlatform = false;
|
||||||
|
|
||||||
@@ -84,11 +85,16 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.removeListener(_onSignatureChanged);
|
_controller.removeListener(_onSignatureChanged);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_noteController.dispose();
|
||||||
_restoreOrientation();
|
_restoreOrientation();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildSvgFromPoints(List<Point?> points, {double strokeWidth = 3.0, String strokeColor = '#000000'}) {
|
String _buildSvgFromPoints(
|
||||||
|
List<Point?> points, {
|
||||||
|
double strokeWidth = 3.0,
|
||||||
|
String strokeColor = '#000000',
|
||||||
|
}) {
|
||||||
// Convert collected signature points (with null separators for stroke breaks) into an SVG string
|
// Convert collected signature points (with null separators for stroke breaks) into an SVG string
|
||||||
// Determine bounds
|
// Determine bounds
|
||||||
double? minX, minY, maxX, maxY;
|
double? minX, minY, maxX, maxY;
|
||||||
@@ -130,7 +136,8 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String svg = '<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
final String svg =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,25 +148,30 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
|||||||
if (!hasAnyPoint) {
|
if (!hasAnyPoint) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(AppLocalizations.of(context).signatureRequired)),
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).signatureRequired),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SVG from the captured signature points
|
// Build SVG from the captured signature points
|
||||||
final String svg = _buildSvgFromPoints(_controller.points);
|
final String svg = _buildSvgFromPoints(_controller.points);
|
||||||
|
final String note = _noteController.text.trim();
|
||||||
|
|
||||||
// Close this screen first to show the updated TaskView quickly
|
// Close this screen first to show the updated TaskView quickly
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_restoreOrientation();
|
_restoreOrientation();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
// Then notify the caller (SVG only)
|
// Then notify the caller (SVG + Bemerkung)
|
||||||
widget.onSignatureCompleted(svg);
|
widget.onSignatureCompleted(svg, note);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('${AppLocalizations.of(context).signatureError}: $e')),
|
SnackBar(
|
||||||
|
content: Text('${AppLocalizations.of(context).signatureError}: $e'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +181,6 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context).signatureCapture),
|
title: Text(AppLocalizations.of(context).signatureCapture),
|
||||||
backgroundColor: Colors.deepPurple[100],
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -197,61 +208,71 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).signatureInstruction,
|
AppLocalizations.of(context).signatureInstruction,
|
||||||
style: TextStyle(color: Colors.grey[700]),
|
style: const TextStyle(color: AppColors.textMuted),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey[400]!),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Signature(
|
|
||||||
controller: _controller,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.borderStrong),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Signature(
|
||||||
|
controller: _controller,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _noteController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppLocalizations.of(context).completeTaskNote,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
minLines: 1,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
// The listener will automatically update _hasSignature when points are cleared
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(AppLocalizations.of(context).clear),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _hasSignature ? _finish : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: Text(AppLocalizations.of(context).finish),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
_controller.clear();
|
|
||||||
// The listener will automatically update _hasSignature when points are cleared
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: Text(AppLocalizations.of(context).clear),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
SizedBox(
|
|
||||||
width: 160,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _hasSignature ? _finish : null,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
|
||||||
child: Text(AppLocalizations.of(context).finish),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart' as file_selector;
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||||
|
import '../app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ChatPhotoDialog extends StatefulWidget {
|
class ChatPhotoDialog extends StatefulWidget {
|
||||||
@@ -278,7 +279,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
|
|||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning, color: Colors.orange[700], size: 40),
|
const Icon(Icons.warning, color: AppColors.warning, size: 40),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(_errorMessage!, textAlign: TextAlign.center),
|
Text(_errorMessage!, textAlign: TextAlign.center),
|
||||||
],
|
],
|
||||||
@@ -330,11 +331,7 @@ class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
|
|||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.photo_camera_back, color: AppColors.primary, size: 48),
|
||||||
Icons.photo_camera_back,
|
|
||||||
color: Colors.deepPurple[400],
|
|
||||||
size: 48,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
const Text(
|
||||||
'Wähle ein Foto von deinem Gerät aus.',
|
'Wähle ein Foto von deinem Gerät aus.',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||||
import 'package:votianlt_app/services/websocket_service.dart';
|
import 'package:votianlt_app/services/websocket_service.dart';
|
||||||
import 'package:votianlt_app/services/dart_mq.dart';
|
import 'package:votianlt_app/services/dart_mq.dart';
|
||||||
|
import '../app_theme.dart';
|
||||||
|
|
||||||
class OfflineBanner extends StatefulWidget {
|
class OfflineBanner extends StatefulWidget {
|
||||||
const OfflineBanner({super.key});
|
const OfflineBanner({super.key});
|
||||||
@@ -24,8 +25,13 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
|||||||
// Check if we're already connected (e.g., coming back to this screen)
|
// Check if we're already connected (e.g., coming back to this screen)
|
||||||
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
|
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
|
||||||
// Initialize countdown based on current connection state
|
// Initialize countdown based on current connection state
|
||||||
_onConnectionChange(_stompService.isConnected && _stompService.isAuthenticated);
|
_onConnectionChange(
|
||||||
_connSub = DartMQ().subscribe<bool>(MQTopics.connectionStatus, _onConnectionChange);
|
_stompService.isConnected && _stompService.isAuthenticated,
|
||||||
|
);
|
||||||
|
_connSub = DartMQ().subscribe<bool>(
|
||||||
|
MQTopics.connectionStatus,
|
||||||
|
_onConnectionChange,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onConnectionChange(bool isConnected) {
|
void _onConnectionChange(bool isConnected) {
|
||||||
@@ -68,7 +74,10 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
|||||||
// Only auto-reconnect if we already know the target; discovery remains user-initiated
|
// Only auto-reconnect if we already know the target; discovery remains user-initiated
|
||||||
await _stompService.connect();
|
await _stompService.connect();
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
developer.log('Auto-reconnect attempt failed: $e', name: 'OfflineBanner');
|
developer.log(
|
||||||
|
'Auto-reconnect attempt failed: $e',
|
||||||
|
name: 'OfflineBanner',
|
||||||
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
|
developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,19 +123,19 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
|||||||
title = 'Offline – Verbindung verloren';
|
title = 'Offline – Verbindung verloren';
|
||||||
subtitle = 'Verbindung wird wiederhergestellt.';
|
subtitle = 'Verbindung wird wiederhergestellt.';
|
||||||
icon = Icons.wifi_off;
|
icon = Icons.wifi_off;
|
||||||
bgColor = Colors.red[50];
|
bgColor = AppColors.dangerSoft;
|
||||||
iconColor = Colors.red[700];
|
iconColor = AppColors.danger;
|
||||||
titleColor = Colors.red[900];
|
titleColor = AppColors.danger;
|
||||||
subtitleColor = Colors.red[800];
|
subtitleColor = AppColors.danger.withValues(alpha: 0.85);
|
||||||
} else {
|
} else {
|
||||||
// Initial connection attempt
|
// Initial connection attempt
|
||||||
title = 'Verbinde mit Server...';
|
title = 'Verbinde mit Server...';
|
||||||
subtitle = 'Bitte warten.';
|
subtitle = 'Bitte warten.';
|
||||||
icon = Icons.sync;
|
icon = Icons.sync;
|
||||||
bgColor = Colors.orange[50];
|
bgColor = AppColors.warningSoft;
|
||||||
iconColor = Colors.orange[700];
|
iconColor = AppColors.warning;
|
||||||
titleColor = Colors.orange[900];
|
titleColor = AppColors.warning;
|
||||||
subtitleColor = Colors.orange[800];
|
subtitleColor = AppColors.warning.withValues(alpha: 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -152,10 +161,7 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(color: subtitleColor, fontSize: 12),
|
||||||
color: subtitleColor,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -165,4 +171,3 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.9.12+1
|
version: 0.9.16+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
|
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
|
||||||
readonly BACKEND_DIR="${SCRIPT_DIR}/backend"
|
readonly BACKEND_DIR="${SCRIPT_DIR}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.9.14</revision>
|
<revision>0.9.17</revision>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
@@ -44,6 +44,31 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Mockito + ByteBuddy hochziehen, weil die Spring-Boot-3.4.3-Defaults
|
||||||
|
(Mockito 5.14.2 / ByteBuddy 1.15.11) den Inline-Mock-Maker auf
|
||||||
|
JDK 25 nicht laden können — die JVM lehnt die Class-Modifikation
|
||||||
|
von java.lang.Object ab. Mockito 5.18 + ByteBuddy 1.17.5 sind die
|
||||||
|
erste stabile Kombination, die JDK 25 offiziell trägt. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<version>5.18.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
|
<version>5.18.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.bytebuddy</groupId>
|
||||||
|
<artifactId>byte-buddy</artifactId>
|
||||||
|
<version>1.17.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.bytebuddy</groupId>
|
||||||
|
<artifactId>byte-buddy-agent</artifactId>
|
||||||
|
<version>1.17.5</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -346,11 +346,14 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Nettobetrag column header (right-aligned)
|
// Nettobetrag column header (right-aligned)
|
||||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
||||||
|
|
||||||
|
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||||
|
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||||
|
|
||||||
// Sample data rows (placeholder)
|
// Sample data rows (placeholder)
|
||||||
var sampleData = [
|
var sampleData = [
|
||||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: '19%', net: '450,00 €' },
|
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
||||||
{ name: 'Entsorgung Möbel', vat: '19%', net: '85,00 €' },
|
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
||||||
{ name: 'Montage/De-Montage', vat: '19%', net: '120,00 €' }
|
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
||||||
];
|
];
|
||||||
|
|
||||||
var currentY = y + rowHeight;
|
var currentY = y + rowHeight;
|
||||||
@@ -415,9 +418,8 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
// Calculate totals from sample data
|
// Calculate totals from sample data
|
||||||
var netTotal = 655.00; // 450 + 85 + 120
|
var netTotal = 655.00; // 450 + 85 + 120
|
||||||
var vatRate = 0.19;
|
var vatTotal = netTotal * vatRate;
|
||||||
var vatTotal = 124.45; // 655 * 0.19
|
var grossTotal = netTotal + vatTotal;
|
||||||
var grossTotal = 779.45; // 655 + 124.45
|
|
||||||
|
|
||||||
// Draw summary lines
|
// Draw summary lines
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -435,7 +437,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Umsatzsteuer - label left, value right
|
// Umsatzsteuer - label left, value right
|
||||||
ctx.font = fontSize + 'px Arial';
|
ctx.font = fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('zzgl. 19% USt:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
||||||
@@ -1135,6 +1137,12 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.updateProfileVatRate = function(rate) {
|
||||||
|
if (rate == null || isNaN(rate)) return;
|
||||||
|
window.profileInvoiceVatRate = rate;
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
window.updateProfileMasterdataValue = function(key, value) {
|
window.updateProfileMasterdataValue = function(key, value) {
|
||||||
if (!window.masterdataValues) window.masterdataValues = {};
|
if (!window.masterdataValues) window.masterdataValues = {};
|
||||||
window.masterdataValues[key] = value;
|
window.masterdataValues[key] = value;
|
||||||
|
|||||||
@@ -1068,6 +1068,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgba(255, 255, 255, 0.84);
|
||||||
box-shadow: var(--app-shadow-sm);
|
box-shadow: var(--app-shadow-sm);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-card,
|
.route-card,
|
||||||
@@ -1095,6 +1096,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
box-shadow: var(--app-shadow-sm);
|
box-shadow: var(--app-shadow-sm);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card,
|
.detail-card,
|
||||||
@@ -1143,6 +1145,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
|
|||||||
|
|
||||||
.dialog-task-card {
|
.dialog-task-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding-top: calc(var(--lumo-space-m) + 5px) !important;
|
||||||
|
gap: calc(var(--lumo-space-m) / 16) !important;
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,14 +1156,112 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
|
|||||||
border-color: rgba(37, 99, 235, 0.24);
|
border-color: rgba(37, 99, 235, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-floating-delete {
|
.dialog-task-card.drag-over-top::before,
|
||||||
|
.dialog-task-card.drag-over-bottom::after {
|
||||||
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.65rem;
|
left: 0;
|
||||||
right: 0.65rem;
|
right: 0;
|
||||||
z-index: 10;
|
height: 3px;
|
||||||
|
background: var(--lumo-primary-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 6px rgba(37, 99, 235, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-card.drag-over-top::before {
|
||||||
|
top: calc(-0.5 * var(--lumo-space-m) - 1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-card.drag-over-bottom::after {
|
||||||
|
bottom: calc(-0.5 * var(--lumo-space-m) - 1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-card.dragging {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.96);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compressed cards during reorder drag */
|
||||||
|
.tasks-reordering .dialog-task-card {
|
||||||
|
padding: 0.4rem 0.8rem !important;
|
||||||
|
transition: padding 0.2s ease, max-height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-reordering .dialog-task-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-reordering .dialog-task-config,
|
||||||
|
.tasks-reordering .dialog-floating-delete,
|
||||||
|
.tasks-reordering .dialog-task-drag-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-reordering .dialog-task-card vaadin-combo-box {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-summary {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: var(--lumo-font-size-s);
|
||||||
|
color: var(--lumo-body-text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-summary .task-type-label {
|
||||||
|
color: var(--lumo-primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-summary .task-desc-label {
|
||||||
|
color: var(--lumo-secondary-text-color);
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-reordering .dialog-task-summary {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-card[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--lumo-secondary-text-color);
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
min-width: 1.7rem;
|
min-width: 1.7rem;
|
||||||
min-height: 1.7rem;
|
min-height: 1.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: 15px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-task-drag-handle:hover {
|
||||||
|
color: var(--lumo-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-floating-delete {
|
||||||
|
padding: 0.2rem;
|
||||||
|
min-width: 1.7rem;
|
||||||
|
min-height: 1.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: 15px;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-caption,
|
.inline-caption,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.assecutor.votianlt.config;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell.
|
||||||
|
*
|
||||||
|
* Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf
|
||||||
|
* <code>type=INVOICE, status=ISSUED</code> gesetzt – sie sind in der Praxis bereits
|
||||||
|
* ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF.
|
||||||
|
*
|
||||||
|
* Ein Audit-Eintrag dokumentiert die Migration (R-37).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(50)
|
||||||
|
public class InvoiceLifecycleMigration implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
|
||||||
|
public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
List<CustomerInvoice> legacyInvoices = invoiceRepository.findByStatusIsNull();
|
||||||
|
if (legacyInvoices.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (CustomerInvoice invoice : legacyInvoices) {
|
||||||
|
invoice.setType(InvoiceType.INVOICE);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
if (invoice.getIssuedAt() == null) {
|
||||||
|
invoice.setIssuedAt(now);
|
||||||
|
}
|
||||||
|
InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system",
|
||||||
|
"Migration: Bestandsrechnung auf Status ISSUED gesetzt.");
|
||||||
|
invoice.addAuditEntry(entry);
|
||||||
|
}
|
||||||
|
invoiceRepository.saveAll(legacyInvoices);
|
||||||
|
log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import de.assecutor.votianlt.model.AppUser;
|
|||||||
import de.assecutor.votianlt.model.CargoItem;
|
import de.assecutor.votianlt.model.CargoItem;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.task.BaseTask;
|
import de.assecutor.votianlt.model.task.BaseTask;
|
||||||
|
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
import de.assecutor.votianlt.repository.CargoItemRepository;
|
import de.assecutor.votianlt.repository.CargoItemRepository;
|
||||||
@@ -133,6 +134,14 @@ public class MessageController {
|
|||||||
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
|
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
|
||||||
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
|
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
|
||||||
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
|
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
|
||||||
|
for (BaseTask task : tasks) {
|
||||||
|
if (task instanceof SignatureTask signatureTask && task.getId() != null) {
|
||||||
|
List<Signature> signatures = signatureRepository.findByTaskIdOrderByCreatedAtDesc(task.getId());
|
||||||
|
if (!signatures.isEmpty()) {
|
||||||
|
signatureTask.setNote(signatures.get(0).getNote());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return new JobWithRelatedDataDTO(job, cargoItems, tasks);
|
return new JobWithRelatedDataDTO(job, cargoItems, tasks);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -246,13 +255,18 @@ public class MessageController {
|
|||||||
Object extra = payload.get("extraData");
|
Object extra = payload.get("extraData");
|
||||||
if (extra instanceof Map<?, ?> extraData) {
|
if (extra instanceof Map<?, ?> extraData) {
|
||||||
Object signatureSvgObj = extraData.get("signatureSvg");
|
Object signatureSvgObj = extraData.get("signatureSvg");
|
||||||
|
Object signatureNoteObj = extraData.get("signatureNote");
|
||||||
|
String signatureNote = signatureNoteObj instanceof String s ? s : null;
|
||||||
if (signatureSvgObj instanceof String signatureSvg) {
|
if (signatureSvgObj instanceof String signatureSvg) {
|
||||||
if (!signatureSvg.isBlank()) {
|
if (!signatureSvg.isBlank()) {
|
||||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||||
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
||||||
completedBy);
|
signatureNote, completedBy);
|
||||||
signatureRepository.save(signatureEntry);
|
signatureRepository.save(signatureEntry);
|
||||||
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
|
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
|
||||||
|
if (signatureNote != null && !signatureNote.isBlank()) {
|
||||||
|
extraDataSummary += ", Bemerkung: " + signatureNote;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
extraDataSummary = "Leere Unterschrift";
|
extraDataSummary = "Leere Unterschrift";
|
||||||
}
|
}
|
||||||
@@ -375,7 +389,9 @@ public class MessageController {
|
|||||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
||||||
checkAndHandleJobCompletion(jobId, completedBy);
|
// Job completion is no longer auto-triggered by task completion.
|
||||||
|
// It is now driven by explicit station_completed messages from the app
|
||||||
|
// (see handleStationCompleted).
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore email notification errors
|
// Ignore email notification errors
|
||||||
}
|
}
|
||||||
@@ -430,6 +446,47 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle station completion message from app. Client sends to
|
||||||
|
* /server/station_completed with payload:
|
||||||
|
* {
|
||||||
|
* "jobId": "jobnum:ABC123",
|
||||||
|
* "jobNumber": "ABC123",
|
||||||
|
* "stationOrder": 0,
|
||||||
|
* "completedAt": "2026-04-13T12:34:56.789Z",
|
||||||
|
* "hasIncompleteOptionalTasks": false
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* The job is marked as completed once this message is received and all
|
||||||
|
* mandatory tasks across all stations are completed.
|
||||||
|
*/
|
||||||
|
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
||||||
|
try {
|
||||||
|
String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null;
|
||||||
|
if (jobNumber == null || jobNumber.isBlank()) {
|
||||||
|
log.warn("[STATION] station_completed without jobNumber");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Job> jobOpt = jobRepository.findByJobNumber(jobNumber);
|
||||||
|
if (jobOpt.isEmpty()) {
|
||||||
|
log.warn("[STATION] Job with jobNumber {} not found", jobNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectId jobId = jobOpt.get().getId();
|
||||||
|
String completedBy = appUserId != null ? appUserId : "Unknown";
|
||||||
|
|
||||||
|
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber,
|
||||||
|
payload.get("stationOrder"));
|
||||||
|
|
||||||
|
checkAndHandleJobCompletion(jobId, completedBy);
|
||||||
|
jobUpdateBroadcaster.broadcast(jobId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[STATION] Error handling station_completed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message from a client via WebSocket. Client sends to
|
* Handle incoming message from a client via WebSocket. Client sends to
|
||||||
* /server/message with payload: { "content": "message payload", "contentType":
|
* /server/message with payload: { "content": "message payload", "contentType":
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import org.bson.types.ObjectId;
|
|||||||
* Normalized payload for chat messages sent by mobile clients via WebSocket.
|
* Normalized payload for chat messages sent by mobile clients via WebSocket.
|
||||||
* receiver = AppUser ID (clientId) extracted from topic
|
* receiver = AppUser ID (clientId) extracted from topic
|
||||||
*/
|
*/
|
||||||
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
|
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType,
|
||||||
String jobNumber) {
|
String messageId, ObjectId jobId, String jobNumber) {
|
||||||
|
|
||||||
public ChatMessageInboundPayload {
|
public ChatMessageInboundPayload {
|
||||||
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
||||||
@@ -23,10 +23,11 @@ public record ChatMessageInboundPayload(String receiver, String content, Message
|
|||||||
String receiver = extractRequiredString(payload, "receiver");
|
String receiver = extractRequiredString(payload, "receiver");
|
||||||
String content = extractRequiredString(payload, "content");
|
String content = extractRequiredString(payload, "content");
|
||||||
MessageContentType contentType = extractContentType(payload.get("contentType"));
|
MessageContentType contentType = extractContentType(payload.get("contentType"));
|
||||||
|
String messageId = extractOptionalString(payload.get("messageId"));
|
||||||
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
|
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
|
||||||
String jobNumber = extractOptionalString(payload.get("jobNumber"));
|
String jobNumber = extractOptionalString(payload.get("jobNumber"));
|
||||||
|
|
||||||
return new ChatMessageInboundPayload(receiver, content, contentType, jobId, jobNumber);
|
return new ChatMessageInboundPayload(receiver, content, contentType, messageId, jobId, jobNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasJobContext() {
|
public boolean hasJobContext() {
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ public class MessagingConfig {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Station completion handler — marks a job as completed once all mandatory
|
||||||
|
// tasks have been finished and the app confirms the station is done.
|
||||||
|
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
|
||||||
|
handlePayload(payload, payloadMap -> {
|
||||||
|
messageController.handleStationCompleted(appUserId, payloadMap);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Chat message handler
|
// Chat message handler
|
||||||
webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
|
webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
|
||||||
handlePayload(payload, payloadMap -> {
|
handlePayload(payload, payloadMap -> {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package de.assecutor.votianlt.messaging;
|
package de.assecutor.votianlt.messaging;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||||
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
|
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||||||
@Value("${app.messaging.websocket.allowed-origins:*}")
|
@Value("${app.messaging.websocket.allowed-origins:*}")
|
||||||
private String allowedOrigins;
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Value("${app.messaging.websocket.max-text-message-size:10485760}")
|
||||||
|
private int maxTextMessageSize;
|
||||||
|
|
||||||
|
@Value("${app.messaging.websocket.max-session-idle-timeout:300000}")
|
||||||
|
private long maxSessionIdleTimeout;
|
||||||
|
|
||||||
public WebSocketConfig(WebSocketService webSocketService) {
|
public WebSocketConfig(WebSocketService webSocketService) {
|
||||||
this.webSocketService = webSocketService;
|
this.webSocketService = webSocketService;
|
||||||
}
|
}
|
||||||
@@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||||||
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
|
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
|
||||||
.addInterceptors(new HttpSessionHandshakeInterceptor());
|
.addInterceptors(new HttpSessionHandshakeInterceptor());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||||
|
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||||
|
container.setMaxTextMessageBufferSize(maxTextMessageSize);
|
||||||
|
container.setMaxBinaryMessageBufferSize(maxTextMessageSize);
|
||||||
|
container.setMaxSessionIdleTimeout(maxSessionIdleTimeout);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -53,4 +53,10 @@ public class Customer {
|
|||||||
|
|
||||||
@Field("owner")
|
@Field("owner")
|
||||||
private ObjectId owner;
|
private ObjectId owner;
|
||||||
|
|
||||||
|
@Field("internal")
|
||||||
|
private boolean internal;
|
||||||
|
|
||||||
|
@Field("usrId")
|
||||||
|
private Integer usrId;
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,13 @@ public class Message {
|
|||||||
@Field("receiver")
|
@Field("receiver")
|
||||||
private String receiver;
|
private String receiver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional stable client-side ID used for idempotent retries from the mobile
|
||||||
|
* app.
|
||||||
|
*/
|
||||||
|
@Field("client_message_id")
|
||||||
|
private String clientMessageId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp when the message was created
|
* Timestamp when the message was created
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class Signature {
|
|||||||
|
|
||||||
private ObjectId taskId;
|
private ObjectId taskId;
|
||||||
private String signatureSvg;
|
private String signatureSvg;
|
||||||
|
private String note;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private String completedBy;
|
private String completedBy;
|
||||||
|
|
||||||
@@ -35,4 +36,9 @@ public class Signature {
|
|||||||
this.signatureSvg = signatureSvg;
|
this.signatureSvg = signatureSvg;
|
||||||
this.completedBy = completedBy;
|
this.completedBy = completedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Signature(ObjectId taskId, String signatureSvg, String note, String completedBy) {
|
||||||
|
this(taskId, signatureSvg, completedBy);
|
||||||
|
this.note = note;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import org.springframework.data.mongodb.core.mapping.Document;
|
|||||||
import org.springframework.data.mongodb.core.mapping.Field;
|
import org.springframework.data.mongodb.core.mapping.Field;
|
||||||
import org.springframework.data.mongodb.core.index.Indexed;
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -68,4 +69,7 @@ public class User {
|
|||||||
// Spracheinstellung (standardmäßig Deutsch)
|
// Spracheinstellung (standardmäßig Deutsch)
|
||||||
@Field("language")
|
@Field("language")
|
||||||
private Language language = Language.DE;
|
private Language language = Language.DE;
|
||||||
|
|
||||||
|
// Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %)
|
||||||
|
private BigDecimal vatRate = new BigDecimal("0.19");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
|
|||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.mongodb.core.mapping.Document;
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Document(collection = "customerInvoices")
|
@Document(collection = "customerInvoices")
|
||||||
@@ -12,6 +14,33 @@ public class CustomerInvoice {
|
|||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
|
// Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22
|
||||||
|
private InvoiceStatus status = InvoiceStatus.DRAFT;
|
||||||
|
private InvoiceType type = InvoiceType.INVOICE;
|
||||||
|
|
||||||
|
// Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30)
|
||||||
|
private String originalInvoiceId;
|
||||||
|
private String originalInvoiceNumber;
|
||||||
|
private LocalDate originalInvoiceDate;
|
||||||
|
|
||||||
|
// Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege.
|
||||||
|
private String cancellationInvoiceId;
|
||||||
|
private String correctionInvoiceId;
|
||||||
|
private String replacementInvoiceId;
|
||||||
|
|
||||||
|
// Zeitstempel für Statusübergänge
|
||||||
|
private LocalDateTime issuedAt;
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
private LocalDateTime cancelledAt;
|
||||||
|
|
||||||
|
// Änderungsprotokoll (R-36 bis R-39)
|
||||||
|
private List<InvoiceAuditEntry> auditLog = new ArrayList<>();
|
||||||
|
|
||||||
|
// Zahlungsstatus gemäß R-23 bis R-26
|
||||||
|
private PaymentStatus paymentStatus = PaymentStatus.UNPAID;
|
||||||
|
private BigDecimal paidAmount;
|
||||||
|
private LocalDateTime lastPaymentAt;
|
||||||
|
|
||||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||||
private LocalDate invoiceDate; // Rechnungsdatum
|
private LocalDate invoiceDate; // Rechnungsdatum
|
||||||
@@ -372,4 +401,134 @@ public class CustomerInvoice {
|
|||||||
public void setPdfData(byte[] pdfData) {
|
public void setPdfData(byte[] pdfData) {
|
||||||
this.pdfData = pdfData;
|
this.pdfData = pdfData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InvoiceStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(InvoiceStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(InvoiceType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOriginalInvoiceId() {
|
||||||
|
return originalInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceId(String originalInvoiceId) {
|
||||||
|
this.originalInvoiceId = originalInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOriginalInvoiceNumber() {
|
||||||
|
return originalInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceNumber(String originalInvoiceNumber) {
|
||||||
|
this.originalInvoiceNumber = originalInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getOriginalInvoiceDate() {
|
||||||
|
return originalInvoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) {
|
||||||
|
this.originalInvoiceDate = originalInvoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCancellationInvoiceId() {
|
||||||
|
return cancellationInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancellationInvoiceId(String cancellationInvoiceId) {
|
||||||
|
this.cancellationInvoiceId = cancellationInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCorrectionInvoiceId() {
|
||||||
|
return correctionInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCorrectionInvoiceId(String correctionInvoiceId) {
|
||||||
|
this.correctionInvoiceId = correctionInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReplacementInvoiceId() {
|
||||||
|
return replacementInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReplacementInvoiceId(String replacementInvoiceId) {
|
||||||
|
this.replacementInvoiceId = replacementInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getIssuedAt() {
|
||||||
|
return issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||||
|
this.issuedAt = issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getSentAt() {
|
||||||
|
return sentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSentAt(LocalDateTime sentAt) {
|
||||||
|
this.sentAt = sentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCancelledAt() {
|
||||||
|
return cancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancelledAt(LocalDateTime cancelledAt) {
|
||||||
|
this.cancelledAt = cancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<InvoiceAuditEntry> getAuditLog() {
|
||||||
|
if (auditLog == null) {
|
||||||
|
auditLog = new ArrayList<>();
|
||||||
|
}
|
||||||
|
return auditLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuditLog(List<InvoiceAuditEntry> auditLog) {
|
||||||
|
this.auditLog = auditLog != null ? auditLog : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addAuditEntry(InvoiceAuditEntry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getAuditLog().add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentStatus getPaymentStatus() {
|
||||||
|
return paymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaymentStatus(PaymentStatus paymentStatus) {
|
||||||
|
this.paymentStatus = paymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getPaidAmount() {
|
||||||
|
return paidAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaidAmount(BigDecimal paidAmount) {
|
||||||
|
this.paidAmount = paidAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastPaymentAt() {
|
||||||
|
return lastPaymentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
|
||||||
|
this.lastPaymentAt = lastPaymentAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden.
|
||||||
|
*/
|
||||||
|
public enum InvoiceAuditAction {
|
||||||
|
CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39.
|
||||||
|
*
|
||||||
|
* Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals
|
||||||
|
* geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest.
|
||||||
|
*/
|
||||||
|
public class InvoiceAuditEntry {
|
||||||
|
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
private String userId;
|
||||||
|
private String userDisplayName;
|
||||||
|
private InvoiceAuditAction action;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
/** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */
|
||||||
|
private String resultingInvoiceId;
|
||||||
|
private String resultingInvoiceNumber;
|
||||||
|
|
||||||
|
public InvoiceAuditEntry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) {
|
||||||
|
this.timestamp = LocalDateTime.now();
|
||||||
|
this.action = action;
|
||||||
|
this.userId = userId;
|
||||||
|
this.userDisplayName = userDisplayName;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(LocalDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(String userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserDisplayName() {
|
||||||
|
return userDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserDisplayName(String userDisplayName) {
|
||||||
|
this.userDisplayName = userDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceAuditAction getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAction(InvoiceAuditAction action) {
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResultingInvoiceId() {
|
||||||
|
return resultingInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResultingInvoiceId(String resultingInvoiceId) {
|
||||||
|
this.resultingInvoiceId = resultingInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResultingInvoiceNumber() {
|
||||||
|
return resultingInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
|
||||||
|
this.resultingInvoiceNumber = resultingInvoiceNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||||
|
import org.springframework.data.mongodb.core.index.CompoundIndexes;
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer.
|
||||||
|
* Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt
|
||||||
|
* werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet
|
||||||
|
* verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD.
|
||||||
|
*
|
||||||
|
* Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits-
|
||||||
|
* Index erzwingt das auch bei nebenläufigen Aufrufen.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Document(collection = "invoice_number_reservations")
|
||||||
|
@CompoundIndexes({
|
||||||
|
@CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true),
|
||||||
|
@CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}")
|
||||||
|
})
|
||||||
|
public class InvoiceNumberReservation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private ObjectId id;
|
||||||
|
|
||||||
|
@Indexed
|
||||||
|
private ObjectId userId;
|
||||||
|
|
||||||
|
/** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */
|
||||||
|
private String number;
|
||||||
|
|
||||||
|
/** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */
|
||||||
|
private long sequence;
|
||||||
|
|
||||||
|
/** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */
|
||||||
|
private String prefix;
|
||||||
|
|
||||||
|
private Instant reservedAt;
|
||||||
|
|
||||||
|
/** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */
|
||||||
|
private String reservedBy;
|
||||||
|
|
||||||
|
private InvoiceNumberReservationStatus status;
|
||||||
|
|
||||||
|
/** Bei status=USED: ID der ausgestellten Rechnung. */
|
||||||
|
private String invoiceId;
|
||||||
|
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
/** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */
|
||||||
|
private String voidReason;
|
||||||
|
|
||||||
|
private Instant voidedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status einer aus dem Nummernkreis gezogenen Rechnungsnummer.
|
||||||
|
*
|
||||||
|
* RESERVED – Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben.
|
||||||
|
* Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen
|
||||||
|
* abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige
|
||||||
|
* Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG).
|
||||||
|
* USED – Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch.
|
||||||
|
* VOIDED – Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender
|
||||||
|
* hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist
|
||||||
|
* dokumentiert und betriebsprüfungstauglich.
|
||||||
|
*/
|
||||||
|
public enum InvoiceNumberReservationStatus {
|
||||||
|
RESERVED, USED, VOIDED
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lebenszyklus einer Rechnung gemäß R-01 bis R-04.
|
||||||
|
*
|
||||||
|
* DRAFT – noch in Bearbeitung, darf editiert oder gelöscht werden.
|
||||||
|
* ISSUED – formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden.
|
||||||
|
* SENT – an den Empfänger versendet.
|
||||||
|
* CANCELLED – durch eine Stornorechnung aufgehoben.
|
||||||
|
* CORRECTED – durch eine Berichtigung formal korrigiert (Original bleibt sichtbar).
|
||||||
|
*/
|
||||||
|
public enum InvoiceStatus {
|
||||||
|
DRAFT, ISSUED, SENT, CANCELLED, CORRECTED;
|
||||||
|
|
||||||
|
public boolean isFinalized() {
|
||||||
|
return this != DRAFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMutable() {
|
||||||
|
return this == DRAFT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff.
|
||||||
|
*
|
||||||
|
* INVOICE – reguläre Ausgangsrechnung.
|
||||||
|
* CORRECTION – Rechnungsberichtigung für formale Fehler (R-12 bis R-16).
|
||||||
|
* Verweist eindeutig auf die zu korrigierende Originalrechnung.
|
||||||
|
* CANCELLATION – Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22).
|
||||||
|
* Verweist eindeutig auf die zu stornierende Originalrechnung.
|
||||||
|
*/
|
||||||
|
public enum InvoiceType {
|
||||||
|
INVOICE, CORRECTION, CANCELLATION
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahlungsstatus einer Rechnung gemäß R-23 bis R-26.
|
||||||
|
*
|
||||||
|
* UNPAID – noch nicht bezahlt.
|
||||||
|
* PARTIALLY_PAID – Teilzahlung erhalten, Restbetrag offen.
|
||||||
|
* PAID – vollständig bezahlt.
|
||||||
|
* OVERPAID – Zahlbetrag übersteigt den Rechnungsbetrag.
|
||||||
|
* REFUND_DUE – Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung).
|
||||||
|
*/
|
||||||
|
public enum PaymentStatus {
|
||||||
|
UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
package de.assecutor.votianlt.model.task;
|
package de.assecutor.votianlt.model.task;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Transient;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
public class SignatureTask extends BaseTask {
|
public class SignatureTask extends BaseTask {
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
@JsonIgnore
|
||||||
|
private String note;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getTaskType() {
|
public String getTaskType() {
|
||||||
return "SIGNATURE";
|
return "SIGNATURE";
|
||||||
@@ -21,11 +27,17 @@ public class SignatureTask extends BaseTask {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object getTaskSpecificData() {
|
public Object getTaskSpecificData() {
|
||||||
return new TaskSpecificData();
|
return new TaskSpecificData(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
public class TaskSpecificData {
|
public class TaskSpecificData {
|
||||||
public String taskType = getTaskType();
|
public String taskType = getTaskType();
|
||||||
// No specific data for signature task
|
public String note;
|
||||||
|
|
||||||
|
public TaskSpecificData(String note) {
|
||||||
|
this.note = note;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.assecutor.votianlt.pages.base.ui.component;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.Customer;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class CustomerAddressLabelHelper {
|
||||||
|
|
||||||
|
private CustomerAddressLabelHelper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void putUnique(Map<String, Customer> target, Customer customer, String fallbackLabel) {
|
||||||
|
String label = build(customer, fallbackLabel);
|
||||||
|
String uniqueLabel = label;
|
||||||
|
int counter = 2;
|
||||||
|
while (target.containsKey(uniqueLabel)) {
|
||||||
|
uniqueLabel = label + " (" + counter++ + ")";
|
||||||
|
}
|
||||||
|
target.put(uniqueLabel, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String build(Customer customer, String fallbackLabel) {
|
||||||
|
if (customer == null) {
|
||||||
|
return fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
String companyName = trimToNull(customer.getCompanyName());
|
||||||
|
if (companyName != null) {
|
||||||
|
return companyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fullName = trimToNull(join(" ", customer.getFirstname(), customer.getLastName()));
|
||||||
|
return fullName != null ? fullName : fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) {
|
||||||
|
if (addressOptions.containsKey(comboValue)) {
|
||||||
|
Customer customer = addressOptions.get(comboValue);
|
||||||
|
return customer != null ? customer.getCompanyName() : null;
|
||||||
|
}
|
||||||
|
return comboValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String join(String separator, String first, String second) {
|
||||||
|
String left = first != null ? first.trim() : "";
|
||||||
|
String right = second != null ? second.trim() : "";
|
||||||
|
return (left + separator + right).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import com.vaadin.flow.component.button.Button;
|
|||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.checkbox.Checkbox;
|
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.dnd.DragSource;
|
||||||
|
import com.vaadin.flow.component.dnd.DropEffect;
|
||||||
|
import com.vaadin.flow.component.dnd.DropTarget;
|
||||||
|
import com.vaadin.flow.component.dnd.EffectAllowed;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.component.html.H3;
|
import com.vaadin.flow.component.html.H3;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
@@ -53,10 +56,28 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
private String zip;
|
private String zip;
|
||||||
private String city;
|
private String city;
|
||||||
private boolean saveAddress;
|
private boolean saveAddress;
|
||||||
|
private boolean addressDiffersFromCustomer;
|
||||||
|
private org.bson.types.ObjectId customerId;
|
||||||
|
|
||||||
|
public boolean isAddressDiffersFromCustomer() {
|
||||||
|
return addressDiffersFromCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
|
||||||
|
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
|
||||||
|
}
|
||||||
private List<BaseTask> tasks = new ArrayList<>();
|
private List<BaseTask> tasks = new ArrayList<>();
|
||||||
private boolean addressValidatedByGoogle;
|
private boolean addressValidatedByGoogle;
|
||||||
private AddressValidationResult addressValidationResult;
|
private AddressValidationResult addressValidationResult;
|
||||||
|
|
||||||
|
public org.bson.types.ObjectId getCustomerId() {
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerId(org.bson.types.ObjectId customerId) {
|
||||||
|
this.customerId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAddressValidatedByGoogle() {
|
public boolean isAddressValidatedByGoogle() {
|
||||||
return addressValidatedByGoogle;
|
return addressValidatedByGoogle;
|
||||||
}
|
}
|
||||||
@@ -204,12 +225,14 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
|
|
||||||
private final List<BaseTask> tasksState = new ArrayList<>();
|
private final List<BaseTask> tasksState = new ArrayList<>();
|
||||||
private VerticalLayout tasksList;
|
private VerticalLayout tasksList;
|
||||||
|
private VerticalLayout draggedTaskContainer;
|
||||||
|
|
||||||
private Span addressTabError;
|
private Span addressTabError;
|
||||||
private Span tasksTabError;
|
private Span tasksTabError;
|
||||||
|
|
||||||
private final DeliveryStationTile.TranslationHelper translationHelper;
|
private final DeliveryStationTile.TranslationHelper translationHelper;
|
||||||
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
|
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
|
||||||
|
private org.bson.types.ObjectId selectedCustomerId;
|
||||||
|
|
||||||
public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
|
public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
|
||||||
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
|
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
|
||||||
@@ -231,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
formLayout.setSpacing(true);
|
formLayout.setSpacing(true);
|
||||||
formLayout.setWidthFull();
|
formLayout.setWidthFull();
|
||||||
|
|
||||||
// Company with autocomplete
|
// Delivery address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers);
|
setupCompanyAutocomplete(company, customers);
|
||||||
@@ -367,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
addressTabError = createTabErrorIndicator();
|
addressTabError = createTabErrorIndicator();
|
||||||
tasksTabError = createTabErrorIndicator();
|
tasksTabError = createTabErrorIndicator();
|
||||||
|
|
||||||
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
|
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.delivery.address"), formLayout);
|
||||||
addressTab.add(addressTabError);
|
addressTab.add(addressTabError);
|
||||||
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
|
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
|
||||||
createTasksTab(templates, templateSaveCallback));
|
createTasksTab(templates, templateSaveCallback));
|
||||||
@@ -441,25 +464,21 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
close();
|
close();
|
||||||
} else {
|
} else {
|
||||||
// Adresse nicht gefunden: Benutzer fragen
|
// Adresse nicht gefunden: Benutzer fragen
|
||||||
ConfirmDialog confirmDialog = new ConfirmDialog();
|
Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
confirmDialog.setHeader(
|
translationHelper.getTranslation("addjob.validation.address.not.found.title"),
|
||||||
translationHelper.getTranslation("addjob.validation.address.not.found.title"));
|
translationHelper.getTranslation("addjob.validation.address.not.found.message"),
|
||||||
confirmDialog.setText(
|
"560px",
|
||||||
translationHelper.getTranslation("addjob.validation.address.not.found.message"));
|
translationHelper.getTranslation("addjob.validation.address.correct"),
|
||||||
confirmDialog.setConfirmText(
|
translationHelper.getTranslation("addjob.validation.address.save.anyway"),
|
||||||
translationHelper.getTranslation("addjob.validation.address.save.anyway"));
|
() -> {
|
||||||
confirmDialog.setConfirmButtonTheme("primary");
|
data.setAddressValidatedByGoogle(false);
|
||||||
confirmDialog.setCancelable(true);
|
data.setAddressValidationResult(validationResult);
|
||||||
confirmDialog.setCancelText(
|
if (saveListener != null) {
|
||||||
translationHelper.getTranslation("addjob.validation.address.correct"));
|
saveListener.onSave(data);
|
||||||
confirmDialog.addConfirmListener(ev -> {
|
}
|
||||||
data.setAddressValidatedByGoogle(false);
|
close();
|
||||||
data.setAddressValidationResult(validationResult);
|
},
|
||||||
if (saveListener != null) {
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
saveListener.onSave(data);
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
confirmDialog.open();
|
confirmDialog.open();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -512,6 +531,13 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
zip.setValue(data.getZip());
|
zip.setValue(data.getZip());
|
||||||
if (data.getCity() != null)
|
if (data.getCity() != null)
|
||||||
city.setValue(data.getCity());
|
city.setValue(data.getCity());
|
||||||
|
selectedCustomerId = data.getCustomerId();
|
||||||
|
if (selectedCustomerId == null && customerSelectedFromOptions) {
|
||||||
|
Customer matched = companyAddressOptions.get(companyOption);
|
||||||
|
if (matched != null) {
|
||||||
|
selectedCustomerId = matched.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
|
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
|
|
||||||
@@ -548,10 +574,43 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
data.setZip(zip.getValue());
|
data.setZip(zip.getValue());
|
||||||
data.setCity(city.getValue());
|
data.setCity(city.getValue());
|
||||||
data.setSaveAddress(saveAddress.getValue());
|
data.setSaveAddress(saveAddress.getValue());
|
||||||
|
data.setCustomerId(selectedCustomerId);
|
||||||
|
data.setAddressDiffersFromCustomer(computeAddressDiffers());
|
||||||
data.setTasks(new ArrayList<>(tasksState));
|
data.setTasks(new ArrayList<>(tasksState));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean computeAddressDiffers() {
|
||||||
|
boolean hasAnyValue = !isBlank(resolveCompanyValue(company.getValue())) || !isBlank(firstName.getValue())
|
||||||
|
|| !isBlank(lastName.getValue()) || !isBlank(phone.getValue()) || !isBlank(mail.getValue())
|
||||||
|
|| !isBlank(street.getValue()) || !isBlank(houseNumber.getValue())
|
||||||
|
|| !isBlank(addressAddition.getValue()) || !isBlank(zip.getValue()) || !isBlank(city.getValue());
|
||||||
|
if (!hasAnyValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedCustomerId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Customer linked = findCustomerById(selectedCustomerId);
|
||||||
|
return linked == null || !matchesCurrentCustomer(linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Customer findCustomerById(org.bson.types.ObjectId id) {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Customer c : companyAddressOptions.values()) {
|
||||||
|
if (c != null && id.equals(c.getId())) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String value) {
|
||||||
|
return value == null || value.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean validateRequiredFields() {
|
private boolean validateRequiredFields() {
|
||||||
// Address tab validation
|
// Address tab validation
|
||||||
boolean addressValid = true;
|
boolean addressValid = true;
|
||||||
@@ -601,11 +660,9 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
String value = mail.getValue();
|
String value = mail.getValue();
|
||||||
String normalizedValue = value == null ? "" : value.trim();
|
String normalizedValue = value == null ? "" : value.trim();
|
||||||
boolean empty = normalizedValue.isEmpty();
|
boolean empty = normalizedValue.isEmpty();
|
||||||
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
|
|
||||||
boolean invalid = !empty && !normalizedValue.contains("@");
|
boolean invalid = !empty && !normalizedValue.contains("@");
|
||||||
boolean hasError = invalid || (required && empty);
|
applyErrorStyling(mail, invalid);
|
||||||
applyErrorStyling(mail, hasError);
|
return !invalid;
|
||||||
return !hasError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) {
|
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) {
|
||||||
@@ -630,17 +687,8 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
||||||
companyAddressOptions.clear();
|
companyAddressOptions.clear();
|
||||||
for (Customer customer : customers) {
|
for (Customer customer : customers) {
|
||||||
String label = buildCompanyAddressLabel(customer);
|
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
|
||||||
if (label == null) {
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (companyAddressOptions.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
companyAddressOptions.put(uniqueLabel, customer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
||||||
@@ -648,10 +696,12 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
Customer customer = companyAddressOptions.get(event.getValue());
|
Customer customer = companyAddressOptions.get(event.getValue());
|
||||||
if (customer == null) {
|
if (customer == null) {
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedCustomerId = customer.getId();
|
||||||
if (customer.getTitle() != null
|
if (customer.getTitle() != null
|
||||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||||
@@ -680,74 +730,38 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
|
|
||||||
companyField.addCustomValueSetListener(event -> {
|
companyField.addCustomValueSetListener(event -> {
|
||||||
companyField.setValue(event.getDetail());
|
companyField.setValue(event.getDetail());
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateSaveAddressState() {
|
private void updateSaveAddressState() {
|
||||||
Customer selectedCustomer = companyAddressOptions.get(company.getValue());
|
Customer selectedCustomer = companyAddressOptions.get(company.getValue());
|
||||||
boolean customerSelectedFromOptions = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
|
boolean customerDataMatches = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
|
||||||
|
|
||||||
if (customerSelectedFromOptions) {
|
if (customerDataMatches) {
|
||||||
saveAddress.setValue(false);
|
saveAddress.setValue(false);
|
||||||
saveAddress.setEnabled(false);
|
saveAddress.setEnabled(false);
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
|
||||||
updateMailRequirement();
|
updateMailRequirement();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAddress.setEnabled(true);
|
saveAddress.setEnabled(true);
|
||||||
|
if (selectedCustomerId != null) {
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
|
||||||
|
} else {
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
|
||||||
|
}
|
||||||
updateMailRequirement();
|
updateMailRequirement();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateMailRequirement() {
|
private void updateMailRequirement() {
|
||||||
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
|
mail.setRequiredIndicatorVisible(false);
|
||||||
}
|
|
||||||
|
|
||||||
private String buildCompanyAddressLabel(Customer customer) {
|
|
||||||
if (customer == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> leftParts = new ArrayList<>();
|
|
||||||
if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
|
||||||
leftParts.add(customer.getCompanyName().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
String fullName = ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
|
|
||||||
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim();
|
|
||||||
if (!fullName.isBlank()) {
|
|
||||||
leftParts.add(fullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> rightParts = new ArrayList<>();
|
|
||||||
String streetLine = ((customer.getStreet() != null ? customer.getStreet() : "") + " "
|
|
||||||
+ (customer.getHouseNumber() != null ? customer.getHouseNumber() : "")).trim();
|
|
||||||
if (!streetLine.isBlank()) {
|
|
||||||
rightParts.add(streetLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
String cityLine = ((customer.getZip() != null ? customer.getZip() : "") + " "
|
|
||||||
+ (customer.getCity() != null ? customer.getCity() : "")).trim();
|
|
||||||
if (!cityLine.isBlank()) {
|
|
||||||
rightParts.add(cityLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
String left = String.join(" | ", leftParts);
|
|
||||||
String right = String.join(", ", rightParts);
|
|
||||||
String label = left;
|
|
||||||
if (!right.isBlank()) {
|
|
||||||
label = label.isBlank() ? right : left + " | " + right;
|
|
||||||
}
|
|
||||||
|
|
||||||
return label.isBlank() ? null : label;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveCompanyValue(String comboValue) {
|
private String resolveCompanyValue(String comboValue) {
|
||||||
Customer customer = companyAddressOptions.get(comboValue);
|
return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
|
||||||
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
|
||||||
return customer.getCompanyName();
|
|
||||||
}
|
|
||||||
return comboValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String findCompanyOptionLabel(DeliveryData data) {
|
private String findCompanyOptionLabel(DeliveryData data) {
|
||||||
@@ -871,6 +885,15 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
taskContainer.setSpacing(true);
|
taskContainer.setSpacing(true);
|
||||||
taskContainer.addClassName("dialog-task-card");
|
taskContainer.addClassName("dialog-task-card");
|
||||||
|
|
||||||
|
// Drag handle
|
||||||
|
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
|
||||||
|
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
dragHandle.addClassName("dialog-task-drag-handle");
|
||||||
|
|
||||||
|
// Compact summary shown during drag
|
||||||
|
HorizontalLayout summaryRow = createDragSummary("", "");
|
||||||
|
summaryRow.addClassName("dialog-task-summary");
|
||||||
|
|
||||||
// Task type selection
|
// Task type selection
|
||||||
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
|
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
|
||||||
taskTypeCombo.setItems(TaskType.values());
|
taskTypeCombo.setItems(TaskType.values());
|
||||||
@@ -882,6 +905,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
VerticalLayout configContainer = new VerticalLayout();
|
VerticalLayout configContainer = new VerticalLayout();
|
||||||
configContainer.setPadding(false);
|
configContainer.setPadding(false);
|
||||||
configContainer.setSpacing(true);
|
configContainer.setSpacing(true);
|
||||||
|
configContainer.addClassName("dialog-task-config");
|
||||||
|
|
||||||
// Red X button positioned in top-right corner
|
// Red X button positioned in top-right corner
|
||||||
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
|
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
|
||||||
@@ -889,8 +913,14 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
deleteXButton.addClassName("dialog-floating-delete");
|
deleteXButton.addClassName("dialog-floating-delete");
|
||||||
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
|
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
|
||||||
|
|
||||||
taskContainer.add(taskTypeCombo, configContainer);
|
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
|
||||||
taskContainer.add(deleteXButton);
|
headerRow.setAlignItems(FlexComponent.Alignment.START);
|
||||||
|
headerRow.setWidthFull();
|
||||||
|
headerRow.setFlexGrow(1, taskTypeCombo);
|
||||||
|
|
||||||
|
taskContainer.add(headerRow, configContainer);
|
||||||
|
|
||||||
|
setupDragAndDrop(taskContainer);
|
||||||
|
|
||||||
// Create Task and add to state with correct order
|
// Create Task and add to state with correct order
|
||||||
BaseTask task = new ConfirmationTask("");
|
BaseTask task = new ConfirmationTask("");
|
||||||
@@ -901,6 +931,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
|
|
||||||
taskTypeCombo.setValue(TaskType.CONFIRMATION);
|
taskTypeCombo.setValue(TaskType.CONFIRMATION);
|
||||||
updateTaskConfiguration(configContainer, currentTask[0]);
|
updateTaskConfiguration(configContainer, currentTask[0]);
|
||||||
|
updateDragSummary(summaryRow, TaskType.CONFIRMATION, task);
|
||||||
|
|
||||||
taskTypeCombo.addValueChangeListener(ev -> {
|
taskTypeCombo.addValueChangeListener(ev -> {
|
||||||
TaskType selectedType = ev.getValue();
|
TaskType selectedType = ev.getValue();
|
||||||
@@ -945,6 +976,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTaskConfiguration(configContainer, newTask);
|
updateTaskConfiguration(configContainer, newTask);
|
||||||
|
updateDragSummary(summaryRow, selectedType, newTask);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -958,6 +990,18 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
taskContainer.setSpacing(true);
|
taskContainer.setSpacing(true);
|
||||||
taskContainer.addClassName("dialog-task-card");
|
taskContainer.addClassName("dialog-task-card");
|
||||||
|
|
||||||
|
// Drag handle
|
||||||
|
Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL));
|
||||||
|
dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
dragHandle.addClassName("dialog-task-drag-handle");
|
||||||
|
|
||||||
|
// Compact summary shown during drag
|
||||||
|
TaskType initialTaskType = getTaskTypeFromTask(task);
|
||||||
|
HorizontalLayout summaryRow = createDragSummary(
|
||||||
|
initialTaskType != null ? initialTaskType.getDisplayName() : "",
|
||||||
|
task.getDescription() != null ? task.getDescription() : "");
|
||||||
|
summaryRow.addClassName("dialog-task-summary");
|
||||||
|
|
||||||
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
|
ComboBox<TaskType> taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype"));
|
||||||
taskTypeCombo.setItems(TaskType.values());
|
taskTypeCombo.setItems(TaskType.values());
|
||||||
taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName);
|
taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName);
|
||||||
@@ -967,21 +1011,27 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
VerticalLayout configContainer = new VerticalLayout();
|
VerticalLayout configContainer = new VerticalLayout();
|
||||||
configContainer.setPadding(false);
|
configContainer.setPadding(false);
|
||||||
configContainer.setSpacing(true);
|
configContainer.setSpacing(true);
|
||||||
|
configContainer.addClassName("dialog-task-config");
|
||||||
|
|
||||||
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
|
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
|
||||||
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
||||||
deleteXButton.addClassName("dialog-floating-delete");
|
deleteXButton.addClassName("dialog-floating-delete");
|
||||||
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
|
deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
|
||||||
|
|
||||||
taskContainer.add(taskTypeCombo, configContainer);
|
HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton);
|
||||||
taskContainer.add(deleteXButton);
|
headerRow.setAlignItems(FlexComponent.Alignment.START);
|
||||||
|
headerRow.setWidthFull();
|
||||||
|
headerRow.setFlexGrow(1, taskTypeCombo);
|
||||||
|
|
||||||
|
taskContainer.add(headerRow, configContainer);
|
||||||
|
|
||||||
|
setupDragAndDrop(taskContainer);
|
||||||
|
|
||||||
final BaseTask[] currentTask = { task };
|
final BaseTask[] currentTask = { task };
|
||||||
|
|
||||||
// Set the combo value BEFORE registering the listener
|
// Set the combo value BEFORE registering the listener
|
||||||
TaskType taskType = getTaskTypeFromTask(task);
|
if (initialTaskType != null) {
|
||||||
if (taskType != null) {
|
taskTypeCombo.setValue(initialTaskType);
|
||||||
taskTypeCombo.setValue(taskType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the listener for user-initiated type changes only
|
// Register the listener for user-initiated type changes only
|
||||||
@@ -1026,11 +1076,13 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTaskConfiguration(configContainer, newTask);
|
updateTaskConfiguration(configContainer, newTask);
|
||||||
|
updateDragSummary(summaryRow, selectedType, newTask);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render the UI with the loaded task
|
// Render the UI with the loaded task
|
||||||
updateTaskConfiguration(configContainer, task);
|
updateTaskConfiguration(configContainer, task);
|
||||||
|
updateDragSummary(summaryRow, initialTaskType, task);
|
||||||
|
|
||||||
tasksList.add(taskContainer);
|
tasksList.add(taskContainer);
|
||||||
updateTaskDeleteAvailability();
|
updateTaskDeleteAvailability();
|
||||||
@@ -1047,6 +1099,160 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout createDragSummary(String typeName, String description) {
|
||||||
|
Span typeLabel = new Span(typeName);
|
||||||
|
typeLabel.addClassName("task-type-label");
|
||||||
|
Span descLabel = new Span(description != null && !description.isBlank() ? " — " + description : "");
|
||||||
|
descLabel.addClassName("task-desc-label");
|
||||||
|
HorizontalLayout layout = new HorizontalLayout(typeLabel, descLabel);
|
||||||
|
layout.setSpacing(false);
|
||||||
|
layout.setPadding(false);
|
||||||
|
layout.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDragSummary(HorizontalLayout summaryRow, TaskType taskType, BaseTask task) {
|
||||||
|
summaryRow.getChildren()
|
||||||
|
.filter(Span.class::isInstance)
|
||||||
|
.map(Span.class::cast)
|
||||||
|
.forEach(span -> {
|
||||||
|
if (span.getClassNames().contains("task-type-label")) {
|
||||||
|
span.setText(taskType != null ? taskType.getDisplayName() : "");
|
||||||
|
} else if (span.getClassNames().contains("task-desc-label")) {
|
||||||
|
String desc = task.getDescription();
|
||||||
|
span.setText(desc != null && !desc.isBlank() ? " — " + desc : "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearAllDropIndicators() {
|
||||||
|
if (tasksList == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tasksList.removeClassName("tasks-reordering");
|
||||||
|
tasksList.getChildren()
|
||||||
|
.filter(VerticalLayout.class::isInstance)
|
||||||
|
.map(VerticalLayout.class::cast)
|
||||||
|
.forEach(c -> {
|
||||||
|
c.removeClassName("drag-over-top");
|
||||||
|
c.removeClassName("drag-over-bottom");
|
||||||
|
c.removeClassName("dragging");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupDragAndDrop(VerticalLayout taskContainer) {
|
||||||
|
DragSource<VerticalLayout> dragSource = DragSource.create(taskContainer);
|
||||||
|
dragSource.setEffectAllowed(EffectAllowed.MOVE);
|
||||||
|
dragSource.addDragStartListener(e -> {
|
||||||
|
draggedTaskContainer = taskContainer;
|
||||||
|
taskContainer.addClassName("dragging");
|
||||||
|
if (tasksList != null) {
|
||||||
|
// Update all summaries with latest description values before compressing
|
||||||
|
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
|
||||||
|
for (int i = 0; i < rows.size() && i < tasksState.size(); i++) {
|
||||||
|
if (rows.get(i) instanceof VerticalLayout row) {
|
||||||
|
row.getChildren()
|
||||||
|
.filter(HorizontalLayout.class::isInstance)
|
||||||
|
.map(HorizontalLayout.class::cast)
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(headerRow -> headerRow.getChildren()
|
||||||
|
.filter(HorizontalLayout.class::isInstance)
|
||||||
|
.map(HorizontalLayout.class::cast)
|
||||||
|
.filter(c -> c.getClassNames().contains("dialog-task-summary"))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(summary -> {
|
||||||
|
int idx = rows.indexOf(row);
|
||||||
|
if (idx >= 0 && idx < tasksState.size()) {
|
||||||
|
BaseTask t = tasksState.get(idx);
|
||||||
|
TaskType tt = getTaskTypeFromTask(t);
|
||||||
|
updateDragSummary(summary, tt, t);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasksList.addClassName("tasks-reordering");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dragSource.addDragEndListener(e -> {
|
||||||
|
draggedTaskContainer = null;
|
||||||
|
clearAllDropIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
DropTarget<VerticalLayout> dropTarget = DropTarget.create(taskContainer);
|
||||||
|
dropTarget.setDropEffect(DropEffect.MOVE);
|
||||||
|
dropTarget.addDropListener(e -> {
|
||||||
|
if (draggedTaskContainer != null && draggedTaskContainer != taskContainer) {
|
||||||
|
moveTaskRow(draggedTaskContainer, taskContainer);
|
||||||
|
}
|
||||||
|
clearAllDropIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client-side drag events with position detection and no-op suppression
|
||||||
|
taskContainer.getElement().executeJs(
|
||||||
|
"const el = this;"
|
||||||
|
+ "function clearIndicators() {"
|
||||||
|
+ " var p = el.parentElement;"
|
||||||
|
+ " if (p) p.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach("
|
||||||
|
+ " function(c) { c.classList.remove('drag-over-top', 'drag-over-bottom'); });"
|
||||||
|
+ "}"
|
||||||
|
+ "function getSiblings() {"
|
||||||
|
+ " return Array.from(el.parentElement.children).filter("
|
||||||
|
+ " function(c) { return c.classList.contains('dialog-task-card'); });"
|
||||||
|
+ "}"
|
||||||
|
+ "el.addEventListener('dragstart', function() {"
|
||||||
|
+ " el.parentElement.__draggedEl = el;"
|
||||||
|
+ "});"
|
||||||
|
+ "el.addEventListener('dragend', function() {"
|
||||||
|
+ " el.parentElement.__draggedEl = null;"
|
||||||
|
+ "});"
|
||||||
|
+ "el.addEventListener('dragover', function(e) {"
|
||||||
|
+ " e.preventDefault();"
|
||||||
|
+ " var dragged = el.parentElement.__draggedEl;"
|
||||||
|
+ " if (!dragged || dragged === el) { clearIndicators(); return; }"
|
||||||
|
+ " var cards = getSiblings();"
|
||||||
|
+ " var dragIdx = cards.indexOf(dragged);"
|
||||||
|
+ " var myIdx = cards.indexOf(el);"
|
||||||
|
+ " clearIndicators();"
|
||||||
|
+ " var rect = el.getBoundingClientRect();"
|
||||||
|
+ " var midY = rect.top + rect.height / 2;"
|
||||||
|
+ " if (e.clientY < midY) {"
|
||||||
|
+ " if (myIdx !== dragIdx + 1) el.classList.add('drag-over-top');"
|
||||||
|
+ " } else {"
|
||||||
|
+ " if (myIdx !== dragIdx - 1) el.classList.add('drag-over-bottom');"
|
||||||
|
+ " }"
|
||||||
|
+ "});"
|
||||||
|
+ "el.addEventListener('dragleave', function() {"
|
||||||
|
+ " el.classList.remove('drag-over-top', 'drag-over-bottom');"
|
||||||
|
+ "});"
|
||||||
|
+ "el.addEventListener('drop', function() {"
|
||||||
|
+ " clearIndicators();"
|
||||||
|
+ "});");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void moveTaskRow(VerticalLayout source, VerticalLayout target) {
|
||||||
|
List<com.vaadin.flow.component.Component> rows = tasksList.getChildren().toList();
|
||||||
|
int fromIndex = rows.indexOf(source);
|
||||||
|
int toIndex = rows.indexOf(target);
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder tasksState
|
||||||
|
BaseTask movedTask = tasksState.remove(fromIndex);
|
||||||
|
tasksState.add(toIndex, movedTask);
|
||||||
|
|
||||||
|
// Reorder UI: remove all, re-add in new order
|
||||||
|
List<com.vaadin.flow.component.Component> rowList = new ArrayList<>(rows);
|
||||||
|
com.vaadin.flow.component.Component movedRow = rowList.remove(fromIndex);
|
||||||
|
rowList.add(toIndex, movedRow);
|
||||||
|
|
||||||
|
tasksList.removeAll();
|
||||||
|
rowList.forEach(tasksList::add);
|
||||||
|
|
||||||
|
// Update taskOrder values
|
||||||
|
reorderTasksAfterDeletion();
|
||||||
|
}
|
||||||
|
|
||||||
private void reorderTasksAfterDeletion() {
|
private void reorderTasksAfterDeletion() {
|
||||||
for (int i = 0; i < tasksState.size(); i++) {
|
for (int i = 0; i < tasksState.size(); i++) {
|
||||||
BaseTask task = tasksState.get(i);
|
BaseTask task = tasksState.get(i);
|
||||||
@@ -1099,11 +1305,15 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
.filter(VerticalLayout.class::isInstance)
|
.filter(VerticalLayout.class::isInstance)
|
||||||
.map(VerticalLayout.class::cast)
|
.map(VerticalLayout.class::cast)
|
||||||
.forEach(taskContainer -> taskContainer.getChildren()
|
.forEach(taskContainer -> taskContainer.getChildren()
|
||||||
.filter(Button.class::isInstance)
|
.filter(HorizontalLayout.class::isInstance)
|
||||||
.map(Button.class::cast)
|
.map(HorizontalLayout.class::cast)
|
||||||
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
|
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.ifPresent(button -> button.setEnabled(deletable)));
|
.ifPresent(headerRow -> headerRow.getChildren()
|
||||||
|
.filter(Button.class::isInstance)
|
||||||
|
.map(Button.class::cast)
|
||||||
|
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(button -> button.setEnabled(deletable))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
|
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
|
||||||
@@ -1147,10 +1357,14 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case SIGNATURE:
|
case SIGNATURE:
|
||||||
Span info = new Span(translationHelper.getTranslation("addjob.tasks.signature.noconfig"));
|
TextField signatureNoteField = new TextField(
|
||||||
info.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
translationHelper.getTranslation("addjob.tasks.signature.notelabel"));
|
||||||
info.getStyle().set("font-style", "italic");
|
signatureNoteField.setPlaceholder(
|
||||||
configContainer.add(info);
|
translationHelper.getTranslation("addjob.tasks.signature.notelabel.placeholder"));
|
||||||
|
signatureNoteField.setWidthFull();
|
||||||
|
signatureNoteField.setValue(task.getDescription() != null ? task.getDescription() : "");
|
||||||
|
signatureNoteField.addValueChangeListener(ev -> task.setDescription(ev.getValue()));
|
||||||
|
configContainer.add(signatureNoteField);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TODOLIST:
|
case TODOLIST:
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.model.DeliveryStation;
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A self-contained tile for one delivery station in the AddJob form. Contains
|
* A self-contained tile for one delivery station in the AddJob form. Contains
|
||||||
@@ -51,6 +53,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
private final TextField city;
|
private final TextField city;
|
||||||
private final Checkbox saveAddress;
|
private final Checkbox saveAddress;
|
||||||
private final H3 title;
|
private final H3 title;
|
||||||
|
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
private ChangeListener changeListener;
|
private ChangeListener changeListener;
|
||||||
private DeleteListener deleteListener;
|
private DeleteListener deleteListener;
|
||||||
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
|
|
||||||
add(titleLayout);
|
add(titleLayout);
|
||||||
|
|
||||||
// Company with autocomplete
|
// Delivery address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers, translationHelper);
|
setupCompanyAutocomplete(company, customers, translationHelper);
|
||||||
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
|
|
||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
||||||
TranslationHelper translationHelper) {
|
TranslationHelper translationHelper) {
|
||||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
companyAddressOptions.clear();
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
for (Customer customer : customers) {
|
||||||
|
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
|
||||||
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
|
}
|
||||||
|
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
||||||
|
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Customer> matchingCustomer = customers.stream()
|
Customer customer = companyAddressOptions.get(selectedAddress);
|
||||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
if (customer != null) {
|
||||||
|
|
||||||
if (matchingCustomer.isPresent()) {
|
|
||||||
Customer customer = matchingCustomer.get();
|
|
||||||
if (customer.getTitle() != null
|
if (customer.getTitle() != null
|
||||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||||
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
*/
|
*/
|
||||||
public DeliveryStation getDeliveryStation() {
|
public DeliveryStation getDeliveryStation() {
|
||||||
DeliveryStation station = new DeliveryStation();
|
DeliveryStation station = new DeliveryStation();
|
||||||
station.setCompany(company.getValue());
|
station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
|
||||||
station.setSalutation(salutation.getValue());
|
station.setSalutation(salutation.getValue());
|
||||||
station.setFirstName(firstName.getValue());
|
station.setFirstName(firstName.getValue());
|
||||||
station.setLastName(lastName.getValue());
|
station.setLastName(lastName.getValue());
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package de.assecutor.votianlt.pages.base.ui.component;
|
package de.assecutor.votianlt.pages.base.ui.component;
|
||||||
|
|
||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
|
||||||
@@ -20,6 +23,33 @@ public final class DialogStylingHelper {
|
|||||||
return dialog;
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Dialog createConfirmationDialog(String title, String message, String width, String cancelText,
|
||||||
|
String confirmText, Runnable onConfirm, ButtonVariant... confirmVariants) {
|
||||||
|
Dialog dialog = createStyledDialog(title, width);
|
||||||
|
dialog.setCloseOnEsc(true);
|
||||||
|
dialog.setCloseOnOutsideClick(true);
|
||||||
|
|
||||||
|
VerticalLayout dialogContent = createContentLayout("320px");
|
||||||
|
if (message != null && !message.isBlank()) {
|
||||||
|
dialogContent.add(new Span(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
Button cancelButton = new Button(cancelText, event -> dialog.close());
|
||||||
|
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmButton = new Button(confirmText, event -> {
|
||||||
|
dialog.close();
|
||||||
|
onConfirm.run();
|
||||||
|
});
|
||||||
|
if (confirmVariants != null && confirmVariants.length > 0) {
|
||||||
|
confirmButton.addThemeVariants(confirmVariants);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.add(wrapContent(dialogContent));
|
||||||
|
dialog.getFooter().add(cancelButton, confirmButton);
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
public static void apply(Dialog dialog, String title, String width) {
|
public static void apply(Dialog dialog, String title, String width) {
|
||||||
if (title != null && !title.isBlank()) {
|
if (title != null && !title.isBlank()) {
|
||||||
dialog.setHeaderTitle(title);
|
dialog.setHeaderTitle(title);
|
||||||
@@ -32,16 +62,40 @@ public final class DialogStylingHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Component wrapContent(Component content) {
|
public static Component wrapContent(Component content) {
|
||||||
|
return wrapContent(content, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Component wrapContent(Component content, boolean fillHeight) {
|
||||||
Div frame = new Div();
|
Div frame = new Div();
|
||||||
frame.getStyle().set("border", "10px solid transparent");
|
frame.getStyle().set("border", "10px solid transparent");
|
||||||
frame.getStyle().set("border-radius", "0");
|
frame.getStyle().set("border-radius", "0");
|
||||||
frame.getStyle().set("box-sizing", "border-box");
|
frame.getStyle().set("box-sizing", "border-box");
|
||||||
|
if (fillHeight) {
|
||||||
|
frame.getStyle().set("display", "flex");
|
||||||
|
frame.getStyle().set("flex-direction", "column");
|
||||||
|
frame.getStyle().set("height", "100%");
|
||||||
|
frame.getStyle().set("min-height", "0");
|
||||||
|
frame.getStyle().set("flex", "1");
|
||||||
|
}
|
||||||
frame.setWidthFull();
|
frame.setWidthFull();
|
||||||
|
|
||||||
Div whiteCard = new Div();
|
Div whiteCard = new Div();
|
||||||
whiteCard.getStyle().set("background", "white");
|
whiteCard.getStyle().set("background", "white");
|
||||||
whiteCard.getStyle().set("border-radius", "24px");
|
whiteCard.getStyle().set("border-radius", "24px");
|
||||||
whiteCard.getStyle().set("overflow", "auto");
|
if (fillHeight) {
|
||||||
|
whiteCard.getStyle().set("display", "flex");
|
||||||
|
whiteCard.getStyle().set("flex-direction", "column");
|
||||||
|
whiteCard.getStyle().set("height", "100%");
|
||||||
|
whiteCard.getStyle().set("min-height", "0");
|
||||||
|
whiteCard.getStyle().set("flex", "1");
|
||||||
|
whiteCard.getStyle().set("overflow", "hidden");
|
||||||
|
content.getElement().getStyle().set("width", "100%");
|
||||||
|
content.getElement().getStyle().set("height", "100%");
|
||||||
|
content.getElement().getStyle().set("min-height", "0");
|
||||||
|
content.getElement().getStyle().set("flex", "1");
|
||||||
|
} else {
|
||||||
|
whiteCard.getStyle().set("overflow", "auto");
|
||||||
|
}
|
||||||
whiteCard.setWidthFull();
|
whiteCard.setWidthFull();
|
||||||
whiteCard.add(content);
|
whiteCard.add(content);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import com.vaadin.flow.component.textfield.NumberField;
|
|||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.component.timepicker.TimePicker;
|
import com.vaadin.flow.component.timepicker.TimePicker;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.progressbar.ProgressBar;
|
import com.vaadin.flow.component.progressbar.ProgressBar;
|
||||||
import de.assecutor.votianlt.model.AddressValidationResult;
|
import de.assecutor.votianlt.model.AddressValidationResult;
|
||||||
@@ -36,7 +35,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +227,25 @@ public class PickupStationDialog extends Dialog {
|
|||||||
public void setCargoItems(List<CargoItem> cargoItems) {
|
public void setCargoItems(List<CargoItem> cargoItems) {
|
||||||
this.cargoItems = cargoItems != null ? cargoItems : new ArrayList<>();
|
this.cargoItems = cargoItems != null ? cargoItems : new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private org.bson.types.ObjectId customerId;
|
||||||
|
private boolean addressDiffersFromCustomer;
|
||||||
|
|
||||||
|
public org.bson.types.ObjectId getCustomerId() {
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerId(org.bson.types.ObjectId customerId) {
|
||||||
|
this.customerId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAddressDiffersFromCustomer() {
|
||||||
|
return addressDiffersFromCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddressDiffersFromCustomer(boolean addressDiffersFromCustomer) {
|
||||||
|
this.addressDiffersFromCustomer = addressDiffersFromCustomer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface SaveListener {
|
public interface SaveListener {
|
||||||
@@ -251,6 +268,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
private final ComboBox<String> customerComboBox;
|
private final ComboBox<String> customerComboBox;
|
||||||
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
|
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
|
||||||
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
|
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
|
||||||
|
private org.bson.types.ObjectId selectedCustomerId;
|
||||||
private DatePicker appointmentDatePicker;
|
private DatePicker appointmentDatePicker;
|
||||||
private TimePicker appointmentTimePicker;
|
private TimePicker appointmentTimePicker;
|
||||||
private Checkbox digitalProcessingCheckbox;
|
private Checkbox digitalProcessingCheckbox;
|
||||||
@@ -284,7 +302,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
formLayout.setSpacing(true);
|
formLayout.setSpacing(true);
|
||||||
formLayout.setWidthFull();
|
formLayout.setWidthFull();
|
||||||
|
|
||||||
// Customer selection
|
// Principal selection
|
||||||
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
|
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
|
||||||
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
|
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
|
||||||
customerComboBox.setRequiredIndicatorVisible(true);
|
customerComboBox.setRequiredIndicatorVisible(true);
|
||||||
@@ -292,27 +310,14 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
customerLabelMap.clear();
|
customerLabelMap.clear();
|
||||||
for (Customer c : customers) {
|
for (Customer c : customers) {
|
||||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
CustomerAddressLabelHelper.putUnique(customerLabelMap, c,
|
||||||
? c.getCompanyName() + " | "
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
|
|
||||||
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
|
|
||||||
if (label.isBlank()) {
|
|
||||||
label = translationHelper.getTranslation("addjob.customer.unnamed");
|
|
||||||
}
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (customerLabelMap.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
customerLabelMap.put(uniqueLabel, c);
|
|
||||||
}
|
}
|
||||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
|
||||||
// Company with autocomplete
|
// Pickup address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers);
|
setupCompanyAutocomplete(company, customers);
|
||||||
@@ -432,18 +437,18 @@ public class PickupStationDialog extends Dialog {
|
|||||||
customerComboBox.addValueChangeListener(ev -> {
|
customerComboBox.addValueChangeListener(ev -> {
|
||||||
String selected = ev.getValue();
|
String selected = ev.getValue();
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Customer c = customerLabelMap.get(selected);
|
Customer c = customerLabelMap.get(selected);
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (c.getCompanyName() != null)
|
selectedCustomerId = c.getId();
|
||||||
company.setValue(c.getCompanyName());
|
setCompanySelection(c);
|
||||||
else
|
|
||||||
company.clear();
|
|
||||||
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|
||||||
|| "Divers".equalsIgnoreCase(c.getTitle())))
|
|| "Divers".equalsIgnoreCase(c.getTitle())))
|
||||||
salutation.setValue(c.getTitle());
|
salutation.setValue(c.getTitle());
|
||||||
@@ -489,7 +494,12 @@ public class PickupStationDialog extends Dialog {
|
|||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
});
|
});
|
||||||
|
|
||||||
formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, mail, streetLayout,
|
Div addressDivider = new Div();
|
||||||
|
addressDivider.setWidthFull();
|
||||||
|
addressDivider.getStyle().set("border-top", "1px solid var(--lumo-contrast-20pct)");
|
||||||
|
addressDivider.getStyle().set("margin", "var(--lumo-space-m) 0 var(--lumo-space-s)");
|
||||||
|
|
||||||
|
formLayout.add(customerComboBox, addressDivider, company, salutation, firstName, lastName, phone, mail, streetLayout,
|
||||||
addressAddition, zipCityLayout, saveAddress);
|
addressAddition, zipCityLayout, saveAddress);
|
||||||
|
|
||||||
// TabSheet with address, appointments, and cargo tabs
|
// TabSheet with address, appointments, and cargo tabs
|
||||||
@@ -501,7 +511,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
appointmentsTabError = createTabErrorIndicator();
|
appointmentsTabError = createTabErrorIndicator();
|
||||||
cargoTabError = createTabErrorIndicator();
|
cargoTabError = createTabErrorIndicator();
|
||||||
|
|
||||||
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
|
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.pickup.address"), formLayout);
|
||||||
addressTab.add(addressTabError);
|
addressTab.add(addressTabError);
|
||||||
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
|
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
|
||||||
createAppointmentsTab(availableAppUsers));
|
createAppointmentsTab(availableAppUsers));
|
||||||
@@ -577,25 +587,21 @@ public class PickupStationDialog extends Dialog {
|
|||||||
close();
|
close();
|
||||||
} else {
|
} else {
|
||||||
// Adresse nicht gefunden: Benutzer fragen
|
// Adresse nicht gefunden: Benutzer fragen
|
||||||
ConfirmDialog confirmDialog = new ConfirmDialog();
|
Dialog confirmDialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
confirmDialog.setHeader(
|
translationHelper.getTranslation("addjob.validation.address.not.found.title"),
|
||||||
translationHelper.getTranslation("addjob.validation.address.not.found.title"));
|
translationHelper.getTranslation("addjob.validation.address.not.found.message"),
|
||||||
confirmDialog.setText(
|
"560px",
|
||||||
translationHelper.getTranslation("addjob.validation.address.not.found.message"));
|
translationHelper.getTranslation("addjob.validation.address.correct"),
|
||||||
confirmDialog.setConfirmText(
|
translationHelper.getTranslation("addjob.validation.address.save.anyway"),
|
||||||
translationHelper.getTranslation("addjob.validation.address.save.anyway"));
|
() -> {
|
||||||
confirmDialog.setConfirmButtonTheme("primary");
|
data.setAddressValidatedByGoogle(false);
|
||||||
confirmDialog.setCancelable(true);
|
data.setAddressValidationResult(validationResult);
|
||||||
confirmDialog.setCancelText(
|
if (saveListener != null) {
|
||||||
translationHelper.getTranslation("addjob.validation.address.correct"));
|
saveListener.onSave(data);
|
||||||
confirmDialog.addConfirmListener(ev -> {
|
}
|
||||||
data.setAddressValidatedByGoogle(false);
|
close();
|
||||||
data.setAddressValidationResult(validationResult);
|
},
|
||||||
if (saveListener != null) {
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
saveListener.onSave(data);
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
confirmDialog.open();
|
confirmDialog.open();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -619,13 +625,23 @@ public class PickupStationDialog extends Dialog {
|
|||||||
public void setData(PickupData data) {
|
public void setData(PickupData data) {
|
||||||
if (data == null)
|
if (data == null)
|
||||||
return;
|
return;
|
||||||
if (data.getCustomerSelection() != null) {
|
String customerSelection = normalizeValue(data.getCustomerSelection());
|
||||||
customerComboBox.setValue(data.getCustomerSelection());
|
if (!customerSelection.isEmpty()) {
|
||||||
|
if (!customerLabelMap.containsKey(customerSelection)) {
|
||||||
|
customerLabelMap.put(customerSelection, null);
|
||||||
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
}
|
||||||
|
customerComboBox.setValue(customerSelection);
|
||||||
} else {
|
} else {
|
||||||
customerComboBox.clear();
|
customerComboBox.clear();
|
||||||
}
|
}
|
||||||
if (data.getCompany() != null)
|
String companyOption = findCompanyOptionLabel(data);
|
||||||
|
boolean customerSelectedFromOptions = companyOption != null;
|
||||||
|
if (companyOption != null) {
|
||||||
|
company.setValue(companyOption);
|
||||||
|
} else if (data.getCompany() != null) {
|
||||||
company.setValue(data.getCompany());
|
company.setValue(data.getCompany());
|
||||||
|
}
|
||||||
if (data.getSalutation() != null)
|
if (data.getSalutation() != null)
|
||||||
salutation.setValue(data.getSalutation());
|
salutation.setValue(data.getSalutation());
|
||||||
if (data.getFirstName() != null)
|
if (data.getFirstName() != null)
|
||||||
@@ -668,13 +684,19 @@ public class PickupStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAddress.setValue(data.isSaveAddress());
|
if (data.getCustomerId() != null) {
|
||||||
|
selectedCustomerId = data.getCustomerId();
|
||||||
|
} else {
|
||||||
|
Customer matched = companyOption != null ? companyCustomerMap.get(companyOption) : null;
|
||||||
|
selectedCustomerId = matched != null ? matched.getId() : null;
|
||||||
|
}
|
||||||
|
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PickupData collectData() {
|
private PickupData collectData() {
|
||||||
PickupData data = new PickupData();
|
PickupData data = new PickupData();
|
||||||
data.setCompany(company.getValue());
|
data.setCompany(resolveCompanyValue(company.getValue()));
|
||||||
data.setSalutation(salutation.getValue());
|
data.setSalutation(salutation.getValue());
|
||||||
data.setFirstName(firstName.getValue());
|
data.setFirstName(firstName.getValue());
|
||||||
data.setLastName(lastName.getValue());
|
data.setLastName(lastName.getValue());
|
||||||
@@ -686,6 +708,8 @@ public class PickupStationDialog extends Dialog {
|
|||||||
data.setZip(zip.getValue());
|
data.setZip(zip.getValue());
|
||||||
data.setCity(city.getValue());
|
data.setCity(city.getValue());
|
||||||
data.setSaveAddress(saveAddress.getValue());
|
data.setSaveAddress(saveAddress.getValue());
|
||||||
|
data.setCustomerId(selectedCustomerId);
|
||||||
|
data.setAddressDiffersFromCustomer(computeAddressDiffers());
|
||||||
data.setCustomerSelection(customerComboBox.getValue());
|
data.setCustomerSelection(customerComboBox.getValue());
|
||||||
if (appointmentDatePicker != null) {
|
if (appointmentDatePicker != null) {
|
||||||
data.setAppointmentDate(appointmentDatePicker.getValue());
|
data.setAppointmentDate(appointmentDatePicker.getValue());
|
||||||
@@ -752,12 +776,9 @@ public class PickupStationDialog extends Dialog {
|
|||||||
private boolean validateMailField() {
|
private boolean validateMailField() {
|
||||||
String value = mail.getValue();
|
String value = mail.getValue();
|
||||||
String normalizedValue = value == null ? "" : value.trim();
|
String normalizedValue = value == null ? "" : value.trim();
|
||||||
boolean empty = normalizedValue.isEmpty();
|
boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
|
||||||
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
|
applyErrorStyling(mail, invalid);
|
||||||
boolean invalid = !empty && !normalizedValue.contains("@");
|
return !invalid;
|
||||||
boolean hasError = invalid || (required && empty);
|
|
||||||
applyErrorStyling(mail, hasError);
|
|
||||||
return !hasError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateCargoItems() {
|
private boolean validateCargoItems() {
|
||||||
@@ -809,54 +830,24 @@ public class PickupStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
||||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
|
||||||
|
|
||||||
companyCustomerMap.clear();
|
companyCustomerMap.clear();
|
||||||
for (Customer customer : customers) {
|
for (Customer customer : customers) {
|
||||||
String companyName = normalizeValue(customer.getCompanyName());
|
CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer,
|
||||||
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) {
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
companyCustomerMap.put(companyName, customer);
|
|
||||||
}
|
}
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
|
||||||
|
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Customer> matchingCustomer = customers.stream()
|
Customer customer = companyCustomerMap.get(selectedAddress);
|
||||||
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst();
|
if (customer != null) {
|
||||||
|
applyCustomerAddress(customer);
|
||||||
if (matchingCustomer.isPresent()) {
|
|
||||||
Customer customer = matchingCustomer.get();
|
|
||||||
if (customer.getTitle() != null
|
|
||||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
|
||||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
|
||||||
salutation.setValue(customer.getTitle());
|
|
||||||
}
|
|
||||||
if (customer.getFirstname() != null)
|
|
||||||
firstName.setValue(customer.getFirstname());
|
|
||||||
if (customer.getLastName() != null)
|
|
||||||
lastName.setValue(customer.getLastName());
|
|
||||||
if (customer.getTelephone() != null)
|
|
||||||
phone.setValue(customer.getTelephone());
|
|
||||||
if (customer.getMail() != null)
|
|
||||||
mail.setValue(customer.getMail());
|
|
||||||
if (customer.getStreet() != null)
|
|
||||||
street.setValue(customer.getStreet());
|
|
||||||
if (customer.getHouseNumber() != null)
|
|
||||||
houseNumber.setValue(customer.getHouseNumber());
|
|
||||||
if (customer.getAddressAddition() != null)
|
|
||||||
addressAddition.setValue(customer.getAddressAddition());
|
|
||||||
if (customer.getZip() != null)
|
|
||||||
zip.setValue(customer.getZip());
|
|
||||||
if (customer.getCity() != null)
|
|
||||||
city.setValue(customer.getCity());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
@@ -864,33 +855,40 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
companyField.addCustomValueSetListener(event -> {
|
companyField.addCustomValueSetListener(event -> {
|
||||||
companyField.setValue(event.getDetail());
|
companyField.setValue(event.getDetail());
|
||||||
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateSaveAddressState() {
|
private void updateSaveAddressState() {
|
||||||
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
|
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
|
||||||
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue()));
|
Customer selectedCompanyCustomer = companyCustomerMap.get(company.getValue());
|
||||||
boolean existingCustomerSelected = selectedCustomer != null && matchesCustomer(selectedCustomer);
|
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
|
||||||
boolean existingCompanySelected = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
|
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
|
||||||
|
|
||||||
if (existingCustomerSelected || existingCompanySelected) {
|
if (customerDataMatches || companyDataMatches) {
|
||||||
saveAddress.setValue(false);
|
saveAddress.setValue(false);
|
||||||
saveAddress.setEnabled(false);
|
saveAddress.setEnabled(false);
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
|
||||||
updateMailRequirement();
|
updateMailRequirement();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAddress.setEnabled(true);
|
saveAddress.setEnabled(true);
|
||||||
|
if (selectedCustomerId != null) {
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.update"));
|
||||||
|
} else {
|
||||||
|
saveAddress.setLabel(translationHelper.getTranslation("addjob.address.save"));
|
||||||
|
}
|
||||||
updateMailRequirement();
|
updateMailRequirement();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateMailRequirement() {
|
private void updateMailRequirement() {
|
||||||
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
|
mail.setRequiredIndicatorVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean matchesCustomer(Customer customer) {
|
private boolean matchesCustomer(Customer customer) {
|
||||||
return sameValue(company.getValue(), customer.getCompanyName())
|
return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
|
||||||
&& sameValue(salutation.getValue(), customer.getTitle())
|
&& sameValue(salutation.getValue(), customer.getTitle())
|
||||||
&& sameValue(firstName.getValue(), customer.getFirstname())
|
&& sameValue(firstName.getValue(), customer.getFirstname())
|
||||||
&& sameValue(lastName.getValue(), customer.getLastName())
|
&& sameValue(lastName.getValue(), customer.getLastName())
|
||||||
@@ -911,6 +909,142 @@ public class PickupStationDialog extends Dialog {
|
|||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean computeAddressDiffers() {
|
||||||
|
boolean hasAnyValue = !normalizeValue(resolveCompanyValue(company.getValue())).isEmpty()
|
||||||
|
|| !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty()
|
||||||
|
|| !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|
||||||
|
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
|
||||||
|
|| !normalizeValue(addressAddition.getValue()).isEmpty() || !normalizeValue(zip.getValue()).isEmpty()
|
||||||
|
|| !normalizeValue(city.getValue()).isEmpty();
|
||||||
|
if (!hasAnyValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedCustomerId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Customer linked = findCustomerById(selectedCustomerId);
|
||||||
|
return linked == null || !matchesCustomer(linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Customer findCustomerById(org.bson.types.ObjectId id) {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Customer c : customerLabelMap.values()) {
|
||||||
|
if (c != null && id.equals(c.getId())) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Customer c : companyCustomerMap.values()) {
|
||||||
|
if (c != null && id.equals(c.getId())) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCustomerAddress(Customer customer) {
|
||||||
|
selectedCustomerId = customer.getId();
|
||||||
|
if (customer.getTitle() != null
|
||||||
|
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||||
|
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||||
|
salutation.setValue(customer.getTitle());
|
||||||
|
} else {
|
||||||
|
salutation.clear();
|
||||||
|
}
|
||||||
|
if (customer.getFirstname() != null)
|
||||||
|
firstName.setValue(customer.getFirstname());
|
||||||
|
else
|
||||||
|
firstName.clear();
|
||||||
|
if (customer.getLastName() != null)
|
||||||
|
lastName.setValue(customer.getLastName());
|
||||||
|
else
|
||||||
|
lastName.clear();
|
||||||
|
if (customer.getTelephone() != null)
|
||||||
|
phone.setValue(customer.getTelephone());
|
||||||
|
else
|
||||||
|
phone.clear();
|
||||||
|
if (customer.getMail() != null)
|
||||||
|
mail.setValue(customer.getMail());
|
||||||
|
else
|
||||||
|
mail.clear();
|
||||||
|
if (customer.getStreet() != null)
|
||||||
|
street.setValue(customer.getStreet());
|
||||||
|
else
|
||||||
|
street.clear();
|
||||||
|
if (customer.getHouseNumber() != null)
|
||||||
|
houseNumber.setValue(customer.getHouseNumber());
|
||||||
|
else
|
||||||
|
houseNumber.clear();
|
||||||
|
if (customer.getAddressAddition() != null)
|
||||||
|
addressAddition.setValue(customer.getAddressAddition());
|
||||||
|
else
|
||||||
|
addressAddition.clear();
|
||||||
|
if (customer.getZip() != null)
|
||||||
|
zip.setValue(customer.getZip());
|
||||||
|
else
|
||||||
|
zip.clear();
|
||||||
|
if (customer.getCity() != null)
|
||||||
|
city.setValue(customer.getCity());
|
||||||
|
else
|
||||||
|
city.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCompanySelection(Customer customer) {
|
||||||
|
String label = findCompanyOptionLabel(customer);
|
||||||
|
if (label != null) {
|
||||||
|
company.setValue(label);
|
||||||
|
} else if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
||||||
|
company.setValue(customer.getCompanyName());
|
||||||
|
} else {
|
||||||
|
company.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCompanyValue(String comboValue) {
|
||||||
|
return CustomerAddressLabelHelper.resolveCompanyValue(companyCustomerMap, comboValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findCompanyOptionLabel(Customer customer) {
|
||||||
|
if (customer == null || customer.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
|
||||||
|
Customer option = entry.getValue();
|
||||||
|
if (option != null && customer.getId().equals(option.getId())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findCompanyOptionLabel(PickupData data) {
|
||||||
|
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
|
||||||
|
Customer customer = entry.getValue();
|
||||||
|
if (data.getCustomerId() != null && customer.getId() != null && data.getCustomerId().equals(customer.getId())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
if (matchesCustomer(customer, data)) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesCustomer(Customer customer, PickupData data) {
|
||||||
|
return sameValue(customer.getCompanyName(), data.getCompany())
|
||||||
|
&& sameValue(customer.getTitle(), data.getSalutation())
|
||||||
|
&& sameValue(customer.getFirstname(), data.getFirstName())
|
||||||
|
&& sameValue(customer.getLastName(), data.getLastName())
|
||||||
|
&& sameValue(customer.getTelephone(), data.getPhone())
|
||||||
|
&& sameValue(customer.getMail(), data.getMail())
|
||||||
|
&& sameValue(customer.getStreet(), data.getStreet())
|
||||||
|
&& sameValue(customer.getHouseNumber(), data.getHouseNumber())
|
||||||
|
&& sameValue(customer.getAddressAddition(), data.getAddressAddition())
|
||||||
|
&& sameValue(customer.getZip(), data.getZip())
|
||||||
|
&& sameValue(customer.getCity(), data.getCity());
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Appointments & Processing Tab
|
// Appointments & Processing Tab
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.vaadin.flow.component.sidenav.SideNav;
|
|||||||
import com.vaadin.flow.component.sidenav.SideNavItem;
|
import com.vaadin.flow.component.sidenav.SideNavItem;
|
||||||
import com.vaadin.flow.router.Layout;
|
import com.vaadin.flow.router.Layout;
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.view.EditProfileView;
|
import de.assecutor.votianlt.pages.view.EditProfileView;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ public final class AdminLayout extends AppLayout {
|
|||||||
// Profile display with navigation
|
// Profile display with navigation
|
||||||
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
|
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
|
||||||
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
|
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
|
||||||
userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout());
|
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
|
||||||
|
|
||||||
// Update function for username and avatar
|
// Update function for username and avatar
|
||||||
Runnable updateUserInfo = () -> {
|
Runnable updateUserInfo = () -> {
|
||||||
@@ -151,4 +152,17 @@ public final class AdminLayout extends AppLayout {
|
|||||||
|
|
||||||
return userMenu;
|
return userMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openLogoutConfirmDialog() {
|
||||||
|
var dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
|
getTranslation("logout.confirm.title"),
|
||||||
|
getTranslation("logout.confirm.message"),
|
||||||
|
"460px",
|
||||||
|
getTranslation("button.cancel"),
|
||||||
|
getTranslation("nav.logout"),
|
||||||
|
securityService::logout,
|
||||||
|
com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY,
|
||||||
|
com.vaadin.flow.component.button.ButtonVariant.LUMO_ERROR);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.vaadin.flow.server.auth.AnonymousAllowed;
|
|||||||
import com.vaadin.flow.shared.Registration;
|
import com.vaadin.flow.shared.Registration;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.Language;
|
import de.assecutor.votianlt.model.Language;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
import de.assecutor.votianlt.pages.view.EditProfileView;
|
import de.assecutor.votianlt.pages.view.EditProfileView;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
@@ -327,8 +328,7 @@ public final class MainLayout extends AppLayout {
|
|||||||
// Profil anzeigen mit Navigation
|
// Profil anzeigen mit Navigation
|
||||||
userMenuItem.getSubMenu().addItem(getTranslation("nav.showprofile"),
|
userMenuItem.getSubMenu().addItem(getTranslation("nav.showprofile"),
|
||||||
e -> UI.getCurrent().navigate(EditProfileView.class));
|
e -> UI.getCurrent().navigate(EditProfileView.class));
|
||||||
userMenuItem.getSubMenu().addItem(getTranslation("nav.settings"));
|
userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> openLogoutConfirmDialog());
|
||||||
userMenuItem.getSubMenu().addItem(getTranslation("nav.logout"), e -> securityService.logout());
|
|
||||||
|
|
||||||
// Update-Funktion für Benutzername und Avatar
|
// Update-Funktion für Benutzername und Avatar
|
||||||
Runnable updateUserInfo = () -> {
|
Runnable updateUserInfo = () -> {
|
||||||
@@ -344,6 +344,19 @@ public final class MainLayout extends AppLayout {
|
|||||||
return userMenu;
|
return userMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openLogoutConfirmDialog() {
|
||||||
|
var dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
|
getTranslation("logout.confirm.title"),
|
||||||
|
getTranslation("logout.confirm.message"),
|
||||||
|
"460px",
|
||||||
|
getTranslation("button.cancel"),
|
||||||
|
getTranslation("nav.logout"),
|
||||||
|
securityService::logout,
|
||||||
|
com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY,
|
||||||
|
com.vaadin.flow.component.button.ButtonVariant.LUMO_ERROR);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
super.onAttach(attachEvent);
|
super.onAttach(attachEvent);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.bson.types.ObjectId;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Slice;
|
import org.springframework.data.domain.Slice;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.mongodb.repository.Query;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
|
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
|
||||||
@@ -13,4 +14,9 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
|
|||||||
Slice<Customer> findAllBy(Pageable pageable);
|
Slice<Customer> findAllBy(Pageable pageable);
|
||||||
|
|
||||||
List<Customer> findByOwner(ObjectId owner);
|
List<Customer> findByOwner(ObjectId owner);
|
||||||
|
|
||||||
|
// $ne: true matches documents where internal is false, null, or the field is missing
|
||||||
|
// (legacy data without the internal field still shows up in customer dropdowns).
|
||||||
|
@Query("{ 'owner' : ?0, 'internal' : { $ne: true } }")
|
||||||
|
List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class AddCustomerService {
|
public class AddCustomerService {
|
||||||
private final AddCustomerRepository addCustomerRepository;
|
private final AddCustomerRepository addCustomerRepository;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final SequenceGeneratorService sequenceGeneratorService;
|
||||||
|
|
||||||
AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService) {
|
AddCustomerService(AddCustomerRepository addCustomerRepository, SecurityService securityService,
|
||||||
|
SequenceGeneratorService sequenceGeneratorService) {
|
||||||
this.addCustomerRepository = addCustomerRepository;
|
this.addCustomerRepository = addCustomerRepository;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.sequenceGeneratorService = sequenceGeneratorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addCustomer(Customer customer) {
|
public void addCustomer(Customer customer) {
|
||||||
@@ -25,6 +28,35 @@ public class AddCustomerService {
|
|||||||
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
|
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
|
||||||
customer.setCreatedBy(currentUser.getId());
|
customer.setCreatedBy(currentUser.getId());
|
||||||
customer.setOwner(currentUser.getId());
|
customer.setOwner(currentUser.getId());
|
||||||
|
if (customer.getUsrId() == null) {
|
||||||
|
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustomerRepository.save(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addInternalCustomer(Customer customer) {
|
||||||
|
if (customer == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customer.setId(null);
|
||||||
|
customer.setInternal(true);
|
||||||
|
addCustomer(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateCustomer(Customer customer) {
|
||||||
|
if (customer == null || customer.getId() == null) {
|
||||||
|
throw new IllegalArgumentException("Kunden-ID fehlt");
|
||||||
|
}
|
||||||
|
validateCustomer(customer);
|
||||||
|
|
||||||
|
Customer existing = addCustomerRepository.findById(customer.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Kunde nicht gefunden"));
|
||||||
|
customer.setCreatedBy(existing.getCreatedBy());
|
||||||
|
customer.setOwner(existing.getOwner());
|
||||||
|
if (customer.getUsrId() == null) {
|
||||||
|
customer.setUsrId(existing.getUsrId());
|
||||||
|
}
|
||||||
|
|
||||||
addCustomerRepository.save(customer);
|
addCustomerRepository.save(customer);
|
||||||
}
|
}
|
||||||
@@ -35,13 +67,10 @@ public class AddCustomerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mail = customer.getMail() != null ? customer.getMail().trim() : "";
|
String mail = customer.getMail() != null ? customer.getMail().trim() : "";
|
||||||
if (mail.isEmpty()) {
|
if (!mail.isEmpty() && !mail.contains("@")) {
|
||||||
throw new IllegalArgumentException("E-Mail-Adresse ist ein Pflichtfeld");
|
|
||||||
}
|
|
||||||
if (!mail.contains("@")) {
|
|
||||||
throw new IllegalArgumentException("Bitte geben Sie eine gültige E-Mail-Adresse ein");
|
throw new IllegalArgumentException("Bitte geben Sie eine gültige E-Mail-Adresse ein");
|
||||||
}
|
}
|
||||||
|
|
||||||
customer.setMail(mail);
|
customer.setMail(mail.isEmpty() ? null : mail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class CustomerService {
|
|||||||
|
|
||||||
public List<Customer> findAllForCurrentOwner() {
|
public List<Customer> findAllForCurrentOwner() {
|
||||||
ObjectId ownerId = securityService.getCurrentUserId();
|
ObjectId ownerId = securityService.getCurrentUserId();
|
||||||
return todoRepository.findByOwner(ownerId);
|
return todoRepository.findByOwnerAndInternalFalse(ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Customer save(Customer customer) {
|
public Customer save(Customer customer) {
|
||||||
@@ -43,4 +43,8 @@ public class CustomerService {
|
|||||||
return todoRepository.findById(id).orElse(null);
|
return todoRepository.findById(id).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void deleteById(ObjectId id) {
|
||||||
|
todoRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
package de.assecutor.votianlt.pages.service;
|
package de.assecutor.votianlt.pages.service;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.UserInvoiceData;
|
import de.assecutor.votianlt.model.UserInvoiceData;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
|
||||||
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
import org.springframework.data.mongodb.core.query.Criteria;
|
import org.springframework.data.mongodb.core.query.Criteria;
|
||||||
@@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query;
|
|||||||
import org.springframework.data.mongodb.core.query.Update;
|
import org.springframework.data.mongodb.core.query.Update;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserInvoiceDataService {
|
public class UserInvoiceDataService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
|
||||||
|
|
||||||
private final UserInvoiceDataRepository userInvoiceDataRepository;
|
private final UserInvoiceDataRepository userInvoiceDataRepository;
|
||||||
private final MongoTemplate mongoTemplate;
|
private final MongoTemplate mongoTemplate;
|
||||||
|
private final InvoiceNumberReservationRepository reservationRepository;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
|
||||||
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) {
|
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate,
|
||||||
|
InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) {
|
||||||
this.userInvoiceDataRepository = userInvoiceDataRepository;
|
this.userInvoiceDataRepository = userInvoiceDataRepository;
|
||||||
this.mongoTemplate = mongoTemplate;
|
this.mongoTemplate = mongoTemplate;
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
this.securityService = securityService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
|
public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
|
||||||
@@ -64,6 +78,12 @@ public class UserInvoiceDataService {
|
|||||||
/**
|
/**
|
||||||
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
|
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
|
||||||
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
|
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
|
||||||
|
*
|
||||||
|
* Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED
|
||||||
|
* persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem
|
||||||
|
* Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener
|
||||||
|
* Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später
|
||||||
|
* vom Lifecycle-Service auf USED bzw. VOIDED gesetzt.
|
||||||
*/
|
*/
|
||||||
public String generateNextInvoiceNumber(ObjectId userId) {
|
public String generateNextInvoiceNumber(ObjectId userId) {
|
||||||
Query query = Query.query(Criteria.where("userId").is(userId));
|
Query query = Query.query(Criteria.where("userId").is(userId));
|
||||||
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
|
|||||||
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
|
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
|
||||||
return findByUserId(userId).map(d -> {
|
return findByUserId(userId).map(d -> {
|
||||||
String prefix = d.getPrefix() != null ? d.getPrefix() : "";
|
String prefix = d.getPrefix() != null ? d.getPrefix() : "";
|
||||||
return prefix + String.format("%06d", d.getNextInvoiceNumber());
|
long sequence = d.getNextInvoiceNumber();
|
||||||
|
String number = prefix + String.format("%06d", sequence);
|
||||||
|
recordReservation(userId, number, sequence, prefix);
|
||||||
|
return number;
|
||||||
}).orElse("000000");
|
}).orElse("000000");
|
||||||
}
|
}
|
||||||
|
|
||||||
String prefix = before.getPrefix() != null ? before.getPrefix() : "";
|
String prefix = before.getPrefix() != null ? before.getPrefix() : "";
|
||||||
return prefix + String.format("%06d", before.getNextInvoiceNumber());
|
long sequence = before.getNextInvoiceNumber();
|
||||||
|
String number = prefix + String.format("%06d", sequence);
|
||||||
|
recordReservation(userId, number, sequence, prefix);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags
|
||||||
|
* ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository
|
||||||
|
* vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir
|
||||||
|
* loggen den Fehler und vertrauen darauf, dass die anschließende Lücken-
|
||||||
|
* Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung
|
||||||
|
* sichtbar macht.
|
||||||
|
*/
|
||||||
|
private void recordReservation(ObjectId userId, String number, long sequence, String prefix) {
|
||||||
|
try {
|
||||||
|
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
|
||||||
|
reservation.setUserId(userId);
|
||||||
|
reservation.setNumber(number);
|
||||||
|
reservation.setSequence(sequence);
|
||||||
|
reservation.setPrefix(prefix);
|
||||||
|
reservation.setReservedAt(Instant.now());
|
||||||
|
reservation.setReservedBy(currentUserDisplayName());
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.RESERVED);
|
||||||
|
reservationRepository.save(reservation);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}",
|
||||||
|
number, userId, ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentUserDisplayName() {
|
||||||
|
try {
|
||||||
|
var user = securityService.getCurrentDatabaseUser();
|
||||||
|
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
|
||||||
|
return composed.isBlank() ? safe(user.getEmail()) : composed;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +46,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
public AddCustomerView(AddCustomerService todoService, Clock clock) {
|
public AddCustomerView(AddCustomerService todoService, Clock clock) {
|
||||||
this.addCustomerService = todoService;
|
this.addCustomerService = todoService;
|
||||||
|
|
||||||
// Firma (Pflichtfeld)
|
// Firma (optional; auch Privatpersonen können im Adressbuch stehen)
|
||||||
companyName = new TextField(getTranslation("profile.company"));
|
companyName = new TextField(getTranslation("profile.company"));
|
||||||
companyName.setRequiredIndicatorVisible(true);
|
|
||||||
companyName.setWidthFull();
|
companyName.setWidthFull();
|
||||||
companyName.addBlurListener(e -> validateField(companyName));
|
|
||||||
|
|
||||||
// Anrede (Dropdown)
|
// Anrede (Dropdown)
|
||||||
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||||
@@ -81,9 +79,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
fax = new TextField(getTranslation("profile.fax"));
|
fax = new TextField(getTranslation("profile.fax"));
|
||||||
fax.setWidthFull();
|
fax.setWidthFull();
|
||||||
|
|
||||||
// E-Mail (Pflichtfeld)
|
// E-Mail (optional)
|
||||||
mail = new TextField(getTranslation("profile.email"));
|
mail = new TextField(getTranslation("profile.email"));
|
||||||
mail.setRequiredIndicatorVisible(true);
|
|
||||||
mail.setWidthFull();
|
mail.setWidthFull();
|
||||||
mail.addBlurListener(e -> validateEmail());
|
mail.addBlurListener(e -> validateEmail());
|
||||||
|
|
||||||
@@ -163,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void configureBinder() {
|
private void configureBinder() {
|
||||||
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required"))
|
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||||
.bind(Customer::getCompanyName, Customer::setCompanyName);
|
|
||||||
|
|
||||||
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
|
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
|
||||||
|
|
||||||
@@ -179,8 +175,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
|
|
||||||
binder.forField(fax).bind(Customer::getFax, Customer::setFax);
|
binder.forField(fax).bind(Customer::getFax, Customer::setFax);
|
||||||
|
|
||||||
binder.forField(mail).asRequired(getTranslation("profile.validation.email.required"))
|
binder.forField(mail)
|
||||||
.withValidator(email -> email.contains("@"), getTranslation("profile.validation.email.invalid"))
|
.withValidator(email -> email == null || email.isBlank() || email.contains("@"),
|
||||||
|
getTranslation("profile.validation.email.invalid"))
|
||||||
.bind(Customer::getMail, Customer::setMail);
|
.bind(Customer::getMail, Customer::setMail);
|
||||||
|
|
||||||
binder.forField(street).asRequired(getTranslation("profile.validation.street.required"))
|
binder.forField(street).asRequired(getTranslation("profile.validation.street.required"))
|
||||||
@@ -247,10 +244,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
|
|
||||||
private void validateEmail() {
|
private void validateEmail() {
|
||||||
String value = mail.getValue();
|
String value = mail.getValue();
|
||||||
if (value == null || value.trim().isEmpty()) {
|
if (value != null && !value.trim().isEmpty() && !value.contains("@")) {
|
||||||
mail.setInvalid(true);
|
|
||||||
mail.setErrorMessage(getTranslation("profile.email.required"));
|
|
||||||
} else if (!value.contains("@")) {
|
|
||||||
mail.setInvalid(true);
|
mail.setInvalid(true);
|
||||||
mail.setErrorMessage(getTranslation("profile.validation.email.invalid"));
|
mail.setErrorMessage(getTranslation("profile.validation.email.invalid"));
|
||||||
} else {
|
} else {
|
||||||
@@ -260,7 +254,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateAllFields() {
|
private boolean validateAllFields() {
|
||||||
validateField(companyName);
|
|
||||||
validateField(firstName);
|
validateField(firstName);
|
||||||
validateField(lastName);
|
validateField(lastName);
|
||||||
validateField(telephone);
|
validateField(telephone);
|
||||||
@@ -270,9 +263,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
validateField(city);
|
validateField(city);
|
||||||
validateEmail();
|
validateEmail();
|
||||||
|
|
||||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
|
return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
|
||||||
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
|
&& !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
|
||||||
&& !city.isInvalid();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.vaadin.flow.component.button.Button;
|
|||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.checkbox.Checkbox;
|
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
|
|
||||||
@@ -64,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
|
|||||||
import de.assecutor.votianlt.model.RouteCalculationResult;
|
import de.assecutor.votianlt.model.RouteCalculationResult;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
|
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
|
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.CustomerAddressLabelHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
|
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
|
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
@@ -138,12 +138,16 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
private TextField pickupZip;
|
private TextField pickupZip;
|
||||||
private TextField pickupCity;
|
private TextField pickupCity;
|
||||||
private Checkbox savePickupAddress;
|
private Checkbox savePickupAddress;
|
||||||
|
private org.bson.types.ObjectId pickupCustomerId;
|
||||||
|
private boolean pickupAddressDiffers;
|
||||||
|
|
||||||
// Delivery stations as tiles in a 3x3 grid (max 7 delivery + 1 pickup + 1 plus
|
// Delivery stations as tiles in a 3x3 grid (max 7 delivery + 1 pickup + 1 plus
|
||||||
// = 9)
|
// = 9)
|
||||||
private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
|
private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
|
||||||
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
|
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
|
||||||
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
|
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
|
||||||
|
private final List<org.bson.types.ObjectId> deliveryStationsCustomerId = new ArrayList<>();
|
||||||
|
private final List<Boolean> deliveryStationsAddressDiffers = new ArrayList<>();
|
||||||
private final List<String> deliveryStationsMailState = new ArrayList<>();
|
private final List<String> deliveryStationsMailState = new ArrayList<>();
|
||||||
private final List<Div> deliveryStationSlotList = new ArrayList<>();
|
private final List<Div> deliveryStationSlotList = new ArrayList<>();
|
||||||
private final List<Span> deliveryStationDistanceChips = new ArrayList<>();
|
private final List<Span> deliveryStationDistanceChips = new ArrayList<>();
|
||||||
@@ -233,32 +237,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
|
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
|
||||||
customerSelection.setWidthFull();
|
customerSelection.setWidthFull();
|
||||||
customerSelection.setRequiredIndicatorVisible(true);
|
customerSelection.setRequiredIndicatorVisible(true);
|
||||||
|
customerSelection.setAllowCustomValue(true);
|
||||||
|
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
|
||||||
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
|
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
|
||||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||||
customerLabelToEntity.clear();
|
customerLabelToEntity.clear();
|
||||||
for (Customer c : ownerCustomers) {
|
for (Customer c : ownerCustomers) {
|
||||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||||
? c.getCompanyName() + " | "
|
|
||||||
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
|
|
||||||
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
|
|
||||||
if (label.isBlank()) {
|
|
||||||
label = getTranslation("addjob.customer.unnamed");
|
|
||||||
}
|
|
||||||
// Bei Duplikaten Label einzigartig machen
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (customerLabelToEntity.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
customerLabelToEntity.put(uniqueLabel, c);
|
|
||||||
}
|
}
|
||||||
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||||
|
|
||||||
// Pickup address
|
// Pickup address
|
||||||
pickupCompany = new ComboBox<>(getTranslation("profile.company"));
|
pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
|
||||||
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder"));
|
pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
|
||||||
pickupCompany.setAllowCustomValue(true);
|
pickupCompany.setAllowCustomValue(true);
|
||||||
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
|
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
|
||||||
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||||
@@ -722,6 +713,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
// Add empty state for this station
|
// Add empty state for this station
|
||||||
deliveryStationsState.add(new DeliveryStation());
|
deliveryStationsState.add(new DeliveryStation());
|
||||||
deliveryStationsSaveAddress.add(true);
|
deliveryStationsSaveAddress.add(true);
|
||||||
|
deliveryStationsCustomerId.add(null);
|
||||||
|
deliveryStationsAddressDiffers.add(false);
|
||||||
deliveryStationsMailState.add(null);
|
deliveryStationsMailState.add(null);
|
||||||
deliveryStationsValidatedByGoogle.add(false);
|
deliveryStationsValidatedByGoogle.add(false);
|
||||||
|
|
||||||
@@ -756,86 +749,91 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
if (idx < 0)
|
if (idx < 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
ConfirmDialog dialog = new ConfirmDialog();
|
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
dialog.setHeader(getTranslation("addjob.station.remove.confirm", idx + 1));
|
getTranslation("addjob.station.remove.confirm", idx + 1),
|
||||||
dialog.setCancelable(true);
|
null,
|
||||||
dialog.setCancelText(getTranslation("dialog.cancel"));
|
"460px",
|
||||||
dialog.setConfirmText(getTranslation("dialog.confirm"));
|
getTranslation("dialog.cancel"),
|
||||||
dialog.addConfirmListener(e -> {
|
getTranslation("dialog.confirm"),
|
||||||
int removeIdx = deliveryStationTilesList.indexOf(tile);
|
() -> {
|
||||||
if (removeIdx < 0)
|
int removeIdx = deliveryStationTilesList.indexOf(tile);
|
||||||
return;
|
if (removeIdx < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
deliveryStationTilesList.remove(removeIdx);
|
deliveryStationTilesList.remove(removeIdx);
|
||||||
deliveryStationsState.remove(removeIdx);
|
deliveryStationsState.remove(removeIdx);
|
||||||
deliveryStationsSaveAddress.remove(removeIdx);
|
deliveryStationsSaveAddress.remove(removeIdx);
|
||||||
deliveryStationsMailState.remove(removeIdx);
|
deliveryStationsCustomerId.remove(removeIdx);
|
||||||
deliveryStationsValidatedByGoogle.remove(removeIdx);
|
deliveryStationsAddressDiffers.remove(removeIdx);
|
||||||
deliveryStationTasksState.remove(removeIdx);
|
deliveryStationsMailState.remove(removeIdx);
|
||||||
Div removedSlot = deliveryStationSlotList.remove(removeIdx);
|
deliveryStationsValidatedByGoogle.remove(removeIdx);
|
||||||
deliveryStationDistanceChips.remove(removeIdx);
|
deliveryStationTasksState.remove(removeIdx);
|
||||||
pickupToDeliveryRouteResults.remove(removeIdx);
|
Div removedSlot = deliveryStationSlotList.remove(removeIdx);
|
||||||
// Re-index tasks state for remaining stations
|
deliveryStationDistanceChips.remove(removeIdx);
|
||||||
Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
|
pickupToDeliveryRouteResults.remove(removeIdx);
|
||||||
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
|
// Re-index tasks state for remaining stations
|
||||||
int oldIdx = entry.getKey();
|
Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
|
||||||
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
|
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
|
||||||
reindexed.put(newIdx, entry.getValue());
|
int oldIdx = entry.getKey();
|
||||||
}
|
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
|
||||||
deliveryStationTasksState.clear();
|
reindexed.put(newIdx, entry.getValue());
|
||||||
deliveryStationTasksState.putAll(reindexed);
|
}
|
||||||
|
deliveryStationTasksState.clear();
|
||||||
|
deliveryStationTasksState.putAll(reindexed);
|
||||||
|
|
||||||
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
|
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
|
||||||
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
|
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
|
||||||
int oldIdx = entry.getKey();
|
int oldIdx = entry.getKey();
|
||||||
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
|
int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx;
|
||||||
reindexedRoutes.put(newIdx, entry.getValue());
|
reindexedRoutes.put(newIdx, entry.getValue());
|
||||||
}
|
}
|
||||||
pickupToDeliveryRouteResults.clear();
|
pickupToDeliveryRouteResults.clear();
|
||||||
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
|
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
|
||||||
|
|
||||||
for (SelectedServiceEntry selectedService : selectedServices) {
|
for (SelectedServiceEntry selectedService : selectedServices) {
|
||||||
Integer stationOrder = selectedService.getDeliveryStationOrder();
|
Integer stationOrder = selectedService.getDeliveryStationOrder();
|
||||||
if (stationOrder == null) {
|
if (stationOrder == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (stationOrder == removeIdx) {
|
if (stationOrder == removeIdx) {
|
||||||
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
|
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
|
||||||
} else if (stationOrder > removeIdx) {
|
} else if (stationOrder > removeIdx) {
|
||||||
selectedService.setDeliveryStationOrder(stationOrder - 1);
|
selectedService.setDeliveryStationOrder(stationOrder - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stationsGridContainer.remove(removedSlot);
|
stationsGridContainer.remove(removedSlot);
|
||||||
|
|
||||||
// Renumber remaining tiles and update click listeners
|
// Renumber remaining tiles and update click listeners
|
||||||
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
|
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
|
||||||
StationTile t = deliveryStationTilesList.get(i);
|
StationTile t = deliveryStationTilesList.get(i);
|
||||||
int newNumber = i + 1;
|
int newNumber = i + 1;
|
||||||
t.updateStationNumber(newNumber);
|
t.updateStationNumber(newNumber);
|
||||||
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
|
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
|
||||||
// Update click listener to use correct index
|
// Update click listener to use correct index
|
||||||
final int newIdx = i;
|
final int newIdx = i;
|
||||||
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
|
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
|
||||||
// First station should not be removable
|
// First station should not be removable
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
t.setDeleteListener(null);
|
t.setDeleteListener(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure "+" button is visible if under max
|
// Ensure "+" button is visible if under max
|
||||||
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS && addStationButtonSlot.getParent().isEmpty()) {
|
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
|
||||||
stationsGridContainer.add(addStationButtonSlot);
|
&& addStationButtonSlot.getParent().isEmpty()) {
|
||||||
}
|
stationsGridContainer.add(addStationButtonSlot);
|
||||||
|
}
|
||||||
|
|
||||||
resetRouteInformation();
|
resetRouteInformation();
|
||||||
resetStationsAppliedState();
|
resetStationsAppliedState();
|
||||||
if (servicesGrid != null) {
|
if (servicesGrid != null) {
|
||||||
servicesGrid.getDataProvider().refreshAll();
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
}
|
}
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
triggerValidation();
|
triggerValidation();
|
||||||
updateTabLabels();
|
updateTabLabels();
|
||||||
});
|
},
|
||||||
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
dialog.open();
|
dialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
translationHelper, data -> {
|
translationHelper, data -> {
|
||||||
// Update customer selection from dialog
|
// Update customer selection from dialog
|
||||||
if (data.getCustomerSelection() != null) {
|
if (data.getCustomerSelection() != null) {
|
||||||
customerSelection.setValue(data.getCustomerSelection());
|
setCustomerSelectionValue(data.getCustomerSelection());
|
||||||
} else {
|
} else {
|
||||||
customerSelection.clear();
|
customerSelection.clear();
|
||||||
}
|
}
|
||||||
@@ -865,6 +863,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
pickupZip.setValue(data.getZip() != null ? data.getZip() : "");
|
pickupZip.setValue(data.getZip() != null ? data.getZip() : "");
|
||||||
pickupCity.setValue(data.getCity() != null ? data.getCity() : "");
|
pickupCity.setValue(data.getCity() != null ? data.getCity() : "");
|
||||||
savePickupAddress.setValue(data.isSaveAddress());
|
savePickupAddress.setValue(data.isSaveAddress());
|
||||||
|
pickupCustomerId = data.getCustomerId();
|
||||||
|
pickupAddressDiffers = data.isAddressDiffersFromCustomer();
|
||||||
|
|
||||||
// Sync appointment fields for binder/submit
|
// Sync appointment fields for binder/submit
|
||||||
pickupDate.setValue(data.getAppointmentDate());
|
pickupDate.setValue(data.getAppointmentDate());
|
||||||
@@ -911,6 +911,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
currentData.setZip(pickupZip.getValue());
|
currentData.setZip(pickupZip.getValue());
|
||||||
currentData.setCity(pickupCity.getValue());
|
currentData.setCity(pickupCity.getValue());
|
||||||
currentData.setSaveAddress(savePickupAddress.getValue());
|
currentData.setSaveAddress(savePickupAddress.getValue());
|
||||||
|
currentData.setCustomerId(pickupCustomerId);
|
||||||
currentData.setCustomerSelection(customerSelection.getValue());
|
currentData.setCustomerSelection(customerSelection.getValue());
|
||||||
// Pre-fill pickup-specific fields
|
// Pre-fill pickup-specific fields
|
||||||
currentData.setAppointmentDate(pickupDate.getValue());
|
currentData.setAppointmentDate(pickupDate.getValue());
|
||||||
@@ -1103,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
return trimmed.isEmpty() ? null : trimmed;
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setCustomerSelectionValue(String value) {
|
||||||
|
String normalizedValue = trimToNull(value);
|
||||||
|
if (normalizedValue == null) {
|
||||||
|
customerSelection.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customerLabelToEntity.containsKey(normalizedValue)) {
|
||||||
|
customerLabelToEntity.put(normalizedValue, null);
|
||||||
|
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||||
|
}
|
||||||
|
customerSelection.setValue(normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
private void openDeliveryDialog(StationTile tile, int stationIndex) {
|
private void openDeliveryDialog(StationTile tile, int stationIndex) {
|
||||||
// Ensure index is valid (could have changed due to deletions)
|
// Ensure index is valid (could have changed due to deletions)
|
||||||
int actualIndex = deliveryStationTilesList.indexOf(tile);
|
int actualIndex = deliveryStationTilesList.indexOf(tile);
|
||||||
@@ -1135,6 +1149,14 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
station.setCity(data.getCity());
|
station.setCity(data.getCity());
|
||||||
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
|
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
|
||||||
deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
|
deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
|
||||||
|
while (deliveryStationsCustomerId.size() <= idx) {
|
||||||
|
deliveryStationsCustomerId.add(null);
|
||||||
|
}
|
||||||
|
deliveryStationsCustomerId.set(idx, data.getCustomerId());
|
||||||
|
while (deliveryStationsAddressDiffers.size() <= idx) {
|
||||||
|
deliveryStationsAddressDiffers.add(false);
|
||||||
|
}
|
||||||
|
deliveryStationsAddressDiffers.set(idx, data.isAddressDiffersFromCustomer());
|
||||||
deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
|
deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
|
||||||
|
|
||||||
// Store tasks for this delivery station
|
// Store tasks for this delivery station
|
||||||
@@ -1180,6 +1202,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
currentData.setZip(station.getZip());
|
currentData.setZip(station.getZip());
|
||||||
currentData.setCity(station.getCity());
|
currentData.setCity(station.getCity());
|
||||||
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
|
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
|
||||||
|
if (actualIndex < deliveryStationsCustomerId.size()) {
|
||||||
|
currentData.setCustomerId(deliveryStationsCustomerId.get(actualIndex));
|
||||||
|
}
|
||||||
if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
|
if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
|
||||||
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
|
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
|
||||||
}
|
}
|
||||||
@@ -1388,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
// Get all customers for the current owner
|
// Get all customers for the current owner
|
||||||
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
|
||||||
|
|
||||||
// Extract unique company names (filter out null/empty values)
|
Map<String, Customer> addressOptions = new LinkedHashMap<>();
|
||||||
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName)
|
for (Customer customer : allCustomers) {
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
|
||||||
|
}
|
||||||
|
|
||||||
// Set items for autocomplete
|
// Set items for autocomplete
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(addressOptions.keySet()));
|
||||||
|
|
||||||
// Add selection listener to auto-fill pickup address fields when company is
|
// Add selection listener to auto-fill pickup address fields when company is
|
||||||
// selected
|
// selected
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
|
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
|
||||||
resetRouteInformation();
|
resetRouteInformation();
|
||||||
|
|
||||||
// Find the first customer with this company name
|
Customer customer = addressOptions.get(selectedAddress);
|
||||||
Optional<Customer> matchingCustomer = allCustomers.stream()
|
|
||||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
|
||||||
|
|
||||||
if (matchingCustomer.isPresent()) {
|
if (customer != null) {
|
||||||
Customer customer = matchingCustomer.get();
|
pickupCustomerId = customer.getId();
|
||||||
|
|
||||||
// Fill pickup address fields
|
// Fill pickup address fields
|
||||||
if (customer.getTitle() != null
|
if (customer.getTitle() != null
|
||||||
@@ -1452,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
|
|
||||||
// Reactivate save checkbox for custom values
|
// Reactivate save checkbox for custom values
|
||||||
savePickupAddress.setValue(true);
|
savePickupAddress.setValue(true);
|
||||||
|
pickupCustomerId = null;
|
||||||
pickupMail = null;
|
pickupMail = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1815,39 +1840,60 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEU: Kunden anlegen, wenn Checkboxen aktiviert
|
// Kunden anlegen/aktualisieren bzw. intern sichern
|
||||||
|
Customer pickupCustomer = new Customer();
|
||||||
|
pickupCustomer.setCompanyName(pickupCompany.getValue());
|
||||||
|
pickupCustomer.setTitle(pickupSalutation.getValue());
|
||||||
|
pickupCustomer.setFirstname(pickupFirstName.getValue());
|
||||||
|
pickupCustomer.setLastName(pickupLastName.getValue());
|
||||||
|
pickupCustomer.setTelephone(pickupPhone.getValue());
|
||||||
|
pickupCustomer.setMail(pickupMail);
|
||||||
|
pickupCustomer.setStreet(pickupStreet.getValue());
|
||||||
|
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
|
||||||
|
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
|
||||||
|
pickupCustomer.setZip(pickupZip.getValue());
|
||||||
|
pickupCustomer.setCity(pickupCity.getValue());
|
||||||
if (savePickupAddress.getValue()) {
|
if (savePickupAddress.getValue()) {
|
||||||
Customer pickupCustomer = new Customer();
|
if (pickupCustomerId != null) {
|
||||||
pickupCustomer.setCompanyName(pickupCompany.getValue());
|
pickupCustomer.setId(pickupCustomerId);
|
||||||
pickupCustomer.setTitle(pickupSalutation.getValue());
|
addCustomerService.updateCustomer(pickupCustomer);
|
||||||
pickupCustomer.setFirstname(pickupFirstName.getValue());
|
} else {
|
||||||
pickupCustomer.setLastName(pickupLastName.getValue());
|
addCustomerService.addCustomer(pickupCustomer);
|
||||||
pickupCustomer.setTelephone(pickupPhone.getValue());
|
}
|
||||||
pickupCustomer.setMail(pickupMail);
|
} else if (pickupAddressDiffers) {
|
||||||
pickupCustomer.setStreet(pickupStreet.getValue());
|
addCustomerService.addInternalCustomer(pickupCustomer);
|
||||||
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
|
|
||||||
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
|
|
||||||
pickupCustomer.setZip(pickupZip.getValue());
|
|
||||||
pickupCustomer.setCity(pickupCity.getValue());
|
|
||||||
addCustomerService.addCustomer(pickupCustomer);
|
|
||||||
}
|
}
|
||||||
// Save delivery station addresses as customers if checkbox is checked
|
// Delivery-Stationen: anlegen, aktualisieren oder als intern sichern
|
||||||
for (int i = 0; i < deliveryStationsState.size(); i++) {
|
for (int i = 0; i < deliveryStationsState.size(); i++) {
|
||||||
if (i < deliveryStationsSaveAddress.size() && deliveryStationsSaveAddress.get(i)) {
|
DeliveryStation ds = deliveryStationsState.get(i);
|
||||||
DeliveryStation ds = deliveryStationsState.get(i);
|
Customer deliveryCustomer = new Customer();
|
||||||
Customer deliveryCustomer = new Customer();
|
deliveryCustomer.setCompanyName(ds.getCompany());
|
||||||
deliveryCustomer.setCompanyName(ds.getCompany());
|
deliveryCustomer.setTitle(ds.getSalutation());
|
||||||
deliveryCustomer.setTitle(ds.getSalutation());
|
deliveryCustomer.setFirstname(ds.getFirstName());
|
||||||
deliveryCustomer.setFirstname(ds.getFirstName());
|
deliveryCustomer.setLastName(ds.getLastName());
|
||||||
deliveryCustomer.setLastName(ds.getLastName());
|
deliveryCustomer.setTelephone(ds.getPhone());
|
||||||
deliveryCustomer.setTelephone(ds.getPhone());
|
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
|
||||||
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
|
deliveryCustomer.setStreet(ds.getStreet());
|
||||||
deliveryCustomer.setStreet(ds.getStreet());
|
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
|
||||||
deliveryCustomer.setHouseNumber(ds.getHouseNumber());
|
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
|
||||||
deliveryCustomer.setAddressAddition(ds.getAddressAddition());
|
deliveryCustomer.setZip(ds.getZip());
|
||||||
deliveryCustomer.setZip(ds.getZip());
|
deliveryCustomer.setCity(ds.getCity());
|
||||||
deliveryCustomer.setCity(ds.getCity());
|
boolean saveRequested = i < deliveryStationsSaveAddress.size()
|
||||||
addCustomerService.addCustomer(deliveryCustomer);
|
&& deliveryStationsSaveAddress.get(i);
|
||||||
|
org.bson.types.ObjectId existingId = i < deliveryStationsCustomerId.size()
|
||||||
|
? deliveryStationsCustomerId.get(i)
|
||||||
|
: null;
|
||||||
|
boolean addressDiffers = i < deliveryStationsAddressDiffers.size()
|
||||||
|
&& deliveryStationsAddressDiffers.get(i);
|
||||||
|
if (saveRequested) {
|
||||||
|
if (existingId != null) {
|
||||||
|
deliveryCustomer.setId(existingId);
|
||||||
|
addCustomerService.updateCustomer(deliveryCustomer);
|
||||||
|
} else {
|
||||||
|
addCustomerService.addCustomer(deliveryCustomer);
|
||||||
|
}
|
||||||
|
} else if (addressDiffers) {
|
||||||
|
addCustomerService.addInternalCustomer(deliveryCustomer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1942,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
*/
|
*/
|
||||||
private void loadJobIntoForm(Job job) {
|
private void loadJobIntoForm(Job job) {
|
||||||
if (job.getCustomerSelection() != null) {
|
if (job.getCustomerSelection() != null) {
|
||||||
customerSelection.setValue(job.getCustomerSelection());
|
setCustomerSelectionValue(job.getCustomerSelection());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2091,7 +2137,105 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alle leeren (nicht editierten) Lieferstationen, sofern mindestens
|
||||||
|
* eine valide Lieferstation übrig bleibt. Wird aufgerufen bevor die Stationen
|
||||||
|
* übernommen werden.
|
||||||
|
*/
|
||||||
|
private void removeEmptyDeliveryStations() {
|
||||||
|
// Indizes der leeren Stationen sammeln (absteigend, damit beim Entfernen die Indizes stabil bleiben)
|
||||||
|
List<Integer> emptyIndices = new ArrayList<>();
|
||||||
|
for (int i = 0; i < deliveryStationsState.size(); i++) {
|
||||||
|
if (hasDeliveryStationValidationErrors(deliveryStationsState.get(i))) {
|
||||||
|
emptyIndices.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestens eine valide Station muss übrig bleiben
|
||||||
|
if (emptyIndices.size() >= deliveryStationsState.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Von hinten nach vorne entfernen, damit Indizes stabil bleiben
|
||||||
|
for (int k = emptyIndices.size() - 1; k >= 0; k--) {
|
||||||
|
int idx = emptyIndices.get(k);
|
||||||
|
|
||||||
|
deliveryStationTilesList.remove(idx);
|
||||||
|
deliveryStationsState.remove(idx);
|
||||||
|
deliveryStationsSaveAddress.remove(idx);
|
||||||
|
deliveryStationsCustomerId.remove(idx);
|
||||||
|
deliveryStationsAddressDiffers.remove(idx);
|
||||||
|
deliveryStationsMailState.remove(idx);
|
||||||
|
deliveryStationsValidatedByGoogle.remove(idx);
|
||||||
|
deliveryStationTasksState.remove(idx);
|
||||||
|
Div removedSlot = deliveryStationSlotList.remove(idx);
|
||||||
|
deliveryStationDistanceChips.remove(idx);
|
||||||
|
pickupToDeliveryRouteResults.remove(idx);
|
||||||
|
stationsGridContainer.remove(removedSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks und Routen-Maps re-indizieren
|
||||||
|
Map<Integer, List<BaseTask>> reindexedTasks = new HashMap<>();
|
||||||
|
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
|
||||||
|
int oldIdx = entry.getKey();
|
||||||
|
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
|
||||||
|
reindexedTasks.put(newIdx, entry.getValue());
|
||||||
|
}
|
||||||
|
deliveryStationTasksState.clear();
|
||||||
|
deliveryStationTasksState.putAll(reindexedTasks);
|
||||||
|
|
||||||
|
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
|
||||||
|
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
|
||||||
|
int oldIdx = entry.getKey();
|
||||||
|
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
|
||||||
|
reindexedRoutes.put(newIdx, entry.getValue());
|
||||||
|
}
|
||||||
|
pickupToDeliveryRouteResults.clear();
|
||||||
|
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
|
||||||
|
|
||||||
|
// Service-Zuordnungen anpassen
|
||||||
|
for (SelectedServiceEntry selectedService : selectedServices) {
|
||||||
|
Integer stationOrder = selectedService.getDeliveryStationOrder();
|
||||||
|
if (stationOrder == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (emptyIndices.contains(stationOrder)) {
|
||||||
|
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
|
||||||
|
} else {
|
||||||
|
int newOrder = stationOrder - (int) emptyIndices.stream().filter(ei -> ei < stationOrder).count();
|
||||||
|
selectedService.setDeliveryStationOrder(newOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiles neu nummerieren und Click-Listener aktualisieren
|
||||||
|
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
|
||||||
|
StationTile t = deliveryStationTilesList.get(i);
|
||||||
|
int newNumber = i + 1;
|
||||||
|
t.updateStationNumber(newNumber);
|
||||||
|
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
|
||||||
|
final int newIdx = i;
|
||||||
|
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
|
||||||
|
if (i == 0) {
|
||||||
|
t.setDeleteListener(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+" Button wieder anzeigen falls unter Maximum
|
||||||
|
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
|
||||||
|
&& addStationButtonSlot.getParent().isEmpty()) {
|
||||||
|
stationsGridContainer.add(addStationButtonSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servicesGrid != null) {
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
}
|
||||||
|
updatePriceSummary();
|
||||||
|
triggerValidation();
|
||||||
|
updateTabLabels();
|
||||||
|
}
|
||||||
|
|
||||||
private void handleApplyStations() {
|
private void handleApplyStations() {
|
||||||
|
removeEmptyDeliveryStations();
|
||||||
revealPriceAndDetailsSection();
|
revealPriceAndDetailsSection();
|
||||||
|
|
||||||
if (!areAllStationsValidatedByGoogle()) {
|
if (!areAllStationsValidatedByGoogle()) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.vaadin.flow.component.html.Span;
|
|||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
|
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
@@ -23,14 +24,16 @@ import de.assecutor.votianlt.model.Service;
|
|||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.service.CustomerService;
|
import de.assecutor.votianlt.pages.service.CustomerService;
|
||||||
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -45,7 +48,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.html.IFrame;
|
import com.vaadin.flow.component.html.IFrame;
|
||||||
|
|
||||||
@@ -61,13 +63,15 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final UserInvoiceDataService userInvoiceDataService;
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
|
||||||
private final CustomerService customerService;
|
private final CustomerService customerService;
|
||||||
|
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||||
private User currentUser;
|
private User currentUser;
|
||||||
private Job currentJob;
|
private Job currentJob;
|
||||||
private List<ServiceRow> gridRows = new ArrayList<>();
|
private List<ServiceRow> gridRows = new ArrayList<>();
|
||||||
private Grid<ServiceRow> servicesGrid;
|
private Grid<ServiceRow> servicesGrid;
|
||||||
private Div servicesSection;
|
private Div servicesSection;
|
||||||
|
private Div summarySection;
|
||||||
|
private NumberField vatField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class to represent a row in the services grid
|
* Helper class to represent a row in the services grid
|
||||||
@@ -114,8 +118,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
|
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
|
||||||
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
||||||
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
||||||
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository,
|
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
|
||||||
CustomerService customerService) {
|
InvoiceLifecycleService invoiceLifecycleService) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.serviceRepository = serviceRepository;
|
this.serviceRepository = serviceRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -123,8 +127,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
this.invoiceTemplateService = invoiceTemplateService;
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
|
||||||
this.customerService = customerService;
|
this.customerService = customerService;
|
||||||
|
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -176,6 +180,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentUser = securityService.getAuthenticatedUser()
|
||||||
|
.flatMap(auth -> userRepository.findByEmail(auth.getUsername())).orElse(null);
|
||||||
|
|
||||||
createInvoiceView();
|
createInvoiceView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +210,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
Div servicesSection = createServicesSelectionSection();
|
Div servicesSection = createServicesSelectionSection();
|
||||||
add(servicesSection);
|
add(servicesSection);
|
||||||
|
|
||||||
|
// VAT Section (must exist before summary so effectiveVatRate() can read the field)
|
||||||
|
Div vatSection = createVatSection();
|
||||||
|
add(vatSection);
|
||||||
|
|
||||||
// Summary Section
|
// Summary Section
|
||||||
Div summarySection = createSummarySection();
|
summarySection = createSummarySection();
|
||||||
add(summarySection);
|
add(summarySection);
|
||||||
|
|
||||||
// Create Invoice Button
|
// Create Invoice Button
|
||||||
@@ -336,13 +347,16 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
section.setWidthFull();
|
section.setWidthFull();
|
||||||
section.addClassName("invoice-section-card");
|
section.addClassName("invoice-section-card");
|
||||||
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
|
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
|
||||||
|
populateSummarySection(section);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateSummarySection(Div section) {
|
||||||
H3 sectionTitle = new H3(getTranslation("createinvoice.section.summary"));
|
H3 sectionTitle = new H3(getTranslation("createinvoice.section.summary"));
|
||||||
section.add(sectionTitle);
|
section.add(sectionTitle);
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
BigDecimal netAmount = calculateNetAmount();
|
BigDecimal netAmount = calculateNetAmount();
|
||||||
BigDecimal vatRate = Service.FIXED_VAT_RATE;
|
BigDecimal vatRate = effectiveVatRate();
|
||||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
@@ -355,9 +369,40 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
totalAmount.setScale(2, RoundingMode.HALF_UP) + " €", true));
|
totalAmount.setScale(2, RoundingMode.HALF_UP) + " €", true));
|
||||||
|
|
||||||
section.add(priceTable);
|
section.add(priceTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div createVatSection() {
|
||||||
|
Div section = new Div();
|
||||||
|
section.setWidthFull();
|
||||||
|
section.addClassName("invoice-section-card");
|
||||||
|
section.getStyle().set("margin-bottom", "var(--lumo-space-m)");
|
||||||
|
|
||||||
|
H3 sectionTitle = new H3(getTranslation("createinvoice.section.vat"));
|
||||||
|
section.add(sectionTitle);
|
||||||
|
|
||||||
|
vatField = new NumberField();
|
||||||
|
vatField.setLabel(getTranslation("createinvoice.field.vatrate"));
|
||||||
|
vatField.setSuffixComponent(new Span("%"));
|
||||||
|
vatField.setStep(0.01);
|
||||||
|
vatField.setMin(0);
|
||||||
|
BigDecimal initialRate = currentUser != null && currentUser.getVatRate() != null
|
||||||
|
? currentUser.getVatRate()
|
||||||
|
: Service.FIXED_VAT_RATE;
|
||||||
|
vatField.setValue(initialRate.multiply(new BigDecimal("100")).doubleValue());
|
||||||
|
vatField.addValueChangeListener(e -> refreshSummarySection());
|
||||||
|
|
||||||
|
section.add(vatField);
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshSummarySection() {
|
||||||
|
if (summarySection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summarySection.removeAll();
|
||||||
|
populateSummarySection(summarySection);
|
||||||
|
}
|
||||||
|
|
||||||
private Div createPriceRow(String label, String value, boolean bold) {
|
private Div createPriceRow(String label, String value, boolean bold) {
|
||||||
Div row = new Div();
|
Div row = new Div();
|
||||||
row.addClassName("price-row");
|
row.addClassName("price-row");
|
||||||
@@ -427,6 +472,17 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
return units;
|
return units;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BigDecimal effectiveVatRate() {
|
||||||
|
if (vatField != null && vatField.getValue() != null) {
|
||||||
|
return new BigDecimal(Double.toString(vatField.getValue()))
|
||||||
|
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
if (currentUser != null && currentUser.getVatRate() != null) {
|
||||||
|
return currentUser.getVatRate();
|
||||||
|
}
|
||||||
|
return Service.FIXED_VAT_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
private BigDecimal calculateNetAmount() {
|
private BigDecimal calculateNetAmount() {
|
||||||
BigDecimal total = BigDecimal.ZERO;
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
|
||||||
@@ -514,7 +570,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
|
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
|
||||||
|
|
||||||
BigDecimal netAmount = calculateNetAmount();
|
BigDecimal netAmount = calculateNetAmount();
|
||||||
BigDecimal vatRate = Service.FIXED_VAT_RATE;
|
BigDecimal vatRate = effectiveVatRate();
|
||||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
@@ -529,8 +585,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
invoice.setVatRate(vatRate);
|
invoice.setVatRate(vatRate);
|
||||||
invoice.setVatAmount(vatAmount);
|
invoice.setVatAmount(vatAmount);
|
||||||
invoice.setTotalAmount(totalAmount);
|
invoice.setTotalAmount(totalAmount);
|
||||||
|
|
||||||
invoice.setPdfData(pdfBytes);
|
invoice.setPdfData(pdfBytes);
|
||||||
CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice);
|
|
||||||
|
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
|
||||||
|
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
|
||||||
|
"Rechnung erstellt aus Auftrag " + currentJob.getJobNumber());
|
||||||
|
|
||||||
currentJob.setInvoiceId(savedInvoice.getId());
|
currentJob.setInvoiceId(savedInvoice.getId());
|
||||||
jobRepository.save(currentJob);
|
jobRepository.save(currentJob);
|
||||||
@@ -539,6 +599,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
|
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
|
||||||
Notification.Position.BOTTOM_END);
|
Notification.Position.BOTTOM_END);
|
||||||
|
|
||||||
|
} catch (InvoiceLifecycleException lifecycleEx) {
|
||||||
|
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
|
||||||
|
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Fehler beim Speichern der Rechnung", ex);
|
log.error("Fehler beim Speichern der Rechnung", ex);
|
||||||
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
|
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
|
||||||
@@ -553,7 +616,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
BigDecimal netAmount = calculateNetAmount();
|
BigDecimal netAmount = calculateNetAmount();
|
||||||
BigDecimal vatRate = Service.FIXED_VAT_RATE;
|
BigDecimal vatRate = effectiveVatRate();
|
||||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
@@ -659,9 +722,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
private void showPdfPreviewDialog(byte[] pdfBytes, String templateData, User user) {
|
private void showPdfPreviewDialog(byte[] pdfBytes, String templateData, User user) {
|
||||||
String title = getTranslation("createinvoice.preview.title");
|
String title = getTranslation("createinvoice.preview.title");
|
||||||
|
|
||||||
Dialog pdfDialog = new Dialog();
|
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
|
||||||
pdfDialog.setHeaderTitle(title);
|
|
||||||
pdfDialog.setWidth("90vw");
|
|
||||||
pdfDialog.setHeight("90vh");
|
pdfDialog.setHeight("90vh");
|
||||||
|
|
||||||
IFrame pdfFrame = new IFrame();
|
IFrame pdfFrame = new IFrame();
|
||||||
@@ -676,19 +737,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> {
|
Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> {
|
||||||
ConfirmDialog confirm = new ConfirmDialog();
|
Dialog confirm = DialogStylingHelper.createConfirmationDialog(
|
||||||
confirm.setHeader(getTranslation("createinvoice.confirm.save.title"));
|
getTranslation("createinvoice.confirm.save.title"),
|
||||||
confirm.setText(getTranslation("createinvoice.confirm.save.message"));
|
getTranslation("createinvoice.confirm.save.message"),
|
||||||
confirm.setConfirmText(getTranslation("createinvoice.confirm.save.confirm"));
|
"560px",
|
||||||
confirm.setConfirmButtonTheme("primary");
|
getTranslation("button.cancel"),
|
||||||
confirm.setCancelText(getTranslation("button.cancel"));
|
getTranslation("createinvoice.confirm.save.confirm"),
|
||||||
confirm.setCancelable(true);
|
() -> saveInvoice(templateData, user, pdfDialog),
|
||||||
confirm.addConfirmListener(ev -> saveInvoice(templateData, user, pdfDialog));
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
confirm.open();
|
confirm.open();
|
||||||
});
|
});
|
||||||
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
|
|
||||||
pdfDialog.add(pdfFrame);
|
pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
|
||||||
pdfDialog.getFooter().add(closeButton, saveButton);
|
pdfDialog.getFooter().add(closeButton, saveButton);
|
||||||
pdfDialog.open();
|
pdfDialog.open();
|
||||||
}
|
}
|
||||||
@@ -696,9 +757,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
public static void showSavedInvoiceDialog(byte[] pdfBytes, String invoiceNumber,
|
public static void showSavedInvoiceDialog(byte[] pdfBytes, String invoiceNumber,
|
||||||
com.vaadin.flow.component.Component parent) {
|
com.vaadin.flow.component.Component parent) {
|
||||||
String title = "Rechnung " + invoiceNumber;
|
String title = "Rechnung " + invoiceNumber;
|
||||||
Dialog pdfDialog = new Dialog();
|
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(title, "90vw");
|
||||||
pdfDialog.setHeaderTitle(title);
|
|
||||||
pdfDialog.setWidth("90vw");
|
|
||||||
pdfDialog.setHeight("90vh");
|
pdfDialog.setHeight("90vh");
|
||||||
|
|
||||||
IFrame pdfFrame = new IFrame();
|
IFrame pdfFrame = new IFrame();
|
||||||
@@ -720,7 +779,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
Button closeButton = new Button("Schließen", e -> pdfDialog.close());
|
Button closeButton = new Button("Schließen", e -> pdfDialog.close());
|
||||||
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
pdfDialog.add(pdfFrame);
|
pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
|
||||||
pdfDialog.getFooter().add(downloadButton, closeButton);
|
pdfDialog.getFooter().add(downloadButton, closeButton);
|
||||||
pdfDialog.open();
|
pdfDialog.open();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ public class CustomersView extends Main implements HasDynamicTitle {
|
|||||||
createBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
createBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
todoGrid = new Grid<>();
|
todoGrid = new Grid<>();
|
||||||
todoGrid.setItems(query -> todoService.list(toSpringPageRequest(query)).stream());
|
todoGrid.setItems(query -> todoService.list(toSpringPageRequest(query)).stream()
|
||||||
|
.filter(c -> !c.isInternal()));
|
||||||
todoGrid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"));
|
todoGrid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"));
|
||||||
todoGrid.setSizeFull();
|
todoGrid.setSizeFull();
|
||||||
todoGrid.addClassName("data-grid");
|
todoGrid.addClassName("data-grid");
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
|||||||
|
|
||||||
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
|
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
|
||||||
if (customer != null && customer.getId() != null) {
|
if (customer != null && customer.getId() != null) {
|
||||||
|
customerService.deleteById(customer.getId());
|
||||||
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
|
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
|
||||||
Notification.Position.MIDDLE);
|
Notification.Position.MIDDLE);
|
||||||
confirmDialog.close();
|
confirmDialog.close();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private UserInvoiceData currentInvoiceData;
|
private UserInvoiceData currentInvoiceData;
|
||||||
private Checkbox billingEnabled;
|
private Checkbox billingEnabled;
|
||||||
|
private NumberField vatRateField;
|
||||||
private VerticalLayout propertiesPanelProfile;
|
private VerticalLayout propertiesPanelProfile;
|
||||||
|
|
||||||
private final ServiceRepository serviceRepository;
|
private final ServiceRepository serviceRepository;
|
||||||
@@ -145,7 +146,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
TextField faxField = new TextField(getTranslation("profile.fax"));
|
TextField faxField = new TextField(getTranslation("profile.fax"));
|
||||||
TextField mobileField = new TextField(getTranslation("profile.mobile"));
|
TextField mobileField = new TextField(getTranslation("profile.mobile"));
|
||||||
|
|
||||||
EmailField emailField = new EmailField(getTranslation("profile.email.required"));
|
EmailField emailField = new EmailField(getTranslation("profile.email"));
|
||||||
emailField.addBlurListener(e -> validateEmailField(emailField));
|
emailField.addBlurListener(e -> validateEmailField(emailField));
|
||||||
|
|
||||||
TextField streetField = new TextField(getTranslation("profile.street"));
|
TextField streetField = new TextField(getTranslation("profile.street"));
|
||||||
@@ -330,10 +331,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
termsTextArea = new TextArea();
|
termsTextArea = new TextArea();
|
||||||
pdfFrame = new IFrame();
|
pdfFrame = new IFrame();
|
||||||
|
|
||||||
// Nur die Checkbox "Rechnungslegung über votianLT"
|
// Checkbox "Rechnungslegung über votianLT"
|
||||||
billingEnabled = new Checkbox(getTranslation("profile.billing.enabled"));
|
billingEnabled = new Checkbox(getTranslation("profile.billing.enabled"));
|
||||||
billingEnabled.setValue(true); // Standardmäßig aktiviert
|
billingEnabled.setValue(true); // Standardmäßig aktiviert
|
||||||
billingTab.add(billingEnabled);
|
|
||||||
|
|
||||||
prefixField.setLabel(getTranslation("profile.billing.prefix"));
|
prefixField.setLabel(getTranslation("profile.billing.prefix"));
|
||||||
prefixField.setPlaceholder("z.B. RE-2024-");
|
prefixField.setPlaceholder("z.B. RE-2024-");
|
||||||
@@ -347,7 +347,30 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
"if (window.updateProfileMasterdataValue) { window.updateProfileMasterdataValue('masterdata.invoice_number', '"
|
"if (window.updateProfileMasterdataValue) { window.updateProfileMasterdataValue('masterdata.invoice_number', '"
|
||||||
+ invNr.replace("'", "\\'") + "'); }");
|
+ invNr.replace("'", "\\'") + "'); }");
|
||||||
});
|
});
|
||||||
billingTab.add(prefixField);
|
|
||||||
|
vatRateField = new NumberField();
|
||||||
|
vatRateField.setLabel(getTranslation("profile.settings.vatrate"));
|
||||||
|
vatRateField.setSuffixComponent(new Span("%"));
|
||||||
|
vatRateField.setStep(0.01);
|
||||||
|
vatRateField.setMin(0);
|
||||||
|
vatRateField.setMaxWidth("200px");
|
||||||
|
if (currentUser.getVatRate() != null) {
|
||||||
|
vatRateField.setValue(currentUser.getVatRate().multiply(new java.math.BigDecimal("100")).doubleValue());
|
||||||
|
}
|
||||||
|
vatRateField.addValueChangeListener(e -> {
|
||||||
|
Double v = e.getValue();
|
||||||
|
if (v != null) {
|
||||||
|
currentUser.setVatRate(new java.math.BigDecimal(Double.toString(v))
|
||||||
|
.divide(new java.math.BigDecimal("100"), 4, java.math.RoundingMode.HALF_UP));
|
||||||
|
getElement().executeJs("if (window.updateProfileVatRate) { window.updateProfileVatRate($0); }",
|
||||||
|
v / 100.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField);
|
||||||
|
billingHeaderLayout.setSpacing(true);
|
||||||
|
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
||||||
|
billingTab.add(billingHeaderLayout);
|
||||||
|
|
||||||
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||||
final HorizontalLayout mainLayout = new HorizontalLayout();
|
final HorizontalLayout mainLayout = new HorizontalLayout();
|
||||||
@@ -451,6 +474,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
billingEnabled.addValueChangeListener(e -> {
|
billingEnabled.addValueChangeListener(e -> {
|
||||||
boolean visible = e.getValue();
|
boolean visible = e.getValue();
|
||||||
prefixField.setVisible(visible);
|
prefixField.setVisible(visible);
|
||||||
|
vatRateField.setVisible(visible);
|
||||||
mainLayout.setVisible(visible);
|
mainLayout.setVisible(visible);
|
||||||
actionLayout.setVisible(visible);
|
actionLayout.setVisible(visible);
|
||||||
});
|
});
|
||||||
@@ -843,6 +867,17 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
&& !houseNumberField.isInvalid() && !zipField.isInvalid() && !cityField.isInvalid();
|
&& !houseNumberField.isInvalid() && !zipField.isInvalid() && !cityField.isInvalid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BigDecimal getPreviewVatRate() {
|
||||||
|
if (vatRateField != null && vatRateField.getValue() != null) {
|
||||||
|
return new BigDecimal(Double.toString(vatRateField.getValue())).divide(new BigDecimal("100"), 4,
|
||||||
|
java.math.RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
if (currentUser != null && currentUser.getVatRate() != null) {
|
||||||
|
return currentUser.getVatRate();
|
||||||
|
}
|
||||||
|
return Service.FIXED_VAT_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
// Methoden für den Rechnungsgenerator im Profil
|
// Methoden für den Rechnungsgenerator im Profil
|
||||||
private void generatePreviewPdfFromProfile() {
|
private void generatePreviewPdfFromProfile() {
|
||||||
try {
|
try {
|
||||||
@@ -862,8 +897,9 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
} else {
|
} else {
|
||||||
templateData = result.toString();
|
templateData = result.toString();
|
||||||
}
|
}
|
||||||
|
BigDecimal previewVatRate = getPreviewVatRate();
|
||||||
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
|
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
|
||||||
currentUser, prefixField.getValue());
|
currentUser, prefixField.getValue(), previewVatRate);
|
||||||
showPdfInDialog(pdfBytes);
|
showPdfInDialog(pdfBytes);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
|
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
|
||||||
@@ -882,9 +918,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
String dataUrl = "data:application/pdf;base64," + base64Pdf;
|
String dataUrl = "data:application/pdf;base64," + base64Pdf;
|
||||||
|
|
||||||
// Create dialog
|
// Create dialog
|
||||||
Dialog pdfDialog = new Dialog();
|
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("profile.invoice.pdf.preview"), "90vw");
|
||||||
pdfDialog.setHeaderTitle(getTranslation("profile.invoice.pdf.preview"));
|
|
||||||
pdfDialog.setWidth("90vw");
|
|
||||||
pdfDialog.setHeight("90vh");
|
pdfDialog.setHeight("90vh");
|
||||||
|
|
||||||
// Create a Div to hold the PDF viewer
|
// Create a Div to hold the PDF viewer
|
||||||
@@ -914,7 +948,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
});
|
});
|
||||||
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
pdfDialog.add(pdfContainer);
|
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
|
||||||
pdfDialog.getFooter().add(downloadButton, closeButton);
|
pdfDialog.getFooter().add(downloadButton, closeButton);
|
||||||
pdfDialog.open();
|
pdfDialog.open();
|
||||||
}
|
}
|
||||||
@@ -1426,9 +1460,14 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
|
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
|
||||||
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
|
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
|
||||||
+ "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}";
|
+ "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}";
|
||||||
|
BigDecimal rate = currentUser.getVatRate() != null ? currentUser.getVatRate()
|
||||||
|
: Service.FIXED_VAT_RATE;
|
||||||
|
double vatRateJs = rate.doubleValue();
|
||||||
getElement().executeJs("setTimeout(function() { "
|
getElement().executeJs("setTimeout(function() { "
|
||||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||||
+ " console.log('Loading template into canvas...'); " + " window.masterdataValues = "
|
+ " console.log('Loading template into canvas...'); "
|
||||||
|
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
||||||
|
+ " window.masterdataValues = "
|
||||||
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
||||||
+ " window.loadProfileTemplate(templateData); " + " } else { "
|
+ " window.loadProfileTemplate(templateData); " + " } else { "
|
||||||
+ " console.error('loadProfileTemplate or canvas not available'); " + " } "
|
+ " console.error('loadProfileTemplate or canvas not available'); " + " } "
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H5;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import jakarta.annotation.security.PermitAll;
|
import jakarta.annotation.security.PermitAll;
|
||||||
@@ -25,29 +28,82 @@ public class ImprintView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint"));
|
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint"));
|
||||||
content.add(toolbar);
|
content.add(toolbar);
|
||||||
|
|
||||||
try {
|
Div imprintCard = new Div();
|
||||||
// Load HTML content from resources
|
imprintCard.addClassNames("form-card", "form-shell");
|
||||||
ClassPathResource resource = new ClassPathResource("html/imprint.html");
|
imprintCard.getStyle().set("max-width", "800px").set("margin", "0 auto");
|
||||||
String htmlContent = new String(resource.getInputStream().readAllBytes());
|
|
||||||
|
|
||||||
// Create a Div to hold the HTML content
|
VerticalLayout imprintContent = new VerticalLayout();
|
||||||
Div imprintDiv = new Div();
|
imprintContent.setPadding(false);
|
||||||
imprintDiv.addClassNames("form-card", "form-shell");
|
imprintContent.setSpacing(false);
|
||||||
imprintDiv.getElement().setProperty("innerHTML", htmlContent);
|
imprintContent.getStyle().set("gap", "var(--lumo-space-l)");
|
||||||
|
|
||||||
content.add(imprintDiv);
|
imprintContent.add(
|
||||||
|
createSection("Assecutor Data Service GmbH",
|
||||||
|
createLinesBlock(createLine("Gerhart-Hauptmann-Weg 14"), createLine("21502 Geesthacht"),
|
||||||
|
createLine(getTranslation("imprint.country")),
|
||||||
|
createLine(getTranslation("imprint.phone") + ": +49 40 18 123 771 0"),
|
||||||
|
createEmailLine())),
|
||||||
|
createSection(getTranslation("imprint.management"),
|
||||||
|
createLinesBlock(createLine("Carsten Annacker"), createLine("Gunnar Timm"))),
|
||||||
|
createSection(getTranslation("imprint.registeredoffice"),
|
||||||
|
createLinesBlock(createLine("Gerhart-Hauptmann-Weg 14, 21502 Geesthacht"))),
|
||||||
|
createSection(getTranslation("imprint.commercialregister"),
|
||||||
|
createLinesBlock(createLine("HRB 8595 HL"))),
|
||||||
|
createSection(getTranslation("imprint.vatid"), createLinesBlock(createLine("DE261094748"))),
|
||||||
|
createSection(getTranslation("imprint.imagecredits"),
|
||||||
|
createLinesBlock(createSectionHeading(getTranslation("imprint.backgroundimage")),
|
||||||
|
createLine("MAN Financial Services (EURO-Leasing), flickr"),
|
||||||
|
createLine(
|
||||||
|
"(Creative Commons, Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0))"),
|
||||||
|
createExternalLink(
|
||||||
|
"https://www.flickr.com/photos/mbwa_pr/15969764443/in/album-72157632488355514/"))));
|
||||||
|
|
||||||
} catch (Exception e) {
|
imprintCard.add(imprintContent);
|
||||||
// Fallback content in case of error
|
content.add(imprintCard);
|
||||||
Div errorDiv = new Div();
|
|
||||||
errorDiv.addClassNames("form-card", "form-shell");
|
|
||||||
errorDiv.setText(getTranslation("imprint.error", e.getMessage()));
|
|
||||||
content.add(errorDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
add(content);
|
add(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Component createSection(String title, Component body) {
|
||||||
|
Div section = new Div();
|
||||||
|
section.getStyle().set("text-align", "left");
|
||||||
|
section.add(createSectionHeading(title), body);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private H5 createSectionHeading(String title) {
|
||||||
|
H5 heading = new H5(title);
|
||||||
|
heading.getStyle().set("margin", "0 0 var(--lumo-space-s) 0");
|
||||||
|
return heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div createLinesBlock(Component... lines) {
|
||||||
|
Div block = new Div();
|
||||||
|
block.getStyle().set("display", "flex").set("flex-direction", "column").set("gap", "4px");
|
||||||
|
block.add(lines);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Span createLine(String text) {
|
||||||
|
Span line = new Span(text);
|
||||||
|
line.getStyle().set("display", "block");
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div createEmailLine() {
|
||||||
|
Div line = new Div();
|
||||||
|
line.getStyle().set("display", "block");
|
||||||
|
line.add(new Span(getTranslation("imprint.email") + ": "));
|
||||||
|
line.add(new Anchor("mailto:ahoi@assecutor.de", "ahoi@assecutor.de"));
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Anchor createExternalLink(String href) {
|
||||||
|
Anchor link = new Anchor(href, href);
|
||||||
|
link.setTarget("_blank");
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getPageTitle() {
|
public String getPageTitle() {
|
||||||
return getTranslation("page.title.imprint");
|
return getTranslation("page.title.imprint");
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import com.vaadin.flow.component.html.Input;
|
import com.vaadin.flow.component.html.Input;
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.html.IFrame;
|
import com.vaadin.flow.component.html.IFrame;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
|
||||||
import elemental.json.JsonValue;
|
import elemental.json.JsonValue;
|
||||||
import elemental.json.JsonType;
|
import elemental.json.JsonType;
|
||||||
import com.vaadin.flow.component.upload.Upload;
|
import com.vaadin.flow.component.upload.Upload;
|
||||||
@@ -306,15 +305,8 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showPdfInDialog(byte[] pdfBytes) {
|
private void showPdfInDialog(byte[] pdfBytes) {
|
||||||
// Create a stream resource for the PDF
|
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
|
||||||
StreamResource resource = new StreamResource("preview.pdf", () -> new java.io.ByteArrayInputStream(pdfBytes));
|
"90vw");
|
||||||
resource.setContentType("application/pdf");
|
|
||||||
resource.setCacheTime(0);
|
|
||||||
|
|
||||||
// Create dialog
|
|
||||||
Dialog pdfDialog = new Dialog();
|
|
||||||
pdfDialog.setHeaderTitle(getTranslation("invoicegenerator.pdf.preview.title"));
|
|
||||||
pdfDialog.setWidth("90vw");
|
|
||||||
pdfDialog.setHeight("90vh");
|
pdfDialog.setHeight("90vh");
|
||||||
|
|
||||||
// Create a Div to hold the PDF viewer
|
// Create a Div to hold the PDF viewer
|
||||||
@@ -347,7 +339,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
});
|
});
|
||||||
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
pdfDialog.add(pdfContainer);
|
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
|
||||||
pdfDialog.getFooter().add(downloadButton, closeButton);
|
pdfDialog.getFooter().add(downloadButton, closeButton);
|
||||||
pdfDialog.open();
|
pdfDialog.open();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,82 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.UI;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H3;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.server.StreamRegistration;
|
||||||
|
import com.vaadin.flow.server.StreamResource;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceExportService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
|
import de.assecutor.votianlt.service.InvoicePermissionService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import com.vaadin.flow.server.StreamResource;
|
|
||||||
import com.vaadin.flow.server.StreamRegistration;
|
|
||||||
|
|
||||||
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@RolesAllowed({ "USER", "ADMIN" })
|
@RolesAllowed({ "USER", "ADMIN" })
|
||||||
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
private final Grid<CustomerInvoice> invoiceGrid;
|
private final Grid<CustomerInvoice> invoiceGrid;
|
||||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||||
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
|
private final InvoiceExportService invoiceExportService;
|
||||||
|
private final InvoicePermissionService invoicePermissionService;
|
||||||
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService) {
|
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
|
||||||
|
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
|
||||||
|
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
|
||||||
|
UserInvoiceDataService userInvoiceDataService,
|
||||||
|
UserRepository userRepository) {
|
||||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||||
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
|
this.invoiceExportService = invoiceExportService;
|
||||||
|
this.invoicePermissionService = invoicePermissionService;
|
||||||
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
@@ -43,60 +86,502 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
addClassName("data-view");
|
addClassName("data-view");
|
||||||
|
|
||||||
add(new ViewToolbar(getTranslation("invoices.title")));
|
add(new ViewToolbar(getTranslation("invoices.title")));
|
||||||
|
add(buildLegalDisclaimer());
|
||||||
|
|
||||||
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
|
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
|
||||||
invoiceGrid.setWidthFull();
|
invoiceGrid.setWidthFull();
|
||||||
invoiceGrid.addClassName("data-grid");
|
invoiceGrid.addClassName("data-grid");
|
||||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
|
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
|
||||||
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
|
||||||
|
invoiceGrid.addComponentColumn(this::renderTypeBadge)
|
||||||
|
.setHeader(getTranslation("invoices.column.type")).setAutoWidth(true);
|
||||||
|
invoiceGrid.addComponentColumn(this::renderStatusBadge)
|
||||||
|
.setHeader(getTranslation("invoices.column.status")).setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
|
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
|
||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
|
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
|
||||||
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
|
invoiceGrid.addComponentColumn(this::renderActions)
|
||||||
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||||
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
|
|
||||||
invoiceGrid.getStyle().set("cursor", "pointer");
|
|
||||||
|
|
||||||
invoiceGrid.addItemClickListener(event -> {
|
invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
|
||||||
CustomerInvoice invoice = event.getItem();
|
|
||||||
if (invoice != null) {
|
|
||||||
downloadInvoicePdf(invoice);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadInvoices();
|
|
||||||
Div gridPanel = new Div(invoiceGrid);
|
Div gridPanel = new Div(invoiceGrid);
|
||||||
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||||
gridPanel.setWidthFull();
|
gridPanel.setWidthFull();
|
||||||
|
|
||||||
add(gridPanel);
|
add(gridPanel);
|
||||||
|
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component buildLegalDisclaimer() {
|
||||||
|
Div banner = new Div();
|
||||||
|
banner.addClassName("surface-panel");
|
||||||
|
banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)")
|
||||||
|
.set("background", "var(--lumo-contrast-5pct)");
|
||||||
|
Span text = new Span(getTranslation("invoices.disclaimer"));
|
||||||
|
text.getStyle().set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
banner.add(text);
|
||||||
|
return banner;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadInvoices() {
|
private void loadInvoices() {
|
||||||
String currentUserId = securityService.getCurrentUserId().toHexString();
|
String currentUserId = securityService.getCurrentUserId().toHexString();
|
||||||
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
|
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
|
||||||
.filter(this::hasPdfData).sorted((left, right) -> {
|
.sorted(Comparator
|
||||||
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
|
.comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
|
||||||
return 0;
|
: i.getInvoiceDate())
|
||||||
}
|
.reversed())
|
||||||
if (left.getInvoiceDate() == null) {
|
.toList();
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (right.getInvoiceDate() == null) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
|
|
||||||
}).toList();
|
|
||||||
invoiceGrid.setItems(invoices);
|
invoiceGrid.setItems(invoices);
|
||||||
|
}
|
||||||
|
|
||||||
if (invoices.isEmpty()) {
|
private Component renderStatusBadge(CustomerInvoice invoice) {
|
||||||
Span emptyState = new Span(getTranslation("invoices.empty"));
|
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
|
||||||
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
|
||||||
add(emptyState);
|
badge.getElement().getThemeList().add("badge");
|
||||||
|
switch (status) {
|
||||||
|
case DRAFT -> badge.getElement().getThemeList().add("contrast");
|
||||||
|
case SENT -> badge.getElement().getThemeList().add("success");
|
||||||
|
case CANCELLED -> badge.getElement().getThemeList().add("error");
|
||||||
|
case CORRECTED -> badge.getElement().getThemeList().add("warning");
|
||||||
|
default -> {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component renderTypeBadge(CustomerInvoice invoice) {
|
||||||
|
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
||||||
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
|
layout.setSpacing(true);
|
||||||
|
layout.setPadding(false);
|
||||||
|
|
||||||
|
Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT)));
|
||||||
|
badge.getElement().getThemeList().add("badge");
|
||||||
|
if (type == InvoiceType.CANCELLATION) {
|
||||||
|
badge.getElement().getThemeList().add("error");
|
||||||
|
} else if (type == InvoiceType.CORRECTION) {
|
||||||
|
badge.getElement().getThemeList().add("warning");
|
||||||
|
}
|
||||||
|
layout.add(badge);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component renderActions(CustomerInvoice invoice) {
|
||||||
|
HorizontalLayout actions = new HorizontalLayout();
|
||||||
|
actions.setSpacing(true);
|
||||||
|
actions.setPadding(false);
|
||||||
|
|
||||||
|
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
|
||||||
|
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
|
||||||
|
actions.add(viewBtn);
|
||||||
|
|
||||||
|
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
|
||||||
|
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(historyBtn);
|
||||||
|
|
||||||
|
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
|
||||||
|
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
|
||||||
|
// Aktionen nur für reguläre, noch aktive Rechnungen anbieten
|
||||||
|
boolean isLiveInvoice = type == InvoiceType.INVOICE
|
||||||
|
&& (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT);
|
||||||
|
|
||||||
|
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
|
||||||
|
&& invoicePermissionService.canMarkAsSent(currentUser)) {
|
||||||
|
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
|
||||||
|
e -> markAsSent(invoice));
|
||||||
|
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(sentBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLiveInvoice) {
|
||||||
|
if (invoicePermissionService.canCorrect(currentUser)) {
|
||||||
|
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
|
||||||
|
e -> openCorrectionDialog(invoice));
|
||||||
|
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(correctBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoicePermissionService.canCancel(currentUser)) {
|
||||||
|
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
|
||||||
|
e -> openCancellationDialog(invoice));
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
|
||||||
|
actions.add(cancelBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
|
||||||
|
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
|
||||||
|
&& invoicePermissionService.canRecordPayment(currentUser)) {
|
||||||
|
Button payBtn = new Button(getTranslation("invoices.action.payment"),
|
||||||
|
e -> openPaymentDialog(invoice));
|
||||||
|
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(payBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belegpaket exportieren (R-33/R-34)
|
||||||
|
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
|
||||||
|
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(exportBtn);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPaymentDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
|
||||||
|
Span hint = new Span(getTranslation("invoices.payment.hint",
|
||||||
|
java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding)));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
NumberField amountField = new NumberField(getTranslation("invoices.payment.amount"));
|
||||||
|
amountField.setStep(0.01);
|
||||||
|
amountField.setValue(outstanding.doubleValue());
|
||||||
|
amountField.setRequiredIndicatorVisible(true);
|
||||||
|
amountField.setWidthFull();
|
||||||
|
content.add(amountField);
|
||||||
|
|
||||||
|
TextField referenceField = new TextField(getTranslation("invoices.payment.reference"));
|
||||||
|
referenceField.setWidthFull();
|
||||||
|
content.add(referenceField);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("80px");
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.payment.confirm"), e -> {
|
||||||
|
Double amount = amountField.getValue();
|
||||||
|
if (amount == null || amount == 0d) {
|
||||||
|
amountField.setInvalid(true);
|
||||||
|
amountField.setErrorMessage(getTranslation("invoices.payment.amount.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(),
|
||||||
|
reasonField.getValue(), dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason,
|
||||||
|
Dialog dialog) {
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requirePayment(invoicePermissionService.currentUser());
|
||||||
|
invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void exportPackage(CustomerInvoice invoice) {
|
||||||
|
try {
|
||||||
|
byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice);
|
||||||
|
String fileName = invoiceExportService.suggestFilename(invoice);
|
||||||
|
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes));
|
||||||
|
resource.setContentType("application/zip");
|
||||||
|
resource.setCacheTime(0);
|
||||||
|
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
|
||||||
|
.registerResource(resource);
|
||||||
|
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markAsSent(CustomerInvoice invoice) {
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
|
||||||
|
invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert");
|
||||||
|
Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCancellationDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("invoices.cancel.hint"));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("100px");
|
||||||
|
reasonField.setRequired(true);
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.cancel.confirm"), e -> {
|
||||||
|
String reason = reasonField.getValue();
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
reasonField.setInvalid(true);
|
||||||
|
reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performCancellation(invoice, reason, dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) {
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireCancel(currentUser);
|
||||||
|
User issuer = resolveIssuer(invoice);
|
||||||
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason);
|
||||||
|
invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.cancelled", number), 4000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCorrectionDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("invoices.correct.hint"));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields"));
|
||||||
|
fieldsField.setWidthFull();
|
||||||
|
fieldsField.setMinHeight("100px");
|
||||||
|
fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper"));
|
||||||
|
fieldsField.setRequired(true);
|
||||||
|
content.add(fieldsField);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("80px");
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.correct.confirm"), e -> {
|
||||||
|
String fields = fieldsField.getValue();
|
||||||
|
if (fields == null || fields.isBlank()) {
|
||||||
|
fieldsField.setInvalid(true);
|
||||||
|
fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performCorrection(invoice, fields, reasonField.getValue(), dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) {
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireCorrect(currentUser);
|
||||||
|
User issuer = resolveIssuer(invoice);
|
||||||
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields);
|
||||||
|
invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.corrected", number), 4000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private User resolveIssuer(CustomerInvoice invoice) {
|
||||||
|
if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) {
|
||||||
|
try {
|
||||||
|
return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId()))
|
||||||
|
.orElseGet(securityService::getCurrentDatabaseUser);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// userId ist kein gültiger ObjectId – Fallback auf eingeloggten Nutzer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return securityService.getCurrentDatabaseUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openHistoryDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
// Verkettung anzeigen, falls vorhanden
|
||||||
|
Div linksBlock = renderRelatedInvoiceLinks(invoice);
|
||||||
|
if (linksBlock != null) {
|
||||||
|
content.add(linksBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
H3 logTitle = new H3(getTranslation("invoices.history.log"));
|
||||||
|
content.add(logTitle);
|
||||||
|
|
||||||
|
List<InvoiceAuditEntry> log = invoice.getAuditLog();
|
||||||
|
if (log == null || log.isEmpty()) {
|
||||||
|
content.add(new Span(getTranslation("invoices.history.empty")));
|
||||||
|
} else {
|
||||||
|
log.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null
|
||||||
|
? java.time.LocalDateTime.MIN
|
||||||
|
: e.getTimestamp())
|
||||||
|
.reversed())
|
||||||
|
.forEach(entry -> content.add(renderAuditEntry(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content, true));
|
||||||
|
|
||||||
|
Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close());
|
||||||
|
closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
dialog.getFooter().add(closeBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) {
|
||||||
|
Div block = new Div();
|
||||||
|
boolean hasContent = false;
|
||||||
|
if (invoice.getOriginalInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.original"),
|
||||||
|
invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getCancellationInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.cancellation"),
|
||||||
|
null, invoice.getCancellationInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getCorrectionInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.correction"),
|
||||||
|
null, invoice.getCorrectionInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getReplacementInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.replacement"),
|
||||||
|
null, invoice.getReplacementInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
return hasContent ? block : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) {
|
||||||
|
HorizontalLayout row = new HorizontalLayout();
|
||||||
|
row.setSpacing(true);
|
||||||
|
row.setPadding(false);
|
||||||
|
Span lbl = new Span(label);
|
||||||
|
lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
row.add(lbl);
|
||||||
|
|
||||||
|
CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null);
|
||||||
|
String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber()
|
||||||
|
: fallbackNumber != null ? fallbackNumber : invoiceId;
|
||||||
|
if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) {
|
||||||
|
Anchor link = new Anchor("javascript:void(0)", number);
|
||||||
|
link.getElement().addEventListener("click", e -> downloadInvoicePdf(related));
|
||||||
|
row.add(link);
|
||||||
|
} else {
|
||||||
|
row.add(new Span(number));
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div renderAuditEntry(InvoiceAuditEntry entry) {
|
||||||
|
Div container = new Div();
|
||||||
|
container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px")
|
||||||
|
.set("border-left", "3px solid var(--lumo-contrast-30pct)")
|
||||||
|
.set("background", "var(--lumo-contrast-5pct)");
|
||||||
|
|
||||||
|
String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "—";
|
||||||
|
String actionLabel = entry.getAction() != null
|
||||||
|
? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT))
|
||||||
|
: "?";
|
||||||
|
String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system";
|
||||||
|
|
||||||
|
Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel);
|
||||||
|
header.getStyle().set("font-weight", "600");
|
||||||
|
container.add(header);
|
||||||
|
|
||||||
|
if (entry.getReason() != null && !entry.getReason().isBlank()) {
|
||||||
|
Div reason = new Div();
|
||||||
|
reason.setText(entry.getReason());
|
||||||
|
reason.getStyle().set("margin-top", "4px");
|
||||||
|
container.add(reason);
|
||||||
|
}
|
||||||
|
if (entry.getResultingInvoiceNumber() != null) {
|
||||||
|
Div link = new Div();
|
||||||
|
link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber()));
|
||||||
|
link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
container.add(link);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void downloadInvoicePdf(CustomerInvoice invoice) {
|
private void downloadInvoicePdf(CustomerInvoice invoice) {
|
||||||
@@ -123,10 +608,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasPdfData(CustomerInvoice invoice) {
|
|
||||||
return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getRecipientLabel(CustomerInvoice invoice) {
|
private String getRecipientLabel(CustomerInvoice invoice) {
|
||||||
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
|
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import de.assecutor.votianlt.model.JobHistoryType;
|
|||||||
import de.assecutor.votianlt.model.Barcode;
|
import de.assecutor.votianlt.model.Barcode;
|
||||||
import de.assecutor.votianlt.model.Photo;
|
import de.assecutor.votianlt.model.Photo;
|
||||||
import de.assecutor.votianlt.model.Signature;
|
import de.assecutor.votianlt.model.Signature;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
import de.assecutor.votianlt.repository.BarcodeRepository;
|
import de.assecutor.votianlt.repository.BarcodeRepository;
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
@@ -182,8 +183,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
Icon typeIcon = getTypeIcon(entry.getChangeType());
|
Icon typeIcon = getTypeIcon(entry.getChangeType());
|
||||||
typeIcon.getStyle().set("color", getTypeColor(entry.getChangeType()));
|
typeIcon.getStyle().set("color", getTypeColor(entry.getChangeType()));
|
||||||
|
|
||||||
Span reason = new Span(
|
Span reason = new Span(getLocalizedReason(entry));
|
||||||
entry.getReason() != null ? entry.getReason() : getTranslation("jobhistory.entry.unknown"));
|
|
||||||
reason.addClassName("timeline-reason");
|
reason.addClassName("timeline-reason");
|
||||||
|
|
||||||
Span timestamp = new Span(formatDateTime(entry.getTimestamp()));
|
Span timestamp = new Span(formatDateTime(entry.getTimestamp()));
|
||||||
@@ -202,8 +202,9 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
cardContent.add(headerRow);
|
cardContent.add(headerRow);
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if (entry.getDescription() != null && !entry.getDescription().isBlank()) {
|
String localizedDescription = getLocalizedDescription(entry);
|
||||||
Span description = new Span(entry.getDescription());
|
if (localizedDescription != null && !localizedDescription.isBlank()) {
|
||||||
|
Span description = new Span(localizedDescription);
|
||||||
description.addClassName("timeline-description");
|
description.addClassName("timeline-description");
|
||||||
cardContent.add(description);
|
cardContent.add(description);
|
||||||
}
|
}
|
||||||
@@ -252,6 +253,37 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getLocalizedReason(JobHistory entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||||
|
return getTranslation("jobhistory.entry.create.reason");
|
||||||
|
}
|
||||||
|
return entry.getReason() != null ? entry.getReason() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getLocalizedDescription(JobHistory entry) {
|
||||||
|
if (entry == null || entry.getDescription() == null || entry.getDescription().isBlank()) {
|
||||||
|
return entry != null ? entry.getDescription() : "";
|
||||||
|
}
|
||||||
|
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||||
|
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription()));
|
||||||
|
}
|
||||||
|
return entry.getDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractDescriptionValue(String description) {
|
||||||
|
if (description == null || description.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int separatorIndex = description.indexOf(':');
|
||||||
|
if (separatorIndex < 0 || separatorIndex == description.length() - 1) {
|
||||||
|
return description.trim();
|
||||||
|
}
|
||||||
|
return description.substring(separatorIndex + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
private Icon getTypeIcon(JobHistoryType type) {
|
private Icon getTypeIcon(JobHistoryType type) {
|
||||||
if (type == null)
|
if (type == null)
|
||||||
return new Icon(VaadinIcon.INFO_CIRCLE);
|
return new Icon(VaadinIcon.INFO_CIRCLE);
|
||||||
@@ -300,15 +332,15 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
|
|
||||||
private String formatStatus(de.assecutor.votianlt.model.JobStatus status) {
|
private String formatStatus(de.assecutor.votianlt.model.JobStatus status) {
|
||||||
if (status == null)
|
if (status == null)
|
||||||
return getTranslation("jobhistory.entry.unknown");
|
return "";
|
||||||
|
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case CREATED -> getTranslation("jobstatus.CREATED");
|
case CREATED -> getTranslation("jobstatus.CREATED");
|
||||||
case IN_PROGRESS -> getTranslation("jobstatus.IN_PROGRESS");
|
case IN_PROGRESS -> getTranslation("jobstatus.IN_PROGRESS");
|
||||||
case PICKUP_SCHEDULED -> "Abholung geplant";
|
case PICKUP_SCHEDULED -> getTranslation("jobhistory.status.pickupscheduled");
|
||||||
case PICKED_UP -> "Abgeholt";
|
case PICKED_UP -> getTranslation("jobhistory.status.pickedup");
|
||||||
case IN_TRANSIT -> "Unterwegs";
|
case IN_TRANSIT -> getTranslation("jobhistory.status.intransit");
|
||||||
case DELIVERED -> "Zugestellt";
|
case DELIVERED -> getTranslation("jobhistory.status.delivered");
|
||||||
case COMPLETED -> getTranslation("jobstatus.COMPLETED");
|
case COMPLETED -> getTranslation("jobstatus.COMPLETED");
|
||||||
case CANCELLED -> getTranslation("jobstatus.CANCELLED");
|
case CANCELLED -> getTranslation("jobstatus.CANCELLED");
|
||||||
};
|
};
|
||||||
@@ -393,8 +425,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showEnlargedPhoto(String base64Photo) {
|
private void showEnlargedPhoto(String base64Photo) {
|
||||||
Dialog photoDialog = new Dialog();
|
Dialog photoDialog = DialogStylingHelper.createStyledDialog(getTranslation("jobhistory.image.alt"), "80vw");
|
||||||
photoDialog.setWidth("80vw");
|
|
||||||
photoDialog.setHeight("80vh");
|
photoDialog.setHeight("80vh");
|
||||||
photoDialog.setModal(true);
|
photoDialog.setModal(true);
|
||||||
photoDialog.setCloseOnOutsideClick(true);
|
photoDialog.setCloseOnOutsideClick(true);
|
||||||
@@ -412,7 +443,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER);
|
dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER);
|
||||||
dialogContent.setSizeFull();
|
dialogContent.setSizeFull();
|
||||||
|
|
||||||
photoDialog.add(dialogContent);
|
photoDialog.add(DialogStylingHelper.wrapContent(dialogContent, true));
|
||||||
photoDialog.open();
|
photoDialog.open();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -549,8 +580,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showEnlargedSignature(String svgContent) {
|
private void showEnlargedSignature(String svgContent) {
|
||||||
Dialog signatureDialog = new Dialog();
|
Dialog signatureDialog = DialogStylingHelper.createStyledDialog(getTranslation("tasktype.SIGNATURE"), "60vw");
|
||||||
signatureDialog.setWidth("60vw");
|
|
||||||
signatureDialog.setHeight("40vh");
|
signatureDialog.setHeight("40vh");
|
||||||
signatureDialog.setModal(true);
|
signatureDialog.setModal(true);
|
||||||
signatureDialog.setCloseOnOutsideClick(true);
|
signatureDialog.setCloseOnOutsideClick(true);
|
||||||
@@ -567,7 +597,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
dialogContent.setPadding(true);
|
dialogContent.setPadding(true);
|
||||||
|
|
||||||
dialogContent.add(enlargedSignature);
|
dialogContent.add(enlargedSignature);
|
||||||
signatureDialog.add(dialogContent);
|
signatureDialog.add(DialogStylingHelper.wrapContent(dialogContent, true));
|
||||||
signatureDialog.open();
|
signatureDialog.open();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -0,0 +1,698 @@
|
|||||||
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H3;
|
||||||
|
import com.vaadin.flow.component.html.Main;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
|
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.IntegerField;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.router.BeforeEvent;
|
||||||
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
|
import com.vaadin.flow.router.HasUrlParameter;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
|
import de.assecutor.votianlt.model.Job;
|
||||||
|
import de.assecutor.votianlt.model.JobHistoryType;
|
||||||
|
import de.assecutor.votianlt.model.JobServiceSelection;
|
||||||
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
|
import de.assecutor.votianlt.model.Service;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
|
@RolesAllowed("USER")
|
||||||
|
@Slf4j
|
||||||
|
public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle {
|
||||||
|
|
||||||
|
private static final class ServiceRow {
|
||||||
|
private final Service service;
|
||||||
|
private final JobServiceSelection selection;
|
||||||
|
|
||||||
|
private ServiceRow(Service service, JobServiceSelection selection) {
|
||||||
|
this.service = service;
|
||||||
|
this.selection = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JobRepository jobRepository;
|
||||||
|
private final JobHistoryService jobHistoryService;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
private final ServiceRepository serviceRepository;
|
||||||
|
private final VerticalLayout content;
|
||||||
|
|
||||||
|
private Job job;
|
||||||
|
private final List<ServiceRow> serviceRows = new ArrayList<>();
|
||||||
|
private Grid<ServiceRow> servicesGrid;
|
||||||
|
private Span netTotalLabel;
|
||||||
|
private Span grossTotalLabel;
|
||||||
|
private TextArea remarkArea;
|
||||||
|
private BigDecimal vatRate = Service.FIXED_VAT_RATE;
|
||||||
|
private Double manualDistanceKm;
|
||||||
|
private Integer manualDurationSeconds;
|
||||||
|
|
||||||
|
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
|
||||||
|
SecurityService securityService, ServiceRepository serviceRepository) {
|
||||||
|
this.jobRepository = jobRepository;
|
||||||
|
this.jobHistoryService = jobHistoryService;
|
||||||
|
this.securityService = securityService;
|
||||||
|
this.serviceRepository = serviceRepository;
|
||||||
|
|
||||||
|
setSizeFull();
|
||||||
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
|
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL);
|
||||||
|
addClassName("data-view");
|
||||||
|
|
||||||
|
add(new ViewToolbar(getTranslation("jobsummary.dialog.manualcomplete.title")));
|
||||||
|
|
||||||
|
content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(true);
|
||||||
|
content.setWidthFull();
|
||||||
|
content.addClassNames("form-shell", "form-card");
|
||||||
|
add(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPageTitle() {
|
||||||
|
return getTranslation("jobsummary.dialog.manualcomplete.title");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setParameter(BeforeEvent event, String parameter) {
|
||||||
|
content.removeAll();
|
||||||
|
serviceRows.clear();
|
||||||
|
job = null;
|
||||||
|
|
||||||
|
if (parameter == null || parameter.isBlank()) {
|
||||||
|
content.add(new Span(getTranslation("jobhistory.error.no.id")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectId jobId;
|
||||||
|
try {
|
||||||
|
jobId = new ObjectId(parameter);
|
||||||
|
} catch (Exception e) {
|
||||||
|
content.add(new Span(getTranslation("jobhistory.error.invalid.id", parameter)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Job loaded = jobRepository.findById(jobId).orElse(null);
|
||||||
|
if (loaded == null) {
|
||||||
|
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job = loaded;
|
||||||
|
loadVatRate();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadVatRate() {
|
||||||
|
try {
|
||||||
|
User user = securityService.getCurrentDatabaseUser();
|
||||||
|
if (user != null && user.getVatRate() != null) {
|
||||||
|
vatRate = user.getVatRate();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not load user VAT rate, falling back to default: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void render() {
|
||||||
|
manualDistanceKm = job.getRouteDistanceKm();
|
||||||
|
manualDurationSeconds = job.getRouteDurationSeconds();
|
||||||
|
|
||||||
|
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
|
||||||
|
warningText.getStyle().set("color", "var(--lumo-error-text-color)");
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("jobsummary.dialog.manualcomplete.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("100px");
|
||||||
|
reasonField.setRequired(true);
|
||||||
|
|
||||||
|
content.add(warningText, reasonField);
|
||||||
|
|
||||||
|
boolean hasRouteData = manualDistanceKm != null && manualDistanceKm > 0;
|
||||||
|
content.add(hasRouteData ? createRouteSection() : createManualRouteSection());
|
||||||
|
|
||||||
|
content.add(createServicesSection());
|
||||||
|
content.add(createSummarySection());
|
||||||
|
content.add(createRemarkSection());
|
||||||
|
|
||||||
|
HorizontalLayout buttonBar = new HorizontalLayout();
|
||||||
|
buttonBar.setWidthFull();
|
||||||
|
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
|
||||||
|
buttonBar.setSpacing(true);
|
||||||
|
|
||||||
|
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
|
||||||
|
e -> getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())));
|
||||||
|
|
||||||
|
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
|
||||||
|
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
||||||
|
confirmButton.addClickListener(e -> confirm(reasonField));
|
||||||
|
|
||||||
|
buttonBar.add(cancelButton, confirmButton);
|
||||||
|
content.add(buttonBar);
|
||||||
|
|
||||||
|
loadSelectedServicesFromJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRouteSection() {
|
||||||
|
VerticalLayout routeBox = new VerticalLayout();
|
||||||
|
routeBox.setPadding(true);
|
||||||
|
routeBox.setSpacing(true);
|
||||||
|
routeBox.setWidthFull();
|
||||||
|
routeBox.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
|
||||||
|
routeBox.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
routeBox.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
|
||||||
|
routeBox.addClassName("route-card");
|
||||||
|
|
||||||
|
H3 routeTitle = new H3(getTranslation("addjob.route.title"));
|
||||||
|
routeTitle.getStyle().set("margin", "0");
|
||||||
|
routeTitle.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
|
||||||
|
HorizontalLayout distanceRow = new HorizontalLayout();
|
||||||
|
distanceRow.setWidthFull();
|
||||||
|
distanceRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
distanceRow.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
Span distanceLabel = new Span(getTranslation("addjob.route.distance") + ":");
|
||||||
|
Span distanceValue = new Span(formatDistance(job.getRouteDistanceKm()));
|
||||||
|
distanceValue.getStyle().set("font-weight", "bold");
|
||||||
|
distanceValue.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
distanceValue.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
distanceRow.add(distanceLabel, distanceValue);
|
||||||
|
|
||||||
|
HorizontalLayout durationRow = new HorizontalLayout();
|
||||||
|
durationRow.setWidthFull();
|
||||||
|
durationRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
durationRow.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
Span durationLabel = new Span(getTranslation("addjob.route.duration") + ":");
|
||||||
|
Span durationValue = new Span(formatDuration(job.getRouteDurationSeconds()));
|
||||||
|
durationValue.getStyle().set("font-weight", "bold");
|
||||||
|
durationValue.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
durationRow.add(durationLabel, durationValue);
|
||||||
|
|
||||||
|
routeBox.add(routeTitle, distanceRow, durationRow);
|
||||||
|
return routeBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createManualRouteSection() {
|
||||||
|
VerticalLayout box = new VerticalLayout();
|
||||||
|
box.setPadding(true);
|
||||||
|
box.setSpacing(true);
|
||||||
|
box.setWidthFull();
|
||||||
|
box.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
|
||||||
|
box.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
box.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
|
||||||
|
box.addClassName("route-card");
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.route.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
title.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
|
||||||
|
NumberField distanceField = new NumberField(getTranslation("addjob.route.distance.km"));
|
||||||
|
distanceField.setMin(0);
|
||||||
|
distanceField.setStep(0.1);
|
||||||
|
distanceField.setPlaceholder(getTranslation("addjob.route.distance.placeholder"));
|
||||||
|
distanceField.setWidthFull();
|
||||||
|
|
||||||
|
IntegerField hoursField = new IntegerField(getTranslation("jobmanualcomplete.route.hours"));
|
||||||
|
hoursField.setMin(0);
|
||||||
|
hoursField.setStepButtonsVisible(true);
|
||||||
|
hoursField.setWidthFull();
|
||||||
|
|
||||||
|
IntegerField minutesField = new IntegerField(getTranslation("jobmanualcomplete.route.minutes"));
|
||||||
|
minutesField.setMin(0);
|
||||||
|
minutesField.setMax(59);
|
||||||
|
minutesField.setStepButtonsVisible(true);
|
||||||
|
minutesField.setWidthFull();
|
||||||
|
|
||||||
|
if (manualDurationSeconds != null && manualDurationSeconds > 0) {
|
||||||
|
hoursField.setValue(manualDurationSeconds / 3600);
|
||||||
|
minutesField.setValue((manualDurationSeconds % 3600) / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
distanceField.addValueChangeListener(e -> {
|
||||||
|
manualDistanceKm = e.getValue();
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
Runnable recalcDuration = () -> {
|
||||||
|
Integer h = hoursField.getValue();
|
||||||
|
Integer m = minutesField.getValue();
|
||||||
|
int hours = h != null ? Math.max(0, h) : 0;
|
||||||
|
int minutes = m != null ? Math.max(0, Math.min(59, m)) : 0;
|
||||||
|
int total = hours * 3600 + minutes * 60;
|
||||||
|
manualDurationSeconds = total > 0 ? total : null;
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
};
|
||||||
|
hoursField.addValueChangeListener(e -> recalcDuration.run());
|
||||||
|
minutesField.addValueChangeListener(e -> recalcDuration.run());
|
||||||
|
|
||||||
|
HorizontalLayout durationRow = new HorizontalLayout(hoursField, minutesField);
|
||||||
|
durationRow.setWidthFull();
|
||||||
|
durationRow.setSpacing(true);
|
||||||
|
hoursField.getStyle().set("flex", "1");
|
||||||
|
minutesField.getStyle().set("flex", "1");
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("jobmanualcomplete.route.manual.hint"));
|
||||||
|
hint.getStyle().set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
hint.getStyle().set("font-style", "italic");
|
||||||
|
|
||||||
|
box.add(title, distanceField, durationRow, hint);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createServicesSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(true);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.services.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
|
||||||
|
servicesGrid = new Grid<>();
|
||||||
|
servicesGrid.setWidthFull();
|
||||||
|
servicesGrid.setHeight("250px");
|
||||||
|
servicesGrid.setItems(serviceRows);
|
||||||
|
servicesGrid.addClassName("data-grid");
|
||||||
|
|
||||||
|
servicesGrid.addColumn(row -> row.service != null ? row.service.getName() : "")
|
||||||
|
.setHeader(getTranslation("common.service")).setSortable(true);
|
||||||
|
servicesGrid.addColumn(row -> formatDeliveryStationLabel(
|
||||||
|
row.selection != null ? row.selection.getDeliveryStationOrder() : null))
|
||||||
|
.setHeader(getTranslation("addjob.services.deliverystation")).setSortable(false);
|
||||||
|
servicesGrid.addColumn(row -> formatCalculationBasis(row.service))
|
||||||
|
.setHeader(getTranslation("addjob.services.calculation")).setSortable(true);
|
||||||
|
servicesGrid.addColumn(this::formatPrice).setHeader(getTranslation("common.price")).setSortable(false);
|
||||||
|
servicesGrid.addComponentColumn(row -> {
|
||||||
|
if (row.service != null && row.service.isMandatory()) {
|
||||||
|
return new Span("");
|
||||||
|
}
|
||||||
|
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
|
||||||
|
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
|
||||||
|
ButtonVariant.LUMO_SMALL);
|
||||||
|
removeButton.addClickListener(e -> {
|
||||||
|
serviceRows.remove(row);
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
});
|
||||||
|
return removeButton;
|
||||||
|
}).setHeader(getTranslation("common.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
|
Div gridPanel = new Div(servicesGrid);
|
||||||
|
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||||
|
gridPanel.setWidthFull();
|
||||||
|
|
||||||
|
Button addButton = new Button(getTranslation("addjob.services.add"), new Icon(VaadinIcon.PLUS));
|
||||||
|
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
addButton.addClickListener(e -> openAddServiceDialog());
|
||||||
|
|
||||||
|
section.add(title, gridPanel, addButton);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createSummarySection() {
|
||||||
|
VerticalLayout summary = new VerticalLayout();
|
||||||
|
summary.setPadding(true);
|
||||||
|
summary.setSpacing(true);
|
||||||
|
summary.setWidthFull();
|
||||||
|
summary.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
|
||||||
|
summary.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
summary.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
|
||||||
|
summary.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
summary.addClassName("summary-card");
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.summary.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
summary.add(title);
|
||||||
|
|
||||||
|
Div priceTable = new Div();
|
||||||
|
priceTable.getStyle().set("width", "100%");
|
||||||
|
|
||||||
|
Div netRow = new Div();
|
||||||
|
netRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
|
||||||
|
Span netLabel = new Span(getTranslation("addjob.summary.net") + ":");
|
||||||
|
netLabel.getStyle().set("padding-right", "8px");
|
||||||
|
netTotalLabel = new Span("0,00 €");
|
||||||
|
netTotalLabel.getStyle().set("font-weight", "bold").set("white-space", "nowrap");
|
||||||
|
netRow.add(netLabel, netTotalLabel);
|
||||||
|
|
||||||
|
Div grossRow = new Div();
|
||||||
|
grossRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
|
||||||
|
Span grossLabel = new Span(getTranslation("addjob.summary.gross") + ":");
|
||||||
|
grossLabel.getStyle().set("padding-right", "8px").set("font-weight", "bold");
|
||||||
|
grossTotalLabel = new Span("0,00 €");
|
||||||
|
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
grossTotalLabel.getStyle().set("font-weight", "bold");
|
||||||
|
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)").set("white-space", "nowrap");
|
||||||
|
grossRow.add(grossLabel, grossTotalLabel);
|
||||||
|
|
||||||
|
priceTable.add(netRow, grossRow);
|
||||||
|
summary.add(priceTable);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRemarkSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(true);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.tasks.remark"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
|
||||||
|
remarkArea = new TextArea();
|
||||||
|
remarkArea.setPlaceholder(getTranslation("addjob.tasks.remark.placeholder"));
|
||||||
|
remarkArea.setWidthFull();
|
||||||
|
remarkArea.setMinHeight("120px");
|
||||||
|
if (job.getRemark() != null) {
|
||||||
|
remarkArea.setValue(job.getRemark());
|
||||||
|
}
|
||||||
|
|
||||||
|
section.add(title, remarkArea);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadSelectedServicesFromJob() {
|
||||||
|
serviceRows.clear();
|
||||||
|
|
||||||
|
if (job.getSelectedServices() != null && !job.getSelectedServices().isEmpty()) {
|
||||||
|
for (JobServiceSelection selection : job.getSelectedServices()) {
|
||||||
|
if (selection.getServiceId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serviceRepository.findById(selection.getServiceId())
|
||||||
|
.ifPresent(service -> serviceRows.add(new ServiceRow(service, selection)));
|
||||||
|
}
|
||||||
|
} else if (job.getServiceIds() != null && !job.getServiceIds().isEmpty()) {
|
||||||
|
for (String serviceId : job.getServiceIds()) {
|
||||||
|
serviceRepository.findById(serviceId)
|
||||||
|
.ifPresent(service -> serviceRows.add(new ServiceRow(service, null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAddServiceDialog() {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.services.dialog.title"), "720px");
|
||||||
|
dialog.setCloseOnOutsideClick(false);
|
||||||
|
|
||||||
|
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
|
||||||
|
|
||||||
|
User currentUser = securityService.getCurrentDatabaseUser();
|
||||||
|
List<Service> availableServices = currentUser != null
|
||||||
|
? serviceRepository.findByUserId(currentUser.getId().toString())
|
||||||
|
: List.of();
|
||||||
|
|
||||||
|
ComboBox<Service> serviceCombo = new ComboBox<>(getTranslation("common.service"));
|
||||||
|
serviceCombo.setWidthFull();
|
||||||
|
serviceCombo.setItems(availableServices);
|
||||||
|
serviceCombo.setItemLabelGenerator(service -> {
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
|
||||||
|
&& service.getEffectivePrice() != null) {
|
||||||
|
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
|
||||||
|
}
|
||||||
|
return service.getName();
|
||||||
|
});
|
||||||
|
serviceCombo.setPlaceholder(getTranslation("addjob.services.dialog.placeholder"));
|
||||||
|
serviceCombo.setRequired(true);
|
||||||
|
|
||||||
|
List<Integer> stationOrders = availableDeliveryStationOrders();
|
||||||
|
ComboBox<Integer> stationCombo = new ComboBox<>(getTranslation("addjob.services.deliverystation"));
|
||||||
|
stationCombo.setWidthFull();
|
||||||
|
stationCombo.setRequired(true);
|
||||||
|
stationCombo.setRequiredIndicatorVisible(true);
|
||||||
|
stationCombo.setItems(stationOrders);
|
||||||
|
stationCombo.setItemLabelGenerator(this::buildDeliveryStationSelectionLabel);
|
||||||
|
stationCombo.setPlaceholder(getTranslation("addjob.services.dialog.station.placeholder"));
|
||||||
|
if (!stationOrders.isEmpty()) {
|
||||||
|
stationCombo.setValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogContent.add(serviceCombo, stationCombo);
|
||||||
|
|
||||||
|
Button cancel = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button add = new Button(getTranslation("addjob.services.dialog.add"), e -> {
|
||||||
|
Service service = serviceCombo.getValue();
|
||||||
|
Integer stationOrder = stationCombo.getValue();
|
||||||
|
if (service == null || stationOrder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JobServiceSelection selection = new JobServiceSelection();
|
||||||
|
selection.setServiceId(service.getId());
|
||||||
|
selection.setDeliveryStationOrder(stationOrder);
|
||||||
|
selection.setRouteDistanceKm(manualDistanceKm);
|
||||||
|
selection.setRouteDurationSeconds(manualDurationSeconds);
|
||||||
|
serviceRows.add(new ServiceRow(service, selection));
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
|
||||||
|
dialog.getFooter().add(cancel, add);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> availableDeliveryStationOrders() {
|
||||||
|
List<Integer> orders = new ArrayList<>();
|
||||||
|
if (job.getDeliveryStations() != null) {
|
||||||
|
for (int i = 0; i < job.getDeliveryStations().size(); i++) {
|
||||||
|
orders.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeliveryStationSelectionLabel(Integer order) {
|
||||||
|
if (order == null || job.getDeliveryStations() == null || order < 0
|
||||||
|
|| order >= job.getDeliveryStations().size()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
DeliveryStation station = job.getDeliveryStations().get(order);
|
||||||
|
StringBuilder label = new StringBuilder(getTranslation("addjob.station.delivery", order + 1));
|
||||||
|
if (station.getCity() != null && !station.getCity().isBlank()) {
|
||||||
|
label.append(" - ").append(station.getCity());
|
||||||
|
} else if (station.getCompany() != null && !station.getCompany().isBlank()) {
|
||||||
|
label.append(" - ").append(station.getCompany());
|
||||||
|
}
|
||||||
|
return label.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDeliveryStationLabel(Integer order) {
|
||||||
|
if (order == null || order < 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return getTranslation("addjob.station.delivery", order + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatCalculationBasis(Service service) {
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return switch (service.getCalculationBasis()) {
|
||||||
|
case DISTANCE -> getTranslation("addjob.services.basis.distance");
|
||||||
|
case TIME -> getTranslation("addjob.services.basis.time");
|
||||||
|
case FLAT_RATE -> getTranslation("addjob.services.basis.flatrate");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatPrice(ServiceRow row) {
|
||||||
|
Service service = row.service;
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
BigDecimal price = calculateServicePrice(row);
|
||||||
|
if (price != null && price.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
return price.setScale(2, RoundingMode.HALF_UP) + " €";
|
||||||
|
}
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && service.getPricePerKilometer() != null
|
||||||
|
&& routeDistanceFor(row.selection) == null) {
|
||||||
|
return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km ("
|
||||||
|
+ getTranslation("addjob.services.route.missing") + ")";
|
||||||
|
}
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.TIME && service.getPricePer15Minutes() != null
|
||||||
|
&& routeDurationFor(row.selection) == null) {
|
||||||
|
return service.getPricePer15Minutes().setScale(2, RoundingMode.HALF_UP) + " €/15 Min. ("
|
||||||
|
+ getTranslation("addjob.services.route.missing") + ")";
|
||||||
|
}
|
||||||
|
return service.getEffectivePrice() != null
|
||||||
|
? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateServicePrice(ServiceRow row) {
|
||||||
|
Service service = row.service;
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
switch (service.getCalculationBasis()) {
|
||||||
|
case FLAT_RATE:
|
||||||
|
return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO;
|
||||||
|
case DISTANCE: {
|
||||||
|
Double km = routeDistanceFor(row.selection);
|
||||||
|
if (service.getPricePerKilometer() != null && km != null && km > 0) {
|
||||||
|
return service.getPricePerKilometer().multiply(BigDecimal.valueOf(km));
|
||||||
|
}
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
case TIME: {
|
||||||
|
Integer seconds = routeDurationFor(row.selection);
|
||||||
|
if (service.getPricePer15Minutes() != null && seconds != null && seconds > 0) {
|
||||||
|
int units = seconds / 900;
|
||||||
|
if (seconds % 900 > 0) {
|
||||||
|
units++;
|
||||||
|
}
|
||||||
|
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
|
||||||
|
}
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double routeDistanceFor(JobServiceSelection selection) {
|
||||||
|
if (selection != null && selection.getRouteDistanceKm() != null) {
|
||||||
|
return selection.getRouteDistanceKm();
|
||||||
|
}
|
||||||
|
return manualDistanceKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer routeDurationFor(JobServiceSelection selection) {
|
||||||
|
if (selection != null && selection.getRouteDurationSeconds() != null) {
|
||||||
|
return selection.getRouteDurationSeconds();
|
||||||
|
}
|
||||||
|
return manualDurationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePriceSummary() {
|
||||||
|
BigDecimal net = BigDecimal.ZERO;
|
||||||
|
for (ServiceRow row : serviceRows) {
|
||||||
|
net = net.add(calculateServicePrice(row));
|
||||||
|
}
|
||||||
|
BigDecimal gross = net.add(net.multiply(vatRate));
|
||||||
|
netTotalLabel.setText(formatAmount(net));
|
||||||
|
grossTotalLabel.setText(formatAmount(gross));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAmount(BigDecimal amount) {
|
||||||
|
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDistance(Double km) {
|
||||||
|
if (km == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return String.format(Locale.GERMANY, "%.1f km", km);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDuration(Integer seconds) {
|
||||||
|
if (seconds == null || seconds <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
int hours = seconds / 3600;
|
||||||
|
int minutes = (seconds % 3600) / 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format("%d Std. %d Min.", hours, minutes);
|
||||||
|
}
|
||||||
|
return String.format("%d Min.", minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirm(TextArea reasonField) {
|
||||||
|
String reason = reasonField.getValue();
|
||||||
|
if (reason == null || reason.trim().isEmpty()) {
|
||||||
|
reasonField.setInvalid(true);
|
||||||
|
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JobStatus oldStatus = job.getStatus();
|
||||||
|
|
||||||
|
List<JobServiceSelection> selections = new ArrayList<>();
|
||||||
|
for (ServiceRow row : serviceRows) {
|
||||||
|
if (row.service == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JobServiceSelection selection = row.selection != null ? row.selection : new JobServiceSelection();
|
||||||
|
selection.setServiceId(row.service.getId());
|
||||||
|
if (selection.getDeliveryStationOrder() == null && row.selection != null) {
|
||||||
|
selection.setDeliveryStationOrder(row.selection.getDeliveryStationOrder());
|
||||||
|
}
|
||||||
|
selections.add(selection);
|
||||||
|
}
|
||||||
|
job.setSelectedServices(selections);
|
||||||
|
|
||||||
|
String remark = remarkArea.getValue();
|
||||||
|
job.setRemark(remark != null && !remark.isBlank() ? remark.trim() : null);
|
||||||
|
|
||||||
|
if (job.getRouteDistanceKm() == null || job.getRouteDistanceKm() <= 0) {
|
||||||
|
job.setRouteDistanceKm(manualDistanceKm);
|
||||||
|
job.setRouteDurationSeconds(manualDurationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobRepository.save(job);
|
||||||
|
|
||||||
|
String currentUser = securityService.getCurrentUsername();
|
||||||
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
|
||||||
|
|
||||||
|
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s", currentUser,
|
||||||
|
reason.trim());
|
||||||
|
jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"),
|
||||||
|
description, currentUser, JobHistoryType.STATUS_CHANGE);
|
||||||
|
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ import de.assecutor.votianlt.service.LocationService;
|
|||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
import de.assecutor.votianlt.service.TaskAssignmentService;
|
import de.assecutor.votianlt.service.TaskAssignmentService;
|
||||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -135,8 +134,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
if (parameter == null || parameter.isBlank()) {
|
if (parameter == null || parameter.isBlank()) {
|
||||||
content.removeAll();
|
content.removeAll();
|
||||||
removeAll();
|
removeAll();
|
||||||
add(new ViewToolbar("Zusammenfassung"));
|
add(new ViewToolbar(getTranslation("jobsummary.title")));
|
||||||
content.add(new Span("Fehler: Keine Job-ID angegeben"));
|
content.add(new Span(getTranslation("jobsummary.error.noid")));
|
||||||
add(content);
|
add(content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -146,8 +145,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
content.removeAll();
|
content.removeAll();
|
||||||
removeAll();
|
removeAll();
|
||||||
add(new ViewToolbar("Zusammenfassung"));
|
add(new ViewToolbar(getTranslation("jobsummary.title")));
|
||||||
content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter));
|
content.add(new Span(getTranslation("jobsummary.error.invalidid", parameter)));
|
||||||
add(content);
|
add(content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,8 +185,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
|
|
||||||
Job job = jobRepository.findById(currentJobId).orElse(null);
|
Job job = jobRepository.findById(currentJobId).orElse(null);
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
add(new ViewToolbar("Zusammenfassung"));
|
add(new ViewToolbar(getTranslation("jobsummary.title")));
|
||||||
content.add(new Span("Fehler: Job mit ID " + currentJobId.toHexString() + " nicht gefunden"));
|
content.add(new Span(getTranslation("jobsummary.error.notfound", currentJobId.toHexString())));
|
||||||
add(content);
|
add(content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,6 +212,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId));
|
getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create Manual Completion Button for app jobs (digital processing)
|
||||||
|
Button manualCompleteButton = null;
|
||||||
|
if (job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||||
|
&& job.getStatus() != JobStatus.CANCELLED) {
|
||||||
|
manualCompleteButton = new Button(getTranslation("jobsummary.button.manualcomplete"));
|
||||||
|
manualCompleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
|
||||||
|
manualCompleteButton.addClickListener(e -> getUI()
|
||||||
|
.ifPresent(ui -> ui.navigate("job_manual_complete/" + job.getId().toHexString())));
|
||||||
|
}
|
||||||
|
|
||||||
// Create Job History Button for toolbar
|
// Create Job History Button for toolbar
|
||||||
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
|
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
|
||||||
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
@@ -220,8 +229,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString()));
|
getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add toolbar with both buttons in top right (Send Message button on the left)
|
// Add toolbar with buttons
|
||||||
add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton));
|
if (manualCompleteButton != null) {
|
||||||
|
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
|
||||||
|
jobHistoryButton));
|
||||||
|
} else {
|
||||||
|
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
|
||||||
|
}
|
||||||
|
|
||||||
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
|
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
|
||||||
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
|
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
|
||||||
@@ -325,33 +339,33 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
new Icon(VaadinIcon.CHECK_CIRCLE));
|
new Icon(VaadinIcon.CHECK_CIRCLE));
|
||||||
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
completeButton.addClickListener(e -> {
|
completeButton.addClickListener(e -> {
|
||||||
ConfirmDialog dialog = new ConfirmDialog();
|
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
dialog.setHeader(getTranslation("jobsummary.dialog.complete.title"));
|
getTranslation("jobsummary.dialog.complete.title"),
|
||||||
dialog.setText(getTranslation("jobsummary.dialog.complete.text", job.getJobNumber()));
|
getTranslation("jobsummary.dialog.complete.text", job.getJobNumber()),
|
||||||
dialog.setCancelable(true);
|
"560px",
|
||||||
dialog.setCancelText(getTranslation("jobsummary.dialog.complete.cancel"));
|
getTranslation("jobsummary.dialog.complete.cancel"),
|
||||||
dialog.setConfirmText(getTranslation("jobsummary.dialog.complete.confirm"));
|
getTranslation("jobsummary.dialog.complete.confirm"),
|
||||||
dialog.setConfirmButtonTheme("primary");
|
() -> {
|
||||||
dialog.addConfirmListener(ev -> {
|
try {
|
||||||
try {
|
JobStatus oldStatus = job.getStatus();
|
||||||
JobStatus oldStatus = job.getStatus();
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
job.setStatus(JobStatus.COMPLETED);
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
jobRepository.save(job);
|
||||||
jobRepository.save(job);
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||||
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
Notification
|
||||||
Notification
|
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()),
|
||||||
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
|
3000, Notification.Position.BOTTOM_END)
|
||||||
Notification.Position.BOTTOM_END)
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
// Re-render the page
|
||||||
// Re-render the page
|
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
||||||
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
} catch (Exception ex) {
|
||||||
} catch (Exception ex) {
|
Notification
|
||||||
Notification
|
.show(getTranslation("jobsummary.notification.complete.error",
|
||||||
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
|
ex.getMessage()), 5000, Notification.Position.BOTTOM_END)
|
||||||
Notification.Position.BOTTOM_END)
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
}
|
||||||
}
|
},
|
||||||
});
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
dialog.open();
|
dialog.open();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -896,8 +910,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
// Gespeicherte Dauer formatieren
|
// Gespeicherte Dauer formatieren
|
||||||
int hours = savedDuration / 3600;
|
int hours = savedDuration / 3600;
|
||||||
int minutes = (savedDuration % 3600) / 60;
|
int minutes = (savedDuration % 3600) / 60;
|
||||||
String savedDurationText = hours > 0 ? String.format("%d Std. %d Min.", hours, minutes)
|
String savedDurationText = formatDurationShort(hours, minutes);
|
||||||
: String.format("%d Min.", minutes);
|
String plannedRouteLabel = escapeJs(getTranslation("jobsummary.route.planned"));
|
||||||
|
String durationLabel = escapeJs(getTranslation("createinvoice.route.duration"));
|
||||||
|
|
||||||
// Build waypoints JS array
|
// Build waypoints JS array
|
||||||
StringBuilder waypointsJs = new StringBuilder("[");
|
StringBuilder waypointsJs = new StringBuilder("[");
|
||||||
@@ -925,6 +940,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
var hasSavedRouteData = %s;
|
var hasSavedRouteData = %s;
|
||||||
var savedDistance = %s;
|
var savedDistance = %s;
|
||||||
var savedDurationText = '%s';
|
var savedDurationText = '%s';
|
||||||
|
var plannedRouteLabel = '%s';
|
||||||
|
var durationLabel = '%s';
|
||||||
var waypoints = %s;
|
var waypoints = %s;
|
||||||
|
|
||||||
var appUserMarker = null;
|
var appUserMarker = null;
|
||||||
@@ -968,7 +985,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
savedRouteDiv.style.backgroundColor = '#e3f2fd';
|
savedRouteDiv.style.backgroundColor = '#e3f2fd';
|
||||||
savedRouteDiv.style.borderRadius = '4px';
|
savedRouteDiv.style.borderRadius = '4px';
|
||||||
savedRouteDiv.style.fontWeight = 'bold';
|
savedRouteDiv.style.fontWeight = 'bold';
|
||||||
savedRouteDiv.textContent = '📍 Geplante Route: ' + savedDistance.toFixed(1) + ' km • Fahrtzeit: ' + savedDurationText;
|
savedRouteDiv.textContent = '📍 ' + plannedRouteLabel + ': ' + savedDistance.toFixed(1) + ' km • ' + durationLabel + ': ' + savedDurationText;
|
||||||
infoEl.appendChild(savedRouteDiv);
|
infoEl.appendChild(savedRouteDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,7 +1092,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
|
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
|
||||||
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate),
|
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate),
|
||||||
Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText),
|
Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText),
|
||||||
waypointsJs.toString());
|
plannedRouteLabel, durationLabel, waypointsJs.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDurationShort(int hours, int minutes) {
|
||||||
|
String hourUnit = getTranslation("duration.hours.short");
|
||||||
|
String minuteUnit = getTranslation("duration.minutes.short");
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format("%d %s %d %s", hours, hourUnit, minutes, minuteUnit);
|
||||||
|
}
|
||||||
|
return String.format("%d %s", minutes, minuteUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings
|
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
|
|||||||
|
|
||||||
private HorizontalLayout createMessageInputArea() {
|
private HorizontalLayout createMessageInputArea() {
|
||||||
messageInput = new TextArea();
|
messageInput = new TextArea();
|
||||||
messageInput.setPlaceholder("Nachricht eingeben...");
|
messageInput.setPlaceholder(getTranslation("messagedetails.placeholder"));
|
||||||
messageInput.setWidthFull();
|
messageInput.setWidthFull();
|
||||||
messageInput.getStyle().set("min-height", "60px");
|
messageInput.getStyle().set("min-height", "60px");
|
||||||
messageInput.getStyle().set("max-height", "120px");
|
messageInput.getStyle().set("max-height", "120px");
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ public class ShowCustomersView extends VerticalLayout implements HasDynamicTitle
|
|||||||
var customers = customerService.findAll();
|
var customers = customerService.findAll();
|
||||||
var currentUserId = securityService.getCurrentUserId();
|
var currentUserId = securityService.getCurrentUserId();
|
||||||
var ownCustomers = customers.stream()
|
var ownCustomers = customers.stream()
|
||||||
.filter(c -> c.getCreatedBy() != null && c.getCreatedBy().equals(currentUserId)).toList();
|
.filter(c -> c.getCreatedBy() != null && c.getCreatedBy().equals(currentUserId))
|
||||||
|
.filter(c -> !c.isInternal()).toList();
|
||||||
grid.setItems(ownCustomers);
|
grid.setItems(ownCustomers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package de.assecutor.votianlt.pages.view;
|
|||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.datepicker.DatePicker;
|
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
import com.vaadin.flow.component.html.Anchor;
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
@@ -21,6 +21,7 @@ import com.vaadin.flow.router.Route;
|
|||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
@@ -49,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final ClientConnectionService clientConnectionService;
|
private final ClientConnectionService clientConnectionService;
|
||||||
private final MessagingPublisher messagingPublisher;
|
private final MessagingPublisher messagingPublisher;
|
||||||
|
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||||
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -60,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
this.clientConnectionService = clientConnectionService;
|
this.clientConnectionService = clientConnectionService;
|
||||||
this.messagingPublisher = messagingPublisher;
|
this.messagingPublisher = messagingPublisher;
|
||||||
|
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -214,53 +217,54 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showCompleteJobDialog(Job job) {
|
private void showCompleteJobDialog(Job job) {
|
||||||
ConfirmDialog dialog = new ConfirmDialog();
|
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
dialog.setHeader(getTranslation("jobs.dialog.complete.title"));
|
getTranslation("jobs.dialog.complete.title"),
|
||||||
dialog.setText(getTranslation("jobs.dialog.complete.text", job.getJobNumber()));
|
getTranslation("jobs.dialog.complete.text", job.getJobNumber()),
|
||||||
dialog.setCancelable(true);
|
"560px",
|
||||||
dialog.setCancelText(getTranslation("button.cancel"));
|
getTranslation("button.cancel"),
|
||||||
dialog.setConfirmText(getTranslation("jobs.dialog.complete.confirm"));
|
getTranslation("jobs.dialog.complete.confirm"),
|
||||||
dialog.setConfirmButtonTheme("primary");
|
() -> {
|
||||||
dialog.addConfirmListener(e -> {
|
try {
|
||||||
try {
|
JobStatus oldStatus = job.getStatus();
|
||||||
JobStatus oldStatus = job.getStatus();
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
job.setStatus(JobStatus.COMPLETED);
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
jobRepository.save(job);
|
||||||
jobRepository.save(job);
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||||
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
Notification.show(getTranslation("jobs.notification.completed", job.getJobNumber()), 3000,
|
||||||
Notification.show(getTranslation("jobs.notification.completed", job.getJobNumber()), 3000,
|
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
loadData();
|
||||||
loadData();
|
} catch (Exception ex) {
|
||||||
} catch (Exception ex) {
|
Notification.show(getTranslation("jobs.notification.complete.error", ex.getMessage()), 5000,
|
||||||
Notification.show(getTranslation("jobs.notification.complete.error", ex.getMessage()), 5000,
|
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
}
|
||||||
}
|
},
|
||||||
});
|
ButtonVariant.LUMO_PRIMARY);
|
||||||
dialog.open();
|
dialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showDeleteJobDialog(Job job) {
|
private void showDeleteJobDialog(Job job) {
|
||||||
ConfirmDialog dialog = new ConfirmDialog();
|
Dialog dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
dialog.setHeader(getTranslation("jobs.dialog.delete.title"));
|
getTranslation("jobs.dialog.delete.title"),
|
||||||
dialog.setText(getTranslation("jobs.dialog.delete.text", job.getJobNumber()));
|
getTranslation("jobs.dialog.delete.text", job.getJobNumber()),
|
||||||
dialog.setCancelable(true);
|
"560px",
|
||||||
dialog.setCancelText(getTranslation("button.cancel"));
|
getTranslation("button.cancel"),
|
||||||
dialog.setConfirmText(getTranslation("button.delete"));
|
getTranslation("button.delete"),
|
||||||
dialog.setConfirmButtonTheme("error primary");
|
() -> {
|
||||||
dialog.addConfirmListener(e -> {
|
try {
|
||||||
try {
|
// Notify client before deleting if online
|
||||||
// Notify client before deleting if online
|
notifyClientJobDeleted(job);
|
||||||
notifyClientJobDeleted(job);
|
|
||||||
|
|
||||||
jobRepository.delete(job);
|
jobRepository.delete(job);
|
||||||
Notification.show(getTranslation("jobs.notification.deleted", job.getJobNumber()), 3000,
|
Notification.show(getTranslation("jobs.notification.deleted", job.getJobNumber()), 3000,
|
||||||
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show(getTranslation("jobs.notification.delete.error", ex.getMessage()), 5000,
|
Notification.show(getTranslation("jobs.notification.delete.error", ex.getMessage()), 5000,
|
||||||
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
ButtonVariant.LUMO_PRIMARY,
|
||||||
|
ButtonVariant.LUMO_ERROR);
|
||||||
dialog.open();
|
dialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.vaadin.flow.router.BeforeEnterEvent;
|
|||||||
import com.vaadin.flow.router.BeforeEnterObserver;
|
import com.vaadin.flow.router.BeforeEnterObserver;
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
||||||
import de.assecutor.votianlt.model.Language;
|
import de.assecutor.votianlt.model.Language;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.security.SessionAuthenticationService;
|
import de.assecutor.votianlt.security.SessionAuthenticationService;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.DemoModeService;
|
import de.assecutor.votianlt.service.DemoModeService;
|
||||||
@@ -241,17 +242,15 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
|
|||||||
String currentUser = securityService.getCurrentUsername();
|
String currentUser = securityService.getCurrentUsername();
|
||||||
ComboBox<String> userCombo = new ComboBox<>();
|
ComboBox<String> userCombo = new ComboBox<>();
|
||||||
userCombo.setPlaceholder(currentUser);
|
userCombo.setPlaceholder(currentUser);
|
||||||
userCombo.setItems("Profil anzeigen", "Einstellungen", "Abmelden");
|
userCombo.setItems("Profil anzeigen", "Abmelden");
|
||||||
userCombo.addValueChangeListener(event -> {
|
userCombo.addValueChangeListener(event -> {
|
||||||
String value = event.getValue();
|
String value = event.getValue();
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case "Profil anzeigen":
|
case "Profil anzeigen":
|
||||||
break;
|
break;
|
||||||
case "Einstellungen":
|
|
||||||
break;
|
|
||||||
case "Abmelden":
|
case "Abmelden":
|
||||||
securityService.logout();
|
openLogoutConfirmDialog();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
userCombo.clear(); // Reset selection
|
userCombo.clear(); // Reset selection
|
||||||
@@ -450,6 +449,19 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
|
|||||||
return footer;
|
return footer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openLogoutConfirmDialog() {
|
||||||
|
var dialog = DialogStylingHelper.createConfirmationDialog(
|
||||||
|
getTranslation("logout.confirm.title"),
|
||||||
|
getTranslation("logout.confirm.message"),
|
||||||
|
"460px",
|
||||||
|
getTranslation("button.cancel"),
|
||||||
|
getTranslation("nav.logout"),
|
||||||
|
securityService::logout,
|
||||||
|
ButtonVariant.LUMO_PRIMARY,
|
||||||
|
ButtonVariant.LUMO_ERROR);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
private void register() {
|
private void register() {
|
||||||
UI.getCurrent().navigate("register");
|
UI.getCurrent().navigate("register");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt.repository;
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
|
|||||||
Optional<CustomerInvoice> findByJobId(String jobId);
|
Optional<CustomerInvoice> findByJobId(String jobId);
|
||||||
|
|
||||||
List<CustomerInvoice> findByUserId(String userId);
|
List<CustomerInvoice> findByUserId(String userId);
|
||||||
|
|
||||||
|
/** Liefert die – höchstens eine – aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */
|
||||||
|
Optional<CustomerInvoice> findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status);
|
||||||
|
|
||||||
|
/** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */
|
||||||
|
List<CustomerInvoice> findByOriginalInvoiceId(String originalInvoiceId);
|
||||||
|
|
||||||
|
/** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */
|
||||||
|
List<CustomerInvoice> findByStatusIsNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface InvoiceNumberReservationRepository extends MongoRepository<InvoiceNumberReservation, ObjectId> {
|
||||||
|
|
||||||
|
Optional<InvoiceNumberReservation> findByUserIdAndNumber(ObjectId userId, String number);
|
||||||
|
|
||||||
|
Optional<InvoiceNumberReservation> findByUserIdAndSequence(ObjectId userId, long sequence);
|
||||||
|
|
||||||
|
List<InvoiceNumberReservation> findByUserIdOrderBySequenceAsc(ObjectId userId);
|
||||||
|
|
||||||
|
List<InvoiceNumberReservation> findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId,
|
||||||
|
InvoiceNumberReservationStatus status);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import org.springframework.data.mongodb.repository.MongoRepository;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
||||||
@@ -66,6 +67,9 @@ public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
|||||||
List<Message> findByReceiverAndDeliveryStatusOrderByCreatedAtAsc(String receiver,
|
List<Message> findByReceiverAndDeliveryStatusOrderByCreatedAtAsc(String receiver,
|
||||||
MessageDeliveryStatus deliveryStatus);
|
MessageDeliveryStatus deliveryStatus);
|
||||||
|
|
||||||
|
Optional<Message> findFirstByReceiverAndOriginAndClientMessageId(String receiver, MessageOrigin origin,
|
||||||
|
String clientMessageId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all undelivered messages (NOTSEND status) for a receiver
|
* Find all undelivered messages (NOTSEND status) for a receiver
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ import java.util.List;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface SignatureRepository extends MongoRepository<Signature, ObjectId> {
|
public interface SignatureRepository extends MongoRepository<Signature, ObjectId> {
|
||||||
List<Signature> findByTaskId(ObjectId taskId);
|
List<Signature> findByTaskId(ObjectId taskId);
|
||||||
|
|
||||||
|
List<Signature> findByTaskIdOrderByCreatedAtDesc(ObjectId taskId);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.assecutor.votianlt.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42.
|
||||||
|
*
|
||||||
|
* Die Rollen sind als Konstanten definiert und werden in der bestehenden
|
||||||
|
* {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt.
|
||||||
|
*
|
||||||
|
* Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die
|
||||||
|
* {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen
|
||||||
|
* Rechnungsrollen explizit zugewiesen sind.
|
||||||
|
*/
|
||||||
|
public final class InvoiceRoles {
|
||||||
|
|
||||||
|
/** Erstellt Entwürfe und stellt Rechnungen aus. */
|
||||||
|
public static final String CREATOR = "INVOICE_CREATOR";
|
||||||
|
/** Prüft Entwürfe und Folgebelege vor Freigabe. */
|
||||||
|
public static final String REVIEWER = "INVOICE_REVIEWER";
|
||||||
|
/** Gibt Storno- und Berichtigungsbelege frei (R-42). */
|
||||||
|
public static final String APPROVER = "INVOICE_APPROVER";
|
||||||
|
/** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */
|
||||||
|
public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT";
|
||||||
|
|
||||||
|
private InvoiceRoles() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256.
|
||||||
|
*
|
||||||
|
* Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}.
|
||||||
|
* Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert
|
||||||
|
* deterministisch denselben AES-Schlüssel.
|
||||||
|
*
|
||||||
|
* Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten.
|
||||||
|
*/
|
||||||
|
final class AesGcmCipher {
|
||||||
|
|
||||||
|
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||||
|
private static final int IV_LENGTH = 12;
|
||||||
|
private static final int TAG_LENGTH_BITS = 128;
|
||||||
|
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
AesGcmCipher(String masterKey) {
|
||||||
|
if (masterKey == null || masterKey.length() < 16) {
|
||||||
|
throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8"));
|
||||||
|
this.key = new SecretKeySpec(derived, "AES");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encrypt(byte[] plaintext) {
|
||||||
|
try {
|
||||||
|
byte[] iv = new byte[IV_LENGTH];
|
||||||
|
random.nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||||
|
byte[] ciphertext = cipher.doFinal(plaintext);
|
||||||
|
return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decrypt(byte[] ivAndCiphertext) {
|
||||||
|
if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) {
|
||||||
|
throw new IllegalArgumentException("Ciphertext zu kurz.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext);
|
||||||
|
byte[] iv = new byte[IV_LENGTH];
|
||||||
|
buffer.get(iv);
|
||||||
|
byte[] ciphertext = new byte[buffer.remaining()];
|
||||||
|
buffer.get(ciphertext);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||||
|
return cipher.doFinal(ciphertext);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -258,6 +258,13 @@ public class CustomerInvoiceService {
|
|||||||
|
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||||
String invoicePrefix) throws Exception {
|
String invoicePrefix) throws Exception {
|
||||||
|
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||||
|
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
||||||
|
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
||||||
|
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
||||||
// Parse the JSON template data
|
// Parse the JSON template data
|
||||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||||
@@ -458,7 +465,7 @@ public class CustomerInvoiceService {
|
|||||||
}
|
}
|
||||||
} else if ("services.list".equals(variable)) {
|
} else if ("services.list".equals(variable)) {
|
||||||
// Render services list as a table
|
// Render services list as a table
|
||||||
htmlBuilder.append(generateServicesTableHtml(mmWidth));
|
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
||||||
} else if (text.contains("<br>")) {
|
} else if (text.contains("<br>")) {
|
||||||
// Multi-line text: render without nowrap so <br> tags work
|
// Multi-line text: render without nowrap so <br> tags work
|
||||||
htmlBuilder.append("<span>").append(text).append("</span>");
|
htmlBuilder.append("<span>").append(text).append("</span>");
|
||||||
@@ -484,16 +491,23 @@ public class CustomerInvoiceService {
|
|||||||
/**
|
/**
|
||||||
* Generate HTML table for services list with summary section below.
|
* Generate HTML table for services list with summary section below.
|
||||||
*/
|
*/
|
||||||
private String generateServicesTableHtml(double widthMm) {
|
private String generateServicesTableHtml(double widthMm, BigDecimal vatRate) {
|
||||||
StringBuilder html = new StringBuilder();
|
StringBuilder html = new StringBuilder();
|
||||||
|
|
||||||
|
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, java.math.RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros();
|
||||||
|
if (pct.scale() < 0) {
|
||||||
|
pct = pct.setScale(0);
|
||||||
|
}
|
||||||
|
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
|
||||||
|
|
||||||
// Sample data for preview (will be replaced with actual job data later)
|
// Sample data for preview (will be replaced with actual job data later)
|
||||||
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", "19%", "450,00 €" },
|
String[][] sampleData = { { "Umzugsleistung inkl. Verpackung", vatLabel, "450,00 €" },
|
||||||
{ "Entsorgung Möbel", "19%", "85,00 €" }, { "Montage/De-Montage", "19%", "120,00 €" } };
|
{ "Entsorgung Möbel", vatLabel, "85,00 €" }, { "Montage/De-Montage", vatLabel, "120,00 €" } };
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
double netTotal = 655.00;
|
double netTotal = 655.00;
|
||||||
double grossTotal = 779.45;
|
double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
|
||||||
|
|
||||||
// Wrapper div
|
// Wrapper div
|
||||||
html.append("<div style='width:100%;box-sizing:border-box;'>");
|
html.append("<div style='width:100%;box-sizing:border-box;'>");
|
||||||
@@ -797,7 +811,9 @@ public class CustomerInvoiceService {
|
|||||||
|
|
||||||
// Get invoice data from variables
|
// Get invoice data from variables
|
||||||
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
|
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
|
||||||
|
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
|
||||||
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
|
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
|
||||||
|
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
|
||||||
|
|
||||||
// Parse services JSON from variables
|
// Parse services JSON from variables
|
||||||
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
|
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
|
||||||
@@ -822,7 +838,9 @@ public class CustomerInvoiceService {
|
|||||||
// Header row
|
// Header row
|
||||||
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
|
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
|
||||||
html.append(
|
html.append(
|
||||||
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:75%;white-space:nowrap;'>Name</th>");
|
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>");
|
||||||
|
html.append(
|
||||||
|
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>");
|
||||||
html.append(
|
html.append(
|
||||||
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
|
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
@@ -832,7 +850,7 @@ public class CustomerInvoiceService {
|
|||||||
// Fallback: show a single row with no data
|
// Fallback: show a single row with no data
|
||||||
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
|
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
|
||||||
html.append(
|
html.append(
|
||||||
"<td colspan='2' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
|
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
} else {
|
} else {
|
||||||
for (int i = 0; i < servicesData.size(); i++) {
|
for (int i = 0; i < servicesData.size(); i++) {
|
||||||
@@ -843,8 +861,10 @@ public class CustomerInvoiceService {
|
|||||||
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
|
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
|
||||||
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
|
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
|
||||||
html.append(
|
html.append(
|
||||||
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
|
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>")
|
||||||
.append(escapeHtml(name)).append("</td>");
|
.append(escapeHtml(name)).append("</td>");
|
||||||
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
||||||
|
.append(escapeHtml(vatRateLabel)).append("</td>");
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
||||||
.append(netAmount).append(" €</td>");
|
.append(netAmount).append(" €</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
@@ -865,6 +885,15 @@ public class CustomerInvoiceService {
|
|||||||
.append(netTotal).append("</td>");
|
.append(netTotal).append("</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
|
|
||||||
|
// Umsatzsteuer
|
||||||
|
html.append("<tr>");
|
||||||
|
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||||
|
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
|
||||||
|
.append(escapeHtml(vatRateLabel)).append(" USt:</td>");
|
||||||
|
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
|
||||||
|
.append(vatTotal).append("</td>");
|
||||||
|
html.append("</tr>");
|
||||||
|
|
||||||
// Gesamtsumme
|
// Gesamtsumme
|
||||||
html.append("<tr>");
|
html.append("<tr>");
|
||||||
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||||
@@ -892,4 +921,181 @@ public class CustomerInvoiceService {
|
|||||||
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||||
.replace("'", "'");
|
.replace("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19.
|
||||||
|
* Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die
|
||||||
|
* Beträge als negative Werte dar.
|
||||||
|
*/
|
||||||
|
public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
|
||||||
|
String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception {
|
||||||
|
return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason,
|
||||||
|
null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14.
|
||||||
|
* Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben.
|
||||||
|
*/
|
||||||
|
public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
|
||||||
|
String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields)
|
||||||
|
throws Exception {
|
||||||
|
return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate,
|
||||||
|
reason, correctedFields, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] generateCorrectionDocumentPdf(String documentLabel,
|
||||||
|
de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number,
|
||||||
|
java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts)
|
||||||
|
throws Exception {
|
||||||
|
java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now();
|
||||||
|
java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
|
BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount());
|
||||||
|
BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount());
|
||||||
|
BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount())
|
||||||
|
: safeAmount(original.getTotalAmount());
|
||||||
|
|
||||||
|
StringBuilder html = new StringBuilder();
|
||||||
|
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
|
||||||
|
html.append("@page { size: A4; margin: 20mm 18mm 20mm 18mm; }");
|
||||||
|
html.append("body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }");
|
||||||
|
html.append("h1 { font-size: 18pt; letter-spacing: 0.05em; margin: 0 0 8pt 0; }");
|
||||||
|
html.append("h2 { font-size: 13pt; margin: 18pt 0 6pt 0; }");
|
||||||
|
html.append(".doc-number { font-size: 11pt; color: #555; }");
|
||||||
|
html.append(".section { margin-top: 14pt; }");
|
||||||
|
html.append(".reference { background: #f6f6f6; border-left: 3px solid #888; padding: 8pt 12pt; }");
|
||||||
|
html.append(".reason { background: #fff8e6; border-left: 3px solid #d9a300; padding: 8pt 12pt; }");
|
||||||
|
html.append("table.amounts { width: 60%; margin-left: 40%; border-collapse: collapse; margin-top: 10pt; }");
|
||||||
|
html.append("table.amounts td { padding: 3pt 6pt; }");
|
||||||
|
html.append("table.amounts td.value { text-align: right; }");
|
||||||
|
html.append("table.amounts tr.total td { font-weight: bold; border-top: 1px solid #333; }");
|
||||||
|
html.append(".addresses { width: 100%; margin-top: 14pt; }");
|
||||||
|
html.append(".addresses td { width: 50%; vertical-align: top; }");
|
||||||
|
html.append(".muted { color: #666; font-size: 9pt; }");
|
||||||
|
html.append("</style></head><body>");
|
||||||
|
|
||||||
|
html.append("<h1>").append(escapeHtml(documentLabel)).append("</h1>");
|
||||||
|
html.append("<div class='doc-number'>Beleg-Nr.: ")
|
||||||
|
.append(escapeHtml(safe(number)))
|
||||||
|
.append(" · Datum: ").append(escapeHtml(effectiveDate.format(dateFmt)))
|
||||||
|
.append("</div>");
|
||||||
|
|
||||||
|
// Sender / Empfänger
|
||||||
|
html.append("<table class='addresses'><tr><td>");
|
||||||
|
html.append("<strong>Aussteller</strong><br/>");
|
||||||
|
html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(),
|
||||||
|
original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry()));
|
||||||
|
if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) {
|
||||||
|
html.append("<div class='muted'>Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber()))
|
||||||
|
.append("</div>");
|
||||||
|
}
|
||||||
|
if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) {
|
||||||
|
html.append("<div class='muted'>USt-IdNr.: ").append(escapeHtml(original.getSenderVatId()))
|
||||||
|
.append("</div>");
|
||||||
|
}
|
||||||
|
html.append("</td><td>");
|
||||||
|
html.append("<strong>Empfänger</strong><br/>");
|
||||||
|
html.append(formatAddressBlock(
|
||||||
|
firstNonBlank(original.getRecipientCompany(), original.getRecipientName()),
|
||||||
|
original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(),
|
||||||
|
original.getRecipientCountry()));
|
||||||
|
html.append("</td></tr></table>");
|
||||||
|
|
||||||
|
// Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28)
|
||||||
|
html.append("<div class='section reference'>");
|
||||||
|
html.append("<strong>Bezug:</strong> ");
|
||||||
|
html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich ");
|
||||||
|
html.append("eindeutig auf die Rechnung <strong>")
|
||||||
|
.append(escapeHtml(safe(original.getInvoiceNumber()))).append("</strong>");
|
||||||
|
if (original.getInvoiceDate() != null) {
|
||||||
|
html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt)));
|
||||||
|
}
|
||||||
|
html.append(".");
|
||||||
|
html.append("</div>");
|
||||||
|
|
||||||
|
if (correctedFields != null && !correctedFields.isBlank()) {
|
||||||
|
html.append("<div class='section'><h2>Berichtigte Angaben</h2>");
|
||||||
|
html.append("<div>").append(escapeHtml(correctedFields).replace("\n", "<br/>")).append("</div></div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason != null && !reason.isBlank()) {
|
||||||
|
html.append("<div class='section reason'><strong>Grund:</strong> ")
|
||||||
|
.append(escapeHtml(reason).replace("\n", "<br/>")).append("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.append("<h2>Beträge</h2>");
|
||||||
|
html.append("<table class='amounts'>");
|
||||||
|
html.append("<tr><td>Nettobetrag</td><td class='value'>").append(formatCurrency(net)).append("</td></tr>");
|
||||||
|
if (original.getVatRate() != null) {
|
||||||
|
BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100"))
|
||||||
|
.setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
|
||||||
|
if (vatPct.scale() < 0) {
|
||||||
|
vatPct = vatPct.setScale(0);
|
||||||
|
}
|
||||||
|
html.append("<tr><td>zzgl. ").append(vatPct.toPlainString().replace('.', ','))
|
||||||
|
.append("% USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
|
||||||
|
} else {
|
||||||
|
html.append("<tr><td>zzgl. USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
|
||||||
|
}
|
||||||
|
html.append("<tr class='total'><td>Gesamtbetrag</td><td class='value'>").append(formatCurrency(total))
|
||||||
|
.append("</td></tr>");
|
||||||
|
html.append("</table>");
|
||||||
|
|
||||||
|
html.append("<div class='section muted'>");
|
||||||
|
html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und ");
|
||||||
|
html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY)));
|
||||||
|
html.append(" sind gemeinsam aufzubewahren.");
|
||||||
|
html.append("</div>");
|
||||||
|
|
||||||
|
html.append("</body></html>");
|
||||||
|
|
||||||
|
return generatePdfFromHtmlString(html.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal safeAmount(BigDecimal value) {
|
||||||
|
return value != null ? value : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal negateOrZero(BigDecimal value) {
|
||||||
|
return value != null ? value.negate() : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAddressBlock(String name, String street, String postcode, String city, String country) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
sb.append(escapeHtml(name)).append("<br/>");
|
||||||
|
}
|
||||||
|
if (street != null && !street.isBlank()) {
|
||||||
|
sb.append(escapeHtml(street)).append("<br/>");
|
||||||
|
}
|
||||||
|
String line = String.join(" ", filterBlanks(postcode, city)).trim();
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
sb.append(escapeHtml(line)).append("<br/>");
|
||||||
|
}
|
||||||
|
if (country != null && !country.isBlank()) {
|
||||||
|
sb.append(escapeHtml(country));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.util.List<String> filterBlanks(String... values) {
|
||||||
|
java.util.List<String> out = new java.util.ArrayList<>();
|
||||||
|
for (String v : values) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
for (String v : values) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.assecutor.votianlt.service;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -22,6 +24,7 @@ public class EmailService {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
private final TaskAssignmentService taskAssignmentService;
|
private final TaskAssignmentService taskAssignmentService;
|
||||||
|
private final AppUserRepository appUserRepository;
|
||||||
private final JavaMailSender mailSender;
|
private final JavaMailSender mailSender;
|
||||||
|
|
||||||
@Value("${spring.mail.username}")
|
@Value("${spring.mail.username}")
|
||||||
@@ -52,8 +55,10 @@ public class EmailService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String completedByName = resolveCompletedByName(job, completedBy);
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
sendEmail(user, job, taskType);
|
sendEmail(user, job, taskType, completedByName);
|
||||||
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
|
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
|
||||||
taskId);
|
taskId);
|
||||||
|
|
||||||
@@ -63,7 +68,7 @@ public class EmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEmail(User user, Job job, String taskType) {
|
private void sendEmail(User user, Job job, String taskType, String completedByName) {
|
||||||
SimpleMailMessage message = new SimpleMailMessage();
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
message.setFrom(smtpUsername);
|
message.setFrom(smtpUsername);
|
||||||
message.setTo(user.getEmail());
|
message.setTo(user.getEmail());
|
||||||
@@ -71,18 +76,17 @@ public class EmailService {
|
|||||||
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||||
|
|
||||||
String fullName = buildFullName(user);
|
String fullName = buildFullName(user);
|
||||||
String appUserName = buildAppUserName(user);
|
|
||||||
String taskTypeName = getTaskTypeDisplayName(taskType);
|
String taskTypeName = getTaskTypeDisplayName(taskType);
|
||||||
|
|
||||||
StringBuilder body = new StringBuilder();
|
StringBuilder body = new StringBuilder();
|
||||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||||
body.append("eine Aufgabe wurde von ").append(appUserName).append(" abgeschlossen:\n\n");
|
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
|
||||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||||
if (job.getDeliveryCompany() != null) {
|
if (job.getDeliveryCompany() != null) {
|
||||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||||
}
|
}
|
||||||
body.append("Aufgabe: ").append(taskTypeName).append("\n");
|
body.append("Aufgabe: ").append(taskTypeName).append("\n");
|
||||||
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
|
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||||
|
|
||||||
String deliveryCities = job.getDeliveryCitiesDisplay();
|
String deliveryCities = job.getDeliveryCitiesDisplay();
|
||||||
if (job.getPickupCity() != null || deliveryCities != null) {
|
if (job.getPickupCity() != null || deliveryCities != null) {
|
||||||
@@ -121,16 +125,55 @@ public class EmailService {
|
|||||||
return fullName.isEmpty() ? "Benutzer" : fullName;
|
return fullName.isEmpty() ? "Benutzer" : fullName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildAppUserName(User user) {
|
private String buildAppUserName(AppUser appUser) {
|
||||||
StringBuilder name = new StringBuilder();
|
StringBuilder name = new StringBuilder();
|
||||||
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
|
if (appUser.getVorname() != null && !appUser.getVorname().isBlank()) {
|
||||||
name.append(user.getFirstname()).append(" ");
|
name.append(appUser.getVorname()).append(" ");
|
||||||
}
|
}
|
||||||
if (user.getName() != null && !user.getName().isBlank()) {
|
if (appUser.getNachname() != null && !appUser.getNachname().isBlank()) {
|
||||||
name.append(user.getName());
|
name.append(appUser.getNachname());
|
||||||
}
|
}
|
||||||
|
|
||||||
String fullName = name.toString().trim();
|
String fullName = name.toString().trim();
|
||||||
return fullName.isEmpty() ? "App-Benutzer" : fullName;
|
if (!fullName.isEmpty()) {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
if (appUser.getBezeichnung() != null && !appUser.getBezeichnung().isBlank()) {
|
||||||
|
return appUser.getBezeichnung().trim();
|
||||||
|
}
|
||||||
|
if (appUser.getEmail() != null && !appUser.getEmail().isBlank()) {
|
||||||
|
return appUser.getEmail().trim();
|
||||||
|
}
|
||||||
|
return "App-Benutzer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCompletedByName(Job job, String completedBy) {
|
||||||
|
Optional<AppUser> assignedAppUser = findAppUserById(job != null ? job.getAppUser() : null);
|
||||||
|
if (assignedAppUser.isPresent()) {
|
||||||
|
return buildAppUserName(assignedAppUser.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedBy != null && !completedBy.isBlank() && !"Unknown".equalsIgnoreCase(completedBy)) {
|
||||||
|
Optional<AppUser> completingAppUser = findAppUserById(completedBy);
|
||||||
|
if (completingAppUser.isPresent()) {
|
||||||
|
return buildAppUserName(completingAppUser.get());
|
||||||
|
}
|
||||||
|
return completedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "App-Benutzer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AppUser> findAppUserById(String appUserId) {
|
||||||
|
if (appUserId == null || appUserId.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return appUserRepository.findById(new ObjectId(appUserId));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getTaskTypeDisplayName(String taskType) {
|
private String getTaskTypeDisplayName(String taskType) {
|
||||||
@@ -173,8 +216,10 @@ public class EmailService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String completedByName = resolveCompletedByName(job, completedBy);
|
||||||
|
|
||||||
// Send job completion email
|
// Send job completion email
|
||||||
sendJobCompletionEmail(user, job);
|
sendJobCompletionEmail(user, job, completedByName);
|
||||||
log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber());
|
log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -182,7 +227,7 @@ public class EmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendJobCompletionEmail(User user, Job job) {
|
private void sendJobCompletionEmail(User user, Job job, String completedByName) {
|
||||||
SimpleMailMessage message = new SimpleMailMessage();
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
message.setFrom(smtpUsername);
|
message.setFrom(smtpUsername);
|
||||||
message.setTo(user.getEmail());
|
message.setTo(user.getEmail());
|
||||||
@@ -190,7 +235,6 @@ public class EmailService {
|
|||||||
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||||
|
|
||||||
String fullName = buildFullName(user);
|
String fullName = buildFullName(user);
|
||||||
String appUserName = buildAppUserName(user);
|
|
||||||
|
|
||||||
// Count completed tasks
|
// Count completed tasks
|
||||||
var allTasks = taskAssignmentService.findTasksForJob(job);
|
var allTasks = taskAssignmentService.findTasksForJob(job);
|
||||||
@@ -220,7 +264,7 @@ public class EmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
|
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
|
||||||
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
|
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||||
|
|
||||||
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
||||||
body.append("Mit freundlichen Grüßen,\n");
|
body.append("Mit freundlichen Grüßen,\n");
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben
|
||||||
|
* nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden
|
||||||
|
* gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt
|
||||||
|
* durchführen kann statt jeden Fehler einzeln zu beheben.
|
||||||
|
*/
|
||||||
|
public class InvoiceComplianceException extends InvoiceLifecycleException {
|
||||||
|
|
||||||
|
private final List<String> violations;
|
||||||
|
|
||||||
|
public InvoiceComplianceException(List<String> violations) {
|
||||||
|
super(buildMessage(violations));
|
||||||
|
this.violations = List.copyOf(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getViolations() {
|
||||||
|
return Collections.unmodifiableList(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildMessage(List<String> violations) {
|
||||||
|
if (violations == null || violations.isEmpty()) {
|
||||||
|
return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht.";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:");
|
||||||
|
for (String violation : violations) {
|
||||||
|
sb.append("\n • ").append(violation);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG
|
||||||
|
* sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung
|
||||||
|
* (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf
|
||||||
|
* keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt
|
||||||
|
* änderbar ist.
|
||||||
|
*
|
||||||
|
* Nicht abgedeckt (bewusst):
|
||||||
|
* <ul>
|
||||||
|
* <li>Lückenlose Rechnungsnummer-Vergabe (separater Block).</li>
|
||||||
|
* <li>Online-Validierung der USt-IdNr beim Bzst (separater Block).</li>
|
||||||
|
* <li>Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge,
|
||||||
|
* Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs-
|
||||||
|
* differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceComplianceValidator {
|
||||||
|
|
||||||
|
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen,
|
||||||
|
* wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts.
|
||||||
|
*/
|
||||||
|
public void validateForIssuance(CustomerInvoice invoice) {
|
||||||
|
if (invoice == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) {
|
||||||
|
// Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> violations = collectViolations(invoice);
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new InvoiceComplianceException(violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> collectViolations(CustomerInvoice invoice) {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
checkInvoiceNumber(invoice, violations);
|
||||||
|
checkDates(invoice, violations);
|
||||||
|
checkSender(invoice, violations);
|
||||||
|
checkRecipient(invoice, violations);
|
||||||
|
checkItems(invoice, violations);
|
||||||
|
checkAmounts(invoice, violations);
|
||||||
|
checkVatNotices(invoice, violations);
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkInvoiceNumber(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getInvoiceNumber())) {
|
||||||
|
violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkDates(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (invoice.getInvoiceDate() == null) {
|
||||||
|
violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG).");
|
||||||
|
}
|
||||||
|
if (invoice.getDeliveryDate() == null) {
|
||||||
|
violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). "
|
||||||
|
+ "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkSender(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getSenderName())) {
|
||||||
|
violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode())
|
||||||
|
|| isBlank(invoice.getSenderCity())) {
|
||||||
|
violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) {
|
||||||
|
violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRecipient(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getRecipientName())) {
|
||||||
|
violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode())
|
||||||
|
|| isBlank(invoice.getRecipientCity())) {
|
||||||
|
violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkItems(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
List<CustomerInvoiceItem> items = invoice.getItems();
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 5 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
CustomerInvoiceItem item = items.get(i);
|
||||||
|
int rowNumber = i + 1;
|
||||||
|
if (item == null) {
|
||||||
|
violations.add("Position " + rowNumber + ": leere Position.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isBlank(item.getDescription())) {
|
||||||
|
violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt.");
|
||||||
|
}
|
||||||
|
if (item.getQuantity() == null || item.getQuantity().signum() <= 0) {
|
||||||
|
violations.add("Position " + rowNumber + ": Menge muss größer 0 sein.");
|
||||||
|
}
|
||||||
|
if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) {
|
||||||
|
violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkAmounts(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
BigDecimal net = invoice.getNetAmount();
|
||||||
|
BigDecimal vat = invoice.getVatAmount();
|
||||||
|
BigDecimal total = invoice.getTotalAmount();
|
||||||
|
if (net == null || vat == null || total == null) {
|
||||||
|
violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 7 + 8 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent).");
|
||||||
|
}
|
||||||
|
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
|
||||||
|
BigDecimal sumItems = invoice.getItems().stream()
|
||||||
|
.map(CustomerInvoiceItem::getNetTotal)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net
|
||||||
|
+ " ab (Differenz > 1 Cent).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkVatNotices(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
BigDecimal rate = invoice.getVatRate();
|
||||||
|
if (rate == null) {
|
||||||
|
violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rate.signum() == 0) {
|
||||||
|
// Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder
|
||||||
|
// Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine
|
||||||
|
// innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung.
|
||||||
|
// Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft.
|
||||||
|
boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes());
|
||||||
|
if (!hasNotice) {
|
||||||
|
violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich "
|
||||||
|
+ "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, "
|
||||||
|
+ "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). "
|
||||||
|
+ "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BigDecimal expectedVat = invoice.getNetAmount() != null
|
||||||
|
? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
: null;
|
||||||
|
if (expectedVat != null && invoice.getVatAmount() != null
|
||||||
|
&& expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount()
|
||||||
|
+ " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export-Funktion für Rechnungen gemäß R-33 und R-34.
|
||||||
|
*
|
||||||
|
* Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen
|
||||||
|
* (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei
|
||||||
|
* mit Audit-Log und Verkettungsangaben in einer ZIP-Datei.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceExportService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
|
||||||
|
public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen
|
||||||
|
* sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg,
|
||||||
|
* wird automatisch das Bündel um die zugehörige Originalrechnung erweitert.
|
||||||
|
*/
|
||||||
|
public byte[] exportInvoicePackage(CustomerInvoice anchor) {
|
||||||
|
if (anchor == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice root = resolveRoot(anchor);
|
||||||
|
List<CustomerInvoice> bundle = collectBundle(root);
|
||||||
|
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
|
||||||
|
|
||||||
|
for (CustomerInvoice invoice : bundle) {
|
||||||
|
writePdfEntry(zip, invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
String manifest = buildManifest(root, bundle);
|
||||||
|
ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt");
|
||||||
|
zip.putNextEntry(manifestEntry);
|
||||||
|
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
|
||||||
|
zip.finish();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor.
|
||||||
|
*/
|
||||||
|
public String suggestFilename(CustomerInvoice anchor) {
|
||||||
|
CustomerInvoice root = resolveRoot(anchor);
|
||||||
|
String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId();
|
||||||
|
return "Rechnung_" + sanitize(number) + ".zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice resolveRoot(CustomerInvoice anchor) {
|
||||||
|
if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) {
|
||||||
|
return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor);
|
||||||
|
}
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CustomerInvoice> collectBundle(CustomerInvoice root) {
|
||||||
|
List<CustomerInvoice> result = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
result.add(root);
|
||||||
|
seen.add(root.getId());
|
||||||
|
|
||||||
|
for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) {
|
||||||
|
if (related.getId() != null && seen.add(related.getId())) {
|
||||||
|
result.add(related);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException {
|
||||||
|
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) {
|
||||||
|
case INVOICE -> "Rechnung";
|
||||||
|
case CANCELLATION -> "Storno";
|
||||||
|
case CORRECTION -> "Berichtigung";
|
||||||
|
};
|
||||||
|
String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId();
|
||||||
|
String name = sanitize(label + "_" + number) + ".pdf";
|
||||||
|
ZipEntry entry = new ZipEntry(name);
|
||||||
|
zip.putNextEntry(entry);
|
||||||
|
zip.write(invoice.getPdfData());
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildManifest(CustomerInvoice root, List<CustomerInvoice> bundle) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Rechnungspaket\n");
|
||||||
|
sb.append("===============\n\n");
|
||||||
|
sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber()));
|
||||||
|
if (root.getInvoiceDate() != null) {
|
||||||
|
sb.append(" vom ").append(root.getInvoiceDate());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append("Status: ").append(root.getStatus()).append("\n");
|
||||||
|
sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n");
|
||||||
|
if (root.getTotalAmount() != null) {
|
||||||
|
sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n");
|
||||||
|
}
|
||||||
|
if (root.getPaidAmount() != null) {
|
||||||
|
sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nEnthaltene Belege:\n");
|
||||||
|
for (CustomerInvoice invoice : bundle) {
|
||||||
|
sb.append("- [").append(invoice.getType()).append("] ")
|
||||||
|
.append(safe(invoice.getInvoiceNumber()));
|
||||||
|
if (invoice.getInvoiceDate() != null) {
|
||||||
|
sb.append(" vom ").append(invoice.getInvoiceDate());
|
||||||
|
}
|
||||||
|
sb.append(" — Status ").append(invoice.getStatus()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nÄnderungsprotokoll der Originalrechnung:\n");
|
||||||
|
List<InvoiceAuditEntry> log = root.getAuditLog();
|
||||||
|
if (log == null || log.isEmpty()) {
|
||||||
|
sb.append("(keine Einträge)\n");
|
||||||
|
} else {
|
||||||
|
for (InvoiceAuditEntry entry : log) {
|
||||||
|
sb.append("- ");
|
||||||
|
sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-");
|
||||||
|
sb.append(" · ").append(entry.getAction());
|
||||||
|
sb.append(" · ").append(safe(entry.getUserDisplayName()));
|
||||||
|
if (entry.getReason() != null && !entry.getReason().isBlank()) {
|
||||||
|
sb.append(" — ").append(entry.getReason());
|
||||||
|
}
|
||||||
|
if (entry.getResultingInvoiceNumber() != null) {
|
||||||
|
sb.append(" → ").append(entry.getResultingInvoiceNumber());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append(
|
||||||
|
"\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n");
|
||||||
|
sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitize(String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return "Beleg";
|
||||||
|
}
|
||||||
|
return input.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung
|
||||||
|
* gegen die Regeln aus <code>invoices_rules.md</code> verstößt (z.B. R-03, R-08, R-11, R-35).
|
||||||
|
*
|
||||||
|
* Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden.
|
||||||
|
*/
|
||||||
|
public class InvoiceLifecycleException extends RuntimeException {
|
||||||
|
|
||||||
|
public InvoiceLifecycleException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceLifecycleException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.model.invoices.PaymentStatus;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus
|
||||||
|
* <code>invoices_rules.md</code>.
|
||||||
|
*
|
||||||
|
* Phase 1 stellt die Status- und Audit-Mechanik bereit:
|
||||||
|
* <ul>
|
||||||
|
* <li>R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.</li>
|
||||||
|
* <li>R-07: Finalisierung markiert eine Rechnung als verbindlich.</li>
|
||||||
|
* <li>R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.</li>
|
||||||
|
* <li>R-35: Direktes Löschen finalisierter Belege wird abgelehnt.</li>
|
||||||
|
* <li>R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceLifecycleService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
private final InvoiceComplianceValidator complianceValidator;
|
||||||
|
private final InvoiceNumberAuditService numberAuditService;
|
||||||
|
|
||||||
|
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
|
||||||
|
InvoiceComplianceValidator complianceValidator,
|
||||||
|
InvoiceNumberAuditService numberAuditService) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
this.securityService = securityService;
|
||||||
|
this.complianceValidator = complianceValidator;
|
||||||
|
this.numberAuditService = numberAuditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createDraft(CustomerInvoice draft, String reason) {
|
||||||
|
if (draft == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein.");
|
||||||
|
}
|
||||||
|
draft.setStatus(InvoiceStatus.DRAFT);
|
||||||
|
if (draft.getType() == null) {
|
||||||
|
draft.setType(InvoiceType.INVOICE);
|
||||||
|
}
|
||||||
|
draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
return invoiceRepository.save(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED).
|
||||||
|
* Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in
|
||||||
|
* einem Schritt vereint (R-06/R-07).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) {
|
||||||
|
if (invoice == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() == null) {
|
||||||
|
invoice.setType(InvoiceType.INVOICE);
|
||||||
|
}
|
||||||
|
complianceValidator.validateForIssuance(invoice);
|
||||||
|
ensureInvoiceNumberUnique(invoice);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
invoice.setIssuedAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
|
||||||
|
CustomerInvoice saved = invoiceRepository.save(invoice);
|
||||||
|
numberAuditService.markUsed(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05).
|
||||||
|
* Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) {
|
||||||
|
if (draft == null || draft.getId() == null) {
|
||||||
|
throw new IllegalArgumentException("Bestehender Entwurf erwartet.");
|
||||||
|
}
|
||||||
|
CustomerInvoice persisted = invoiceRepository.findById(draft.getId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId()));
|
||||||
|
if (!persisted.getStatus().isMutable()) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. "
|
||||||
|
+ "Bitte erstellen Sie eine Berichtigung oder ein Storno.");
|
||||||
|
}
|
||||||
|
draft.setStatus(InvoiceStatus.DRAFT);
|
||||||
|
draft.setAuditLog(persisted.getAuditLog());
|
||||||
|
draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason));
|
||||||
|
return invoiceRepository.save(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer
|
||||||
|
* eindeutig ist (R-11) und protokolliert den Wechsel.
|
||||||
|
*/
|
||||||
|
public CustomerInvoice issue(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) {
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
if (invoice.getStatus() != InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus());
|
||||||
|
}
|
||||||
|
complianceValidator.validateForIssuance(invoice);
|
||||||
|
ensureInvoiceNumberUnique(invoice);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
invoice.setIssuedAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
|
||||||
|
CustomerInvoice saved = invoiceRepository.save(invoice);
|
||||||
|
numberAuditService.markUsed(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert eine ausgestellte Rechnung als versendet (R-08).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice markAsSent(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine Rechnung muss vor dem Versand zunächst ausgestellt werden.");
|
||||||
|
}
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden.");
|
||||||
|
}
|
||||||
|
invoice.setStatus(InvoiceStatus.SENT);
|
||||||
|
invoice.setSentAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason));
|
||||||
|
return invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22).
|
||||||
|
*
|
||||||
|
* Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION}
|
||||||
|
* mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf
|
||||||
|
* {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über
|
||||||
|
* {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt.
|
||||||
|
*
|
||||||
|
* @param originalId ID der zu stornierenden Rechnung
|
||||||
|
* @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg
|
||||||
|
* @param cancellationDate Belegdatum des Stornos
|
||||||
|
* @param pdfData generiertes PDF des Stornobelegs
|
||||||
|
* @param reason nachvollziehbarer Grund (R-36)
|
||||||
|
*/
|
||||||
|
public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate,
|
||||||
|
byte[] pdfData, String reason) {
|
||||||
|
CustomerInvoice original = requireInvoice(originalId);
|
||||||
|
if (original.getType() != InvoiceType.INVOICE) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType());
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert.");
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet.");
|
||||||
|
}
|
||||||
|
if (cancellationNumber == null || cancellationNumber.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice cancellation = new CustomerInvoice();
|
||||||
|
cancellation.setType(InvoiceType.CANCELLATION);
|
||||||
|
cancellation.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
cancellation.setInvoiceNumber(cancellationNumber);
|
||||||
|
cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now());
|
||||||
|
cancellation.setIssuedAt(LocalDateTime.now());
|
||||||
|
cancellation.setUserId(original.getUserId());
|
||||||
|
cancellation.setJobId(original.getJobId());
|
||||||
|
cancellation.setOriginalInvoiceId(original.getId());
|
||||||
|
cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber());
|
||||||
|
cancellation.setOriginalInvoiceDate(original.getInvoiceDate());
|
||||||
|
|
||||||
|
// Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben
|
||||||
|
copyParties(original, cancellation);
|
||||||
|
cancellation.setItems(original.getItems());
|
||||||
|
|
||||||
|
// Beträge negieren – Storno bucht den Originalbetrag aus
|
||||||
|
cancellation.setNetAmount(negate(original.getNetAmount()));
|
||||||
|
cancellation.setVatRate(original.getVatRate());
|
||||||
|
cancellation.setVatAmount(negate(original.getVatAmount()));
|
||||||
|
cancellation.setTotalAmount(negate(original.getTotalAmount()));
|
||||||
|
|
||||||
|
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
|
||||||
|
cancellation.setPdfData(pdfData);
|
||||||
|
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
|
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
|
||||||
|
cancellation.addAuditEntry(issuedEntry);
|
||||||
|
|
||||||
|
ensureInvoiceNumberUnique(cancellation);
|
||||||
|
CustomerInvoice savedCancellation = invoiceRepository.save(cancellation);
|
||||||
|
numberAuditService.markUsed(savedCancellation);
|
||||||
|
|
||||||
|
// Original markieren und verlinken
|
||||||
|
original.setStatus(InvoiceStatus.CANCELLED);
|
||||||
|
original.setCancelledAt(LocalDateTime.now());
|
||||||
|
original.setCancellationInvoiceId(savedCancellation.getId());
|
||||||
|
// Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26)
|
||||||
|
BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
original.setPaymentStatus(computePaymentStatus(original, paid));
|
||||||
|
InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason);
|
||||||
|
cancelEntry.setResultingInvoiceId(savedCancellation.getId());
|
||||||
|
cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber());
|
||||||
|
original.addAuditEntry(cancelEntry);
|
||||||
|
invoiceRepository.save(original);
|
||||||
|
|
||||||
|
log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(),
|
||||||
|
savedCancellation.getInvoiceNumber());
|
||||||
|
return savedCancellation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16).
|
||||||
|
*
|
||||||
|
* Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe).
|
||||||
|
* Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung
|
||||||
|
* wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den
|
||||||
|
* Berichtigungsbeleg.
|
||||||
|
*
|
||||||
|
* @param originalId ID der zu berichtigenden Rechnung
|
||||||
|
* @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg
|
||||||
|
* @param correctionDate Belegdatum
|
||||||
|
* @param pdfData generiertes PDF des Berichtigungsbelegs
|
||||||
|
* @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14)
|
||||||
|
* @param reason Grund der Berichtigung (R-36)
|
||||||
|
*/
|
||||||
|
public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate,
|
||||||
|
byte[] pdfData, String correctedFields, String reason) {
|
||||||
|
CustomerInvoice original = requireInvoice(originalId);
|
||||||
|
if (original.getType() != InvoiceType.INVOICE) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType());
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet.");
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung.");
|
||||||
|
}
|
||||||
|
if (correctionNumber == null || correctionNumber.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice correction = new CustomerInvoice();
|
||||||
|
correction.setType(InvoiceType.CORRECTION);
|
||||||
|
correction.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
correction.setInvoiceNumber(correctionNumber);
|
||||||
|
correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now());
|
||||||
|
correction.setIssuedAt(LocalDateTime.now());
|
||||||
|
correction.setUserId(original.getUserId());
|
||||||
|
correction.setJobId(original.getJobId());
|
||||||
|
correction.setOriginalInvoiceId(original.getId());
|
||||||
|
correction.setOriginalInvoiceNumber(original.getInvoiceNumber());
|
||||||
|
correction.setOriginalInvoiceDate(original.getInvoiceDate());
|
||||||
|
|
||||||
|
copyParties(original, correction);
|
||||||
|
correction.setItems(original.getItems());
|
||||||
|
correction.setNetAmount(original.getNetAmount());
|
||||||
|
correction.setVatRate(original.getVatRate());
|
||||||
|
correction.setVatAmount(original.getVatAmount());
|
||||||
|
correction.setTotalAmount(original.getTotalAmount());
|
||||||
|
|
||||||
|
String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber();
|
||||||
|
correction.setDescription(
|
||||||
|
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
|
||||||
|
: descriptionPrefix + " — " + correctedFields);
|
||||||
|
correction.setPdfData(pdfData);
|
||||||
|
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
|
issuedEntry.setResultingInvoiceNumber(correctionNumber);
|
||||||
|
correction.addAuditEntry(issuedEntry);
|
||||||
|
|
||||||
|
ensureInvoiceNumberUnique(correction);
|
||||||
|
CustomerInvoice savedCorrection = invoiceRepository.save(correction);
|
||||||
|
numberAuditService.markUsed(savedCorrection);
|
||||||
|
|
||||||
|
original.setStatus(InvoiceStatus.CORRECTED);
|
||||||
|
original.setCorrectionInvoiceId(savedCorrection.getId());
|
||||||
|
InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason);
|
||||||
|
correctEntry.setResultingInvoiceId(savedCorrection.getId());
|
||||||
|
correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber());
|
||||||
|
original.addAuditEntry(correctEntry);
|
||||||
|
invoiceRepository.save(original);
|
||||||
|
|
||||||
|
log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(),
|
||||||
|
savedCorrection.getInvoiceNumber());
|
||||||
|
return savedCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22).
|
||||||
|
* Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte
|
||||||
|
* Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt.
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement,
|
||||||
|
String reason) {
|
||||||
|
CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId);
|
||||||
|
if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden.");
|
||||||
|
}
|
||||||
|
if (replacement == null) {
|
||||||
|
throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
replacement.setType(InvoiceType.INVOICE);
|
||||||
|
replacement.setOriginalInvoiceId(cancelledOriginal.getId());
|
||||||
|
replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber());
|
||||||
|
replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate());
|
||||||
|
|
||||||
|
CustomerInvoice savedReplacement = createAndIssue(replacement,
|
||||||
|
reason != null ? reason : "Ersatzrechnung nach Storno");
|
||||||
|
|
||||||
|
cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId());
|
||||||
|
InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason);
|
||||||
|
replaceEntry.setResultingInvoiceId(savedReplacement.getId());
|
||||||
|
replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber());
|
||||||
|
cancelledOriginal.addAuditEntry(replaceEntry);
|
||||||
|
invoiceRepository.save(cancelledOriginal);
|
||||||
|
|
||||||
|
return savedReplacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung.
|
||||||
|
*/
|
||||||
|
public List<CustomerInvoice> findRelatedDocuments(String originalInvoiceId) {
|
||||||
|
if (originalInvoiceId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfasst eine Zahlung zur Rechnung (R-23 bis R-26).
|
||||||
|
*
|
||||||
|
* Eine Zahlung wird ausschließlich erfasst – die Rechnungsdaten selbst werden nicht
|
||||||
|
* verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu
|
||||||
|
* Bruttobetrag der Rechnung.
|
||||||
|
*
|
||||||
|
* @param invoiceId Rechnung, auf die gebucht wird
|
||||||
|
* @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen)
|
||||||
|
* @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg)
|
||||||
|
* @param reason erläuternder Grund für das Audit-Log
|
||||||
|
*/
|
||||||
|
public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference,
|
||||||
|
String reason) {
|
||||||
|
if (amount == null) {
|
||||||
|
throw new IllegalArgumentException("Zahlbetrag erforderlich.");
|
||||||
|
}
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() == InvoiceType.CORRECTION) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Auf Berichtigungsbelegen werden keine Zahlungen erfasst – buchen Sie auf der Originalrechnung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
BigDecimal newPaid = previous.add(amount);
|
||||||
|
invoice.setPaidAmount(newPaid);
|
||||||
|
invoice.setLastPaymentAt(LocalDateTime.now());
|
||||||
|
invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid));
|
||||||
|
|
||||||
|
StringBuilder logReason = new StringBuilder();
|
||||||
|
logReason.append("Zahlung erfasst: ").append(amount.toPlainString());
|
||||||
|
if (paymentReference != null && !paymentReference.isBlank()) {
|
||||||
|
logReason.append(" (Referenz: ").append(paymentReference).append(")");
|
||||||
|
}
|
||||||
|
if (reason != null && !reason.isBlank()) {
|
||||||
|
logReason.append(" – ").append(reason);
|
||||||
|
}
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString()));
|
||||||
|
|
||||||
|
return invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine
|
||||||
|
* Überzahlung bzw. einen Erstattungsanspruch (R-26).
|
||||||
|
*/
|
||||||
|
public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) {
|
||||||
|
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
|
||||||
|
BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
return total.subtract(paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) {
|
||||||
|
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE;
|
||||||
|
}
|
||||||
|
int cmp = paid.compareTo(total);
|
||||||
|
if (paid.signum() == 0) {
|
||||||
|
return PaymentStatus.UNPAID;
|
||||||
|
}
|
||||||
|
if (cmp == 0) {
|
||||||
|
return PaymentStatus.PAID;
|
||||||
|
}
|
||||||
|
if (cmp < 0) {
|
||||||
|
return PaymentStatus.PARTIALLY_PAID;
|
||||||
|
}
|
||||||
|
return PaymentStatus.OVERPAID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35).
|
||||||
|
*/
|
||||||
|
public void deleteDraft(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (!invoice.getStatus().isMutable()) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Finalisierte Rechnungen können nicht gelöscht werden. "
|
||||||
|
+ "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch.");
|
||||||
|
}
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason));
|
||||||
|
log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason);
|
||||||
|
invoiceRepository.delete(invoice);
|
||||||
|
// Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das
|
||||||
|
// Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung
|
||||||
|
// als unerklärter „RESERVED"-Eintrag im Audit hängen.
|
||||||
|
if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank()
|
||||||
|
&& invoice.getUserId() != null) {
|
||||||
|
try {
|
||||||
|
ObjectId userId = new ObjectId(invoice.getUserId());
|
||||||
|
String voidReason = (reason != null && !reason.isBlank())
|
||||||
|
? reason
|
||||||
|
: "Rechnungsentwurf gelöscht";
|
||||||
|
numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason);
|
||||||
|
} catch (IllegalArgumentException | InvoiceLifecycleException ex) {
|
||||||
|
// Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor
|
||||||
|
// erfasst das später; das Löschen selbst soll nicht blockiert werden.
|
||||||
|
log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<CustomerInvoice> findById(String invoiceId) {
|
||||||
|
return invoiceRepository.findById(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice requireInvoice(String invoiceId) {
|
||||||
|
if (invoiceId == null || invoiceId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Rechnungs-ID erforderlich.");
|
||||||
|
}
|
||||||
|
return invoiceRepository.findById(invoiceId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureInvoiceNumberUnique(CustomerInvoice invoice) {
|
||||||
|
String number = invoice.getInvoiceNumber();
|
||||||
|
if (number == null || number.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer.");
|
||||||
|
}
|
||||||
|
invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> {
|
||||||
|
if (!existing.getId().equals(invoice.getId())) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) {
|
||||||
|
String userId = null;
|
||||||
|
String displayName = "system";
|
||||||
|
try {
|
||||||
|
User user = securityService.getCurrentDatabaseUser();
|
||||||
|
userId = user.getId() != null ? user.getId().toHexString() : null;
|
||||||
|
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
|
||||||
|
displayName = composed.isBlank() ? safe(user.getEmail()) : composed;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration).
|
||||||
|
}
|
||||||
|
return new InvoiceAuditEntry(action, userId, displayName, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyParties(CustomerInvoice source, CustomerInvoice target) {
|
||||||
|
target.setSenderName(source.getSenderName());
|
||||||
|
target.setSenderAddress(source.getSenderAddress());
|
||||||
|
target.setSenderPostcode(source.getSenderPostcode());
|
||||||
|
target.setSenderCity(source.getSenderCity());
|
||||||
|
target.setSenderCountry(source.getSenderCountry());
|
||||||
|
target.setSenderTaxNumber(source.getSenderTaxNumber());
|
||||||
|
target.setSenderVatId(source.getSenderVatId());
|
||||||
|
target.setSenderPhone(source.getSenderPhone());
|
||||||
|
target.setSenderEmail(source.getSenderEmail());
|
||||||
|
target.setSenderWebsite(source.getSenderWebsite());
|
||||||
|
|
||||||
|
target.setRecipientName(source.getRecipientName());
|
||||||
|
target.setRecipientCompany(source.getRecipientCompany());
|
||||||
|
target.setRecipientAddress(source.getRecipientAddress());
|
||||||
|
target.setRecipientPostcode(source.getRecipientPostcode());
|
||||||
|
target.setRecipientCity(source.getRecipientCity());
|
||||||
|
target.setRecipientCountry(source.getRecipientCountry());
|
||||||
|
target.setRecipientVatId(source.getRecipientVatId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal negate(BigDecimal value) {
|
||||||
|
return value != null ? value.negate() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem
|
||||||
|
* Counter wird als RESERVED protokolliert; dieser Service vollzieht die
|
||||||
|
* Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen)
|
||||||
|
* und liefert Lücken-Reports für die Betriebsprüfung.
|
||||||
|
*
|
||||||
|
* Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende
|
||||||
|
* Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede
|
||||||
|
* fehlende Nummer dokumentiert erklärt werden kann (GoBD).
|
||||||
|
*
|
||||||
|
* Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die
|
||||||
|
* fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene
|
||||||
|
* USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceNumberAuditService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class);
|
||||||
|
|
||||||
|
private final InvoiceNumberReservationRepository repository;
|
||||||
|
|
||||||
|
public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die Reservierung der übergebenen Rechnung als USED.
|
||||||
|
* Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen.
|
||||||
|
*/
|
||||||
|
public void markUsed(CustomerInvoice invoice) {
|
||||||
|
if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectId userId = parseUserId(invoice.getUserId());
|
||||||
|
if (userId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Optional<InvoiceNumberReservation> existing = repository.findByUserIdAndNumber(userId,
|
||||||
|
invoice.getInvoiceNumber());
|
||||||
|
InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice));
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.USED);
|
||||||
|
reservation.setInvoiceId(invoice.getId());
|
||||||
|
reservation.setUsedAt(Instant.now());
|
||||||
|
repository.save(reservation);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}",
|
||||||
|
invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED.
|
||||||
|
* Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke.
|
||||||
|
*/
|
||||||
|
public void markVoided(ObjectId userId, String number, String reason) {
|
||||||
|
if (userId == null || number == null || number.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("userId und number sind Pflichtparameter.");
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung.");
|
||||||
|
}
|
||||||
|
InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number)
|
||||||
|
.orElseThrow(() -> new InvoiceLifecycleException(
|
||||||
|
"Keine Reservierung für Nummer " + number + " gefunden."));
|
||||||
|
if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet "
|
||||||
|
+ "und kann nicht verworfen werden.");
|
||||||
|
}
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.VOIDED);
|
||||||
|
reservation.setVoidReason(reason);
|
||||||
|
reservation.setVoidedAt(Instant.now());
|
||||||
|
repository.save(reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge.
|
||||||
|
* Basis für vollständige Audit-Reports.
|
||||||
|
*/
|
||||||
|
public List<InvoiceNumberReservation> findAll(ObjectId userId) {
|
||||||
|
return repository.findByUserIdOrderBySequenceAsc(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers
|
||||||
|
* (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder
|
||||||
|
* enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund.
|
||||||
|
* Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind
|
||||||
|
* unerklärte Lücken und sollten in der UI hervorgehoben werden.
|
||||||
|
*/
|
||||||
|
public List<InvoiceNumberReservation> findUnused(ObjectId userId) {
|
||||||
|
List<InvoiceNumberReservation> all = repository.findByUserIdOrderBySequenceAsc(userId);
|
||||||
|
return all.stream()
|
||||||
|
.filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene
|
||||||
|
* Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit
|
||||||
|
* vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege,
|
||||||
|
* deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt.
|
||||||
|
*/
|
||||||
|
private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) {
|
||||||
|
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
|
||||||
|
reservation.setUserId(userId);
|
||||||
|
reservation.setNumber(invoice.getInvoiceNumber());
|
||||||
|
reservation.setSequence(extractSequence(invoice.getInvoiceNumber()));
|
||||||
|
reservation.setReservedAt(Instant.now());
|
||||||
|
reservation.setReservedBy("system (bootstrap)");
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort-Extraktion der numerischen Sequenz aus einer formatierten
|
||||||
|
* Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine
|
||||||
|
* trailing-Ziffern vorhanden sind — dann ist die Nummer für die
|
||||||
|
* Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten.
|
||||||
|
*/
|
||||||
|
private long extractSequence(String number) {
|
||||||
|
if (number == null) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
int end = number.length();
|
||||||
|
int start = end;
|
||||||
|
while (start > 0 && Character.isDigit(number.charAt(start - 1))) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
if (start == end) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(number.substring(start, end));
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectId parseUserId(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ObjectId(value);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.security.InvoiceRoles;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42.
|
||||||
|
*
|
||||||
|
* Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle,
|
||||||
|
* der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung
|
||||||
|
* — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig.
|
||||||
|
*
|
||||||
|
* Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen
|
||||||
|
* Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoicePermissionService {
|
||||||
|
|
||||||
|
private static final String ROLE_ADMIN = "ADMIN";
|
||||||
|
|
||||||
|
private final SecurityService securityService;
|
||||||
|
|
||||||
|
public InvoicePermissionService(SecurityService securityService) {
|
||||||
|
this.securityService = securityService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCreateOrIssue(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canMarkAsSent(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER)
|
||||||
|
|| isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCancel(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCorrect(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canRecordPayment(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCreate(User user) {
|
||||||
|
if (!canCreateOrIssue(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireSend(User user) {
|
||||||
|
if (!canMarkAsSent(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen als versendet zu markieren.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCancel(User user) {
|
||||||
|
if (!canCancel(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen zu stornieren.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCorrect(User user) {
|
||||||
|
if (!canCorrect(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requirePayment(User user) {
|
||||||
|
if (!canRecordPayment(user)) {
|
||||||
|
throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
|
||||||
|
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
|
||||||
|
*/
|
||||||
|
public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) {
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isAdmin(user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (invoice == null || invoice.getUserId() == null || user.getId() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return invoice.getUserId().equals(user.getId().toHexString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAnyInvoiceRole(User user, String... roles) {
|
||||||
|
if (user == null || user.getRoles() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<String> userRoles = user.getRoles();
|
||||||
|
if (userRoles.contains(ROLE_ADMIN)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String role : roles) {
|
||||||
|
if (userRoles.contains(role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist —
|
||||||
|
* dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat).
|
||||||
|
*/
|
||||||
|
private boolean isUnscoped(User user) {
|
||||||
|
if (user == null || user.getRoles() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<String> roles = user.getRoles();
|
||||||
|
return roles.stream().noneMatch(r -> r.startsWith("INVOICE_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(User user) {
|
||||||
|
return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Lookup für die UI. */
|
||||||
|
public User currentUser() {
|
||||||
|
return securityService.getCurrentDatabaseUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user