Compare commits

..

6 Commits

Author SHA1 Message Date
4e411c727e feat: ZUGFeRD-E-Rechnung über externen PDF-Tool-Service mit ZIP-Download und Versand aus der PDF-Vorschau
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:28:58 +02:00
f4c2fa6ba3 feat: Rechnungsfilter (offen/gesendet), Rabatt-Dialog, Adress-Labels mit voller Adresse und Duplikatsschutz im Adressbuch
- Rechnungen erstellen: Filter Alle/Offene/Gesendete pro Abrechnungsmonat; Versand wird in system_invoice_dispatch protokolliert
- Rabatt pro Kunde jetzt über Icon-Dialog mit Prozentwert und Grund; Grund erscheint auf der Rechnungsposition
- MessageController: stille catch-Blöcke durch Logging ersetzt, offene Pflicht-Tasks werden bei station_completed geloggt
- Auftragserstellung: Abhol- und Lieferadresslisten zeigen die komplette Adresse, Auftraggeber nur den Firmennamen; (2)-Zähler entfernt
- Adressbuch: komplett identische Einträge werden beim Anlegen übersprungen bzw. mit Hinweis abgelehnt
- App: Versionsanzeige liest zur Laufzeit aus dem Build (package_info_plus), pubspec auf 0.9.20+1; Versionspflege dokumentiert (pom.xml fürs Web, pubspec.yaml für die App)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:41:21 +02:00
ef019eecee feat: Preistabelle auf App-Nutzer-Gebühr umgestellt, Rechnungsprüf-Dialog mit E-Mail-Versand und Rabatt-Erfassung
- Preistabelle: App-Nutzungslizenz und Umsatzbeteiligung durch Gebühr pro App-Nutzer ersetzt, Abrechnung pro konfiguriertem App-Nutzer (countByErstelltVon)
- Rechnungen erstellen: Prüf-Dialog mit Vor/Zurück-Navigation, Entfernen einzelner Rechnungen und Versand als PDF-Anhang an die Kunden (EmailService mit Anhang-Unterstützung)
- Rabatt pro Kunde über Icon-Dialog mit Prozentwert und Grund, als eigene Position auf der Rechnung berücksichtigt
- E-Mail-Spalte entfernt, Rechnung-erstellen-Button durch Icon ersetzt, (0 App-Nutzer)-Zusatz entfällt
- Version 0.9.20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:35:20 +02:00
a7ea4fa803 feat: Admin-Firmendaten, Rechnungserstellung für Kunden mit Pro-rata-Abrechnung und Ultimo-Erinnerung
- Neue Seite /admin-profile: Firmendaten des Systembetreibers bearbeiten (MongoDB-persistiert), erreichbar über "Profil anzeigen" im Admin-Menü; Rechnungssteller-Daten aller System-Rechnungen kommen aus dem gespeicherten Profil
- Neue Seite /admin-create-invoices mit Sidebar-Menüpunkt "Rechnungen erstellen": alle aktiven Kunden mit wählbarem Abrechnungsmonat (aktuell bis 12 Monate zurück), Fälligkeit zum Ultimo, anteilige Berechnung bei Monatsstart mitten im Monat, Einzel- und Sammel-PDF (zusammengeführt, eine Rechnung pro Seite) aus dem System-Template
- Rechnungsgenerator: Undo-Vorbereitung aus Vorsession ergänzt um Firmendaten-Anbindung; Menüpunkt "Admin-Einstellungen" entfernt
- MonthlySchedulerService: tägliche Prüfung, drei Tage vor Ultimo Erinnerungsmail an alle Administratoren
- Session-Persistenz profilabhängig: dev und production aktiviert, Basis-Default aus (behebt SESSIONS.ser-Deserialisierungsfehler in anderen Kontexten)
- ShowJobsView: überflüssiges Feld entfernt; AssertJ catchThrowableOfType auf neue Signatur umgestellt; tsconfig ignoreDeprecations
- Übersetzungen für alle neuen Oberflächen in 10 Sprachen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:53:25 +02:00
2313618c05 feat: Admin-Dashboard mit Kundenübersicht, System-Rechnung auf Preistabellen-Positionen umgestellt und Undo im Rechnungsgenerator
- Admin-Dashboard: neue Sektion mit Grid aller Kunden (Kundennummer, Firma, Name, Kontakt, Ort, zugehöriger Nutzer)
- Rechnungsgenerator (System-Template): Positionen ausschließlich aus den drei Parametern der Preistabelle statt Beispiel-Leistungen, in Canvas und PDF-Vorschau; Bausteine Netto-/Bruttosumme entfernt
- AdminLayout: gleicher view-container-Rahmen wie MainLayout, Admin-Seiten damit optisch an Nutzer-Seiten angeglichen
- Canvas-Generatoren (Admin und Profil): Undo-Historie mit bis zu 10 Schritten per Button und Strg+Z
- Übersetzungen für alle 10 Sprachen ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:10:20 +02:00
09db861064 feat: Umstellung auf Anthropic Claude SDK, Rechnungsgenerator-Systemtemplate und Task-Status-Sync
- LLM-Integration von LM Studio (Spring AI OpenAI) auf Anthropic Java SDK umgestellt
- Rechnungsgenerator: System-Template für Rechnungen an Nutzer, Canvas leeren, PDF-Vorschau
- Job manuell beenden sendet job_deleted an verbundene App-Clients
- MessageController: nur offene Jobs ausliefern, station_completed per jobId auflösen
- App: Task-Erledigungsstatus aus Server-Tasks auf Stations-Snapshots übernehmen, lokale Task-Status bei Offline-Pufferung erhalten
- Adressbuch-Wording in allen Sprachdateien, Version 0.9.18

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:16:34 +02:00
56 changed files with 3880 additions and 809 deletions

View File

@@ -13,6 +13,8 @@ flutter run -d <device> # Run app on device
dart run build_runner build # Generate ObjectBox code after entity changes dart run build_runner build # Generate ObjectBox code after entity changes
``` ```
**Versioning:** The Flutter app version is maintained in `pubspec.yaml` (`version:`); the settings view shows it at runtime via `package_info_plus`. The web app/backend version is maintained separately in `backend/pom.xml` (`<revision>`) and surfaced through the `app.version` property.
**Important:** Run `flutter analyze` after every task and fix any reported issues before committing. **Important:** Run `flutter analyze` after every task and fix any reported issues before committing.
## Architecture Overview ## Architecture Overview

View File

@@ -258,6 +258,40 @@ class Job {
return (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0); return (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0);
} }
// The tasks embedded in deliveryStations are snapshots from job creation
// and never reflect later completions. The top-level tasks list comes from
// the server's task collection and carries the current completion state,
// so overlay it onto the station tasks by id.
final topLevelTasksRaw = json['tasks'] ?? jobJson['tasks'];
if (deliveryStations.isNotEmpty && topLevelTasksRaw is List) {
final freshTasksById = <String, Task>{};
for (final rawTask in topLevelTasksRaw) {
if (rawTask is! Map) {
continue;
}
try {
final task = Task.fromJson(Map<String, dynamic>.from(rawTask));
if (task.id.isNotEmpty) {
freshTasksById[task.id] = task;
}
} catch (_) {
// Ignore unparseable entries; the station snapshot stays in place.
}
}
for (final station in deliveryStations) {
for (var i = 0; i < station.tasks.length; i++) {
final fresh = freshTasksById[station.tasks[i].id];
if (fresh != null && fresh.completed && !station.tasks[i].completed) {
station.tasks[i] = station.tasks[i].copyWith(
completed: true,
completedAt: fresh.completedAt,
completedBy: fresh.completedBy,
);
}
}
}
}
// Parse tasks array // Parse tasks array
List<Task> tasks = []; List<Task> tasks = [];
if (deliveryStations.isNotEmpty) { if (deliveryStations.isNotEmpty) {

View File

@@ -116,9 +116,16 @@ class DatabaseService {
final jobBox = _store!.box<JobEntity>(); final jobBox = _store!.box<JobEntity>();
final taskStatusBox = _store!.box<TaskStatusEntity>(); final taskStatusBox = _store!.box<TaskStatusEntity>();
// Clear existing jobs and related task statuses before inserting new ones // Clear existing jobs before inserting new ones. Task statuses are NOT
// cleared wholesale: locally completed tasks may not be confirmed by the
// server yet (offline buffering), so only statuses of tasks that no
// longer exist in any job are removed below.
jobBox.removeAll(); jobBox.removeAll();
taskStatusBox.removeAll();
final existingStatuses = {
for (final entity in taskStatusBox.getAll()) entity.taskId: entity,
};
final knownTaskIds = <String>{};
// Save new jobs // Save new jobs
for (final job in jobs) { for (final job in jobs) {
@@ -134,7 +141,8 @@ class DatabaseService {
// Also persist task completion states from JSON (adopt status on load) // Also persist task completion states from JSON (adopt status on load)
// Only set completed=true entries to avoid overwriting local progress with false // Only set completed=true entries to avoid overwriting local progress with false
for (final task in normalized.tasks) { for (final task in normalized.tasks) {
if (task.completed) { knownTaskIds.add(task.id);
if (task.completed && !existingStatuses.containsKey(task.id)) {
final taskStatusEntity = TaskStatusEntity( final taskStatusEntity = TaskStatusEntity(
taskId: task.id, taskId: task.id,
completed: true, completed: true,
@@ -147,6 +155,13 @@ class DatabaseService {
} }
} }
// Drop statuses of tasks that no longer belong to any assigned job
for (final entry in existingStatuses.entries) {
if (!knownTaskIds.contains(entry.key)) {
taskStatusBox.remove(entry.value.id);
}
}
developer.log( developer.log(
'Jobs and task statuses saved successfully', 'Jobs and task statuses saved successfully',
name: 'DatabaseService', name: 'DatabaseService',

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'app_theme.dart'; import 'app_theme.dart';
@@ -26,11 +27,23 @@ class SettingsView extends StatefulWidget {
class _SettingsViewState extends State<SettingsView> { class _SettingsViewState extends State<SettingsView> {
late String _selectedLanguageCode; late String _selectedLanguageCode;
final AppState _appState = AppState(); final AppState _appState = AppState();
String _appVersion = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedLanguageCode = _appState.languageCode; _selectedLanguageCode = _appState.languageCode;
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (!mounted) {
return;
}
setState(() {
_appVersion = info.version;
});
} }
void _onLanguageSelected(String languageCode) async { void _onLanguageSelected(String languageCode) async {
@@ -213,7 +226,7 @@ class _SettingsViewState extends State<SettingsView> {
ListTile( ListTile(
leading: Icon(Icons.info_outline, color: AppColors.textMuted), leading: Icon(Icons.info_outline, color: AppColors.textMuted),
title: Text(l10n.version), title: Text(l10n.version),
subtitle: const Text('0.9.2'), subtitle: Text(_appVersion),
), ),
const Divider(height: 1, indent: 72), const Divider(height: 1, indent: 72),
], ],

View File

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

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.17</revision> <revision>0.9.20</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>
@@ -184,10 +184,11 @@
<version>5.0.5</version> <version>5.0.5</version>
</dependency> </dependency>
<!-- Spring AI OpenAI (LM Studio kompatibel) --> <!-- Anthropic Claude SDK -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>com.anthropic</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId> <artifactId>anthropic-java</artifactId>
<version>2.34.0</version>
</dependency> </dependency>
<!-- MCP Server mit WebMVC Transport --> <!-- MCP Server mit WebMVC Transport -->

Binary file not shown.

View File

@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
var elements = window.profileInvoiceState.elements; var elements = window.profileInvoiceState.elements;
var selectedElement = window.profileInvoiceState.selectedElement; var selectedElement = window.profileInvoiceState.selectedElement;
var elementCounter = window.profileInvoiceState.elementCounter; var elementCounter = window.profileInvoiceState.elementCounter;
// Undo history: snapshots of the elements array, max 10 steps back.
// Reset on every init so a canvas never restores states of the other
// generator page (profile vs. admin system template).
var undoStack = [];
var MAX_UNDO_STEPS = 10;
function pushUndoState() {
try {
var snapshot = JSON.stringify(elements);
if (undoStack.length && undoStack[undoStack.length - 1] === snapshot) return;
undoStack.push(snapshot);
if (undoStack.length > MAX_UNDO_STEPS) undoStack.shift();
} catch (err) {
console.error('Could not record undo state:', err);
}
}
window.undoProfileCanvas = function() {
if (!undoStack.length) return false;
var snapshot = JSON.parse(undoStack.pop());
// Keep the same array reference (shared with profileInvoiceState)
elements.length = 0;
snapshot.forEach(function(el) { elements.push(el); });
selectedElement = null;
saveState();
notifyElementDeselected();
draw();
return true;
};
var isDragging = false; var isDragging = false;
var dragStart = { x: 0, y: 0 }; var dragStart = { x: 0, y: 0 };
var elementStart = { x: 0, y: 0 }; var elementStart = { x: 0, y: 0 };
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
var colVatX = x + colNameWidth; var colVatX = x + colNameWidth;
var colNetX = colVatX + colVatWidth; var colNetX = colVatX + colVatWidth;
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
// Rows come from window.profileInvoiceServiceData when provided (system
// invoice template: the three admin price table positions); otherwise
// fall back to sample data (per-user profile invoice generator)
var serviceData = window.profileInvoiceServiceData;
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
? serviceData.rows
: [
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
];
// Calculate actual content height based on table + summary // Calculate actual content height based on table + summary
var tableOnlyHeight = rowHeight * 4; // Header + 3 data rows var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight; var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
// Ensure background covers at least the element height or the calculated content // Ensure background covers at least the element height or the calculated content
@@ -346,21 +391,11 @@ 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)
var sampleData = [
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
];
var currentY = y + rowHeight; var currentY = y + rowHeight;
// Draw sample rows // Draw data rows
ctx.font = fontSize + 'px Arial'; ctx.font = fontSize + 'px Arial';
sampleData.forEach(function(row, index) { rows.forEach(function(row, index) {
// Draw row background (alternating) // Draw row background (alternating)
if (index % 2 === 1) { if (index % 2 === 1) {
ctx.fillStyle = 'rgba(0,0,0,0.02)'; ctx.fillStyle = 'rgba(0,0,0,0.02)';
@@ -416,11 +451,20 @@ window.initProfileInvoiceGenerator = function() {
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned) var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
var valueX = x + w - padding; // Value column (right-aligned) var valueX = x + w - padding; // Value column (right-aligned)
// Calculate totals from sample data // Totals: provided with the service data or calculated from sample data
var netTotal = 655.00; // 450 + 85 + 120 var netTotalLabel, vatTotalLabel, grossTotalLabel;
var vatTotal = netTotal * vatRate; if (serviceData && serviceData.netTotal) {
var grossTotal = netTotal + vatTotal; netTotalLabel = serviceData.netTotal;
vatTotalLabel = serviceData.vatTotal;
grossTotalLabel = serviceData.grossTotal;
} else {
var netTotal = 655.00; // 450 + 85 + 120
var vatTotal = netTotal * vatRate;
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €';
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €';
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €';
}
// Draw summary lines // Draw summary lines
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -431,18 +475,18 @@ window.initProfileInvoiceGenerator = function() {
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial'; ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2); ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight; summaryY += summaryRowHeight;
// 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. ' + vatPctLabel + ' 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(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight; summaryY += summaryRowHeight;
// Gesamtsumme - label left, value right // Gesamtsumme - label left, value right
summaryY += summaryGap; // Extra gap before total summaryY += summaryGap; // Extra gap before total
ctx.fillStyle = '#000000'; ctx.fillStyle = '#000000';
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2); ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2); ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
} }
function drawSelection(el) { function drawSelection(el) {
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) { if (selectedElement) {
var handle = getResizeHandle(x, y, selectedElement); var handle = getResizeHandle(x, y, selectedElement);
if (handle >= 0) { if (handle >= 0) {
pushUndoState();
isResizing = true; isResizing = true;
resizeHandle = handle; resizeHandle = handle;
resizeStart = { resizeStart = {
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
} }
if (clickedElement) { if (clickedElement) {
pushUndoState();
selectedElement = clickedElement; selectedElement = clickedElement;
isDragging = true; isDragging = true;
dragStart = { x: x, y: y }; dragStart = { x: x, y: y };
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
// Keyboard navigation // Keyboard navigation
canvas.setAttribute('tabindex', '0'); canvas.setAttribute('tabindex', '0');
canvas.addEventListener('keydown', function(e) { canvas.addEventListener('keydown', function(e) {
// Undo with Ctrl+Z / Cmd+Z (works without a selected element)
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
window.undoProfileCanvas();
e.preventDefault();
return;
}
if (!selectedElement) return; if (!selectedElement) return;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
pushUndoState();
}
var moved = false; var moved = false;
switch(e.key) { switch(e.key) {
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
// Add element function // Add element function
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) { window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
pushUndoState();
elementCounter++; elementCounter++;
var id = 'element-' + elementCounter; var id = 'element-' + elementCounter;
@@ -972,23 +1030,26 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementText = function(id, text) { window.updateProfileElementText = function(id, text) {
var el = elements.find(function(e) { return e.id === id; }); var el = elements.find(function(e) { return e.id === id; });
if (el) { if (el) {
pushUndoState();
el.text = text; el.text = text;
draw(); draw();
} }
}; };
window.updateProfileElementPosition = function(id, x, y) { window.updateProfileElementPosition = function(id, x, y) {
var el = elements.find(function(e) { return e.id === id; }); var el = elements.find(function(e) { return e.id === id; });
if (el) { if (el) {
pushUndoState();
if (x !== null) el.x = snapToGrid(x); if (x !== null) el.x = snapToGrid(x);
if (y !== null) el.y = snapToGrid(y); if (y !== null) el.y = snapToGrid(y);
draw(); draw();
} }
}; };
window.updateProfileElementFontSize = function(id, size) { window.updateProfileElementFontSize = function(id, size) {
var el = elements.find(function(e) { return e.id === id; }); var el = elements.find(function(e) { return e.id === id; });
if (el) { if (el) {
pushUndoState();
el.fontSize = size; el.fontSize = size;
// Update height based on text content and new font size // Update height based on text content and new font size
if (el.type !== 'line' && el.type !== 'image') { if (el.type !== 'line' && el.type !== 'image') {
@@ -1004,14 +1065,16 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementColor = function(id, color) { window.updateProfileElementColor = function(id, color) {
var el = elements.find(function(e) { return e.id === id; }); var el = elements.find(function(e) { return e.id === id; });
if (el) { if (el) {
pushUndoState();
el.color = color; el.color = color;
draw(); draw();
} }
}; };
window.updateProfileElementSize = function(id, width, height) { window.updateProfileElementSize = function(id, width, height) {
var el = elements.find(function(e) { return e.id === id; }); var el = elements.find(function(e) { return e.id === id; });
if (el) { if (el) {
pushUndoState();
if (width !== null) el.width = width; if (width !== null) el.width = width;
if (height !== null) el.height = height; if (height !== null) el.height = height;
draw(); draw();
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
// Load image to get dimensions // Load image to get dimensions
var img = new Image(); var img = new Image();
img.onload = function() { img.onload = function() {
pushUndoState();
var MAX_WIDTH = 1090; var MAX_WIDTH = 1090;
var width = img.width; var width = img.width;
var height = img.height; var height = img.height;
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
window.deleteProfileElement = function(id) { window.deleteProfileElement = function(id) {
var index = elements.findIndex(function(e) { return e.id === id; }); var index = elements.findIndex(function(e) { return e.id === id; });
if (index > -1) { if (index > -1) {
pushUndoState();
elements.splice(index, 1); elements.splice(index, 1);
if (selectedElement && selectedElement.id === id) { if (selectedElement && selectedElement.id === id) {
selectedElement = null; selectedElement = null;
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
// Clear canvas function // Clear canvas function
window.clearProfileCanvas = function() { window.clearProfileCanvas = function() {
elements = []; if (elements.length) {
pushUndoState();
}
// Keep the same array reference (shared with profileInvoiceState and undo)
elements.length = 0;
selectedElement = null; selectedElement = null;
window.profileInvoiceState.elements = []; saveState();
window.profileInvoiceState.selectedElement = null;
notifyElementDeselected(); notifyElementDeselected();
draw(); draw();
}; };

View File

@@ -1,124 +1,57 @@
package de.assecutor.votianlt.ai.config; package de.assecutor.votianlt.ai.config;
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.MessageCountTokensParams;
import com.anthropic.models.messages.MessageTokensCount;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Base64;
/** /**
* Configuration for LLM integration via LM Studio. The LM Studio instance * Configuration for LLM integration via the Anthropic Claude API.
* exposes an OpenAI-compatible API at {@code /v1/chat/completions}.
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
public class LlmConfig { public class LlmConfig {
@Value("${app.ai.lmstudio.base-url}") @Value("${app.ai.anthropic.api-key}")
private String lmstudioBaseUrl; private String apiKey;
@Value("${app.ai.lmstudio.model}") @Value("${app.ai.anthropic.model:claude-opus-4-8}")
private String lmstudioModel; private String model;
@Value("${app.ai.lmstudio.htaccess-username}")
private String lmstudioHtaccessUsername;
@Value("${app.ai.lmstudio.htaccess-password}")
private String lmstudioHtaccessPassword;
@PostConstruct @PostConstruct
public void logConfig() { public void logConfig() {
log.info("=== LLM Configuration ==="); log.info("=== LLM Configuration ===");
log.info("Provider: lmstudio"); log.info("Provider: Anthropic Claude");
log.info("Base URL: {}", lmstudioBaseUrl); log.info("Model: {}", model);
log.info("Model: {}", lmstudioModel); log.info("API key: {}", apiKey != null && !apiKey.isBlank() ? "configured" : "NOT configured");
log.info("HTACCESS auth: {}", hasHtaccessCredentials() ? "configured" : "not configured"); testConnection();
testConnection(lmstudioBaseUrl, lmstudioModel);
} }
private void testConnection(String baseUrl, String model) { /**
log.info("Testing LLM connection to: {}", baseUrl); * Validate API key and connectivity using the free count_tokens endpoint (no
* tokens are billed).
// Test 1: Models endpoint */
testEndpoint(baseUrl + "/v1/models", "GET", null); private void testConnection() {
if (apiKey == null || apiKey.isBlank()) {
// Test 2: Chat completions (no streaming) log.warn("Skipping Claude connection test - no API key configured");
String testPayload = "{\"model\":\"" + model return;
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}"; }
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
}
private void testEndpoint(String endpoint, String method, String payload) {
try { try {
log.info("Testing endpoint: {} {}", method, endpoint); AnthropicClient client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
URL url = URI.create(endpoint).toURL(); MessageTokensCount count = client.messages()
HttpURLConnection connection = (HttpURLConnection) url.openConnection(); .countTokens(MessageCountTokensParams.builder().model(model).addUserMessage("ping").build());
connection.setRequestMethod(method); log.info("Claude connection test -> SUCCESS (count_tokens returned {} input tokens)",
connection.setConnectTimeout(5000); count.inputTokens());
connection.setReadTimeout(10000);
if (hasHtaccessCredentials()) {
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
connection.setRequestProperty("Authorization", "Basic " + encoded);
}
if (payload != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
try (var os = connection.getOutputStream()) {
os.write(payload.getBytes());
}
}
int responseCode = connection.getResponseCode();
String responseMessage = connection.getResponseMessage();
if (responseCode >= 200 && responseCode < 300) {
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
} else {
String errorBody = "";
try (var is = connection.getErrorStream()) {
if (is != null) {
errorBody = new String(is.readAllBytes());
}
}
log.warn(" -> HTTP {} {} - {}", responseCode, responseMessage, errorBody);
}
connection.disconnect();
} catch (java.net.ConnectException e) {
log.error(" -> FAILED - Connection refused: {}", e.getMessage());
} catch (java.net.SocketTimeoutException e) {
log.error(" -> FAILED - Timeout: {}", e.getMessage());
} catch (java.net.UnknownHostException e) {
log.error(" -> FAILED - Unknown host: {}", e.getMessage());
} catch (Exception e) { } catch (Exception e) {
log.error(" -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage()); log.error("Claude connection test -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage());
} }
} }
public String getBaseUrl() {
return lmstudioBaseUrl;
}
public String getModel() { public String getModel() {
return lmstudioModel; return model;
}
public boolean hasHtaccessCredentials() {
return lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank()
&& lmstudioHtaccessPassword != null && !lmstudioHtaccessPassword.isBlank();
}
public String getLmstudioHtaccessUsername() {
return lmstudioHtaccessUsername;
}
public String getLmstudioHtaccessPassword() {
return lmstudioHtaccessPassword;
} }
} }

View File

@@ -1,24 +1,20 @@
package de.assecutor.votianlt.ai.service; package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.databind.JsonNode; import com.anthropic.client.AnthropicClient;
import com.fasterxml.jackson.databind.ObjectMapper; import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.Message;
import com.anthropic.models.messages.MessageCreateParams;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
/** /**
* Direct REST client for LM Studio LLM API. Communicates via the * LLM client backed by the Anthropic Claude API (official Java SDK). Keeps the
* OpenAI-compatible /v1/chat/completions endpoint. * same public interface as the previous LM-Studio-based implementation so
* callers (AiStatisticsService, TranslationService) stay unchanged.
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -27,34 +23,14 @@ public class LlmRestClient {
private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>"); private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>");
private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>"); private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>");
private final WebClient webClient; private final AnthropicClient client;
private final ObjectMapper objectMapper;
private final String model; private final String model;
public LlmRestClient(@Value("${app.ai.lmstudio.base-url}") String lmstudioBaseUrl, public LlmRestClient(@Value("${app.ai.anthropic.api-key}") String apiKey,
@Value("${app.ai.lmstudio.model}") String lmstudioModel, @Value("${app.ai.anthropic.model:claude-opus-4-8}") String model) {
@Value("${app.ai.lmstudio.htaccess-username}") String lmstudioHtaccessUsername, this.model = model;
@Value("${app.ai.lmstudio.htaccess-password}") String lmstudioHtaccessPassword, ObjectMapper objectMapper) { this.client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
log.info("LlmRestClient initialized - Provider: Anthropic Claude, Model: {}", model);
this.model = lmstudioModel;
this.objectMapper = objectMapper;
WebClient.Builder builder = WebClient.builder();
builder.baseUrl(lmstudioBaseUrl + "/v1/chat/completions");
if (lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank() && lmstudioHtaccessPassword != null
&& !lmstudioHtaccessPassword.isBlank()) {
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
builder.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encoded);
log.info("LlmRestClient initialized (with HTACCESS auth) - URL: {}/v1/chat/completions, Model: {}",
lmstudioBaseUrl, lmstudioModel);
} else {
log.info("LlmRestClient initialized - URL: {}/v1/chat/completions, Model: {}", lmstudioBaseUrl,
lmstudioModel);
}
this.webClient = builder.build();
} }
/** /**
@@ -78,32 +54,45 @@ public class LlmRestClient {
* @param userMessage * @param userMessage
* User message/question * User message/question
* @param temperature * @param temperature
* Temperature for response randomness (0.0-1.0) * Ignored - current Claude models do not accept sampling
* parameters (kept for signature compatibility)
* @param maxTokens * @param maxTokens
* Maximum tokens in response * Maximum tokens in response
* @return LLM response text, or null on error * @return LLM response text, or null on error
*/ */
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) { public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
try { try {
Map<String, Object> request = Map.of("model", model, "messages", MessageCreateParams.Builder builder = MessageCreateParams.builder().model(model).maxTokens(maxTokens);
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""), if (systemPrompt != null && !systemPrompt.isBlank()) {
Map.of("role", "user", "content", userMessage)), builder.system(systemPrompt);
"temperature", temperature, "max_tokens", maxTokens, "stream", false); }
builder.addUserMessage(userMessage);
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length()); log.info("Sending request to Claude (model: {}, prompt length: {} chars)...", model, userMessage.length());
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve() Message response = client.messages().create(builder.build());
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration); log.info("Claude response received in {}ms (stop_reason: {})", duration, response.stopReason());
log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0);
return extractContent(response); String content = response.content().stream().flatMap(block -> block.text().stream())
.map(textBlock -> textBlock.text()).collect(Collectors.joining("\n"));
if (content.isBlank()) {
log.warn("Claude response content is empty");
return null;
}
String sanitizedContent = sanitizeAssistantContent(content);
if (sanitizedContent.isBlank()) {
log.warn("Claude response content is empty after sanitization");
return null;
}
return sanitizedContent;
} catch (Exception e) { } catch (Exception e) {
log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage()); log.error("Error calling Claude API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("Full stack trace:", e); log.debug("Full stack trace:", e);
} }
@@ -122,35 +111,6 @@ public class LlmRestClient {
return model; return model;
} }
private String extractContent(String response) {
if (response == null || response.isBlank()) {
log.warn("LLM returned null or blank response");
return null;
}
try {
JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) {
String content = choices.get(0).path("message").path("content").asText();
if (content == null || content.isBlank()) {
log.warn("LLM response content is empty");
return null;
}
String sanitizedContent = sanitizeAssistantContent(content);
if (sanitizedContent.isBlank()) {
log.warn("LLM response content is empty after sanitization");
return null;
}
return sanitizedContent;
}
log.warn("Unexpected response structure (no choices): {}", response);
return null;
} catch (Exception e) {
log.error("Error parsing LLM response: {}", e.getMessage());
return null;
}
}
private String sanitizeAssistantContent(String content) { private String sanitizeAssistantContent(String content) {
String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" "); String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" ");
sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" "); sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" ");

View File

@@ -128,8 +128,10 @@ public class MessageController {
log.info("[JOBS] Retrieving assigned jobs for appUserId: {}", appUserId); log.info("[JOBS] Retrieving assigned jobs for appUserId: {}", appUserId);
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId); List<Job> assignedJobs = jobRepository.findByAppUser(appUserId).stream()
log.info("[JOBS] Found {} jobs for appUserId: {}", assignedJobs.size(), appUserId); .filter(job -> job.getStatus() != JobStatus.COMPLETED && job.getStatus() != JobStatus.CANCELLED)
.toList();
log.info("[JOBS] Found {} open jobs for appUserId: {}", assignedJobs.size(), appUserId);
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());
@@ -381,7 +383,8 @@ public class MessageController {
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName, jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
extraDataSummary); extraDataSummary);
} catch (Exception e) { } catch (Exception e) {
// Ignore history logging errors log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
e.getMessage(), e);
} }
// Send email notification for task completion // Send email notification for task completion
@@ -393,7 +396,8 @@ public class MessageController {
// It is now driven by explicit station_completed messages from the app // It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted). // (see handleStationCompleted).
} catch (Exception e) { } catch (Exception e) {
// Ignore email notification errors log.error("[TASK] Could not send completion notification for task {} of job {}: {}", taskIdStr, jobId,
e.getMessage(), e);
} }
jobUpdateBroadcaster.broadcast(jobId); jobUpdateBroadcaster.broadcast(jobId);
@@ -420,11 +424,17 @@ public class MessageController {
try { try {
emailService.sendJobCompletionNotification(jobId, completedBy); emailService.sendJobCompletionNotification(jobId, completedBy);
} catch (Exception e) { } catch (Exception e) {
// Ignore email notification errors log.error("[JOB] Could not send job completion notification for job {}: {}", jobId, e.getMessage(),
e);
} }
} else {
List<String> openTaskIds = mandatoryTasks.stream().filter(task -> !task.isCompleted())
.map(task -> task.getIdAsString()).toList();
log.info("[JOB] Job {} not completed yet, {} of {} mandatory tasks still open: {}", jobId,
openTaskIds.size(), mandatoryTasks.size(), openTaskIds);
} }
} catch (Exception e) { } catch (Exception e) {
// Ignore job completion check errors log.error("[JOB] Completion check failed for job {}: {}", jobId, e.getMessage(), e);
} }
} }
@@ -432,6 +442,7 @@ public class MessageController {
try { try {
Optional<Job> jobOpt = jobRepository.findById(jobId); Optional<Job> jobOpt = jobRepository.findById(jobId);
if (jobOpt.isEmpty()) { if (jobOpt.isEmpty()) {
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
return; return;
} }
@@ -440,9 +451,10 @@ public class MessageController {
job.setStatus(JobStatus.COMPLETED); job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now()); job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job); jobRepository.save(job);
log.info("[JOB] Job {} marked as COMPLETED", jobId);
} }
} catch (Exception e) { } catch (Exception e) {
// Ignore job status update errors log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
} }
} }
@@ -462,15 +474,19 @@ public class MessageController {
*/ */
public void handleStationCompleted(String appUserId, Map<String, Object> payload) { public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
try { try {
String jobIdStr = payload.get("jobId") != null ? payload.get("jobId").toString() : null;
String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null; 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); Optional<Job> jobOpt = Optional.empty();
if (jobIdStr != null && ObjectId.isValid(jobIdStr)) {
jobOpt = jobRepository.findById(new ObjectId(jobIdStr));
}
if (jobOpt.isEmpty() && jobNumber != null && !jobNumber.isBlank()) {
jobOpt = jobRepository.findByJobNumber(jobNumber);
}
if (jobOpt.isEmpty()) { if (jobOpt.isEmpty()) {
log.warn("[STATION] Job with jobNumber {} not found", jobNumber); log.warn("[STATION] Job not found for station_completed (jobId={}, jobNumber={})", jobIdStr,
jobNumber);
return; return;
} }
@@ -505,7 +521,8 @@ public class MessageController {
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload); ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
messageService.receiveMessageFromClient(inboundPayload); messageService.receiveMessageFromClient(inboundPayload);
} catch (Exception e) { } catch (Exception e) {
// Ignore message handling errors log.error("[MESSAGE] Could not process incoming message from app user {}: {}", appUserId, e.getMessage(),
e);
} }
} }

View File

@@ -10,18 +10,18 @@ public class PriceTable {
private String id; private String id;
private String monthlyBasePackage; private String monthlyBasePackage;
private String appUsageLicense;
private String revenueParticipation; /** Fee billed per app user set up by the customer (unit price per month). */
private String appUserFee;
private String statisticalEvaluation; private String statisticalEvaluation;
public PriceTable() { public PriceTable() {
} }
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, public PriceTable(String monthlyBasePackage, String appUserFee, String statisticalEvaluation) {
String statisticalEvaluation) {
this.monthlyBasePackage = monthlyBasePackage; this.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense; this.appUserFee = appUserFee;
this.revenueParticipation = revenueParticipation;
this.statisticalEvaluation = statisticalEvaluation; this.statisticalEvaluation = statisticalEvaluation;
} }
@@ -41,20 +41,12 @@ public class PriceTable {
this.monthlyBasePackage = monthlyBasePackage; this.monthlyBasePackage = monthlyBasePackage;
} }
public String getAppUsageLicense() { public String getAppUserFee() {
return appUsageLicense; return appUserFee;
} }
public void setAppUsageLicense(String appUsageLicense) { public void setAppUserFee(String appUserFee) {
this.appUsageLicense = appUsageLicense; this.appUserFee = appUserFee;
}
public String getRevenueParticipation() {
return revenueParticipation;
}
public void setRevenueParticipation(String revenueParticipation) {
this.revenueParticipation = revenueParticipation;
} }
public String getStatisticalEvaluation() { public String getStatisticalEvaluation() {

View File

@@ -0,0 +1,52 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
/**
* Editable company data of the system operator (admin). Used as issuer data
* on system invoices to the users; falls back to the defaults hardcoded in
* SystemInvoiceData as long as no profile has been saved.
*/
@Data
@Document(collection = "system_company_profile")
public class SystemCompanyProfile {
@Id
private String id;
@Field("company_name")
private String companyName;
@Field("company_subtitle")
private String companySubtitle;
@Field("company_street")
private String companyStreet;
@Field("company_city")
private String companyCity;
@Field("company_phone")
private String companyPhone;
@Field("company_fax")
private String companyFax;
@Field("company_email")
private String companyEmail;
@Field("company_website")
private String companyWebsite;
@Field("sender_line")
private String senderLine;
@Field("payment_terms")
private String paymentTerms;
@Field("footer_text")
private String footerText;
}

View File

@@ -0,0 +1,42 @@
package de.assecutor.votianlt.model.invoices;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
/**
* Records that a system invoice has been sent to a customer by email. Used to
* filter the monthly invoice list into open and already sent invoices.
*/
@Data
@NoArgsConstructor
@Document(collection = "system_invoice_dispatch")
public class SystemInvoiceDispatch {
@Id
private ObjectId id;
/** Invoice number, e.g. SYS-202607-42; one dispatch record per invoice. */
@Indexed(unique = true)
@Field("invoice_number")
private String invoiceNumber;
@Field("user_id")
private ObjectId userId;
/** Billing month in ISO format, e.g. 2026-07. */
@Field("billing_month")
private String billingMonth;
@Field("sent_at")
private LocalDateTime sentAt;
@Field("sent_to")
private String sentTo;
}

View File

@@ -8,28 +8,49 @@ public final class CustomerAddressLabelHelper {
private CustomerAddressLabelHelper() { private CustomerAddressLabelHelper() {
} }
public static void putUnique(Map<String, Customer> target, Customer customer, String fallbackLabel) { /** Adds the customer under its full address label ("Name, Street No, Zip City"). */
String label = build(customer, fallbackLabel); public static void put(Map<String, Customer> target, Customer customer, String fallbackLabel) {
String uniqueLabel = label; target.put(build(customer, fallbackLabel), customer);
int counter = 2;
while (target.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
target.put(uniqueLabel, customer);
} }
/** Adds the customer under its plain name label (company or person name). */
public static void putName(Map<String, Customer> target, Customer customer, String fallbackLabel) {
target.put(buildName(customer, fallbackLabel), customer);
}
/**
* Builds the selection label as full address ("Name, Street No, Zip City")
* so that entries with the same name (e.g. branches of one company) remain
* distinguishable in the combo boxes.
*/
public static String build(Customer customer, String fallbackLabel) { public static String build(Customer customer, String fallbackLabel) {
if (customer == null) { if (customer == null) {
return fallbackLabel; return fallbackLabel;
} }
String companyName = trimToNull(customer.getCompanyName()); StringBuilder label = new StringBuilder(buildName(customer, fallbackLabel));
if (companyName != null) { String street = trimToNull(join(" ", customer.getStreet(), customer.getHouseNumber()));
return companyName; if (street != null) {
label.append(", ").append(street);
}
String city = trimToNull(join(" ", customer.getZip(), customer.getCity()));
if (city != null) {
label.append(", ").append(city);
}
return label.toString();
}
/** Plain name label: company name, otherwise person name, otherwise fallback. */
public static String buildName(Customer customer, String fallbackLabel) {
if (customer == null) {
return fallbackLabel;
} }
String fullName = trimToNull(join(" ", customer.getFirstname(), customer.getLastName())); String name = trimToNull(customer.getCompanyName());
return fullName != null ? fullName : fallbackLabel; if (name == null) {
name = trimToNull(join(" ", customer.getFirstname(), customer.getLastName()));
}
return name != null ? name : fallbackLabel;
} }
public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) { public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) {

View File

@@ -687,7 +687,7 @@ 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) {
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer, CustomerAddressLabelHelper.put(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed")); translationHelper.getTranslation("addjob.customer.unnamed"));
} }

View File

@@ -229,7 +229,7 @@ public class DeliveryStationTile extends VerticalLayout {
TranslationHelper translationHelper) { TranslationHelper translationHelper) {
companyAddressOptions.clear(); companyAddressOptions.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer, CustomerAddressLabelHelper.put(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed")); translationHelper.getTranslation("addjob.customer.unnamed"));
} }

View File

@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear(); customerLabelMap.clear();
for (Customer c : customers) { for (Customer c : customers) {
CustomerAddressLabelHelper.putUnique(customerLabelMap, c, CustomerAddressLabelHelper.putName(customerLabelMap, c,
translationHelper.getTranslation("addjob.customer.unnamed")); translationHelper.getTranslation("addjob.customer.unnamed"));
} }
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
@@ -832,7 +832,7 @@ public class PickupStationDialog extends Dialog {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) { private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
companyCustomerMap.clear(); companyCustomerMap.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer, CustomerAddressLabelHelper.put(companyCustomerMap, customer,
translationHelper.getTranslation("addjob.customer.unnamed")); translationHelper.getTranslation("addjob.customer.unnamed"));
} }
companyField.setItems(new ArrayList<>(companyCustomerMap.keySet())); companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.pages.base.ui.view; package de.assecutor.votianlt.pages.base.ui.view;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasElement;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.Avatar;
@@ -18,7 +19,6 @@ 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.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import static com.vaadin.flow.theme.lumo.LumoUtility.*; import static com.vaadin.flow.theme.lumo.LumoUtility.*;
@@ -31,6 +31,7 @@ public final class AdminLayout extends AppLayout {
private Div headerRef; private Div headerRef;
private Scroller navRef; private Scroller navRef;
private Component userMenuRef; private Component userMenuRef;
private final Div viewContainer = new Div();
public AdminLayout(SecurityService securityService) { public AdminLayout(SecurityService securityService) {
this.securityService = securityService; this.securityService = securityService;
@@ -47,12 +48,25 @@ public final class AdminLayout extends AppLayout {
userMenuRef = createUserMenu(); userMenuRef = createUserMenu();
addToDrawer(headerRef, navRef, userMenuRef); addToDrawer(headerRef, navRef, userMenuRef);
// Same content wrapper as MainLayout so admin pages get the identical
// page frame (margin, rounded corners, shell background)
viewContainer.addClassName("view-container");
getElement().appendChild(viewContainer.getElement());
updateDrawerVisibility(); updateDrawerVisibility();
// Re-check on attach (new UI/session) and on every navigation cycle // Re-check on attach (new UI/session) and on every navigation cycle
addAttachListener(e -> updateDrawerVisibility()); addAttachListener(e -> updateDrawerVisibility());
} }
@Override
public void showRouterLayoutContent(HasElement content) {
viewContainer.getElement().removeAllChildren();
if (content != null) {
viewContainer.getElement().appendChild(content.getElement());
}
}
private void updateDrawerVisibility() { private void updateDrawerVisibility() {
boolean loggedIn = securityService.isUserLoggedIn(); boolean loggedIn = securityService.isUserLoggedIn();
if (headerRef != null) if (headerRef != null)
@@ -90,6 +104,8 @@ public final class AdminLayout extends AppLayout {
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator", SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator",
new Icon(VaadinIcon.FILE_PROCESS)); new Icon(VaadinIcon.FILE_PROCESS));
SideNavItem createInvoices = new SideNavItem("Rechnungen erstellen", "admin-create-invoices",
new Icon(VaadinIcon.INVOICE));
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG)); SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", // SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
// "admin-settings", new Icon(VaadinIcon.COG)); // "admin-settings", new Icon(VaadinIcon.COG));
@@ -100,6 +116,7 @@ public final class AdminLayout extends AppLayout {
nav.addItem(dashboard); nav.addItem(dashboard);
nav.addItem(invoiceGenerator); nav.addItem(invoiceGenerator);
nav.addItem(createInvoices);
nav.addItem(priceTable); nav.addItem(priceTable);
// nav.addItem(systemSettings); // nav.addItem(systemSettings);
// nav.addItem(userManagement); // nav.addItem(userManagement);
@@ -134,9 +151,8 @@ public final class AdminLayout extends AppLayout {
var userMenuItem = userMenu.addItem(avatar); var userMenuItem = userMenu.addItem(avatar);
userMenuItem.add(userNameSpan); userMenuItem.add(userNameSpan);
// Profile display with navigation // Profile display with navigation to the admin company profile
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class)); userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate("admin-profile"));
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog()); userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
// Update function for username and avatar // Update function for username and avatar

View File

@@ -1,9 +1,11 @@
package de.assecutor.votianlt.pages.domain; package de.assecutor.votianlt.pages.domain;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
import java.util.List;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
public interface AddCustomerRepository extends MongoRepository<Customer, ObjectId> { public interface AddCustomerRepository extends MongoRepository<Customer, ObjectId> {
List<Customer> findByOwnerAndInternal(ObjectId owner, boolean internal);
} }

View File

@@ -21,18 +21,29 @@ public class AddCustomerService {
this.sequenceGeneratorService = sequenceGeneratorService; this.sequenceGeneratorService = sequenceGeneratorService;
} }
public void addCustomer(Customer customer) { /**
* Creates the customer unless a completely identical entry already exists
* for the current owner. Returns false if the duplicate was skipped.
*/
public boolean addCustomer(Customer customer) {
validateCustomer(customer); validateCustomer(customer);
// Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session // Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session
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());
// Keine komplett identischen Einträge anlegen
if (identicalCustomerExists(customer)) {
return false;
}
if (customer.getUsrId() == null) { if (customer.getUsrId() == null) {
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber()); customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
} }
addCustomerRepository.save(customer); addCustomerRepository.save(customer);
return true;
} }
public void addInternalCustomer(Customer customer) { public void addInternalCustomer(Customer customer) {
@@ -44,6 +55,28 @@ public class AddCustomerService {
addCustomer(customer); addCustomer(customer);
} }
private boolean identicalCustomerExists(Customer customer) {
return addCustomerRepository.findByOwnerAndInternal(customer.getOwner(), customer.isInternal()).stream()
.anyMatch(existing -> isIdentical(existing, customer));
}
private boolean isIdentical(Customer a, Customer b) {
return equalsNormalized(a.getCompanyName(), b.getCompanyName()) && equalsNormalized(a.getTitle(), b.getTitle())
&& equalsNormalized(a.getFirstname(), b.getFirstname())
&& equalsNormalized(a.getLastName(), b.getLastName())
&& equalsNormalized(a.getTelephone(), b.getTelephone()) && equalsNormalized(a.getMail(), b.getMail())
&& equalsNormalized(a.getStreet(), b.getStreet())
&& equalsNormalized(a.getHouseNumber(), b.getHouseNumber())
&& equalsNormalized(a.getAddressAddition(), b.getAddressAddition())
&& equalsNormalized(a.getZip(), b.getZip()) && equalsNormalized(a.getCity(), b.getCity());
}
private boolean equalsNormalized(String first, String second) {
String left = first != null ? first.trim() : "";
String right = second != null ? second.trim() : "";
return left.equalsIgnoreCase(right);
}
public void updateCustomer(Customer customer) { public void updateCustomer(Customer customer) {
if (customer == null || customer.getId() == null) { if (customer == null || customer.getId() == null) {
throw new IllegalArgumentException("Kunden-ID fehlt"); throw new IllegalArgumentException("Kunden-ID fehlt");

View File

@@ -214,7 +214,13 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
Customer customer = new Customer(); Customer customer = new Customer();
binder.writeBean(customer); binder.writeBean(customer);
addCustomerService.addCustomer(customer); boolean created = addCustomerService.addCustomer(customer);
if (!created) {
com.vaadin.flow.component.notification.Notification.show(
getTranslation("addcustomer.notification.duplicate"), 5000,
com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER);
return;
}
com.vaadin.flow.component.notification.Notification.show(getTranslation("addcustomer.notification.success"), com.vaadin.flow.component.notification.Notification.show(getTranslation("addcustomer.notification.success"),
3000, com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER); 3000, com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER);

View File

@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner(); List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear(); customerLabelToEntity.clear();
for (Customer c : ownerCustomers) { for (Customer c : ownerCustomers) {
CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
} }
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
@@ -1415,7 +1415,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
Map<String, Customer> addressOptions = new LinkedHashMap<>(); Map<String, Customer> addressOptions = new LinkedHashMap<>();
for (Customer customer : allCustomers) { for (Customer customer : allCustomers) {
CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed")); CustomerAddressLabelHelper.put(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
} }
// Set items for autocomplete // Set items for autocomplete

View File

@@ -0,0 +1,921 @@
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.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Paragraph;
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.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.PriceTable;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch;
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.view.AdminLayout;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.PriceTableRepository;
import de.assecutor.votianlt.repository.SystemInvoiceDispatchRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Admin view to create the monthly system invoices for all current customers
* (the users of the system). Invoices are always due at the end of the month;
* customers starting mid-month are only billed pro rata for the remaining days
* until the last day of the month.
*/
@Route(value = "admin-create-invoices", layout = AdminLayout.class)
@RolesAllowed("ADMIN")
@Slf4j
public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
private static final BigDecimal SYSTEM_VAT_RATE = new BigDecimal("0.19");
private final UserRepository userRepository;
private final PriceTableRepository priceTableRepository;
private final AppUserRepository appUserRepository;
private final InvoiceTemplateService invoiceTemplateService;
private final CustomerInvoiceService customerInvoiceService;
private final SystemCompanyProfileService systemCompanyProfileService;
private final EmailService emailService;
private final SystemInvoiceDispatchRepository dispatchRepository;
private final PdfToolService pdfToolService;
private final Grid<BillingInfo> grid = new Grid<>();
/** Billing month, selectable by the user; defaults to the current month. */
private YearMonth billingMonth = YearMonth.now();
/** Filter for the invoice list: all, only open or only sent invoices. */
private enum InvoiceFilter {
ALL, OPEN, SENT
}
private InvoiceFilter invoiceFilter = InvoiceFilter.ALL;
/** Discount with reason per customer (user id), entered via the dialog. */
private final Map<org.bson.types.ObjectId, DiscountEntry> discounts = new HashMap<>();
/** A discount entered for one customer: percent value plus reason. */
private record DiscountEntry(Double percent, String reason) {
}
public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository,
AppUserRepository appUserRepository, InvoiceTemplateService invoiceTemplateService,
CustomerInvoiceService customerInvoiceService, SystemCompanyProfileService systemCompanyProfileService,
EmailService emailService, SystemInvoiceDispatchRepository dispatchRepository,
PdfToolService pdfToolService) {
this.userRepository = userRepository;
this.priceTableRepository = priceTableRepository;
this.appUserRepository = appUserRepository;
this.invoiceTemplateService = invoiceTemplateService;
this.customerInvoiceService = customerInvoiceService;
this.systemCompanyProfileService = systemCompanyProfileService;
this.emailService = emailService;
this.dispatchRepository = dispatchRepository;
this.pdfToolService = pdfToolService;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL);
addClassName("data-view");
Button createSelectedButton = new Button(getTranslation("admincreateinvoices.button.createselected", 0));
createSelectedButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
createSelectedButton.setEnabled(false);
// Billing month selection: current month and up to 12 months back
Select<YearMonth> monthSelect = new Select<>();
monthSelect.setLabel(getTranslation("admincreateinvoices.field.month"));
List<YearMonth> months = new ArrayList<>();
for (int offset = 0; offset >= -12; offset--) {
months.add(YearMonth.now().plusMonths(offset));
}
monthSelect.setItems(months);
monthSelect.setItemLabelGenerator(
month -> month.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale())));
monthSelect.setValue(billingMonth);
// Filter: all invoices of the month, only open or only sent ones
Select<InvoiceFilter> filterSelect = new Select<>();
filterSelect.setLabel(getTranslation("admincreateinvoices.filter.label"));
filterSelect.setItems(InvoiceFilter.values());
filterSelect.setItemLabelGenerator(
filter -> getTranslation("admincreateinvoices.filter." + filter.name().toLowerCase()));
filterSelect.setValue(invoiceFilter);
filterSelect.addValueChangeListener(event -> {
if (event.getValue() != null) {
invoiceFilter = event.getValue();
grid.deselectAll();
grid.setItems(loadBillingInfos());
}
});
add(new ViewToolbar(getTranslation("admincreateinvoices.title")));
// Month and filter selection left-aligned below the heading
com.vaadin.flow.component.orderedlayout.HorizontalLayout selectionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
monthSelect, filterSelect);
selectionLayout.getStyle().set("align-self", "flex-start");
add(selectionLayout);
Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint"));
hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE);
add(hint);
grid.setItems(loadBillingInfos());
grid.addColumn(info -> customerDisplayName(info.user()))
.setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true);
grid.addColumn(info -> info.startDate() != null ? DateTimeFormatUtil.formatDate(info.startDate()) : "")
.setHeader(getTranslation("admincreateinvoices.column.start")).setFlexGrow(1).setSortable(true);
grid.addColumn(info -> info.billedDays() < info.daysInMonth()
? getTranslation("admincreateinvoices.days.partial", info.billedDays(), info.daysInMonth())
: getTranslation("admincreateinvoices.days.full"))
.setHeader(getTranslation("admincreateinvoices.column.days")).setFlexGrow(1);
grid.addColumn(info -> DateTimeFormatUtil.formatDate(info.dueDate()))
.setHeader(getTranslation("admincreateinvoices.column.due")).setFlexGrow(1);
grid.addColumn(info -> formatAmount(applyDiscount(info).netTotal()) + "")
.setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1)
.setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END);
grid.addComponentColumn(info -> {
DiscountEntry entry = discounts.get(info.user().getId());
Button discountButton = new Button(new Icon(VaadinIcon.TAG));
discountButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON,
entry != null ? ButtonVariant.LUMO_PRIMARY : ButtonVariant.LUMO_TERTIARY);
String tooltip = entry != null
? getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()))
+ (entry.reason().isBlank() ? "" : " " + entry.reason())
: getTranslation("admincreateinvoices.discount.dialog.title");
discountButton.setTooltipText(tooltip);
discountButton.setAriaLabel(getTranslation("admincreateinvoices.discount.dialog.title"));
discountButton.addClickListener(e -> openDiscountDialog(info, grid));
return discountButton;
}).setHeader(getTranslation("admincreateinvoices.column.discount")).setAutoWidth(true).setFlexGrow(0);
grid.addComponentColumn(info -> {
Button createButton = new Button(new Icon(VaadinIcon.INVOICE));
createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL,
ButtonVariant.LUMO_ICON);
createButton.setTooltipText(getTranslation("admincreateinvoices.button.create"));
createButton.setAriaLabel(getTranslation("admincreateinvoices.button.create"));
createButton.addClickListener(e -> createInvoicePdf(info));
return createButton;
}).setAutoWidth(true).setFlexGrow(0).setFrozenToEnd(true);
grid.setSelectionMode(Grid.SelectionMode.MULTI);
grid.addSelectionListener(event -> {
int count = event.getAllSelectedItems().size();
createSelectedButton.setText(getTranslation("admincreateinvoices.button.createselected", count));
createSelectedButton.setEnabled(count > 0);
});
createSelectedButton.addClickListener(e -> openInvoiceReviewDialog(new ArrayList<>(grid.getSelectedItems())));
monthSelect.addValueChangeListener(event -> {
if (event.getValue() != null) {
billingMonth = event.getValue();
discounts.clear();
grid.deselectAll();
grid.setItems(loadBillingInfos());
}
});
grid.setSizeFull();
grid.addClassName("data-grid");
Div gridPanel = new Div(grid);
gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setSizeFull();
gridPanel.getStyle().set("min-width", "0");
add(gridPanel);
// Action bar below the table
com.vaadin.flow.component.orderedlayout.HorizontalLayout actionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
createSelectedButton);
actionLayout.setWidthFull();
actionLayout.setJustifyContentMode(
com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode.START);
actionLayout.getStyle().set("flex-shrink", "0");
add(actionLayout);
}
// ==========================================
// Billing calculation
// ==========================================
/**
* Billing data of one customer for the current month.
*/
public record BillingInfo(User user, LocalDate startDate, int billedDays, int daysInMonth, LocalDate dueDate,
List<Map<String, String>> positions, BigDecimal netTotal, BigDecimal vatTotal, BigDecimal grossTotal) {
}
private List<BillingInfo> loadBillingInfos() {
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
List<BillingInfo> infos = userRepository.findAll().stream().filter(this::isCurrentCustomer)
.filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).toList();
if (invoiceFilter == InvoiceFilter.ALL) {
return infos;
}
java.util.Set<String> sentInvoiceNumbers = dispatchRepository.findByBillingMonth(billingMonth.toString())
.stream().map(SystemInvoiceDispatch::getInvoiceNumber).collect(java.util.stream.Collectors.toSet());
boolean wantSent = invoiceFilter == InvoiceFilter.SENT;
return infos.stream()
.filter(info -> sentInvoiceNumbers.contains(buildInvoiceNumber(info.user())) == wantSent).toList();
}
/**
* Customers who only started after the billing month are not billed for it.
*/
private boolean existedInBillingMonth(User user) {
if (user.getCreatedAt() == null) {
return true;
}
return !user.getCreatedAt().toLocalDate().isAfter(billingMonth.atEndOfMonth());
}
/**
* Current customers are all activated users without the ADMIN role.
*/
private boolean isCurrentCustomer(User user) {
boolean isAdmin = user.getRoles() != null && user.getRoles().contains("ADMIN");
return !isAdmin && user.getIsActivated() == 1;
}
private BillingInfo computeBilling(User user, PriceTable priceTable) {
YearMonth month = billingMonth;
int daysInMonth = month.lengthOfMonth();
LocalDate dueDate = month.atEndOfMonth();
LocalDate startDate = user.getCreatedAt() != null ? user.getCreatedAt().toLocalDate() : null;
int billedDays = daysInMonth;
if (startDate != null && YearMonth.from(startDate).equals(month)) {
// Customer started mid-month: bill only the remaining days until
// the last day of the month (start day included)
billedDays = daysInMonth - startDate.getDayOfMonth() + 1;
}
BigDecimal factor = new BigDecimal(billedDays).divide(new BigDecimal(daysInMonth), 10, RoundingMode.HALF_UP);
boolean proRata = billedDays < daysInMonth;
// Number of app users belonging to this customer in app_user: the
// per-app-user fee is billed once per configured app user
long appUserCount = user.getId() != null ? appUserRepository.countByErstelltVon(user.getId()) : 0;
List<Map<String, String>> positions = new ArrayList<>();
BigDecimal netTotal = BigDecimal.ZERO;
// Position entries: label, configured value, quantity
Object[][] entries = {
{ getTranslation("adminpricetable.field.monthly"),
priceTable != null ? priceTable.getMonthlyBasePackage() : null, 1L },
{ getTranslation("adminpricetable.field.appuserfee"),
priceTable != null ? priceTable.getAppUserFee() : null, appUserCount } };
for (Object[] entry : entries) {
String label = (String) entry[0];
String rawValue = entry[1] != null ? ((String) entry[1]).trim() : "";
long quantity = (Long) entry[2];
BigDecimal amount = parseAmount(rawValue);
Map<String, String> position = new HashMap<>();
if (amount != null) {
BigDecimal billed = amount.multiply(new BigDecimal(quantity)).multiply(factor).setScale(2,
RoundingMode.HALF_UP);
String name = label;
if (quantity > 1) {
name += " " + getTranslation("admincreateinvoices.appusers.suffix", quantity);
}
if (proRata) {
name += " " + getTranslation("admincreateinvoices.prorata.suffix", billedDays, daysInMonth);
}
position.put("name", name);
position.put("netAmount", formatAmount(billed));
netTotal = netTotal.add(billed);
} else {
// Non-monetary values are shown as-is and are not part of the
// totals
position.put("name", label);
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
}
positions.add(position);
}
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
BigDecimal grossTotal = netTotal.add(vatTotal);
return new BillingInfo(user, startDate, billedDays, daysInMonth, dueDate, positions, netTotal, vatTotal,
grossTotal);
}
/**
* Opens the dialog to enter (or change) the discount and its reason for
* one customer. An empty or zero discount removes the entry.
*/
private void openDiscountDialog(BillingInfo info, Grid<BillingInfo> grid) {
Dialog dialog = DialogStylingHelper
.createStyledDialog(getTranslation("admincreateinvoices.discount.dialog.title"), "420px");
DiscountEntry entry = discounts.get(info.user().getId());
NumberField percentField = new NumberField(getTranslation("admincreateinvoices.discount.dialog.percent"));
percentField.setMin(0);
percentField.setMax(100);
percentField.setSuffixComponent(new Span("%"));
percentField.setWidthFull();
if (entry != null) {
percentField.setValue(entry.percent());
}
TextField reasonField = new TextField(getTranslation("admincreateinvoices.discount.dialog.reason"));
reasonField.setWidthFull();
if (entry != null) {
reasonField.setValue(entry.reason());
}
VerticalLayout content = DialogStylingHelper.createContentLayout("380px");
content.add(percentField, reasonField);
Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button applyButton = new Button(getTranslation("admincreateinvoices.discount.dialog.apply"), e -> {
Double percent = percentField.getValue();
// Clamp manual input to the valid range 0-100
if (percent != null && percent > 100) {
percent = 100d;
}
if (percent != null && percent > 0) {
discounts.put(info.user().getId(), new DiscountEntry(percent,
reasonField.getValue() != null ? reasonField.getValue().trim() : ""));
} else {
discounts.remove(info.user().getId());
}
dialog.close();
grid.getDataProvider().refreshItem(info);
});
applyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.add(DialogStylingHelper.wrapContent(content));
dialog.getFooter().add(cancelButton, applyButton);
dialog.open();
}
/**
* Applies the discount entered for this customer: adds a negative
* discount position (including the reason) and recalculates the totals.
* Returns the info unchanged if no discount is set.
*/
private BillingInfo applyDiscount(BillingInfo info) {
DiscountEntry entry = discounts.get(info.user().getId());
if (entry == null || entry.percent() == null || entry.percent() <= 0) {
return info;
}
BigDecimal discountAmount = info.netTotal().multiply(BigDecimal.valueOf(entry.percent()))
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
List<Map<String, String>> positions = new ArrayList<>(info.positions());
Map<String, String> position = new HashMap<>();
String name = getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()));
if (!entry.reason().isBlank()) {
name += " " + entry.reason();
}
position.put("name", name);
position.put("netAmount", "-" + formatAmount(discountAmount));
positions.add(position);
BigDecimal netTotal = info.netTotal().subtract(discountAmount);
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
return new BillingInfo(info.user(), info.startDate(), info.billedDays(), info.daysInMonth(), info.dueDate(),
positions, netTotal, vatTotal, netTotal.add(vatTotal));
}
// ==========================================
// PDF generation
// ==========================================
private void createInvoicePdf(BillingInfo info) {
String templateData = loadSystemTemplateData();
if (templateData == null) {
return;
}
info = applyDiscount(info);
try {
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
buildInvoiceVariables(info), null);
showPdfInDialog(new InvoicePreview(info, buildInvoiceNumber(info.user()), pdfBytes));
} catch (Exception ex) {
log.error("Error creating system invoice for user {}: {}", info.user().getEmail(), ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
/** One generated invoice held in the review dialog. */
private record InvoicePreview(BillingInfo info, String invoiceNumber, byte[] pdf) {
}
/**
* Opens the review dialog for the selected customers: one invoice per
* page, navigable via previous/next, removable from the list and sendable
* to the customers by email.
*/
private void openInvoiceReviewDialog(List<BillingInfo> infos) {
if (infos.isEmpty()) {
return;
}
String templateData = loadSystemTemplateData();
if (templateData == null) {
return;
}
List<InvoicePreview> previews = new ArrayList<>();
try {
for (BillingInfo info : infos) {
BillingInfo billed = applyDiscount(info);
previews.add(new InvoicePreview(billed, buildInvoiceNumber(billed.user()), customerInvoiceService
.generatePdfFromCanvasTemplateWithData(templateData, buildInvoiceVariables(billed), null)));
}
} catch (Exception ex) {
log.error("Error creating system invoices for {} users: {}", infos.size(), ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("admincreateinvoices.dialog.title"),
"90vw");
dialog.setHeight("90vh");
Paragraph positionLabel = new Paragraph();
positionLabel.addClassNames(LumoUtility.Margin.NONE, LumoUtility.FontWeight.SEMIBOLD,
LumoUtility.Padding.SMALL);
positionLabel.getStyle().set("flex-shrink", "0");
IFrame pdfFrame = new IFrame();
pdfFrame.setWidth("100%");
pdfFrame.getStyle().set("flex", "1").set("min-height", "0").set("border", "none");
Div content = new Div(positionLabel, pdfFrame);
content.getStyle().set("display", "flex").set("flex-direction", "column");
content.setWidth("100%");
content.setHeight("100%");
Button previousButton = new Button(getTranslation("admincreateinvoices.dialog.button.previous"));
previousButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button nextButton = new Button(getTranslation("admincreateinvoices.dialog.button.next"));
nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button deleteButton = new Button(getTranslation("admincreateinvoices.dialog.button.delete"));
deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"));
zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button sendButton = new Button(getTranslation("admincreateinvoices.dialog.button.send"));
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button closeButton = new Button(getTranslation("button.close"), e -> dialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
int[] index = { 0 };
Runnable updateView = () -> {
InvoicePreview current = previews.get(index[0]);
positionLabel.setText(getTranslation("admincreateinvoices.dialog.position", index[0] + 1, previews.size())
+ " " + customerDisplayName(current.info().user()) + " (" + current.invoiceNumber() + ")");
pdfFrame.getElement().setAttribute("src",
"data:application/pdf;base64," + Base64.getEncoder().encodeToString(current.pdf()));
previousButton.setEnabled(index[0] > 0);
nextButton.setEnabled(index[0] < previews.size() - 1);
};
previousButton.addClickListener(e -> {
if (index[0] > 0) {
index[0]--;
updateView.run();
}
});
nextButton.addClickListener(e -> {
if (index[0] < previews.size() - 1) {
index[0]++;
updateView.run();
}
});
deleteButton.addClickListener(e -> {
previews.remove(index[0]);
Notification.show(getTranslation("admincreateinvoices.dialog.notification.removed"), 3000,
Notification.Position.BOTTOM_CENTER);
if (previews.isEmpty()) {
dialog.close();
return;
}
index[0] = Math.min(index[0], previews.size() - 1);
updateView.run();
});
zugferdButton.addClickListener(e -> downloadZugferdZip(previews.get(index[0])));
sendButton.addClickListener(e -> {
sendButton.setEnabled(false);
sendInvoices(previews);
dialog.close();
});
updateView.run();
dialog.add(DialogStylingHelper.wrapContent(content, true));
dialog.getFooter().add(previousButton, nextButton, deleteButton, zugferdButton, sendButton, closeButton);
dialog.open();
}
/**
* Sends each invoice as PDF attachment to the respective customer's email
* address and reports how many mails were (not) sent.
*/
private void sendInvoices(List<InvoicePreview> previews) {
String issuerName = "";
try {
issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName());
} catch (Exception ex) {
log.warn("Could not load system company profile for invoice mail: {}", ex.getMessage());
}
String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()));
int sent = 0;
int failed = 0;
for (InvoicePreview preview : previews) {
String email = preview.info().user().getEmail();
if (email == null || email.isBlank()) {
log.warn("Customer {} has no email address, invoice {} not sent",
customerDisplayName(preview.info().user()), preview.invoiceNumber());
failed++;
continue;
}
try {
emailService.sendEmailWithAttachment(email,
getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()),
getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel,
issuerName),
preview.pdf(), preview.invoiceNumber() + ".pdf");
recordDispatch(preview, email);
sent++;
} catch (Exception ex) {
log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex);
failed++;
}
}
if (sent > 0) {
grid.deselectAll();
grid.setItems(loadBillingInfos());
Notification
.show(getTranslation("admincreateinvoices.notification.sent", sent), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
if (failed > 0) {
Notification
.show(getTranslation("admincreateinvoices.notification.sendfailed", failed), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
/**
* Persists (or refreshes) the dispatch record of a sent invoice so the
* list can be filtered into open and sent invoices.
*/
private void recordDispatch(InvoicePreview preview, String email) {
try {
SystemInvoiceDispatch dispatch = dispatchRepository.findByInvoiceNumber(preview.invoiceNumber())
.orElseGet(SystemInvoiceDispatch::new);
dispatch.setInvoiceNumber(preview.invoiceNumber());
dispatch.setUserId(preview.info().user().getId());
dispatch.setBillingMonth(billingMonth.toString());
dispatch.setSentAt(LocalDateTime.now());
dispatch.setSentTo(email);
dispatchRepository.save(dispatch);
} catch (Exception ex) {
log.error("Error recording dispatch of invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
}
}
/**
* Returns the stored system template data, or null (with a notification)
* if no template has been saved yet.
*/
private String loadSystemTemplateData() {
Optional<de.assecutor.votianlt.model.InvoiceTemplate> template = invoiceTemplateService
.getTemplateByUserId(InvoiceGeneratorView.SYSTEM_TEMPLATE_USER_ID);
if (template.isEmpty() || template.get().getTemplateData() == null
|| template.get().getTemplateData().isEmpty()) {
Notification
.show(getTranslation("admincreateinvoices.notification.notemplate"), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return null;
}
return template.get().getTemplateData();
}
private Map<String, String> buildInvoiceVariables(BillingInfo info) throws Exception {
SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
User user = info.user();
Map<String, String> variables = new LinkedHashMap<>();
String companyFull = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
variables.put("masterdata.company_name", companyFull);
variables.put("masterdata.street", safe(issuer.getCompanyStreet()));
variables.put("masterdata.city", safe(issuer.getCompanyCity()));
variables.put("masterdata.phone", safe(issuer.getCompanyPhone()));
variables.put("masterdata.email", safe(issuer.getCompanyEmail()));
variables.put("masterdata.website", safe(issuer.getCompanyWebsite()));
variables.put("masterdata.sender_line", safe(issuer.getSenderLine()));
variables.put("masterdata.payment_terms", safe(issuer.getPaymentTerms()));
variables.put("masterdata.footer", safe(issuer.getFooterText()).replace("<br>", "\n"));
variables.put("masterdata.invoice_number", buildInvoiceNumber(user));
variables.put("masterdata.invoice_date",
LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
// Recipient: the user's invoice address if a different one is set
if (user.isDiffInvoiceAddress()) {
variables.put("customer.company_name", firstNonBlank(user.getInvCompany(), user.getCompany()));
variables.put("customer.contact_name",
(safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim());
variables.put("customer.street",
(safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim());
variables.put("customer.city", (safe(user.getInvZip()) + " " + safe(user.getInvCity())).trim());
} else {
variables.put("customer.company_name", safe(user.getCompany()));
variables.put("customer.contact_name", (safe(user.getFirstname()) + " " + safe(user.getName())).trim());
variables.put("customer.street", (safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
variables.put("customer.city", (safe(user.getZip()) + " " + safe(user.getCity())).trim());
}
variables.put("customer.email", safe(user.getEmail()));
variables.put("customer.phone", safe(user.getPhone()));
// Positions and totals
variables.put("services.json",
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(info.positions()));
variables.put("services.count", String.valueOf(info.positions().size()));
variables.put("invoice.net_total", formatAmount(info.netTotal()) + "");
variables.put("invoice.vat_total", formatAmount(info.vatTotal()) + "");
variables.put("invoice.gross_total", formatAmount(info.grossTotal()) + "");
variables.put("invoice.vat_rate",
SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
variables.put("invoice.due_date", DateTimeFormatUtil.formatDate(info.dueDate()));
variables.put("services.net_total", formatAmount(info.netTotal()) + "");
variables.put("services.gross_total", formatAmount(info.grossTotal()) + "");
return variables;
}
private String buildInvoiceNumber(User user) {
String monthPart = billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
String userPart = user.getUsrId() > 0 ? String.valueOf(user.getUsrId())
: (user.getId() != null ? user.getId().toHexString().substring(20) : "0");
return "SYS-" + monthPart + "-" + userPart;
}
private void showPdfInDialog(InvoicePreview preview) {
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
"90vw");
pdfDialog.setHeight("90vh");
Div pdfContainer = new Div();
pdfContainer.setWidth("100%");
pdfContainer.setHeight("100%");
String base64Pdf = Base64.getEncoder().encodeToString(preview.pdf());
IFrame pdfFrame = new IFrame();
pdfFrame.setWidth("100%");
pdfFrame.setHeight("100%");
pdfFrame.getElement().setAttribute("src", "data:application/pdf;base64," + base64Pdf);
pdfContainer.add(pdfFrame);
Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"),
e -> downloadZugferdZip(preview));
zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button sendButton = new Button(getTranslation("admincreateinvoices.pdf.button.send"), e -> {
e.getSource().setEnabled(false);
sendInvoices(List.of(preview));
pdfDialog.close();
});
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
pdfDialog.getFooter().add(zugferdButton, sendButton, closeButton);
pdfDialog.open();
}
// ==========================================
// ZUGFeRD e-invoice (external PDF Tool)
// ==========================================
/**
* Converts the invoice via the external PDF Tool into a ZUGFeRD e-invoice
* and offers the resulting ZIP (signed PDF/A-3, factur-x.xml, validation
* report) as browser download.
*/
private void downloadZugferdZip(InvoicePreview preview) {
try {
PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(),
buildZugferdMetadata(preview));
String base64Zip = Base64.getEncoder().encodeToString(signed.zip());
getElement().executeJs("const link = document.createElement('a');"
+ "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
+ signed.fileName().replace("'", "") + "';"
+ "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
if (signed.valid()) {
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.created",
preview.invoiceNumber()), 5000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} else {
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.invalid"), 7000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_WARNING);
}
} catch (Exception ex) {
log.error("Error creating ZUGFeRD e-invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
/**
* Builds the EN16931 metadata for the PDF Tool from the billing data: the
* system operator as sender, the customer as recipient and the billed
* positions (including a possible negative discount position) as lines.
*/
private PdfToolService.InvoiceMetadata buildZugferdMetadata(InvoicePreview preview) throws Exception {
SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
BillingInfo info = preview.info();
User user = info.user();
String[] senderZipCity = splitZipCity(issuer.getCompanyCity());
String senderName = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
String footer = safe(issuer.getFooterText());
PdfToolService.Party sender = new PdfToolService.Party(senderName, safe(issuer.getCompanyStreet()),
senderZipCity[0], senderZipCity[1], "DE", extractByPattern(footer, "USt-IdNr\\.?:?\\s*([A-Z]{2}[0-9A-Za-z]{2,12})"),
safe(issuer.getCompanyEmail()), safe(issuer.getCompanyPhone()));
PdfToolService.Party recipient;
if (user.isDiffInvoiceAddress()) {
recipient = new PdfToolService.Party(
firstNonBlank(user.getInvCompany(), user.getCompany(),
(safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim()),
(safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim(), safe(user.getInvZip()),
safe(user.getInvCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
} else {
recipient = new PdfToolService.Party(
firstNonBlank(user.getCompany(), (safe(user.getFirstname()) + " " + safe(user.getName())).trim()),
(safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim(), safe(user.getZip()),
safe(user.getCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
}
BigDecimal vatPercent = SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP);
List<PdfToolService.LineItem> items = new ArrayList<>();
for (Map<String, String> position : info.positions()) {
BigDecimal amount = parseSignedAmount(position.get("netAmount"));
if (amount == null) {
// Non-monetary positions cannot be part of an EN16931 invoice
continue;
}
items.add(new PdfToolService.LineItem(position.get("name"), BigDecimal.ONE, amount, vatPercent));
}
if (items.isEmpty()) {
throw new IllegalStateException(getTranslation("admincreateinvoices.notification.zugferd.nopositions"));
}
return new PdfToolService.InvoiceMetadata(preview.invoiceNumber(), LocalDate.now(),
billingMonth.atEndOfMonth(), info.dueDate(), "EUR", safe(issuer.getPaymentTerms()), null,
extractByPattern(footer, "IBAN\\s*:?\\s*([A-Z]{2}[0-9A-Za-z]{13,32})"),
extractByPattern(footer, "BIC\\s*:?\\s*([A-Za-z0-9]{8,11})"), sender, recipient, items);
}
/**
* Parses a formatted position amount like "99,00" or "-49,50" including
* its sign. Returns null for non-monetary values such as "-" or "15 %".
*/
private BigDecimal parseSignedAmount(String value) {
if (value == null || value.isBlank() || value.contains("%")) {
return null;
}
boolean negative = value.trim().startsWith("-");
BigDecimal amount = parseAmount(value.trim().replaceFirst("^-", ""));
if (amount == null) {
return null;
}
return negative ? amount.negate() : amount;
}
/** Splits a combined "zip city" value like "21502 Geesthacht". */
private String[] splitZipCity(String zipCity) {
String trimmed = safe(zipCity).trim();
int space = trimmed.indexOf(' ');
if (space < 0) {
return new String[] { trimmed, trimmed };
}
return new String[] { trimmed.substring(0, space), trimmed.substring(space + 1).trim() };
}
/** Returns the first regex group found in the text, or null. */
private String extractByPattern(String text, String pattern) {
Matcher matcher = Pattern.compile(pattern).matcher(text);
return matcher.find() ? matcher.group(1) : null;
}
// ==========================================
// Helpers
// ==========================================
private String customerDisplayName(User user) {
String company = safe(user.getCompany()).trim();
if (!company.isEmpty()) {
return company;
}
return (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
}
/**
* Parse a price table value like "99,00 €" or "1.234,56" into an amount.
* Returns null for non-monetary values such as "15 %".
*/
private BigDecimal parseAmount(String value) {
if (value == null || value.isBlank() || value.contains("%")) {
return null;
}
Matcher matcher = Pattern.compile("([0-9]{1,3}(?:\\.[0-9]{3})*(?:,[0-9]+)?|[0-9]+(?:[.,][0-9]+)?)")
.matcher(value);
if (!matcher.find()) {
return null;
}
String number = matcher.group(1);
if (number.contains(",")) {
number = number.replace(".", "").replace(',', '.');
}
try {
return new BigDecimal(number);
} catch (NumberFormatException ex) {
return null;
}
}
private String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
}
/** Formats a percent value without trailing zeros, e.g. 10 or 12,5. */
private String formatPercent(Double percent) {
return new BigDecimal(percent.toString()).stripTrailingZeros().toPlainString().replace('.', ',');
}
private String safe(String value) {
return value != null ? value : "";
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
@Override
public String getPageTitle() {
return getTranslation("page.title.admin.createinvoices");
}
}

View File

@@ -1,5 +1,6 @@
package de.assecutor.votianlt.pages.view; package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H1; import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.H3;
@@ -14,15 +15,23 @@ import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Menu; import com.vaadin.flow.router.Menu;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.*; import de.assecutor.votianlt.repository.*;
import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.stream.Collectors;
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class) @Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
@RolesAllowed("ADMIN") @RolesAllowed("ADMIN")
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
private final BarcodeRepository barcodeRepository; private final BarcodeRepository barcodeRepository;
private final SignatureRepository signatureRepository; private final SignatureRepository signatureRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final CustomerRepository customerRepository;
private final Div statisticsContainer; private final Div statisticsContainer;
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository, public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository, AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
PhotoRepository photoRepository, BarcodeRepository barcodeRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository) { SignatureRepository signatureRepository, CommentRepository commentRepository,
CustomerRepository customerRepository) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
this.barcodeRepository = barcodeRepository; this.barcodeRepository = barcodeRepository;
this.signatureRepository = signatureRepository; this.signatureRepository = signatureRepository;
this.commentRepository = commentRepository; this.commentRepository = commentRepository;
this.customerRepository = customerRepository;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
// System overview section // System overview section
mainLayout.add(createSystemOverviewSection()); mainLayout.add(createSystemOverviewSection());
// Customers overview section
mainLayout.add(createCustomersSection());
// Job statistics section // Job statistics section
mainLayout.add(createJobStatisticsSection()); mainLayout.add(createJobStatisticsSection());
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
return section; return section;
} }
private VerticalLayout createCustomersSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.addClassNames("surface-panel", "dashboard-section");
List<Customer> customers = customerRepository.findAll().stream().filter(customer -> !customer.isInternal())
.toList();
H3 title = new H3(getTranslation("admindashboard.section.customers", customers.size()));
title.addClassNames(LumoUtility.Margin.Bottom.MEDIUM, "dashboard-section-title");
if (customers.isEmpty()) {
Span emptyHint = new Span(getTranslation("admindashboard.customers.empty"));
section.add(title, emptyHint);
return section;
}
Map<ObjectId, User> usersById = userRepository.findAll().stream().filter(user -> user.getId() != null)
.collect(Collectors.toMap(User::getId, Function.identity(), (first, second) -> first));
Grid<Customer> grid = new Grid<>();
grid.setItems(customers);
grid.addColumn(Customer::getUsrId).setHeader(getTranslation("admindashboard.customers.column.number"))
.setAutoWidth(true).setFlexGrow(0).setSortable(true);
grid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"))
.setAutoWidth(true).setSortable(true);
grid.addColumn(customer -> ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim())
.setHeader(getTranslation("customers.column.name")).setAutoWidth(true).setSortable(true);
grid.addColumn(Customer::getMail).setHeader(getTranslation("customers.column.email")).setAutoWidth(true);
grid.addColumn(Customer::getTelephone).setHeader(getTranslation("customers.column.phone")).setAutoWidth(true);
grid.addColumn(customer -> ((customer.getZip() != null ? customer.getZip() : "") + " "
+ (customer.getCity() != null ? customer.getCity() : "")).trim())
.setHeader(getTranslation("customers.column.city")).setAutoWidth(true).setSortable(true);
grid.addColumn(customer -> formatOwner(usersById.get(customer.getOwner())))
.setHeader(getTranslation("admindashboard.customers.column.owner")).setAutoWidth(true)
.setSortable(true);
grid.addClassName("data-grid");
grid.setWidthFull();
grid.setHeight("400px");
section.add(title, grid);
return section;
}
private String formatOwner(User owner) {
if (owner == null) {
return "";
}
if (owner.getCompany() != null && !owner.getCompany().isBlank()) {
return owner.getCompany();
}
return ((owner.getFirstname() != null ? owner.getFirstname() : "") + " "
+ (owner.getName() != null ? owner.getName() : "")).trim();
}
private VerticalLayout createJobStatisticsSection() { private VerticalLayout createJobStatisticsSection() {
VerticalLayout section = new VerticalLayout(); VerticalLayout section = new VerticalLayout();
section.setPadding(false); section.setPadding(false);

View File

@@ -1,7 +1,6 @@
package de.assecutor.votianlt.pages.view; 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.html.Div;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
@@ -19,16 +18,14 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
private final PriceTableRepository priceTableRepository; private final PriceTableRepository priceTableRepository;
private final TextField monthlyBasePackage; private final TextField monthlyBasePackage;
private final TextField appUsageLicense; private final TextField appUserFee;
private final TextField revenueParticipation;
public AdminPricetableView(PriceTableRepository priceTableRepository) { public AdminPricetableView(PriceTableRepository priceTableRepository) {
this.priceTableRepository = priceTableRepository; this.priceTableRepository = priceTableRepository;
setSpacing(false); setSpacing(false);
setPadding(false); setPadding(false);
getStyle().set("margin", "14px"); setWidthFull();
setWidth("90%");
addClassName("admin-form-view"); addClassName("admin-form-view");
add(new ViewToolbar(getTranslation("adminpricetable.title"))); add(new ViewToolbar(getTranslation("adminpricetable.title")));
@@ -42,29 +39,20 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
monthlyBasePackage.setWidth("40%"); monthlyBasePackage.setWidth("40%");
monthlyBasePackage.setMaxWidth("40%"); monthlyBasePackage.setMaxWidth("40%");
appUsageLicense = new TextField(); appUserFee = new TextField();
appUsageLicense.setLabel(getTranslation("adminpricetable.field.applicense")); appUserFee.setLabel(getTranslation("adminpricetable.field.appuserfee"));
appUsageLicense.setWidth("40%"); appUserFee.setHelperText(getTranslation("adminpricetable.field.appuserfee.helper"));
appUsageLicense.setMaxWidth("40%"); appUserFee.setWidth("40%");
appUserFee.setMaxWidth("40%");
revenueParticipation = new TextField();
revenueParticipation.setLabel(getTranslation("adminpricetable.field.revenue"));
revenueParticipation.setWidth("40%");
revenueParticipation.setMaxWidth("40%");
fieldsLayout.add(monthlyBasePackage, appUsageLicense, revenueParticipation);
add(fieldsLayout);
Button saveButton = new Button(getTranslation("button.savechanges")); Button saveButton = new Button(getTranslation("button.savechanges"));
saveButton.getStyle().set("margin-top", "20px"); saveButton.getStyle().set("margin-top", "20px");
saveButton.addClickListener(e -> savePriceTable()); saveButton.addClickListener(e -> savePriceTable());
saveButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY); saveButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
Div actions = new Div(saveButton); fieldsLayout.add(monthlyBasePackage, appUserFee, saveButton);
actions.addClassNames("form-shell", "simple-card");
add(fieldsLayout, actions); add(fieldsLayout);
// Load existing data // Load existing data
loadPriceTable(); loadPriceTable();
@@ -76,8 +64,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable()); PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue()); priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
priceTable.setAppUsageLicense(appUsageLicense.getValue()); priceTable.setAppUserFee(appUserFee.getValue());
priceTable.setRevenueParticipation(revenueParticipation.getValue());
priceTableRepository.save(priceTable); priceTableRepository.save(priceTable);
Notification.show(getTranslation("adminpricetable.notification.saved"), 3000, Notification.show(getTranslation("adminpricetable.notification.saved"), 3000,
@@ -95,10 +82,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
if (priceTable != null) { if (priceTable != null) {
monthlyBasePackage monthlyBasePackage
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : ""); .setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
appUsageLicense appUserFee.setValue(priceTable.getAppUserFee() != null ? priceTable.getAppUserFee() : "");
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
revenueParticipation.setValue(
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
} }
} catch (Exception ex) { } catch (Exception ex) {
Notification.show(getTranslation("adminpricetable.notification.load.error", ex.getMessage()), 5000, Notification.show(getTranslation("adminpricetable.notification.load.error", ex.getMessage()), 5000,

View File

@@ -0,0 +1,155 @@
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.formlayout.FormLayout;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.SystemCompanyProfile;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
/**
* Admin profile page for editing the company data of the system operator.
* These values are used as issuer data on system invoices to the users.
*/
@Route(value = "admin-profile", layout = AdminLayout.class)
@RolesAllowed("ADMIN")
@Slf4j
public class AdminProfileView extends VerticalLayout implements HasDynamicTitle {
private final SystemCompanyProfileService profileService;
private final TextField companyName = new TextField();
private final TextField companySubtitle = new TextField();
private final TextField companyStreet = new TextField();
private final TextField companyCity = new TextField();
private final TextField companyPhone = new TextField();
private final TextField companyFax = new TextField();
private final TextField companyEmail = new TextField();
private final TextField companyWebsite = new TextField();
private final TextField senderLine = new TextField();
private final TextArea paymentTerms = new TextArea();
private final TextArea footerText = new TextArea();
private SystemCompanyProfile profile;
public AdminProfileView(SystemCompanyProfileService profileService) {
this.profileService = profileService;
setSpacing(false);
setPadding(false);
setWidthFull();
addClassName("admin-form-view");
add(new ViewToolbar(getTranslation("adminprofile.title")));
companyName.setLabel(getTranslation("adminprofile.field.companyname"));
companySubtitle.setLabel(getTranslation("adminprofile.field.companysubtitle"));
companyStreet.setLabel(getTranslation("adminprofile.field.street"));
companyCity.setLabel(getTranslation("adminprofile.field.city"));
companyPhone.setLabel(getTranslation("adminprofile.field.phone"));
companyFax.setLabel(getTranslation("adminprofile.field.fax"));
companyEmail.setLabel(getTranslation("adminprofile.field.email"));
companyWebsite.setLabel(getTranslation("adminprofile.field.website"));
senderLine.setLabel(getTranslation("adminprofile.field.senderline"));
senderLine.setHelperText(getTranslation("adminprofile.field.senderline.helper"));
paymentTerms.setLabel(getTranslation("adminprofile.field.paymentterms"));
paymentTerms.setMinHeight("80px");
footerText.setLabel(getTranslation("adminprofile.field.footer"));
footerText.setHelperText(getTranslation("adminprofile.field.footer.helper"));
footerText.setMinHeight("120px");
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 2));
form.add(companyName, companySubtitle, companyStreet, companyCity, companyPhone, companyFax, companyEmail,
companyWebsite);
form.add(senderLine, 2);
form.add(paymentTerms, 2);
form.add(footerText, 2);
VerticalLayout fieldsLayout = new VerticalLayout(form);
fieldsLayout.setSpacing(true);
fieldsLayout.setPadding(false);
fieldsLayout.addClassNames("form-shell", "form-card");
add(fieldsLayout);
Button saveButton = new Button(getTranslation("button.savechanges"), e -> saveProfile());
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Div actions = new Div(saveButton);
actions.addClassNames("form-shell", "simple-card");
add(actions);
loadProfile();
}
private void loadProfile() {
try {
profile = profileService.getOrCreateProfile();
companyName.setValue(nvl(profile.getCompanyName()));
companySubtitle.setValue(nvl(profile.getCompanySubtitle()));
companyStreet.setValue(nvl(profile.getCompanyStreet()));
companyCity.setValue(nvl(profile.getCompanyCity()));
companyPhone.setValue(nvl(profile.getCompanyPhone()));
companyFax.setValue(nvl(profile.getCompanyFax()));
companyEmail.setValue(nvl(profile.getCompanyEmail()));
companyWebsite.setValue(nvl(profile.getCompanyWebsite()));
senderLine.setValue(nvl(profile.getSenderLine()));
paymentTerms.setValue(nvl(profile.getPaymentTerms()));
// Footer is stored with <br> line breaks; edit it as multi-line text
footerText.setValue(nvl(profile.getFooterText()).replace("<br>", "\n"));
} catch (Exception ex) {
log.error("Error loading system company profile: {}", ex.getMessage(), ex);
Notification
.show(getTranslation("adminprofile.notification.load.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void saveProfile() {
try {
profile.setCompanyName(companyName.getValue().trim());
profile.setCompanySubtitle(companySubtitle.getValue().trim());
profile.setCompanyStreet(companyStreet.getValue().trim());
profile.setCompanyCity(companyCity.getValue().trim());
profile.setCompanyPhone(companyPhone.getValue().trim());
profile.setCompanyFax(companyFax.getValue().trim());
profile.setCompanyEmail(companyEmail.getValue().trim());
profile.setCompanyWebsite(companyWebsite.getValue().trim());
profile.setSenderLine(senderLine.getValue().trim());
profile.setPaymentTerms(paymentTerms.getValue().trim());
profile.setFooterText(footerText.getValue().trim().replace("\n", "<br>"));
profile = profileService.save(profile);
Notification
.show(getTranslation("adminprofile.notification.saved"), 3000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (Exception ex) {
log.error("Error saving system company profile: {}", ex.getMessage(), ex);
Notification
.show(getTranslation("adminprofile.notification.save.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private String nvl(String value) {
return value != null ? value : "";
}
@Override
public String getPageTitle() {
return getTranslation("page.title.admin.profile");
}
}

View File

@@ -429,6 +429,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
actionLayout.setSpacing(true); actionLayout.setSpacing(true);
actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)"); actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)");
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
undoButton.addClickListener(
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE)); Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile()); previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
@@ -462,7 +467,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
}); });
}); });
actionLayout.add(previewPdfButton, saveTemplateButton); actionLayout.add(undoButton, previewPdfButton, saveTemplateButton);
billingTab.add(actionLayout); billingTab.add(actionLayout);
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true) // Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
@@ -486,7 +491,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Also register this view instance for JavaScript callbacks // Also register this view instance for JavaScript callbacks
tabSheet.addSelectedChangeListener(e -> { tabSheet.addSelectedChangeListener(e -> {
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) { if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" + "setTimeout(function() { " getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); " + " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
+ " console.log('Canvas initialized, now loading template...'); " + " console.log('Canvas initialized, now loading template...'); "
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); " + " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
@@ -1466,6 +1472,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
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...'); " + " console.log('Loading template into canvas...'); "
+ " window.profileInvoiceServiceData = null; "
+ " window.profileInvoiceVatRate = " + vatRateJs + "; " + " window.profileInvoiceVatRate = " + vatRateJs + "; "
+ " window.masterdataValues = " + " window.masterdataValues = "
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); " + masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "

View File

@@ -31,11 +31,13 @@ import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Service; import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.messaging.MessagingPublisher;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; 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.ServiceRepository; import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.ClientConnectionService;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -47,6 +49,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed("USER") @RolesAllowed("USER")
@@ -67,6 +70,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final SecurityService securityService; private final SecurityService securityService;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
private final ClientConnectionService clientConnectionService;
private final MessagingPublisher messagingPublisher;
private final VerticalLayout content; private final VerticalLayout content;
private Job job; private Job job;
@@ -80,11 +85,14 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
private Integer manualDurationSeconds; private Integer manualDurationSeconds;
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService, public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService, ServiceRepository serviceRepository) { SecurityService securityService, ServiceRepository serviceRepository,
ClientConnectionService clientConnectionService, MessagingPublisher messagingPublisher) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService; this.jobHistoryService = jobHistoryService;
this.securityService = securityService; this.securityService = securityService;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -683,6 +691,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"), jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"),
description, currentUser, JobHistoryType.STATUS_CHANGE); description, currentUser, JobHistoryType.STATUS_CHANGE);
notifyClientJobDeleted(job);
Notification Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000, .show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END) Notification.Position.BOTTOM_END)
@@ -695,4 +705,26 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
.addThemeVariants(NotificationVariant.LUMO_ERROR); .addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
} }
private void notifyClientJobDeleted(Job job) {
if (!job.isDigitalProcessing()) {
return;
}
String appUserId = job.getAppUser();
if (appUserId == null || appUserId.isBlank()) {
return;
}
if (!clientConnectionService.isClientConnected(appUserId)) {
log.info("[JOB] Client {} not online, skipping job_deleted notification", appUserId);
return;
}
Map<String, Object> payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber",
job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString());
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);
}
} }

View File

@@ -23,6 +23,7 @@ import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration; import com.vaadin.flow.server.StreamRegistration;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import de.assecutor.votianlt.service.SystemInvoiceService; import de.assecutor.votianlt.service.SystemInvoiceService;
import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData; import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
@@ -49,11 +50,14 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
private final List<MyInvoiceRow> allRows = new ArrayList<>(); // zunächst leer private final List<MyInvoiceRow> allRows = new ArrayList<>(); // zunächst leer
private final Div emptyState = new Div(); private final Div emptyState = new Div();
private final SystemInvoiceService systemInvoiceService; private final SystemInvoiceService systemInvoiceService;
private final SystemCompanyProfileService systemCompanyProfileService;
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY); private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
public MyInvoicesView(SystemInvoiceService systemInvoiceService) { public MyInvoicesView(SystemInvoiceService systemInvoiceService,
SystemCompanyProfileService systemCompanyProfileService) {
this.systemInvoiceService = systemInvoiceService; this.systemInvoiceService = systemInvoiceService;
this.systemCompanyProfileService = systemCompanyProfileService;
addClassName("data-view"); addClassName("data-view");
getStyle().set("max-width", "1100px"); getStyle().set("max-width", "1100px");
getStyle().set("margin-left", "auto"); getStyle().set("margin-left", "auto");
@@ -272,7 +276,7 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
} }
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception { private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
SystemInvoiceData data = new SystemInvoiceData(); SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
data.setInvoiceNumber(row.invoiceNumber()); data.setInvoiceNumber(row.invoiceNumber());
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date())); data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
data.setInvoiceText("Rechnung " + row.invoiceNumber()); data.setInvoiceText("Rechnung " + row.invoiceNumber());

View File

@@ -50,7 +50,6 @@ 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
@@ -62,7 +61,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
this.securityService = securityService; this.securityService = securityService;
this.clientConnectionService = clientConnectionService; this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher; this.messagingPublisher = messagingPublisher;
this.customerInvoiceRepository = customerInvoiceRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);

View File

@@ -23,4 +23,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by bezeichnung // Find AppUser by bezeichnung
AppUser findByBezeichnung(String bezeichnung); AppUser findByBezeichnung(String bezeichnung);
// Number of app users belonging to a customer (for per-app-user billing);
// same association as the customer's own app user list (findByErstelltVon)
long countByErstelltVon(ObjectId erstelltVon);
} }

View File

@@ -0,0 +1,9 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.SystemCompanyProfile;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SystemCompanyProfileRepository extends MongoRepository<SystemCompanyProfile, String> {
}

View File

@@ -0,0 +1,15 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
import java.util.Optional;
public interface SystemInvoiceDispatchRepository extends MongoRepository<SystemInvoiceDispatch, ObjectId> {
List<SystemInvoiceDispatch> findByBillingMonth(String billingMonth);
Optional<SystemInvoiceDispatch> findByInvoiceNumber(String invoiceNumber);
}

View File

@@ -248,7 +248,7 @@ public class CustomerInvoiceService {
* representation of the canvas elements and converts it to PDF. * representation of the canvas elements and converts it to PDF.
*/ */
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception { public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, null, null); return generatePdfFromCanvasTemplate(jsonTemplateData, (de.assecutor.votianlt.model.User) null, null);
} }
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
@@ -265,27 +265,6 @@ public class CustomerInvoiceService {
String invoicePrefix, BigDecimal vatRate) throws Exception { String invoicePrefix, BigDecimal vatRate) throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19")); : (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
// Build HTML content from canvas elements
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>");
htmlBuilder.append("<meta charset='UTF-8'>");
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
htmlBuilder.append(".image { overflow: hidden; }");
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
htmlBuilder.append("</style>");
htmlBuilder.append("</head><body>");
// Prepare variable substitution map // Prepare variable substitution map
java.util.Map<String, String> variables = new java.util.HashMap<>(); java.util.Map<String, String> variables = new java.util.HashMap<>();
@@ -321,6 +300,39 @@ public class CustomerInvoiceService {
variables.put("customer.email", "kunde@beispiel.de"); variables.put("customer.email", "kunde@beispiel.de");
variables.put("customer.phone", "0987 654321"); variables.put("customer.phone", "0987 654321");
return generatePdfFromCanvasTemplate(jsonTemplateData, variables, effectiveVatRate);
}
/**
* Render a canvas template PDF using a pre-built variable substitution map.
* Used e.g. for the system invoice template where the issuer data comes from
* SystemInvoiceData instead of a User.
*/
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, java.util.Map<String, String> variables,
BigDecimal vatRate) throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate : new BigDecimal("0.19");
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
// Build HTML content from canvas elements
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>");
htmlBuilder.append("<meta charset='UTF-8'>");
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
htmlBuilder.append(".image { overflow: hidden; }");
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
htmlBuilder.append("</style>");
htmlBuilder.append("</head><body>");
if (elements != null && elements.isArray()) { if (elements != null && elements.isArray()) {
for (com.fasterxml.jackson.databind.JsonNode element : elements) { for (com.fasterxml.jackson.databind.JsonNode element : elements) {
String type = element.has("type") ? element.get("type").asText("text") : "text"; String type = element.has("type") ? element.get("type").asText("text") : "text";
@@ -464,8 +476,13 @@ public class CustomerInvoiceService {
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>"); "<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
} }
} else if ("services.list".equals(variable)) { } else if ("services.list".equals(variable)) {
// Render services list as a table // Render positions from services.json if provided (e.g. system
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate)); // invoice: price table positions), otherwise sample data
if (variables.containsKey("services.json")) {
htmlBuilder.append(generateServicesTableHtmlWithData(mmWidth, variables));
} else {
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>");
@@ -865,8 +882,11 @@ public class CustomerInvoiceService {
.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%;'>") html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(vatRateLabel)).append("</td>"); .append(escapeHtml(vatRateLabel)).append("</td>");
// Append € only for plain numeric amounts; values like "15 %"
// (revenue participation from the price table) are shown as-is
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + "" : escapeHtml(netAmount);
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(amountDisplay).append("</td>");
html.append("</tr>"); html.append("</tr>");
} }
} }

View File

@@ -6,12 +6,16 @@ import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository; 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 jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -378,6 +382,23 @@ public class EmailService {
mailSender.send(message); mailSender.send(message);
} }
/**
* Send a text email with a single attachment. Synchronous on purpose so
* callers can report per-recipient success or failure to the user.
*/
public void sendEmailWithAttachment(String to, String subject, String body, byte[] attachment,
String attachmentName) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(smtpUsername);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body);
helper.addAttachment(attachmentName, new ByteArrayResource(attachment));
mailSender.send(message);
log.info("Email with attachment {} sent to {}", attachmentName, to);
}
/** /**
* Send a simple text email * Send a simple text email
*/ */

View File

@@ -1,5 +1,7 @@
package de.assecutor.votianlt.service; package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -7,6 +9,9 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
/** /**
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats * Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats
@@ -17,6 +22,61 @@ public class MonthlySchedulerService {
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class); private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
private final EmailService emailService;
private final UserRepository userRepository;
public MonthlySchedulerService(EmailService emailService, UserRepository userRepository) {
this.emailService = emailService;
this.userRepository = userRepository;
}
/**
* Läuft täglich um 08:00 Uhr und erinnert die Administratoren drei Tage vor
* Ultimo per E-Mail daran, die Monatsrechnungen fertigzustellen.
*/
@Scheduled(cron = "0 0 8 * * *")
public void checkAndSendInvoiceReminder() {
LocalDate today = LocalDate.now();
LocalDate lastDayOfMonth = YearMonth.from(today).atEndOfMonth();
if (!today.equals(lastDayOfMonth.minusDays(3))) {
return;
}
logger.info("Drei Tage vor Ultimo ({}): Sende Rechnungs-Erinnerung an die Administratoren.", lastDayOfMonth);
sendInvoiceReminderToAdmins(lastDayOfMonth);
}
private void sendInvoiceReminderToAdmins(LocalDate lastDayOfMonth) {
List<User> admins = userRepository.findAll().stream()
.filter(user -> user.getRoles() != null && user.getRoles().contains("ADMIN"))
.filter(user -> user.getEmail() != null && !user.getEmail().isBlank()).toList();
if (admins.isEmpty()) {
logger.warn("Keine Administratoren mit E-Mail-Adresse gefunden, Rechnungs-Erinnerung entfällt.");
return;
}
String monthLabel = YearMonth.from(lastDayOfMonth)
.format(DateTimeFormatter.ofPattern("LLLL yyyy", Locale.GERMAN));
String dueDateLabel = lastDayOfMonth.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
String subject = "Erinnerung: Rechnungen für " + monthLabel + " erstellen";
String body = "Hallo,\n\n" //
+ "in drei Tagen ist Ultimo (" + dueDateLabel + ").\n"
+ "Bitte die Monatsrechnungen für " + monthLabel
+ " fertigstellen: Seite \"Rechnungen erstellen\" im Admin-Bereich (/admin-create-invoices).\n\n"
+ "Diese Nachricht wurde automatisch von VotianLT versendet.";
for (User admin : admins) {
try {
emailService.sendSimpleEmail(admin.getEmail(), subject, body);
logger.info("Rechnungs-Erinnerung an {} gesendet.", admin.getEmail());
} catch (Exception e) {
logger.error("Rechnungs-Erinnerung an {} fehlgeschlagen: {}", admin.getEmail(), e.getMessage(), e);
}
}
}
/** /**
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag * Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt. * des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.

View File

@@ -0,0 +1,114 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* Client for the external PDF Tool (ZUGFeRD converter). Sends an invoice PDF
* together with the structured invoice data to {@code POST /api/invoices/sign}
* and receives a ZIP file containing the signed e-invoice (PDF/A-3 with
* embedded EN16931 XML), the factur-x.xml and the Mustang validation report.
*/
@Service
@Slf4j
public class PdfToolService {
/** Response header of the PDF Tool with the Mustang validation result. */
private static final String VALIDATION_HEADER = "X-Zugferd-Valid";
private final RestClient restClient;
private final ObjectMapper objectMapper;
public PdfToolService(@Value("${app.pdftool.base-url:http://localhost:8083}") String baseUrl,
ObjectMapper objectMapper) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(5000);
// PDF/A conversion and veraPDF validation can take a while
requestFactory.setReadTimeout(120000);
this.restClient = RestClient.builder().baseUrl(baseUrl).requestFactory(requestFactory).build();
this.objectMapper = objectMapper;
}
/** Party (sender or recipient) of the invoice metadata. */
public record Party(String name, String street, String zip, String city, String countryCode, String vatId,
String email, String phone) {
}
/** One invoice line of the invoice metadata. */
public record LineItem(String description, BigDecimal quantity, BigDecimal unitPriceNet, BigDecimal vatPercent) {
}
/** Invoice metadata as expected by the PDF Tool (EN16931 fields). */
public record InvoiceMetadata(String invoiceNumber, LocalDate issueDate, LocalDate deliveryDate, LocalDate dueDate,
String currency, String paymentTerms, String buyerReference, String iban, String bic, Party sender,
Party recipient, List<LineItem> items) {
}
/** Result of a sign call: the ZIP, its file name and the validation flag. */
public record SignedInvoice(byte[] zip, String fileName, boolean valid) {
}
/**
* Converts the given invoice PDF into a ZUGFeRD e-invoice. Returns the ZIP
* provided by the PDF Tool; throws with the tool's error message if the
* conversion is rejected (e.g. incomplete metadata).
*/
public SignedInvoice signInvoice(byte[] pdfBytes, InvoiceMetadata metadata) throws Exception {
String metadataJson = objectMapper.writeValueAsString(metadata);
MultipartBodyBuilder body = new MultipartBodyBuilder();
body.part("file", new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return metadata.invoiceNumber() + ".pdf";
}
}, MediaType.APPLICATION_PDF);
body.part("metadata", metadataJson, MediaType.APPLICATION_JSON);
ResponseEntity<byte[]> response;
try {
response = restClient.post().uri("/api/invoices/sign").contentType(MediaType.MULTIPART_FORM_DATA)
.body(body.build()).retrieve().toEntity(byte[].class);
} catch (RestClientResponseException ex) {
throw new IllegalStateException(extractErrorMessage(ex), ex);
}
byte[] zip = response.getBody();
if (zip == null || zip.length == 0) {
throw new IllegalStateException("PDF Tool returned an empty response");
}
String fileName = response.getHeaders().getContentDisposition().getFilename();
if (fileName == null || fileName.isBlank()) {
fileName = metadata.invoiceNumber() + "-zugferd.zip";
}
boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER));
return new SignedInvoice(zip, fileName, valid);
}
/** The PDF Tool reports errors as {@code {"error": "..."}} with HTTP 400. */
private String extractErrorMessage(RestClientResponseException ex) {
try {
JsonNode node = objectMapper.readTree(ex.getResponseBodyAsString());
if (node.hasNonNull("error")) {
return node.get("error").asText();
}
} catch (Exception ignored) {
// fall through to the generic message
}
return ex.getStatusText() + " (" + ex.getStatusCode().value() + ")";
}
}

View File

@@ -0,0 +1,78 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.SystemCompanyProfile;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.repository.SystemCompanyProfileRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* Manages the company data of the system operator. As long as no profile has
* been saved, the defaults from SystemInvoiceData are used.
*/
@Service
public class SystemCompanyProfileService {
private final SystemCompanyProfileRepository repository;
public SystemCompanyProfileService(SystemCompanyProfileRepository repository) {
this.repository = repository;
}
/**
* Returns the stored profile, or a new unsaved instance prefilled with the
* SystemInvoiceData defaults for first-time editing.
*/
public SystemCompanyProfile getOrCreateProfile() {
Optional<SystemCompanyProfile> stored = repository.findAll().stream().findFirst();
if (stored.isPresent()) {
return stored.get();
}
SystemInvoiceData defaults = new SystemInvoiceData();
SystemCompanyProfile profile = new SystemCompanyProfile();
profile.setCompanyName(defaults.getCompanyName());
profile.setCompanySubtitle(defaults.getCompanySubtitle());
profile.setCompanyStreet(defaults.getCompanyStreet());
profile.setCompanyCity(defaults.getCompanyCity());
profile.setCompanyPhone(defaults.getCompanyPhone());
profile.setCompanyFax(defaults.getCompanyFax());
profile.setCompanyEmail(defaults.getCompanyEmail());
profile.setCompanyWebsite(defaults.getCompanyWebsite());
profile.setSenderLine(defaults.getSenderLine());
profile.setPaymentTerms(defaults.getPaymentTerms());
profile.setFooterText(defaults.getFooterText());
return profile;
}
public SystemCompanyProfile save(SystemCompanyProfile profile) {
return repository.save(profile);
}
/**
* Creates a SystemInvoiceData whose issuer fields come from the stored
* profile. Once a profile exists it is the source of truth for all issuer
* fields; without one the hardcoded defaults remain.
*/
public SystemInvoiceData createSystemInvoiceData() {
SystemInvoiceData data = new SystemInvoiceData();
repository.findAll().stream().findFirst().ifPresent(profile -> {
data.setCompanyName(nvl(profile.getCompanyName()));
data.setCompanySubtitle(nvl(profile.getCompanySubtitle()));
data.setCompanyStreet(nvl(profile.getCompanyStreet()));
data.setCompanyCity(nvl(profile.getCompanyCity()));
data.setCompanyPhone(nvl(profile.getCompanyPhone()));
data.setCompanyFax(nvl(profile.getCompanyFax()));
data.setCompanyEmail(nvl(profile.getCompanyEmail()));
data.setCompanyWebsite(nvl(profile.getCompanyWebsite()));
data.setSenderLine(nvl(profile.getSenderLine()));
data.setPaymentTerms(nvl(profile.getPaymentTerms()));
data.setFooterText(nvl(profile.getFooterText()));
});
return data;
}
private String nvl(String value) {
return value != null ? value : "";
}
}

View File

@@ -13,6 +13,12 @@ import java.util.List;
@Service @Service
public class SystemInvoiceService { public class SystemInvoiceService {
private final SystemCompanyProfileService systemCompanyProfileService;
public SystemInvoiceService(SystemCompanyProfileService systemCompanyProfileService) {
this.systemCompanyProfileService = systemCompanyProfileService;
}
public byte[] generateInvoicePdfFromHtml() throws Exception { public byte[] generateInvoicePdfFromHtml() throws Exception {
// Read the HTML template // Read the HTML template
String htmlContent = readHtmlTemplate(); String htmlContent = readHtmlTemplate();
@@ -35,7 +41,7 @@ public class SystemInvoiceService {
} }
public SystemInvoiceData createSampleInvoiceData() { public SystemInvoiceData createSampleInvoiceData() {
SystemInvoiceData data = new SystemInvoiceData(); SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
// Set sample data // Set sample data
data.setInvoiceNumber("HHA-2021-007"); data.setInvoiceNumber("HHA-2021-007");

View File

@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
# Enable browser launch in development # Enable browser launch in development
vaadin.launch-browser=true vaadin.launch-browser=true
# Sessions über Neustarts persistieren (eingeloggt bleiben nach DevTools-Restart).
# Hinweis: Nach inkompatiblen Code-Änderungen kann beim Start eine harmlose
# SESSIONS.ser-Deserialisierungs-Warnung im Log erscheinen.
server.servlet.session.persistent=true
# Development logging levels # Development logging levels
logging.level.de.assecutor.votianlt=DEBUG logging.level.de.assecutor.votianlt=DEBUG
logging.level.root=INFO logging.level.root=INFO

View File

@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
# Disable browser launch in production # Disable browser launch in production
vaadin.launch-browser=false vaadin.launch-browser=false
# Sessions über Neustarts persistieren: Nutzer bleiben nach einem
# Deployment/Neustart eingeloggt. Nach inkompatiblen Code-Änderungen verwirft
# Tomcat die alte SESSIONS.ser mit einer harmlosen Log-Warnung.
server.servlet.session.persistent=true
# 2FA Configuration - Enabled for production # 2FA Configuration - Enabled for production
app.security.two-factor.enabled=true app.security.two-factor.enabled=true

View File

@@ -1,5 +1,8 @@
server.port=${PORT:8082} server.port=${PORT:8082}
server.address=0.0.0.0 server.address=0.0.0.0
# Sessions standardmäßig nicht über Neustarts persistieren; nur im dev-Profil aktiviert
# (siehe application-dev.properties)
server.servlet.session.persistent=false
# Default active profile # Default active profile
spring.profiles.active=dev spring.profiles.active=dev
@@ -72,22 +75,15 @@ app.version=@project.version@
# Google Maps API Key # Google Maps API Key
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
# =========================================== # PDF Tool (ZUGFeRD-E-Rechnungs-Konverter), Basis-URL des externen Service
# LLM Configuration (LM Studio) app.pdftool.base-url=${PDFTOOL_BASE_URL:http://localhost:8083}
# ===========================================
app.ai.lmstudio.base-url=${LMSTUDIO_URL}
app.ai.lmstudio.model=${LMSTUDIO_MODEL}
app.ai.lmstudio.htaccess-username=${LMSTUDIO_HTACCESS_USERNAME}
app.ai.lmstudio.htaccess-password=${LMSTUDIO_HTACCESS_PASSWORD}
# Spring AI OpenAI properties (Pflicht für Auto-Configuration, werden vom LlmRestClient überschrieben) # ===========================================
spring.ai.openai.base-url=${LMSTUDIO_URL} # LLM Configuration (Anthropic Claude)
spring.ai.openai.api-key=not-used # ===========================================
spring.ai.openai.chat.options.model=${LMSTUDIO_MODEL} # API-Key kann per Umgebungsvariable ANTHROPIC_API_KEY ueberschrieben werden
spring.ai.openai.chat.options.temperature=0.7 app.ai.anthropic.api-key=${ANTHROPIC_API_KEY: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}
spring.ai.openai.chat.options.stream=false app.ai.anthropic.model=${ANTHROPIC_MODEL:claude-opus-4-8}
spring.ai.openai.connect-timeout=10s
spring.ai.openai.read-timeout=120s
# =========================================== # ===========================================
# MCP Server Configuration # MCP Server Configuration

View File

@@ -241,7 +241,7 @@ page.title.dashboard=VotianLT - Dashboard
page.title.appuser.create=Neuen App-Nutzer anlegen page.title.appuser.create=Neuen App-Nutzer anlegen
page.title.messages=Nachrichten page.title.messages=Nachrichten
page.title.register=Bei VotianLT registrieren page.title.register=Bei VotianLT registrieren
page.title.customers=Kunden page.title.customers=Adressbuch
page.title.customer.edit=Adresse bearbeiten page.title.customer.edit=Adresse bearbeiten
page.title.verwaltung=Verwaltung page.title.verwaltung=Verwaltung
page.title.company.create=Neue Firma anlegen page.title.company.create=Neue Firma anlegen
@@ -249,7 +249,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Profil bearbeiten page.title.profile.edit=Profil bearbeiten
page.title.admin.dashboard=Admin Dashboard page.title.admin.dashboard=Admin Dashboard
page.title.invoice.create=Rechnung erstellen page.title.invoice.create=Rechnung erstellen
page.title.customer.create=Neuen Kunden anlegen page.title.customer.create=Neue Adresse anlegen
page.title.login=Bei VotianLT anmelden page.title.login=Bei VotianLT anmelden
page.title.jobs=Aufträge page.title.jobs=Aufträge
page.title.appuser.edit=App-Nutzer bearbeiten page.title.appuser.edit=App-Nutzer bearbeiten
@@ -328,9 +328,9 @@ editappuser.dialog.delete.text=Möchten Sie diesen App-Nutzer wirklich löschen?
editappuser.dialog.delete.confirm=Löschen editappuser.dialog.delete.confirm=Löschen
# Customers # Customers
customers.title=Kunden customers.title=Adressbuch
customers.button.add=Neuen Kunden hinzufügen customers.button.add=Neue Adresse anlegen
customers.hint.click=Klicken Sie auf einen Kunden, um Details zu sehen customers.hint.click=Klicken Sie auf einen Adresseintrag, um Details zu sehen
customers.column.company=Firma customers.column.company=Firma
customers.column.name=Name customers.column.name=Name
customers.column.email=E-Mail customers.column.email=E-Mail
@@ -349,10 +349,11 @@ editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
editcustomer.dialog.delete.confirm=Löschen editcustomer.dialog.delete.confirm=Löschen
# Add Customer # Add Customer
addcustomer.title=Neuen Kunden anlegen addcustomer.title=Neue Adresse anlegen
addcustomer.button.submit=Kunden anlegen addcustomer.button.submit=Adresse anlegen
addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus
addcustomer.notification.success=Kunde erfolgreich angelegt addcustomer.notification.success=Kunde erfolgreich angelegt
addcustomer.notification.duplicate=Ein identischer Adressbucheintrag existiert bereits
addcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben addcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
addcustomer.notification.error=Fehler: {0} addcustomer.notification.error=Fehler: {0}
addcustomer.validation.required=Dieses Feld ist erforderlich addcustomer.validation.required=Dieses Feld ist erforderlich
@@ -1051,8 +1052,104 @@ misc.retry=Erneut versuchen
# Admin Price Table # Admin Price Table
adminpricetable.title=Preistabelle adminpricetable.title=Preistabelle
adminpricetable.field.monthly=Monatliches Basispaket adminpricetable.field.monthly=Monatliches Basispaket
adminpricetable.field.applicense=App-Nutzungslizenz adminpricetable.field.appuserfee=Gebühr pro App-Nutzer
adminpricetable.field.revenue=Umsatzbeteiligung adminpricetable.field.appuserfee.helper=Wird je eingerichtetem App-Nutzer monatlich berechnet
adminpricetable.notification.saved=Preistabelle wurde gespeichert adminpricetable.notification.saved=Preistabelle wurde gespeichert
adminpricetable.notification.save.error=Fehler beim Speichern: {0} adminpricetable.notification.save.error=Fehler beim Speichern: {0}
adminpricetable.notification.load.error=Fehler beim Laden: {0} adminpricetable.notification.load.error=Fehler beim Laden: {0}
# Rechnungsgenerator - System-Template (Rechnungen an Nutzer)
invoicegenerator.issuer.header=Rechnungssteller
invoicegenerator.issuer.website=Webseite
invoicegenerator.issuer.senderline=Absenderzeile
invoicegenerator.issuer.paymentterms=Zahlungsbedingungen
invoicegenerator.issuer.footer=Fußzeile
invoicegenerator.issuer.invoicedate=Rechnungsdatum
invoicegenerator.recipient.header=Empfänger (Nutzer)
invoicegenerator.recipient.company=Nutzer-Firma
invoicegenerator.recipient.name=Nutzer-Name
invoicegenerator.recipient.street=Nutzer-Straße
invoicegenerator.recipient.city=Nutzer-PLZ/Ort
invoicegenerator.recipient.email=Nutzer-E-Mail
invoicegenerator.template.saved=Template gespeichert
invoicegenerator.template.save.error=Fehler beim Speichern des Templates: {0}
invoicegenerator.button.clear=Leeren
invoicegenerator.notification.cleared=Canvas geleert
invoicegenerator.notification.canvas.error=Canvas-Daten konnten nicht gelesen werden
invoicegenerator.notification.preview.error=Fehler bei der Vorschau: {0}
invoicegenerator.pdf.preview.title=PDF-Vorschau
# Admin Dashboard - Kundenübersicht
admindashboard.section.customers=Kunden ({0})
admindashboard.customers.column.number=Kundennummer
admindashboard.customers.column.owner=Nutzer
admindashboard.customers.empty=Keine Kunden vorhanden
invoicegenerator.positions.header=Positionen (Preistabelle)
invoicegenerator.positions.list=Positionen auflisten
button.undo=Rückgängig
# Admin-Profil (Firmendaten)
page.title.admin.profile=Firmendaten bearbeiten
adminprofile.title=Firmendaten
adminprofile.field.companyname=Firmenname
adminprofile.field.companysubtitle=Firmenzusatz
adminprofile.field.street=Straße und Hausnummer
adminprofile.field.city=PLZ und Ort
adminprofile.field.phone=Telefon
adminprofile.field.fax=Fax
adminprofile.field.email=E-Mail
adminprofile.field.website=Webseite
adminprofile.field.senderline=Absenderzeile
adminprofile.field.senderline.helper=Einzeilige Absenderangabe über der Empfängeradresse auf Rechnungen
adminprofile.field.paymentterms=Zahlungsbedingungen
adminprofile.field.footer=Fußzeile
adminprofile.field.footer.helper=Geschäftsführer, Steuernummer, Bankverbindung jede Zeile erscheint als eigene Zeile auf der Rechnung
adminprofile.notification.saved=Firmendaten gespeichert
adminprofile.notification.save.error=Fehler beim Speichern: {0}
adminprofile.notification.load.error=Fehler beim Laden: {0}
# Rechnungen erstellen (Admin)
page.title.admin.createinvoices=Rechnungen erstellen
admincreateinvoices.title=Rechnungen erstellen
admincreateinvoices.hint=Rechnungen werden zum Monatsende (Ultimo) fällig. Beginnt ein Kunde im laufenden Monat, werden nur die verbleibenden Tage bis Ultimo berechnet.
admincreateinvoices.column.customer=Kunde
admincreateinvoices.column.discount=Rabatt
admincreateinvoices.discount.position=Rabatt ({0} %)
admincreateinvoices.discount.dialog.title=Rabatt erfassen
admincreateinvoices.discount.dialog.percent=Rabatt in %
admincreateinvoices.discount.dialog.reason=Grund für den Rabatt
admincreateinvoices.discount.dialog.apply=Übernehmen
admincreateinvoices.column.start=Kunde seit
admincreateinvoices.column.days=Abgerechnete Tage
admincreateinvoices.column.due=Fällig am
admincreateinvoices.column.net=Nettobetrag
admincreateinvoices.days.full=Voller Monat
admincreateinvoices.days.partial={0} von {1} Tagen
admincreateinvoices.prorata.suffix=(anteilig {0}/{1} Tage)
admincreateinvoices.appusers.suffix=({0} App-Nutzer)
admincreateinvoices.button.create=Rechnung erstellen
admincreateinvoices.notification.notemplate=Kein Rechnungstemplate vorhanden. Bitte zuerst im Rechnungsgenerator ein Template speichern.
admincreateinvoices.notification.error=Fehler beim Erstellen der Rechnung: {0}
admincreateinvoices.button.createselected=Rechnungen erstellen ({0})
admincreateinvoices.field.month=Abrechnungsmonat
admincreateinvoices.filter.label=Anzeigen
admincreateinvoices.filter.all=Alle Rechnungen
admincreateinvoices.filter.open=Nur offene
admincreateinvoices.filter.sent=Nur gesendete
admincreateinvoices.dialog.title=Rechnungen prüfen
admincreateinvoices.dialog.position=Rechnung {0} von {1}
admincreateinvoices.dialog.button.previous=Zurück
admincreateinvoices.dialog.button.next=Weiter
admincreateinvoices.dialog.button.delete=Aus Liste entfernen
admincreateinvoices.dialog.button.send=Rechnungen senden
admincreateinvoices.pdf.button.send=Senden
admincreateinvoices.dialog.notification.removed=Rechnung aus der Liste entfernt
admincreateinvoices.mail.subject=Ihre Rechnung {0}
admincreateinvoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} für den Abrechnungszeitraum {1}.\n\nMit freundlichen Grüßen\n{2}
admincreateinvoices.notification.sent={0} Rechnung(en) per E-Mail versendet
admincreateinvoices.notification.sendfailed={0} Rechnung(en) konnten nicht versendet werden
admincreateinvoices.button.zugferd=E-Rechnung (ZIP)
admincreateinvoices.notification.zugferd.created=E-Rechnung {0} erstellt
admincreateinvoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
admincreateinvoices.notification.zugferd.error=Fehler beim Erstellen der E-Rechnung: {0}
admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positionen für die E-Rechnung vorhanden

View File

@@ -213,7 +213,7 @@ page.title.dashboard=VotianLT - T\u00f6\u00f6laud
page.title.appuser.create=Uue \u00e4pikasutaja loomine page.title.appuser.create=Uue \u00e4pikasutaja loomine
page.title.messages=S\u00f5numid page.title.messages=S\u00f5numid
page.title.register=VotianLT-sse registreerimine page.title.register=VotianLT-sse registreerimine
page.title.customers=Kliendid page.title.customers=Aadressiraamat
page.title.customer.edit=Kliendi muutmine page.title.customer.edit=Kliendi muutmine
page.title.verwaltung=Haldus page.title.verwaltung=Haldus
page.title.company.create=Uue ettev\u00f5tte loomine page.title.company.create=Uue ettev\u00f5tte loomine
@@ -221,7 +221,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Profiili muutmine page.title.profile.edit=Profiili muutmine
page.title.admin.dashboard=Administraatori t\u00f6\u00f6laud page.title.admin.dashboard=Administraatori t\u00f6\u00f6laud
page.title.invoice.create=Arve koostamine page.title.invoice.create=Arve koostamine
page.title.customer.create=Uue kliendi loomine page.title.customer.create=Uue aadressi loomine
page.title.login=VotianLT-sse sisselogimine page.title.login=VotianLT-sse sisselogimine
page.title.jobs=Tellimused page.title.jobs=Tellimused
page.title.appuser.edit=\u00c4pikasutaja muutmine page.title.appuser.edit=\u00c4pikasutaja muutmine
@@ -292,9 +292,9 @@ editappuser.notification.password.enter=Palun sisestage uus parool
editappuser.notification.deleted=\u00c4pikasutaja edukalt kustutatud editappuser.notification.deleted=\u00c4pikasutaja edukalt kustutatud
editappuser.dialog.delete.text=Kas soovite selle \u00e4pikasutaja t\u00f5esti kustutada? editappuser.dialog.delete.text=Kas soovite selle \u00e4pikasutaja t\u00f5esti kustutada?
editappuser.dialog.delete.confirm=Kustuta editappuser.dialog.delete.confirm=Kustuta
customers.title=Kliendid customers.title=Aadressiraamat
customers.button.add=Lisa uus klient customers.button.add=Lisa uus aadress
customers.hint.click=Kl\u00f5psake kliendil, et n\u00e4ha \u00fcksikasju customers.hint.click=Kl\u00f5psake aadressikirjel, et n\u00e4ha \u00fcksikasju
customers.column.company=Ettev\u00f5te customers.column.company=Ettev\u00f5te
customers.column.name=Nimi customers.column.name=Nimi
customers.column.email=E-post customers.column.email=E-post
@@ -309,10 +309,11 @@ editcustomer.notification.check=Palun kontrollige oma sisestusi
editcustomer.notification.deleted=Klient edukalt kustutatud editcustomer.notification.deleted=Klient edukalt kustutatud
editcustomer.dialog.delete.text=Kas soovite selle kliendi t\u00f5esti kustutada? editcustomer.dialog.delete.text=Kas soovite selle kliendi t\u00f5esti kustutada?
editcustomer.dialog.delete.confirm=Kustuta editcustomer.dialog.delete.confirm=Kustuta
addcustomer.title=Uue kliendi loomine addcustomer.title=Uue aadressi loomine
addcustomer.button.submit=Loo klient addcustomer.button.submit=Loo aadress
addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad
addcustomer.notification.success=Klient edukalt loodud addcustomer.notification.success=Klient edukalt loodud
addcustomer.notification.duplicate=Identne aadressiraamatu kirje on juba olemas
addcustomer.notification.check=Palun kontrollige oma sisestusi addcustomer.notification.check=Palun kontrollige oma sisestusi
addcustomer.notification.error=Viga: {0} addcustomer.notification.error=Viga: {0}
addcustomer.validation.required=See v\u00e4li on kohustuslik addcustomer.validation.required=See v\u00e4li on kohustuslik
@@ -881,8 +882,104 @@ misc.error=Ilmnes viga
misc.retry=Proovi uuesti misc.retry=Proovi uuesti
adminpricetable.title=Hinnatabel adminpricetable.title=Hinnatabel
adminpricetable.field.monthly=Igakuine p\u00f5hipakett adminpricetable.field.monthly=Igakuine p\u00f5hipakett
adminpricetable.field.applicense=\u00c4pi kasutuslitsents adminpricetable.field.appuserfee=Tasu äpi kasutaja kohta
adminpricetable.field.revenue=K\u00e4ibest osalus adminpricetable.field.appuserfee.helper=Arvestatakse igakuiselt iga seadistatud äpi kasutaja kohta
adminpricetable.notification.saved=Hinnatabel on salvestatud adminpricetable.notification.saved=Hinnatabel on salvestatud
adminpricetable.notification.save.error=Salvestamisel ilmnes viga: {0} adminpricetable.notification.save.error=Salvestamisel ilmnes viga: {0}
adminpricetable.notification.load.error=Laadimisel ilmnes viga: {0} adminpricetable.notification.load.error=Laadimisel ilmnes viga: {0}
# Arvegeneraator - süsteemi mall (arved kasutajatele)
invoicegenerator.issuer.header=Arve väljastaja
invoicegenerator.issuer.website=Veebileht
invoicegenerator.issuer.senderline=Saatja rida
invoicegenerator.issuer.paymentterms=Maksetingimused
invoicegenerator.issuer.footer=Jalus
invoicegenerator.issuer.invoicedate=Arve kuupäev
invoicegenerator.recipient.header=Saaja (kasutaja)
invoicegenerator.recipient.company=Kasutaja firma
invoicegenerator.recipient.name=Kasutaja nimi
invoicegenerator.recipient.street=Kasutaja tänav
invoicegenerator.recipient.city=Kasutaja linn
invoicegenerator.recipient.email=Kasutaja e-post
invoicegenerator.template.saved=Mall salvestatud
invoicegenerator.template.save.error=Viga malli salvestamisel: {0}
invoicegenerator.button.clear=Tühjenda
invoicegenerator.notification.cleared=Lõuend tühjendatud
invoicegenerator.notification.canvas.error=Lõuendi andmeid ei õnnestunud lugeda
invoicegenerator.notification.preview.error=Eelvaate viga: {0}
invoicegenerator.pdf.preview.title=PDF-i eelvaade
# Admin Dashboard - Klientide ülevaade
admindashboard.section.customers=Kliendid ({0})
admindashboard.customers.column.number=Kliendinumber
admindashboard.customers.column.owner=Kasutaja
admindashboard.customers.empty=Kliente pole
invoicegenerator.positions.header=Positsioonid (hinnatabel)
invoicegenerator.positions.list=Positsioonide loend
button.undo=Võta tagasi
# Admini profiil (ettevõtte andmed)
page.title.admin.profile=Ettevõtte andmete muutmine
adminprofile.title=Ettevõtte andmed
adminprofile.field.companyname=Ettevõtte nimi
adminprofile.field.companysubtitle=Ettevõtte lisand
adminprofile.field.street=Tänav ja majanumber
adminprofile.field.city=Sihtnumber ja linn
adminprofile.field.phone=Telefon
adminprofile.field.fax=Faks
adminprofile.field.email=E-post
adminprofile.field.website=Veebileht
adminprofile.field.senderline=Saatja rida
adminprofile.field.senderline.helper=Üherealine saatja teave arve saaja aadressi kohal
adminprofile.field.paymentterms=Maksetingimused
adminprofile.field.footer=Jalus
adminprofile.field.footer.helper=Tegevjuhid, maksunumber, pangaandmed iga rida kuvatakse arvel eraldi reana
adminprofile.notification.saved=Ettevõtte andmed salvestatud
adminprofile.notification.save.error=Viga salvestamisel: {0}
adminprofile.notification.load.error=Viga laadimisel: {0}
# Arvete koostamine (admin)
page.title.admin.createinvoices=Arvete koostamine
admincreateinvoices.title=Arvete koostamine
admincreateinvoices.hint=Arved kuuluvad tasumisele kuu lõpus. Kui klient alustab kuu keskel, arvestatakse ainult kuu lõpuni jäänud päevad.
admincreateinvoices.column.customer=Klient
admincreateinvoices.column.discount=Allahindlus
admincreateinvoices.discount.position=Allahindlus ({0}%)
admincreateinvoices.discount.dialog.title=Sisesta allahindlus
admincreateinvoices.discount.dialog.percent=Allahindlus (%)
admincreateinvoices.discount.dialog.reason=Allahindluse põhjus
admincreateinvoices.discount.dialog.apply=Rakenda
admincreateinvoices.column.start=Klient alates
admincreateinvoices.column.days=Arvestatud päevad
admincreateinvoices.column.due=Tähtaeg
admincreateinvoices.column.net=Netosumma
admincreateinvoices.days.full=Terve kuu
admincreateinvoices.days.partial={0} / {1} päeva
admincreateinvoices.prorata.suffix=(proportsionaalselt {0}/{1} päeva)
admincreateinvoices.appusers.suffix=({0} äpi kasutajat)
admincreateinvoices.button.create=Koosta arve
admincreateinvoices.notification.notemplate=Arve mall puudub. Palun salvestage kõigepealt mall arvegeneraatoris.
admincreateinvoices.notification.error=Viga arve koostamisel: {0}
admincreateinvoices.button.createselected=Koosta arved ({0})
admincreateinvoices.field.month=Arveldusperiood
admincreateinvoices.filter.label=Kuva
admincreateinvoices.filter.all=Kõik arved
admincreateinvoices.filter.open=Ainult avatud
admincreateinvoices.filter.sent=Ainult saadetud
admincreateinvoices.dialog.title=Arvete ülevaatus
admincreateinvoices.dialog.position=Arve {0} / {1}
admincreateinvoices.dialog.button.previous=Tagasi
admincreateinvoices.dialog.button.next=Edasi
admincreateinvoices.dialog.button.delete=Eemalda loendist
admincreateinvoices.dialog.button.send=Saada arved
admincreateinvoices.pdf.button.send=Saada
admincreateinvoices.dialog.notification.removed=Arve eemaldati loendist
admincreateinvoices.mail.subject=Teie arve {0}
admincreateinvoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} arveldusperioodi {1} eest.\n\nLugupidamisega\n{2}
admincreateinvoices.notification.sent={0} arve(t) saadeti e-postiga
admincreateinvoices.notification.sendfailed={0} arve(t) ei õnnestunud saata
admincreateinvoices.button.zugferd=E-arve (ZIP)
admincreateinvoices.notification.zugferd.created=E-arve {0} loodud
admincreateinvoices.notification.zugferd.invalid=E-arve on loodud, kuid valideerimine ebaõnnestus. Üksikasjad on ZIP-failis olevas valideerimisaruandes.
admincreateinvoices.notification.zugferd.error=Viga e-arve loomisel: {0}
admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arveldatavad positsioonid

View File

@@ -241,7 +241,7 @@ page.title.dashboard=VotianLT - Dashboard
page.title.appuser.create=Create New App User page.title.appuser.create=Create New App User
page.title.messages=Messages page.title.messages=Messages
page.title.register=Register at VotianLT page.title.register=Register at VotianLT
page.title.customers=Customers page.title.customers=Address Book
page.title.customer.edit=Edit Customer page.title.customer.edit=Edit Customer
page.title.verwaltung=Management page.title.verwaltung=Management
page.title.company.create=Create New Company page.title.company.create=Create New Company
@@ -249,7 +249,7 @@ page.title.imprint=Imprint
page.title.profile.edit=Edit Profile page.title.profile.edit=Edit Profile
page.title.admin.dashboard=Admin Dashboard page.title.admin.dashboard=Admin Dashboard
page.title.invoice.create=Create Invoice page.title.invoice.create=Create Invoice
page.title.customer.create=Create New Customer page.title.customer.create=Create New Address
page.title.login=Log In to VotianLT page.title.login=Log In to VotianLT
page.title.jobs=Jobs page.title.jobs=Jobs
page.title.appuser.edit=Edit App User page.title.appuser.edit=Edit App User
@@ -328,9 +328,9 @@ editappuser.dialog.delete.text=Are you sure you want to delete this app user?
editappuser.dialog.delete.confirm=Delete editappuser.dialog.delete.confirm=Delete
# Customers # Customers
customers.title=Customers customers.title=Address Book
customers.button.add=Add New Customer customers.button.add=Add New Address
customers.hint.click=Click on a customer to see details customers.hint.click=Click on an address entry to see details
customers.column.company=Company customers.column.company=Company
customers.column.name=Name customers.column.name=Name
customers.column.email=Email customers.column.email=Email
@@ -349,10 +349,11 @@ editcustomer.dialog.delete.text=Are you sure you want to delete this customer?
editcustomer.dialog.delete.confirm=Delete editcustomer.dialog.delete.confirm=Delete
# Add Customer # Add Customer
addcustomer.title=Create New Customer addcustomer.title=Create New Address
addcustomer.button.submit=Create Customer addcustomer.button.submit=Create Address
addcustomer.notification.validation=Please fill in all required fields addcustomer.notification.validation=Please fill in all required fields
addcustomer.notification.success=Customer created successfully addcustomer.notification.success=Customer created successfully
addcustomer.notification.duplicate=An identical address book entry already exists
addcustomer.notification.check=Please check your input addcustomer.notification.check=Please check your input
addcustomer.notification.error=Error: {0} addcustomer.notification.error=Error: {0}
addcustomer.validation.required=This field is required addcustomer.validation.required=This field is required
@@ -1051,8 +1052,104 @@ misc.retry=Retry
# Admin Price Table # Admin Price Table
adminpricetable.title=Price Table adminpricetable.title=Price Table
adminpricetable.field.monthly=Monthly Base Package adminpricetable.field.monthly=Monthly Base Package
adminpricetable.field.applicense=App Usage License adminpricetable.field.appuserfee=Fee per App User
adminpricetable.field.revenue=Revenue Share adminpricetable.field.appuserfee.helper=Billed monthly per app user set up
adminpricetable.notification.saved=Price table has been saved adminpricetable.notification.saved=Price table has been saved
adminpricetable.notification.save.error=Error saving: {0} adminpricetable.notification.save.error=Error saving: {0}
adminpricetable.notification.load.error=Error loading: {0} adminpricetable.notification.load.error=Error loading: {0}
# Invoice generator - system template (invoices to users)
invoicegenerator.issuer.header=Invoice issuer
invoicegenerator.issuer.website=Website
invoicegenerator.issuer.senderline=Sender line
invoicegenerator.issuer.paymentterms=Payment terms
invoicegenerator.issuer.footer=Footer
invoicegenerator.issuer.invoicedate=Invoice date
invoicegenerator.recipient.header=Recipient (user)
invoicegenerator.recipient.company=User company
invoicegenerator.recipient.name=User name
invoicegenerator.recipient.street=User street
invoicegenerator.recipient.city=User city
invoicegenerator.recipient.email=User email
invoicegenerator.template.saved=Template saved
invoicegenerator.template.save.error=Error saving template: {0}
invoicegenerator.button.clear=Clear
invoicegenerator.notification.cleared=Canvas cleared
invoicegenerator.notification.canvas.error=Could not read canvas data
invoicegenerator.notification.preview.error=Preview error: {0}
invoicegenerator.pdf.preview.title=PDF preview
# Admin Dashboard - Customer overview
admindashboard.section.customers=Customers ({0})
admindashboard.customers.column.number=Customer number
admindashboard.customers.column.owner=User
admindashboard.customers.empty=No customers available
invoicegenerator.positions.header=Positions (price table)
invoicegenerator.positions.list=List positions
button.undo=Undo
# Admin profile (company data)
page.title.admin.profile=Edit company data
adminprofile.title=Company data
adminprofile.field.companyname=Company name
adminprofile.field.companysubtitle=Company addition
adminprofile.field.street=Street and number
adminprofile.field.city=ZIP and city
adminprofile.field.phone=Phone
adminprofile.field.fax=Fax
adminprofile.field.email=Email
adminprofile.field.website=Website
adminprofile.field.senderline=Sender line
adminprofile.field.senderline.helper=Single-line sender information above the recipient address on invoices
adminprofile.field.paymentterms=Payment terms
adminprofile.field.footer=Footer
adminprofile.field.footer.helper=Managing directors, tax number, bank details each line appears as its own line on the invoice
adminprofile.notification.saved=Company data saved
adminprofile.notification.save.error=Error while saving: {0}
adminprofile.notification.load.error=Error while loading: {0}
# Create invoices (admin)
page.title.admin.createinvoices=Create invoices
admincreateinvoices.title=Create invoices
admincreateinvoices.hint=Invoices are due at the end of the month. If a customer starts mid-month, only the remaining days until the end of the month are billed.
admincreateinvoices.column.customer=Customer
admincreateinvoices.column.discount=Discount
admincreateinvoices.discount.position=Discount ({0}%)
admincreateinvoices.discount.dialog.title=Enter discount
admincreateinvoices.discount.dialog.percent=Discount in %
admincreateinvoices.discount.dialog.reason=Reason for the discount
admincreateinvoices.discount.dialog.apply=Apply
admincreateinvoices.column.start=Customer since
admincreateinvoices.column.days=Billed days
admincreateinvoices.column.due=Due on
admincreateinvoices.column.net=Net amount
admincreateinvoices.days.full=Full month
admincreateinvoices.days.partial={0} of {1} days
admincreateinvoices.prorata.suffix=(pro rata {0}/{1} days)
admincreateinvoices.appusers.suffix=({0} app users)
admincreateinvoices.button.create=Create invoice
admincreateinvoices.notification.notemplate=No invoice template available. Please save a template in the invoice generator first.
admincreateinvoices.notification.error=Error creating the invoice: {0}
admincreateinvoices.button.createselected=Create invoices ({0})
admincreateinvoices.field.month=Billing month
admincreateinvoices.filter.label=Show
admincreateinvoices.filter.all=All invoices
admincreateinvoices.filter.open=Only open
admincreateinvoices.filter.sent=Only sent
admincreateinvoices.dialog.title=Review invoices
admincreateinvoices.dialog.position=Invoice {0} of {1}
admincreateinvoices.dialog.button.previous=Back
admincreateinvoices.dialog.button.next=Next
admincreateinvoices.dialog.button.delete=Remove from list
admincreateinvoices.dialog.button.send=Send invoices
admincreateinvoices.pdf.button.send=Send
admincreateinvoices.dialog.notification.removed=Invoice removed from the list
admincreateinvoices.mail.subject=Your invoice {0}
admincreateinvoices.mail.body=Dear Sir or Madam,\n\nplease find attached your invoice {0} for the billing period {1}.\n\nKind regards\n{2}
admincreateinvoices.notification.sent={0} invoice(s) sent by email
admincreateinvoices.notification.sendfailed={0} invoice(s) could not be sent
admincreateinvoices.button.zugferd=E-invoice (ZIP)
admincreateinvoices.notification.zugferd.created=E-invoice {0} created
admincreateinvoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
admincreateinvoices.notification.zugferd.error=Error creating the e-invoice: {0}
admincreateinvoices.notification.zugferd.nopositions=No billable positions available for the e-invoice

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Panel de control
page.title.appuser.create=Crear nuevo usuario de la app page.title.appuser.create=Crear nuevo usuario de la app
page.title.messages=Mensajes page.title.messages=Mensajes
page.title.register=Registrarse en VotianLT page.title.register=Registrarse en VotianLT
page.title.customers=Clientes page.title.customers=Libreta de direcciones
page.title.customer.edit=Editar cliente page.title.customer.edit=Editar cliente
page.title.verwaltung=Administraci\u00f3n page.title.verwaltung=Administraci\u00f3n
page.title.company.create=Crear nueva empresa page.title.company.create=Crear nueva empresa
@@ -248,7 +248,7 @@ page.title.imprint=Aviso legal
page.title.profile.edit=Editar perfil page.title.profile.edit=Editar perfil
page.title.admin.dashboard=Panel de administraci\u00f3n page.title.admin.dashboard=Panel de administraci\u00f3n
page.title.invoice.create=Crear factura page.title.invoice.create=Crear factura
page.title.customer.create=Crear nuevo cliente page.title.customer.create=Crear nueva dirección
page.title.login=Iniciar sesi\u00f3n en VotianLT page.title.login=Iniciar sesi\u00f3n en VotianLT
page.title.jobs=Pedidos page.title.jobs=Pedidos
page.title.appuser.edit=Editar usuario de la app page.title.appuser.edit=Editar usuario de la app
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=\u00bfDesea realmente eliminar este usuario de la
editappuser.dialog.delete.confirm=Eliminar editappuser.dialog.delete.confirm=Eliminar
# Customers # Customers
customers.title=Clientes customers.title=Libreta de direcciones
customers.button.add=A\u00f1adir nuevo cliente customers.button.add=A\u00f1adir nueva direcci\u00f3n
customers.hint.click=Haga clic en un cliente para ver los detalles customers.hint.click=Haga clic en una entrada de direcci\u00f3n para ver los detalles
customers.column.company=Empresa customers.column.company=Empresa
customers.column.name=Nombre customers.column.name=Nombre
customers.column.email=Correo electr\u00f3nico customers.column.email=Correo electr\u00f3nico
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=\u00bfDesea realmente eliminar este cliente?
editcustomer.dialog.delete.confirm=Eliminar editcustomer.dialog.delete.confirm=Eliminar
# Add Customer # Add Customer
addcustomer.title=Crear nuevo cliente addcustomer.title=Crear nueva dirección
addcustomer.button.submit=Crear cliente addcustomer.button.submit=Crear dirección
addcustomer.notification.validation=Por favor, complete todos los campos obligatorios addcustomer.notification.validation=Por favor, complete todos los campos obligatorios
addcustomer.notification.success=Cliente creado correctamente addcustomer.notification.success=Cliente creado correctamente
addcustomer.notification.duplicate=Ya existe una entrada idéntica en la libreta de direcciones
addcustomer.notification.check=Por favor, revise sus datos addcustomer.notification.check=Por favor, revise sus datos
addcustomer.notification.error=Error: {0} addcustomer.notification.error=Error: {0}
addcustomer.validation.required=Este campo es obligatorio addcustomer.validation.required=Este campo es obligatorio
@@ -982,8 +983,104 @@ misc.retry=Reintentar
# Admin Price Table # Admin Price Table
adminpricetable.title=Tabla de precios adminpricetable.title=Tabla de precios
adminpricetable.field.monthly=Paquete b\u00e1sico mensual adminpricetable.field.monthly=Paquete b\u00e1sico mensual
adminpricetable.field.applicense=Licencia de uso de la app adminpricetable.field.appuserfee=Tarifa por usuario de la app
adminpricetable.field.revenue=Participaci\u00f3n en los ingresos adminpricetable.field.appuserfee.helper=Se factura mensualmente por cada usuario de la app configurado
adminpricetable.notification.saved=La tabla de precios ha sido guardada adminpricetable.notification.saved=La tabla de precios ha sido guardada
adminpricetable.notification.save.error=Error al guardar: {0} adminpricetable.notification.save.error=Error al guardar: {0}
adminpricetable.notification.load.error=Error al cargar: {0} adminpricetable.notification.load.error=Error al cargar: {0}
# Generador de facturas - plantilla del sistema (facturas a usuarios)
invoicegenerator.issuer.header=Emisor de la factura
invoicegenerator.issuer.website=Sitio web
invoicegenerator.issuer.senderline=Línea de remitente
invoicegenerator.issuer.paymentterms=Condiciones de pago
invoicegenerator.issuer.footer=Pie de página
invoicegenerator.issuer.invoicedate=Fecha de la factura
invoicegenerator.recipient.header=Destinatario (usuario)
invoicegenerator.recipient.company=Empresa del usuario
invoicegenerator.recipient.name=Nombre del usuario
invoicegenerator.recipient.street=Calle del usuario
invoicegenerator.recipient.city=Ciudad del usuario
invoicegenerator.recipient.email=Correo del usuario
invoicegenerator.template.saved=Plantilla guardada
invoicegenerator.template.save.error=Error al guardar la plantilla: {0}
invoicegenerator.button.clear=Vaciar
invoicegenerator.notification.cleared=Lienzo vaciado
invoicegenerator.notification.canvas.error=No se pudieron leer los datos del lienzo
invoicegenerator.notification.preview.error=Error de vista previa: {0}
invoicegenerator.pdf.preview.title=Vista previa del PDF
# Admin Dashboard - Resumen de clientes
admindashboard.section.customers=Clientes ({0})
admindashboard.customers.column.number=Número de cliente
admindashboard.customers.column.owner=Usuario
admindashboard.customers.empty=No hay clientes
invoicegenerator.positions.header=Posiciones (tabla de precios)
invoicegenerator.positions.list=Listar posiciones
button.undo=Deshacer
# Perfil de administrador (datos de la empresa)
page.title.admin.profile=Editar datos de la empresa
adminprofile.title=Datos de la empresa
adminprofile.field.companyname=Nombre de la empresa
adminprofile.field.companysubtitle=Complemento de la empresa
adminprofile.field.street=Calle y número
adminprofile.field.city=Código postal y ciudad
adminprofile.field.phone=Teléfono
adminprofile.field.fax=Fax
adminprofile.field.email=Correo electrónico
adminprofile.field.website=Sitio web
adminprofile.field.senderline=Línea de remitente
adminprofile.field.senderline.helper=Información del remitente en una línea sobre la dirección del destinatario en las facturas
adminprofile.field.paymentterms=Condiciones de pago
adminprofile.field.footer=Pie de página
adminprofile.field.footer.helper=Gerentes, número fiscal, datos bancarios cada línea aparece como línea propia en la factura
adminprofile.notification.saved=Datos de la empresa guardados
adminprofile.notification.save.error=Error al guardar: {0}
adminprofile.notification.load.error=Error al cargar: {0}
# Crear facturas (admin)
page.title.admin.createinvoices=Crear facturas
admincreateinvoices.title=Crear facturas
admincreateinvoices.hint=Las facturas vencen a fin de mes. Si un cliente comienza a mitad de mes, solo se facturan los días restantes hasta fin de mes.
admincreateinvoices.column.customer=Cliente
admincreateinvoices.column.discount=Descuento
admincreateinvoices.discount.position=Descuento ({0} %)
admincreateinvoices.discount.dialog.title=Registrar descuento
admincreateinvoices.discount.dialog.percent=Descuento en %
admincreateinvoices.discount.dialog.reason=Motivo del descuento
admincreateinvoices.discount.dialog.apply=Aplicar
admincreateinvoices.column.start=Cliente desde
admincreateinvoices.column.days=Días facturados
admincreateinvoices.column.due=Vence el
admincreateinvoices.column.net=Importe neto
admincreateinvoices.days.full=Mes completo
admincreateinvoices.days.partial={0} de {1} días
admincreateinvoices.prorata.suffix=(prorrateado {0}/{1} días)
admincreateinvoices.appusers.suffix=({0} usuarios de la app)
admincreateinvoices.button.create=Crear factura
admincreateinvoices.notification.notemplate=No hay plantilla de factura. Guarde primero una plantilla en el generador de facturas.
admincreateinvoices.notification.error=Error al crear la factura: {0}
admincreateinvoices.button.createselected=Crear facturas ({0})
admincreateinvoices.field.month=Mes de facturación
admincreateinvoices.filter.label=Mostrar
admincreateinvoices.filter.all=Todas las facturas
admincreateinvoices.filter.open=Solo abiertas
admincreateinvoices.filter.sent=Solo enviadas
admincreateinvoices.dialog.title=Revisar facturas
admincreateinvoices.dialog.position=Factura {0} de {1}
admincreateinvoices.dialog.button.previous=Atrás
admincreateinvoices.dialog.button.next=Siguiente
admincreateinvoices.dialog.button.delete=Quitar de la lista
admincreateinvoices.dialog.button.send=Enviar facturas
admincreateinvoices.pdf.button.send=Enviar
admincreateinvoices.dialog.notification.removed=Factura eliminada de la lista
admincreateinvoices.mail.subject=Su factura {0}
admincreateinvoices.mail.body=Estimados señores,\n\nadjunto encontrará su factura {0} correspondiente al período de facturación {1}.\n\nAtentamente\n{2}
admincreateinvoices.notification.sent={0} factura(s) enviada(s) por correo electrónico
admincreateinvoices.notification.sendfailed=No se pudieron enviar {0} factura(s)
admincreateinvoices.button.zugferd=Factura electrónica (ZIP)
admincreateinvoices.notification.zugferd.created=Factura electrónica {0} creada
admincreateinvoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
admincreateinvoices.notification.zugferd.error=Error al crear la factura electrónica: {0}
admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturables disponibles para la factura electrónica

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Tableau de bord
page.title.appuser.create=Cr\u00e9er un nouvel utilisateur d'app page.title.appuser.create=Cr\u00e9er un nouvel utilisateur d'app
page.title.messages=Messages page.title.messages=Messages
page.title.register=S'inscrire sur VotianLT page.title.register=S'inscrire sur VotianLT
page.title.customers=Clients page.title.customers=Carnet d'adresses
page.title.customer.edit=Modifier le client page.title.customer.edit=Modifier le client
page.title.verwaltung=Administration page.title.verwaltung=Administration
page.title.company.create=Cr\u00e9er une nouvelle entreprise page.title.company.create=Cr\u00e9er une nouvelle entreprise
@@ -248,7 +248,7 @@ page.title.imprint=Mentions l\u00e9gales
page.title.profile.edit=Modifier le profil page.title.profile.edit=Modifier le profil
page.title.admin.dashboard=Tableau de bord administrateur page.title.admin.dashboard=Tableau de bord administrateur
page.title.invoice.create=Cr\u00e9er une facture page.title.invoice.create=Cr\u00e9er une facture
page.title.customer.create=Cr\u00e9er un nouveau client page.title.customer.create=Cr\u00e9er une nouvelle adresse
page.title.login=Se connecter \u00e0 VotianLT page.title.login=Se connecter \u00e0 VotianLT
page.title.jobs=Missions page.title.jobs=Missions
page.title.appuser.edit=Modifier l'utilisateur d'app page.title.appuser.edit=Modifier l'utilisateur d'app
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Voulez-vous vraiment supprimer cet utilisateur d'
editappuser.dialog.delete.confirm=Supprimer editappuser.dialog.delete.confirm=Supprimer
# Customers # Customers
customers.title=Clients customers.title=Carnet d'adresses
customers.button.add=Ajouter un nouveau client customers.button.add=Ajouter une nouvelle adresse
customers.hint.click=Cliquez sur un client pour voir les d\u00e9tails customers.hint.click=Cliquez sur une entr\u00e9e d'adresse pour voir les d\u00e9tails
customers.column.company=Entreprise customers.column.company=Entreprise
customers.column.name=Nom customers.column.name=Nom
customers.column.email=E-mail customers.column.email=E-mail
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Voulez-vous vraiment supprimer ce client ?
editcustomer.dialog.delete.confirm=Supprimer editcustomer.dialog.delete.confirm=Supprimer
# Add Customer # Add Customer
addcustomer.title=Cr\u00e9er un nouveau client addcustomer.title=Cr\u00e9er une nouvelle adresse
addcustomer.button.submit=Cr\u00e9er le client addcustomer.button.submit=Cr\u00e9er l'adresse
addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires
addcustomer.notification.success=Client cr\u00e9\u00e9 avec succ\u00e8s addcustomer.notification.success=Client cr\u00e9\u00e9 avec succ\u00e8s
addcustomer.notification.duplicate=Une entr\u00e9e identique existe d\u00e9j\u00e0 dans le carnet d'adresses
addcustomer.notification.check=Veuillez v\u00e9rifier vos saisies addcustomer.notification.check=Veuillez v\u00e9rifier vos saisies
addcustomer.notification.error=Erreur : {0} addcustomer.notification.error=Erreur : {0}
addcustomer.validation.required=Ce champ est obligatoire addcustomer.validation.required=Ce champ est obligatoire
@@ -982,8 +983,104 @@ misc.retry=R\u00e9essayer
# Admin Price Table # Admin Price Table
adminpricetable.title=Tableau des prix adminpricetable.title=Tableau des prix
adminpricetable.field.monthly=Forfait mensuel de base adminpricetable.field.monthly=Forfait mensuel de base
adminpricetable.field.applicense=Licence d'utilisation de l'app adminpricetable.field.appuserfee=Frais par utilisateur de l'app
adminpricetable.field.revenue=Participation au chiffre d'affaires adminpricetable.field.appuserfee.helper=Facturé mensuellement par utilisateur de l'app configuré
adminpricetable.notification.saved=Le tableau des prix a \u00e9t\u00e9 enregistr\u00e9 adminpricetable.notification.saved=Le tableau des prix a \u00e9t\u00e9 enregistr\u00e9
adminpricetable.notification.save.error=Erreur lors de l'enregistrement : {0} adminpricetable.notification.save.error=Erreur lors de l'enregistrement : {0}
adminpricetable.notification.load.error=Erreur lors du chargement : {0} adminpricetable.notification.load.error=Erreur lors du chargement : {0}
# Générateur de factures - modèle système (factures aux utilisateurs)
invoicegenerator.issuer.header=Émetteur de la facture
invoicegenerator.issuer.website=Site web
invoicegenerator.issuer.senderline=Ligne d'expéditeur
invoicegenerator.issuer.paymentterms=Conditions de paiement
invoicegenerator.issuer.footer=Pied de page
invoicegenerator.issuer.invoicedate=Date de la facture
invoicegenerator.recipient.header=Destinataire (utilisateur)
invoicegenerator.recipient.company=Société de l'utilisateur
invoicegenerator.recipient.name=Nom de l'utilisateur
invoicegenerator.recipient.street=Rue de l'utilisateur
invoicegenerator.recipient.city=Ville de l'utilisateur
invoicegenerator.recipient.email=E-mail de l'utilisateur
invoicegenerator.template.saved=Modèle enregistré
invoicegenerator.template.save.error=Erreur lors de l''enregistrement du modèle : {0}
invoicegenerator.button.clear=Vider
invoicegenerator.notification.cleared=Canevas vidé
invoicegenerator.notification.canvas.error=Impossible de lire les données du canevas
invoicegenerator.notification.preview.error=Erreur d''aperçu : {0}
invoicegenerator.pdf.preview.title=Aperçu PDF
# Admin Dashboard - Aperçu des clients
admindashboard.section.customers=Clients ({0})
admindashboard.customers.column.number=Numéro de client
admindashboard.customers.column.owner=Utilisateur
admindashboard.customers.empty=Aucun client
invoicegenerator.positions.header=Positions (grille tarifaire)
invoicegenerator.positions.list=Lister les positions
button.undo=Annuler
# Profil administrateur (données de l'entreprise)
page.title.admin.profile=Modifier les données de l'entreprise
adminprofile.title=Données de l'entreprise
adminprofile.field.companyname=Nom de l'entreprise
adminprofile.field.companysubtitle=Complément de l'entreprise
adminprofile.field.street=Rue et numéro
adminprofile.field.city=Code postal et ville
adminprofile.field.phone=Téléphone
adminprofile.field.fax=Fax
adminprofile.field.email=E-mail
adminprofile.field.website=Site web
adminprofile.field.senderline=Ligne d'expéditeur
adminprofile.field.senderline.helper=Mention de l'expéditeur sur une ligne au-dessus de l'adresse du destinataire sur les factures
adminprofile.field.paymentterms=Conditions de paiement
adminprofile.field.footer=Pied de page
adminprofile.field.footer.helper=Gérants, numéro fiscal, coordonnées bancaires chaque ligne apparaît comme une ligne distincte sur la facture
adminprofile.notification.saved=Données de l'entreprise enregistrées
adminprofile.notification.save.error=Erreur lors de l'enregistrement : {0}
adminprofile.notification.load.error=Erreur lors du chargement : {0}
# Créer des factures (admin)
page.title.admin.createinvoices=Créer des factures
admincreateinvoices.title=Créer des factures
admincreateinvoices.hint=Les factures sont exigibles en fin de mois. Si un client commence en cours de mois, seuls les jours restants jusqu'à la fin du mois sont facturés.
admincreateinvoices.column.customer=Client
admincreateinvoices.column.discount=Remise
admincreateinvoices.discount.position=Remise ({0} %)
admincreateinvoices.discount.dialog.title=Saisir la remise
admincreateinvoices.discount.dialog.percent=Remise en %
admincreateinvoices.discount.dialog.reason=Motif de la remise
admincreateinvoices.discount.dialog.apply=Appliquer
admincreateinvoices.column.start=Client depuis
admincreateinvoices.column.days=Jours facturés
admincreateinvoices.column.due=Échéance le
admincreateinvoices.column.net=Montant net
admincreateinvoices.days.full=Mois complet
admincreateinvoices.days.partial={0} sur {1} jours
admincreateinvoices.prorata.suffix=(au prorata {0}/{1} jours)
admincreateinvoices.appusers.suffix=({0} utilisateurs de l'app)
admincreateinvoices.button.create=Créer la facture
admincreateinvoices.notification.notemplate=Aucun modèle de facture disponible. Veuillez d'abord enregistrer un modèle dans le générateur de factures.
admincreateinvoices.notification.error=Erreur lors de la création de la facture : {0}
admincreateinvoices.button.createselected=Créer les factures ({0})
admincreateinvoices.field.month=Mois de facturation
admincreateinvoices.filter.label=Afficher
admincreateinvoices.filter.all=Toutes les factures
admincreateinvoices.filter.open=Uniquement ouvertes
admincreateinvoices.filter.sent=Uniquement envoyées
admincreateinvoices.dialog.title=Vérifier les factures
admincreateinvoices.dialog.position=Facture {0} sur {1}
admincreateinvoices.dialog.button.previous=Retour
admincreateinvoices.dialog.button.next=Suivant
admincreateinvoices.dialog.button.delete=Retirer de la liste
admincreateinvoices.dialog.button.send=Envoyer les factures
admincreateinvoices.pdf.button.send=Envoyer
admincreateinvoices.dialog.notification.removed=Facture retirée de la liste
admincreateinvoices.mail.subject=Votre facture {0}
admincreateinvoices.mail.body=Madame, Monsieur,\n\nveuillez trouver ci-joint votre facture {0} pour la période de facturation {1}.\n\nCordialement\n{2}
admincreateinvoices.notification.sent={0} facture(s) envoyée(s) par e-mail
admincreateinvoices.notification.sendfailed={0} facture(s) n''ont pas pu être envoyées
admincreateinvoices.button.zugferd=Facture électronique (ZIP)
admincreateinvoices.notification.zugferd.created=Facture électronique {0} créée
admincreateinvoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Détails dans le rapport de validation du fichier ZIP.
admincreateinvoices.notification.zugferd.error=Erreur lors de la création de la facture électronique : {0}
admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable disponible pour la facture électronique

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Valdymo skydas
page.title.appuser.create=Sukurti naują programėlės naudotoją page.title.appuser.create=Sukurti naują programėlės naudotoją
page.title.messages=Žinutės page.title.messages=Žinutės
page.title.register=Registracija VotianLT page.title.register=Registracija VotianLT
page.title.customers=Klientai page.title.customers=Adresų knyga
page.title.customer.edit=Redaguoti klientą page.title.customer.edit=Redaguoti klientą
page.title.verwaltung=Administravimas page.title.verwaltung=Administravimas
page.title.company.create=Sukurti naują įmonę page.title.company.create=Sukurti naują įmonę
@@ -248,7 +248,7 @@ page.title.imprint=Informacija
page.title.profile.edit=Redaguoti profilį page.title.profile.edit=Redaguoti profilį
page.title.admin.dashboard=Administratoriaus valdymo skydas page.title.admin.dashboard=Administratoriaus valdymo skydas
page.title.invoice.create=Sukurti sąskaitą page.title.invoice.create=Sukurti sąskaitą
page.title.customer.create=Sukurti naują klientą page.title.customer.create=Sukurti naują adresą
page.title.login=Prisijungti prie VotianLT page.title.login=Prisijungti prie VotianLT
page.title.jobs=Užsakymai page.title.jobs=Užsakymai
page.title.appuser.edit=Redaguoti programėlės naudotoją page.title.appuser.edit=Redaguoti programėlės naudotoją
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Ar tikrai norite ištrinti šį programėlės nau
editappuser.dialog.delete.confirm=Ištrinti editappuser.dialog.delete.confirm=Ištrinti
# Customers # Customers
customers.title=Klientai customers.title=Adresų knyga
customers.button.add=Pridėti naują klientą customers.button.add=Pridėti naują adresą
customers.hint.click=Spustelėkite klientą, kad pamatytumėte informaciją customers.hint.click=Spustelėkite adreso įrašą, kad pamatytumėte informaciją
customers.column.company=Įmonė customers.column.company=Įmonė
customers.column.name=Pavadinimas customers.column.name=Pavadinimas
customers.column.email=El. paštas customers.column.email=El. paštas
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Ar tikrai norite ištrinti šį klientą?
editcustomer.dialog.delete.confirm=Ištrinti editcustomer.dialog.delete.confirm=Ištrinti
# Add Customer # Add Customer
addcustomer.title=Sukurti naują klientą addcustomer.title=Sukurti naują adresą
addcustomer.button.submit=Sukurti klientą addcustomer.button.submit=Sukurti adresą
addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus
addcustomer.notification.success=Klientas sėkmingai sukurtas addcustomer.notification.success=Klientas sėkmingai sukurtas
addcustomer.notification.duplicate=Identiškas adresų knygos įrašas jau egzistuoja
addcustomer.notification.check=Prašome patikrinti savo įvestį addcustomer.notification.check=Prašome patikrinti savo įvestį
addcustomer.notification.error=Klaida: {0} addcustomer.notification.error=Klaida: {0}
addcustomer.validation.required=Šis laukas yra privalomas addcustomer.validation.required=Šis laukas yra privalomas
@@ -984,8 +985,104 @@ misc.retry=Bandyti dar kartą
# Admin Price Table # Admin Price Table
adminpricetable.title=Kainų lentelė adminpricetable.title=Kainų lentelė
adminpricetable.field.monthly=Mėnesinis bazinis paketas adminpricetable.field.monthly=Mėnesinis bazinis paketas
adminpricetable.field.applicense=Programėlės naudojimo licencija adminpricetable.field.appuserfee=Mokestis už programėlės naudotoją
adminpricetable.field.revenue=Pajamų dalis adminpricetable.field.appuserfee.helper=Skaičiuojamas kas mėnesį už kiekvieną sukurtą programėlės naudotoją
adminpricetable.notification.saved=Kainų lentelė išsaugota adminpricetable.notification.saved=Kainų lentelė išsaugota
adminpricetable.notification.save.error=Klaida išsaugant: {0} adminpricetable.notification.save.error=Klaida išsaugant: {0}
adminpricetable.notification.load.error=Klaida įkeliant: {0} adminpricetable.notification.load.error=Klaida įkeliant: {0}
# Sąskaitų generatorius - sistemos šablonas (sąskaitos naudotojams)
invoicegenerator.issuer.header=Sąskaitos išrašytojas
invoicegenerator.issuer.website=Svetainė
invoicegenerator.issuer.senderline=Siuntėjo eilutė
invoicegenerator.issuer.paymentterms=Mokėjimo sąlygos
invoicegenerator.issuer.footer=Poraštė
invoicegenerator.issuer.invoicedate=Sąskaitos data
invoicegenerator.recipient.header=Gavėjas (naudotojas)
invoicegenerator.recipient.company=Naudotojo įmonė
invoicegenerator.recipient.name=Naudotojo vardas
invoicegenerator.recipient.street=Naudotojo gatvė
invoicegenerator.recipient.city=Naudotojo miestas
invoicegenerator.recipient.email=Naudotojo el. paštas
invoicegenerator.template.saved=Šablonas išsaugotas
invoicegenerator.template.save.error=Klaida išsaugant šabloną: {0}
invoicegenerator.button.clear=Išvalyti
invoicegenerator.notification.cleared=Drobė išvalyta
invoicegenerator.notification.canvas.error=Nepavyko nuskaityti drobės duomenų
invoicegenerator.notification.preview.error=Peržiūros klaida: {0}
invoicegenerator.pdf.preview.title=PDF peržiūra
# Admin Dashboard - Klientų apžvalga
admindashboard.section.customers=Klientai ({0})
admindashboard.customers.column.number=Kliento numeris
admindashboard.customers.column.owner=Naudotojas
admindashboard.customers.empty=Klientų nėra
invoicegenerator.positions.header=Pozicijos (kainų lentelė)
invoicegenerator.positions.list=Pozicijų sąrašas
button.undo=Anuliuoti
# Administratoriaus profilis (įmonės duomenys)
page.title.admin.profile=Redaguoti įmonės duomenis
adminprofile.title=Įmonės duomenys
adminprofile.field.companyname=Įmonės pavadinimas
adminprofile.field.companysubtitle=Įmonės priedas
adminprofile.field.street=Gatvė ir namo numeris
adminprofile.field.city=Pašto kodas ir miestas
adminprofile.field.phone=Telefonas
adminprofile.field.fax=Faksas
adminprofile.field.email=El. paštas
adminprofile.field.website=Svetainė
adminprofile.field.senderline=Siuntėjo eilutė
adminprofile.field.senderline.helper=Vienos eilutės siuntėjo informacija virš gavėjo adreso sąskaitose
adminprofile.field.paymentterms=Mokėjimo sąlygos
adminprofile.field.footer=Poraštė
adminprofile.field.footer.helper=Vadovai, mokesčių numeris, banko rekvizitai kiekviena eilutė sąskaitoje rodoma atskirai
adminprofile.notification.saved=Įmonės duomenys išsaugoti
adminprofile.notification.save.error=Klaida išsaugant: {0}
adminprofile.notification.load.error=Klaida įkeliant: {0}
# Sąskaitų kūrimas (admin)
page.title.admin.createinvoices=Kurti sąskaitas
admincreateinvoices.title=Kurti sąskaitas
admincreateinvoices.hint=Sąskaitos apmokamos mėnesio pabaigoje. Jei klientas pradeda mėnesio viduryje, apmokestinamos tik likusios dienos iki mėnesio pabaigos.
admincreateinvoices.column.customer=Klientas
admincreateinvoices.column.discount=Nuolaida
admincreateinvoices.discount.position=Nuolaida ({0} %)
admincreateinvoices.discount.dialog.title=Įvesti nuolaidą
admincreateinvoices.discount.dialog.percent=Nuolaida %
admincreateinvoices.discount.dialog.reason=Nuolaidos priežastis
admincreateinvoices.discount.dialog.apply=Taikyti
admincreateinvoices.column.start=Klientas nuo
admincreateinvoices.column.days=Apmokestintos dienos
admincreateinvoices.column.due=Terminas
admincreateinvoices.column.net=Neto suma
admincreateinvoices.days.full=Visas mėnuo
admincreateinvoices.days.partial={0} iš {1} dienų
admincreateinvoices.prorata.suffix=(proporcingai {0}/{1} dienų)
admincreateinvoices.appusers.suffix=({0} programėlės naudotojų)
admincreateinvoices.button.create=Sukurti sąskaitą
admincreateinvoices.notification.notemplate=Nėra sąskaitos šablono. Pirmiausia išsaugokite šabloną sąskaitų generatoriuje.
admincreateinvoices.notification.error=Klaida kuriant sąskaitą: {0}
admincreateinvoices.button.createselected=Sukurti sąskaitas ({0})
admincreateinvoices.field.month=Atsiskaitymo mėnuo
admincreateinvoices.filter.label=Rodyti
admincreateinvoices.filter.all=Visos sąskaitos
admincreateinvoices.filter.open=Tik atviros
admincreateinvoices.filter.sent=Tik išsiųstos
admincreateinvoices.dialog.title=Peržiūrėti sąskaitas
admincreateinvoices.dialog.position=Sąskaita {0} iš {1}
admincreateinvoices.dialog.button.previous=Atgal
admincreateinvoices.dialog.button.next=Toliau
admincreateinvoices.dialog.button.delete=Pašalinti iš sąrašo
admincreateinvoices.dialog.button.send=Siųsti sąskaitas
admincreateinvoices.pdf.button.send=Siųsti
admincreateinvoices.dialog.notification.removed=Sąskaita pašalinta iš sąrašo
admincreateinvoices.mail.subject=Jūsų sąskaita {0}
admincreateinvoices.mail.body=Gerbiami klientai,\n\npridedame Jūsų sąskaitą {0} už atsiskaitymo laikotarpį {1}.\n\nPagarbiai\n{2}
admincreateinvoices.notification.sent=El. paštu išsiųsta sąskaitų: {0}
admincreateinvoices.notification.sendfailed=Nepavyko išsiųsti sąskaitų: {0}
admincreateinvoices.button.zugferd=E. sąskaita (ZIP)
admincreateinvoices.notification.zugferd.created=E. sąskaita {0} sukurta
admincreateinvoices.notification.zugferd.invalid=E. sąskaita sukurta, tačiau patikra nepavyko. Išsami informacija patikros ataskaitoje ZIP faile.
admincreateinvoices.notification.zugferd.error=Klaida kuriant e. sąskaitą: {0}
admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokestinamų pozicijų

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Informācijas panelis
page.title.appuser.create=Izveidot jaunu lietotnes lietotāju page.title.appuser.create=Izveidot jaunu lietotnes lietotāju
page.title.messages=Ziņojumi page.title.messages=Ziņojumi
page.title.register=Reģistrēties VotianLT page.title.register=Reģistrēties VotianLT
page.title.customers=Klienti page.title.customers=Adrešu grāmata
page.title.customer.edit=Rediģēt klientu page.title.customer.edit=Rediģēt klientu
page.title.verwaltung=Pārvaldība page.title.verwaltung=Pārvaldība
page.title.company.create=Izveidot jaunu uzņēmumu page.title.company.create=Izveidot jaunu uzņēmumu
@@ -248,7 +248,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Rediģēt profilu page.title.profile.edit=Rediģēt profilu
page.title.admin.dashboard=Administrācijas panelis page.title.admin.dashboard=Administrācijas panelis
page.title.invoice.create=Izveidot rēķinu page.title.invoice.create=Izveidot rēķinu
page.title.customer.create=Izveidot jaunu klientu page.title.customer.create=Izveidot jaunu adresi
page.title.login=Pieteikties VotianLT page.title.login=Pieteikties VotianLT
page.title.jobs=Uzdevumi page.title.jobs=Uzdevumi
page.title.appuser.edit=Rediģēt lietotnes lietotāju page.title.appuser.edit=Rediģēt lietotnes lietotāju
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Vai tiešām vēlaties dzēst šo lietotnes lieto
editappuser.dialog.delete.confirm=Dzēst editappuser.dialog.delete.confirm=Dzēst
# Customers # Customers
customers.title=Klienti customers.title=Adrešu grāmata
customers.button.add=Pievienot jaunu klientu customers.button.add=Pievienot jaunu adresi
customers.hint.click=Noklikšķiniet uz klienta, lai redzētu detaļas customers.hint.click=Noklikšķiniet uz adreses ieraksta, lai redzētu detaļas
customers.column.company=Uzņēmums customers.column.company=Uzņēmums
customers.column.name=Nosaukums customers.column.name=Nosaukums
customers.column.email=E-pasts customers.column.email=E-pasts
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Vai tiešām vēlaties dzēst šo klientu?
editcustomer.dialog.delete.confirm=Dzēst editcustomer.dialog.delete.confirm=Dzēst
# Add Customer # Add Customer
addcustomer.title=Izveidot jaunu klientu addcustomer.title=Izveidot jaunu adresi
addcustomer.button.submit=Izveidot klientu addcustomer.button.submit=Izveidot adresi
addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus
addcustomer.notification.success=Klients veiksmīgi izveidots addcustomer.notification.success=Klients veiksmīgi izveidots
addcustomer.notification.duplicate=Identisks adrešu grāmatas ieraksts jau pastāv
addcustomer.notification.check=Lūdzu, pārbaudiet savus ievadītos datus addcustomer.notification.check=Lūdzu, pārbaudiet savus ievadītos datus
addcustomer.notification.error=Kļūda: {0} addcustomer.notification.error=Kļūda: {0}
addcustomer.validation.required=Šis lauks ir obligāts addcustomer.validation.required=Šis lauks ir obligāts
@@ -982,8 +983,104 @@ misc.retry=Mēģināt vēlreiz
# Admin Price Table # Admin Price Table
adminpricetable.title=Cenu tabula adminpricetable.title=Cenu tabula
adminpricetable.field.monthly=Ikmēneša pamatpakete adminpricetable.field.monthly=Ikmēneša pamatpakete
adminpricetable.field.applicense=Lietotnes licence adminpricetable.field.appuserfee=Maksa par lietotnes lietotāju
adminpricetable.field.revenue=Ieņēmumu daļa adminpricetable.field.appuserfee.helper=Tiek aprēķināta katru mēnesi par katru izveidoto lietotnes lietotāju
adminpricetable.notification.saved=Cenu tabula saglabāta adminpricetable.notification.saved=Cenu tabula saglabāta
adminpricetable.notification.save.error=Kļūda saglabājot: {0} adminpricetable.notification.save.error=Kļūda saglabājot: {0}
adminpricetable.notification.load.error=Kļūda ielādējot: {0} adminpricetable.notification.load.error=Kļūda ielādējot: {0}
# Rēķinu ģenerators - sistēmas veidne (rēķini lietotājiem)
invoicegenerator.issuer.header=Rēķina izrakstītājs
invoicegenerator.issuer.website=Tīmekļa vietne
invoicegenerator.issuer.senderline=Sūtītāja rinda
invoicegenerator.issuer.paymentterms=Apmaksas nosacījumi
invoicegenerator.issuer.footer=Kājene
invoicegenerator.issuer.invoicedate=Rēķina datums
invoicegenerator.recipient.header=Saņēmējs (lietotājs)
invoicegenerator.recipient.company=Lietotāja uzņēmums
invoicegenerator.recipient.name=Lietotāja vārds
invoicegenerator.recipient.street=Lietotāja iela
invoicegenerator.recipient.city=Lietotāja pilsēta
invoicegenerator.recipient.email=Lietotāja e-pasts
invoicegenerator.template.saved=Veidne saglabāta
invoicegenerator.template.save.error=Kļūda saglabājot veidni: {0}
invoicegenerator.button.clear=Notīrīt
invoicegenerator.notification.cleared=Audekls notīrīts
invoicegenerator.notification.canvas.error=Neizdevās nolasīt audekla datus
invoicegenerator.notification.preview.error=Priekšskatījuma kļūda: {0}
invoicegenerator.pdf.preview.title=PDF priekšskatījums
# Admin Dashboard - Klientu pārskats
admindashboard.section.customers=Klienti ({0})
admindashboard.customers.column.number=Klienta numurs
admindashboard.customers.column.owner=Lietotājs
admindashboard.customers.empty=Nav klientu
invoicegenerator.positions.header=Pozīcijas (cenu tabula)
invoicegenerator.positions.list=Pozīciju saraksts
button.undo=Atsaukt
# Administratora profils (uzņēmuma dati)
page.title.admin.profile=Rediģēt uzņēmuma datus
adminprofile.title=Uzņēmuma dati
adminprofile.field.companyname=Uzņēmuma nosaukums
adminprofile.field.companysubtitle=Uzņēmuma papildinājums
adminprofile.field.street=Iela un mājas numurs
adminprofile.field.city=Pasta indekss un pilsēta
adminprofile.field.phone=Tālrunis
adminprofile.field.fax=Fakss
adminprofile.field.email=E-pasts
adminprofile.field.website=Tīmekļa vietne
adminprofile.field.senderline=Sūtītāja rinda
adminprofile.field.senderline.helper=Vienas rindas sūtītāja informācija virs saņēmēja adreses rēķinos
adminprofile.field.paymentterms=Maksājuma nosacījumi
adminprofile.field.footer=Kājene
adminprofile.field.footer.helper=Vadītāji, nodokļu numurs, bankas rekvizīti katra rinda rēķinā parādās atsevišķi
adminprofile.notification.saved=Uzņēmuma dati saglabāti
adminprofile.notification.save.error=Kļūda saglabājot: {0}
adminprofile.notification.load.error=Kļūda ielādējot: {0}
# Rēķinu izveide (admin)
page.title.admin.createinvoices=Izveidot rēķinus
admincreateinvoices.title=Izveidot rēķinus
admincreateinvoices.hint=Rēķini jāapmaksā mēneša beigās. Ja klients sāk mēneša vidū, tiek aprēķinātas tikai atlikušās dienas līdz mēneša beigām.
admincreateinvoices.column.customer=Klients
admincreateinvoices.column.discount=Atlaide
admincreateinvoices.discount.position=Atlaide ({0} %)
admincreateinvoices.discount.dialog.title=Ievadīt atlaidi
admincreateinvoices.discount.dialog.percent=Atlaide %
admincreateinvoices.discount.dialog.reason=Atlaides iemesls
admincreateinvoices.discount.dialog.apply=Piemērot
admincreateinvoices.column.start=Klients kopš
admincreateinvoices.column.days=Aprēķinātās dienas
admincreateinvoices.column.due=Termiņš
admincreateinvoices.column.net=Neto summa
admincreateinvoices.days.full=Pilns mēnesis
admincreateinvoices.days.partial={0} no {1} dienām
admincreateinvoices.prorata.suffix=(proporcionāli {0}/{1} dienas)
admincreateinvoices.appusers.suffix=({0} lietotnes lietotāji)
admincreateinvoices.button.create=Izveidot rēķinu
admincreateinvoices.notification.notemplate=Nav rēķina veidnes. Lūdzu, vispirms saglabājiet veidni rēķinu ģeneratorā.
admincreateinvoices.notification.error=Kļūda, veidojot rēķinu: {0}
admincreateinvoices.button.createselected=Izveidot rēķinus ({0})
admincreateinvoices.field.month=Norēķinu mēnesis
admincreateinvoices.filter.label=Rādīt
admincreateinvoices.filter.all=Visi rēķini
admincreateinvoices.filter.open=Tikai atvērtie
admincreateinvoices.filter.sent=Tikai nosūtītie
admincreateinvoices.dialog.title=Pārskatīt rēķinus
admincreateinvoices.dialog.position=Rēķins {0} no {1}
admincreateinvoices.dialog.button.previous=Atpakaļ
admincreateinvoices.dialog.button.next=Tālāk
admincreateinvoices.dialog.button.delete=Noņemt no saraksta
admincreateinvoices.dialog.button.send=Sūtīt rēķinus
admincreateinvoices.pdf.button.send=Sūtīt
admincreateinvoices.dialog.notification.removed=Rēķins noņemts no saraksta
admincreateinvoices.mail.subject=Jūsu rēķins {0}
admincreateinvoices.mail.body=Cienījamie klienti,\n\npielikumā ir Jūsu rēķins {0} par norēķinu periodu {1}.\n\nAr cieņu\n{2}
admincreateinvoices.notification.sent=Pa e-pastu nosūtīti rēķini: {0}
admincreateinvoices.notification.sendfailed=Neizdevās nosūtīt rēķinus: {0}
admincreateinvoices.button.zugferd=E-rēķins (ZIP)
admincreateinvoices.notification.zugferd.created=E-rēķins {0} izveidots
admincreateinvoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Detaļas skatiet validācijas atskaitē ZIP failā.
admincreateinvoices.notification.zugferd.error=Kļūda, veidojot e-rēķinu: {0}
admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu norēķinu pozīciju

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Panel g\u0142\u00f3wny
page.title.appuser.create=Dodaj nowego u\u017cytkownika aplikacji page.title.appuser.create=Dodaj nowego u\u017cytkownika aplikacji
page.title.messages=Wiadomo\u015bci page.title.messages=Wiadomo\u015bci
page.title.register=Rejestracja w VotianLT page.title.register=Rejestracja w VotianLT
page.title.customers=Klienci page.title.customers=Książka adresowa
page.title.customer.edit=Edytuj klienta page.title.customer.edit=Edytuj klienta
page.title.verwaltung=Zarz\u0105dzanie page.title.verwaltung=Zarz\u0105dzanie
page.title.company.create=Dodaj now\u0105 firm\u0119 page.title.company.create=Dodaj now\u0105 firm\u0119
@@ -248,7 +248,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Edytuj profil page.title.profile.edit=Edytuj profil
page.title.admin.dashboard=Panel administracyjny page.title.admin.dashboard=Panel administracyjny
page.title.invoice.create=Utw\u00f3rz faktur\u0119 page.title.invoice.create=Utw\u00f3rz faktur\u0119
page.title.customer.create=Dodaj nowego klienta page.title.customer.create=Dodaj nowy adres
page.title.login=Logowanie do VotianLT page.title.login=Logowanie do VotianLT
page.title.jobs=Zlecenia page.title.jobs=Zlecenia
page.title.appuser.edit=Edytuj u\u017cytkownika aplikacji page.title.appuser.edit=Edytuj u\u017cytkownika aplikacji
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Czy na pewno chcesz usun\u0105\u0107 tego u\u017c
editappuser.dialog.delete.confirm=Usu\u0144 editappuser.dialog.delete.confirm=Usu\u0144
# Customers # Customers
customers.title=Klienci customers.title=Ksi\u0105\u017cka adresowa
customers.button.add=Dodaj nowego klienta customers.button.add=Dodaj nowy adres
customers.hint.click=Kliknij klienta, aby zobaczy\u0107 szczeg\u00f3\u0142y customers.hint.click=Kliknij wpis adresowy, aby zobaczy\u0107 szczeg\u00f3\u0142y
customers.column.company=Firma customers.column.company=Firma
customers.column.name=Nazwa customers.column.name=Nazwa
customers.column.email=E-mail customers.column.email=E-mail
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Czy na pewno chcesz usun\u0105\u0107 tego klient
editcustomer.dialog.delete.confirm=Usu\u0144 editcustomer.dialog.delete.confirm=Usu\u0144
# Add Customer # Add Customer
addcustomer.title=Dodaj nowego klienta addcustomer.title=Dodaj nowy adres
addcustomer.button.submit=Dodaj klienta addcustomer.button.submit=Dodaj adres
addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola
addcustomer.notification.success=Klient zosta\u0142 pomy\u015blnie dodany addcustomer.notification.success=Klient zosta\u0142 pomy\u015blnie dodany
addcustomer.notification.duplicate=Identyczny wpis w ksi\u0105\u017cce adresowej ju\u017c istnieje
addcustomer.notification.check=Prosz\u0119 sprawdzi\u0107 wprowadzone dane addcustomer.notification.check=Prosz\u0119 sprawdzi\u0107 wprowadzone dane
addcustomer.notification.error=B\u0142\u0105d: {0} addcustomer.notification.error=B\u0142\u0105d: {0}
addcustomer.validation.required=To pole jest wymagane addcustomer.validation.required=To pole jest wymagane
@@ -982,8 +983,104 @@ misc.retry=Spr\u00f3buj ponownie
# Admin Price Table # Admin Price Table
adminpricetable.title=Cennik adminpricetable.title=Cennik
adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy
adminpricetable.field.applicense=Licencja aplikacji adminpricetable.field.appuserfee=Opłata za użytkownika aplikacji
adminpricetable.field.revenue=Udzia\u0142 w przychodach adminpricetable.field.appuserfee.helper=Naliczana miesięcznie za każdego skonfigurowanego użytkownika aplikacji
adminpricetable.notification.saved=Cennik zosta\u0142 zapisany adminpricetable.notification.saved=Cennik zosta\u0142 zapisany
adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {0} adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {0}
adminpricetable.notification.load.error=B\u0142\u0105d podczas \u0142adowania: {0} adminpricetable.notification.load.error=B\u0142\u0105d podczas \u0142adowania: {0}
# Generator faktur - szablon systemowy (faktury dla użytkowników)
invoicegenerator.issuer.header=Wystawca faktury
invoicegenerator.issuer.website=Strona internetowa
invoicegenerator.issuer.senderline=Linia nadawcy
invoicegenerator.issuer.paymentterms=Warunki płatności
invoicegenerator.issuer.footer=Stopka
invoicegenerator.issuer.invoicedate=Data faktury
invoicegenerator.recipient.header=Odbiorca (użytkownik)
invoicegenerator.recipient.company=Firma użytkownika
invoicegenerator.recipient.name=Nazwisko użytkownika
invoicegenerator.recipient.street=Ulica użytkownika
invoicegenerator.recipient.city=Miasto użytkownika
invoicegenerator.recipient.email=E-mail użytkownika
invoicegenerator.template.saved=Szablon zapisany
invoicegenerator.template.save.error=Błąd podczas zapisywania szablonu: {0}
invoicegenerator.button.clear=Wyczyść
invoicegenerator.notification.cleared=Obszar roboczy wyczyszczony
invoicegenerator.notification.canvas.error=Nie można odczytać danych obszaru roboczego
invoicegenerator.notification.preview.error=Błąd podglądu: {0}
invoicegenerator.pdf.preview.title=Podgląd PDF
# Admin Dashboard - Przegląd klientów
admindashboard.section.customers=Klienci ({0})
admindashboard.customers.column.number=Numer klienta
admindashboard.customers.column.owner=Użytkownik
admindashboard.customers.empty=Brak klientów
invoicegenerator.positions.header=Pozycje (cennik)
invoicegenerator.positions.list=Lista pozycji
button.undo=Cofnij
# Profil administratora (dane firmy)
page.title.admin.profile=Edytuj dane firmy
adminprofile.title=Dane firmy
adminprofile.field.companyname=Nazwa firmy
adminprofile.field.companysubtitle=Dodatek do nazwy
adminprofile.field.street=Ulica i numer
adminprofile.field.city=Kod pocztowy i miasto
adminprofile.field.phone=Telefon
adminprofile.field.fax=Faks
adminprofile.field.email=E-mail
adminprofile.field.website=Strona internetowa
adminprofile.field.senderline=Linia nadawcy
adminprofile.field.senderline.helper=Jednoliniowa informacja o nadawcy nad adresem odbiorcy na fakturach
adminprofile.field.paymentterms=Warunki płatności
adminprofile.field.footer=Stopka
adminprofile.field.footer.helper=Zarząd, numer podatkowy, dane bankowe każda linia pojawia się jako osobny wiersz na fakturze
adminprofile.notification.saved=Dane firmy zapisane
adminprofile.notification.save.error=Błąd podczas zapisywania: {0}
adminprofile.notification.load.error=Błąd podczas ładowania: {0}
# Tworzenie faktur (admin)
page.title.admin.createinvoices=Utwórz faktury
admincreateinvoices.title=Utwórz faktury
admincreateinvoices.hint=Faktury są płatne na koniec miesiąca. Jeśli klient rozpoczyna w trakcie miesiąca, rozliczane są tylko dni pozostałe do końca miesiąca.
admincreateinvoices.column.customer=Klient
admincreateinvoices.column.discount=Rabat
admincreateinvoices.discount.position=Rabat ({0} %)
admincreateinvoices.discount.dialog.title=Wprowadź rabat
admincreateinvoices.discount.dialog.percent=Rabat w %
admincreateinvoices.discount.dialog.reason=Powód rabatu
admincreateinvoices.discount.dialog.apply=Zastosuj
admincreateinvoices.column.start=Klient od
admincreateinvoices.column.days=Rozliczone dni
admincreateinvoices.column.due=Termin płatności
admincreateinvoices.column.net=Kwota netto
admincreateinvoices.days.full=Pełny miesiąc
admincreateinvoices.days.partial={0} z {1} dni
admincreateinvoices.prorata.suffix=(proporcjonalnie {0}/{1} dni)
admincreateinvoices.appusers.suffix=({0} użytkowników aplikacji)
admincreateinvoices.button.create=Utwórz fakturę
admincreateinvoices.notification.notemplate=Brak szablonu faktury. Najpierw zapisz szablon w generatorze faktur.
admincreateinvoices.notification.error=Błąd podczas tworzenia faktury: {0}
admincreateinvoices.button.createselected=Utwórz faktury ({0})
admincreateinvoices.field.month=Miesiąc rozliczeniowy
admincreateinvoices.filter.label=Pokaż
admincreateinvoices.filter.all=Wszystkie faktury
admincreateinvoices.filter.open=Tylko otwarte
admincreateinvoices.filter.sent=Tylko wysłane
admincreateinvoices.dialog.title=Przegląd faktur
admincreateinvoices.dialog.position=Faktura {0} z {1}
admincreateinvoices.dialog.button.previous=Wstecz
admincreateinvoices.dialog.button.next=Dalej
admincreateinvoices.dialog.button.delete=Usuń z listy
admincreateinvoices.dialog.button.send=Wyślij faktury
admincreateinvoices.pdf.button.send=Wyślij
admincreateinvoices.dialog.notification.removed=Faktura usunięta z listy
admincreateinvoices.mail.subject=Państwa faktura {0}
admincreateinvoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} za okres rozliczeniowy {1}.\n\nZ poważaniem\n{2}
admincreateinvoices.notification.sent=Wysłano e-mailem faktur: {0}
admincreateinvoices.notification.sendfailed=Nie udało się wysłać faktur: {0}
admincreateinvoices.button.zugferd=E-faktura (ZIP)
admincreateinvoices.notification.zugferd.created=Utworzono e-fakturę {0}
admincreateinvoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Szczegóły w raporcie walidacji w pliku ZIP.
admincreateinvoices.notification.zugferd.error=Błąd podczas tworzenia e-faktury: {0}
admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowych dla e-faktury

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Панель управления
page.title.appuser.create=Создать нового пользователя приложения page.title.appuser.create=Создать нового пользователя приложения
page.title.messages=Сообщения page.title.messages=Сообщения
page.title.register=Регистрация в VotianLT page.title.register=Регистрация в VotianLT
page.title.customers=Клиенты page.title.customers=Адресная книга
page.title.customer.edit=Редактирование клиента page.title.customer.edit=Редактирование клиента
page.title.verwaltung=Управление page.title.verwaltung=Управление
page.title.company.create=Создать новую компанию page.title.company.create=Создать новую компанию
@@ -248,7 +248,7 @@ page.title.imprint=Выходные данные
page.title.profile.edit=Редактирование профиля page.title.profile.edit=Редактирование профиля
page.title.admin.dashboard=Панель администратора page.title.admin.dashboard=Панель администратора
page.title.invoice.create=Создать счёт page.title.invoice.create=Создать счёт
page.title.customer.create=Создать нового клиента page.title.customer.create=Создать новый адрес
page.title.login=Вход в VotianLT page.title.login=Вход в VotianLT
page.title.jobs=Заказы page.title.jobs=Заказы
page.title.appuser.edit=Редактирование пользователя приложения page.title.appuser.edit=Редактирование пользователя приложения
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Вы действительно хотите уд
editappuser.dialog.delete.confirm=Удалить editappuser.dialog.delete.confirm=Удалить
# Customers # Customers
customers.title=Клиенты customers.title=Адресная книга
customers.button.add=Добавить нового клиента customers.button.add=Добавить новый адрес
customers.hint.click=Нажмите на клиента, чтобы увидеть подробности customers.hint.click=Нажмите на запись адреса, чтобы увидеть подробности
customers.column.company=Компания customers.column.company=Компания
customers.column.name=Имя customers.column.name=Имя
customers.column.email=Эл. почта customers.column.email=Эл. почта
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Вы действительно хотите у
editcustomer.dialog.delete.confirm=Удалить editcustomer.dialog.delete.confirm=Удалить
# Add Customer # Add Customer
addcustomer.title=Создать нового клиента addcustomer.title=Создать новый адрес
addcustomer.button.submit=Создать клиента addcustomer.button.submit=Создать адрес
addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля
addcustomer.notification.success=Клиент успешно создан addcustomer.notification.success=Клиент успешно создан
addcustomer.notification.duplicate=Идентичная запись в адресной книге уже существует
addcustomer.notification.check=Пожалуйста, проверьте ваши данные addcustomer.notification.check=Пожалуйста, проверьте ваши данные
addcustomer.notification.error=Ошибка: {0} addcustomer.notification.error=Ошибка: {0}
addcustomer.validation.required=Это поле обязательно для заполнения addcustomer.validation.required=Это поле обязательно для заполнения
@@ -982,8 +983,104 @@ misc.retry=Повторить попытку
# Admin Price Table # Admin Price Table
adminpricetable.title=Таблица цен adminpricetable.title=Таблица цен
adminpricetable.field.monthly=Ежемесячный базовый пакет adminpricetable.field.monthly=Ежемесячный базовый пакет
adminpricetable.field.applicense=Лицензия на приложение adminpricetable.field.appuserfee=Плата за пользователя приложения
adminpricetable.field.revenue=Участие в выручке adminpricetable.field.appuserfee.helper=Начисляется ежемесячно за каждого настроенного пользователя приложения
adminpricetable.notification.saved=Таблица цен сохранена adminpricetable.notification.saved=Таблица цен сохранена
adminpricetable.notification.save.error=Ошибка при сохранении: {0} adminpricetable.notification.save.error=Ошибка при сохранении: {0}
adminpricetable.notification.load.error=Ошибка при загрузке: {0} adminpricetable.notification.load.error=Ошибка при загрузке: {0}
# Генератор счетов - системный шаблон (счета пользователям)
invoicegenerator.issuer.header=Выставитель счёта
invoicegenerator.issuer.website=Веб-сайт
invoicegenerator.issuer.senderline=Строка отправителя
invoicegenerator.issuer.paymentterms=Условия оплаты
invoicegenerator.issuer.footer=Нижний колонтитул
invoicegenerator.issuer.invoicedate=Дата счёта
invoicegenerator.recipient.header=Получатель (пользователь)
invoicegenerator.recipient.company=Компания пользователя
invoicegenerator.recipient.name=Имя пользователя
invoicegenerator.recipient.street=Улица пользователя
invoicegenerator.recipient.city=Город пользователя
invoicegenerator.recipient.email=E-mail пользователя
invoicegenerator.template.saved=Шаблон сохранён
invoicegenerator.template.save.error=Ошибка при сохранении шаблона: {0}
invoicegenerator.button.clear=Очистить
invoicegenerator.notification.cleared=Холст очищен
invoicegenerator.notification.canvas.error=Не удалось прочитать данные холста
invoicegenerator.notification.preview.error=Ошибка предпросмотра: {0}
invoicegenerator.pdf.preview.title=Предпросмотр PDF
# Admin Dashboard - Обзор клиентов
admindashboard.section.customers=Клиенты ({0})
admindashboard.customers.column.number=Номер клиента
admindashboard.customers.column.owner=Пользователь
admindashboard.customers.empty=Клиенты отсутствуют
invoicegenerator.positions.header=Позиции (прайс-лист)
invoicegenerator.positions.list=Список позиций
button.undo=Отменить
# Профиль администратора (данные компании)
page.title.admin.profile=Редактировать данные компании
adminprofile.title=Данные компании
adminprofile.field.companyname=Название компании
adminprofile.field.companysubtitle=Дополнение к названию
adminprofile.field.street=Улица и номер дома
adminprofile.field.city=Индекс и город
adminprofile.field.phone=Телефон
adminprofile.field.fax=Факс
adminprofile.field.email=Эл. почта
adminprofile.field.website=Веб-сайт
adminprofile.field.senderline=Строка отправителя
adminprofile.field.senderline.helper=Однострочная информация об отправителе над адресом получателя в счетах
adminprofile.field.paymentterms=Условия оплаты
adminprofile.field.footer=Нижний колонтитул
adminprofile.field.footer.helper=Руководители, налоговый номер, банковские реквизиты каждая строка отображается отдельной строкой в счете
adminprofile.notification.saved=Данные компании сохранены
adminprofile.notification.save.error=Ошибка при сохранении: {0}
adminprofile.notification.load.error=Ошибка при загрузке: {0}
# Создание счетов (админ)
page.title.admin.createinvoices=Создать счета
admincreateinvoices.title=Создать счета
admincreateinvoices.hint=Счета подлежат оплате в конце месяца. Если клиент начинает в середине месяца, оплачиваются только оставшиеся дни до конца месяца.
admincreateinvoices.column.customer=Клиент
admincreateinvoices.column.discount=Скидка
admincreateinvoices.discount.position=Скидка ({0} %)
admincreateinvoices.discount.dialog.title=Указать скидку
admincreateinvoices.discount.dialog.percent=Скидка в %
admincreateinvoices.discount.dialog.reason=Причина скидки
admincreateinvoices.discount.dialog.apply=Применить
admincreateinvoices.column.start=Клиент с
admincreateinvoices.column.days=Расчетные дни
admincreateinvoices.column.due=Срок оплаты
admincreateinvoices.column.net=Сумма нетто
admincreateinvoices.days.full=Полный месяц
admincreateinvoices.days.partial={0} из {1} дней
admincreateinvoices.prorata.suffix=(пропорционально {0}/{1} дней)
admincreateinvoices.appusers.suffix=({0} пользователей приложения)
admincreateinvoices.button.create=Создать счет
admincreateinvoices.notification.notemplate=Шаблон счета отсутствует. Сначала сохраните шаблон в генераторе счетов.
admincreateinvoices.notification.error=Ошибка при создании счета: {0}
admincreateinvoices.button.createselected=Создать счета ({0})
admincreateinvoices.field.month=Расчетный месяц
admincreateinvoices.filter.label=Показать
admincreateinvoices.filter.all=Все счета
admincreateinvoices.filter.open=Только открытые
admincreateinvoices.filter.sent=Только отправленные
admincreateinvoices.dialog.title=Проверка счетов
admincreateinvoices.dialog.position=Счет {0} из {1}
admincreateinvoices.dialog.button.previous=Назад
admincreateinvoices.dialog.button.next=Далее
admincreateinvoices.dialog.button.delete=Удалить из списка
admincreateinvoices.dialog.button.send=Отправить счета
admincreateinvoices.pdf.button.send=Отправить
admincreateinvoices.dialog.notification.removed=Счет удален из списка
admincreateinvoices.mail.subject=Ваш счет {0}
admincreateinvoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Вы найдете счет {0} за расчетный период {1}.\n\nС уважением\n{2}
admincreateinvoices.notification.sent=Отправлено счетов по эл. почте: {0}
admincreateinvoices.notification.sendfailed=Не удалось отправить счетов: {0}
admincreateinvoices.button.zugferd=Электронный счёт (ZIP)
admincreateinvoices.notification.zugferd.created=Электронный счёт {0} создан
admincreateinvoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. Подробности в отчёте о проверке в ZIP-файле.
admincreateinvoices.notification.zugferd.error=Ошибка при создании электронного счёта: {0}
admincreateinvoices.notification.zugferd.nopositions=Нет позиций для выставления электронного счёта

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Kontrol Paneli
page.title.appuser.create=Yeni Uygulama Kullan\u0131c\u0131s\u0131 Olu\u015ftur page.title.appuser.create=Yeni Uygulama Kullan\u0131c\u0131s\u0131 Olu\u015ftur
page.title.messages=Mesajlar page.title.messages=Mesajlar
page.title.register=VotianLT'ye Kay\u0131t Ol page.title.register=VotianLT'ye Kay\u0131t Ol
page.title.customers=M\u00fc\u015fteriler page.title.customers=Adres defteri
page.title.customer.edit=M\u00fc\u015fteriyi D\u00fczenle page.title.customer.edit=M\u00fc\u015fteriyi D\u00fczenle
page.title.verwaltung=Y\u00f6netim page.title.verwaltung=Y\u00f6netim
page.title.company.create=Yeni \u015eirket Olu\u015ftur page.title.company.create=Yeni \u015eirket Olu\u015ftur
@@ -248,7 +248,7 @@ page.title.imprint=K\u00fcnye
page.title.profile.edit=Profili D\u00fczenle page.title.profile.edit=Profili D\u00fczenle
page.title.admin.dashboard=Y\u00f6netici Kontrol Paneli page.title.admin.dashboard=Y\u00f6netici Kontrol Paneli
page.title.invoice.create=Fatura Olu\u015ftur page.title.invoice.create=Fatura Olu\u015ftur
page.title.customer.create=Yeni M\u00fc\u015fteri Olu\u015ftur page.title.customer.create=Yeni Adres Olu\u015ftur
page.title.login=VotianLT'ye Giri\u015f Yap page.title.login=VotianLT'ye Giri\u015f Yap
page.title.jobs=\u0130\u015fler page.title.jobs=\u0130\u015fler
page.title.appuser.edit=Uygulama Kullan\u0131c\u0131s\u0131n\u0131 D\u00fczenle page.title.appuser.edit=Uygulama Kullan\u0131c\u0131s\u0131n\u0131 D\u00fczenle
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Bu uygulama kullan\u0131c\u0131s\u0131n\u0131 sil
editappuser.dialog.delete.confirm=Sil editappuser.dialog.delete.confirm=Sil
# Customers # Customers
customers.title=M\u00fc\u015fteriler customers.title=Adres defteri
customers.button.add=Yeni M\u00fc\u015fteri Ekle customers.button.add=Yeni Adres Ekle
customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir m\u00fc\u015fteriye t\u0131klay\u0131n customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir adres giri\u015fine t\u0131klay\u0131n
customers.column.company=\u015eirket customers.column.company=\u015eirket
customers.column.name=Ad customers.column.name=Ad
customers.column.email=E-Posta customers.column.email=E-Posta
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Bu m\u00fc\u015fteriyi silmek istedi\u011finizde
editcustomer.dialog.delete.confirm=Sil editcustomer.dialog.delete.confirm=Sil
# Add Customer # Add Customer
addcustomer.title=Yeni M\u00fc\u015fteri Olu\u015ftur addcustomer.title=Yeni Adres Olu\u015ftur
addcustomer.button.submit=M\u00fc\u015fteri Olu\u015ftur addcustomer.button.submit=Adres Olu\u015ftur
addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun
addcustomer.notification.success=M\u00fc\u015fteri ba\u015far\u0131yla olu\u015fturuldu addcustomer.notification.success=M\u00fc\u015fteri ba\u015far\u0131yla olu\u015fturuldu
addcustomer.notification.duplicate=Adres defterinde ayn\u0131 kay\u0131t zaten mevcut
addcustomer.notification.check=L\u00fctfen giri\u015flerinizi kontrol edin addcustomer.notification.check=L\u00fctfen giri\u015flerinizi kontrol edin
addcustomer.notification.error=Hata: {0} addcustomer.notification.error=Hata: {0}
addcustomer.validation.required=Bu alan gereklidir addcustomer.validation.required=Bu alan gereklidir
@@ -982,8 +983,104 @@ misc.retry=Tekrar Dene
# Admin Price Table # Admin Price Table
adminpricetable.title=Fiyat Tablosu adminpricetable.title=Fiyat Tablosu
adminpricetable.field.monthly=Ayl\u0131k Temel Paket adminpricetable.field.monthly=Ayl\u0131k Temel Paket
adminpricetable.field.applicense=Uygulama Kullan\u0131m Lisans\u0131 adminpricetable.field.appuserfee=Uygulama kullanıcısı başına ücret
adminpricetable.field.revenue=Gelir Pay\u0131 adminpricetable.field.appuserfee.helper=Kurulan her uygulama kullanıcısı için aylık olarak faturalandırılır
adminpricetable.notification.saved=Fiyat tablosu kaydedildi adminpricetable.notification.saved=Fiyat tablosu kaydedildi
adminpricetable.notification.save.error=Kaydetme hatas\u0131: {0} adminpricetable.notification.save.error=Kaydetme hatas\u0131: {0}
adminpricetable.notification.load.error=Y\u00fckleme hatas\u0131: {0} adminpricetable.notification.load.error=Y\u00fckleme hatas\u0131: {0}
# Fatura oluşturucu - sistem şablonu (kullanıcılara faturalar)
invoicegenerator.issuer.header=Fatura düzenleyen
invoicegenerator.issuer.website=Web sitesi
invoicegenerator.issuer.senderline=Gönderen satırı
invoicegenerator.issuer.paymentterms=Ödeme koşulları
invoicegenerator.issuer.footer=Alt bilgi
invoicegenerator.issuer.invoicedate=Fatura tarihi
invoicegenerator.recipient.header=Alıcı (kullanıcı)
invoicegenerator.recipient.company=Kullanıcı firması
invoicegenerator.recipient.name=Kullanıcı adı
invoicegenerator.recipient.street=Kullanıcı sokağı
invoicegenerator.recipient.city=Kullanıcı şehri
invoicegenerator.recipient.email=Kullanıcı e-postası
invoicegenerator.template.saved=Şablon kaydedildi
invoicegenerator.template.save.error=Şablon kaydedilirken hata: {0}
invoicegenerator.button.clear=Temizle
invoicegenerator.notification.cleared=Tuval temizlendi
invoicegenerator.notification.canvas.error=Tuval verileri okunamadı
invoicegenerator.notification.preview.error=Önizleme hatası: {0}
invoicegenerator.pdf.preview.title=PDF önizleme
# Admin Dashboard - Müşteri genel bakışı
admindashboard.section.customers=Müşteriler ({0})
admindashboard.customers.column.number=Müşteri numarası
admindashboard.customers.column.owner=Kullanıcı
admindashboard.customers.empty=Müşteri yok
invoicegenerator.positions.header=Kalemler (fiyat tablosu)
invoicegenerator.positions.list=Kalemleri listele
button.undo=Geri al
# Yönetici profili (şirket verileri)
page.title.admin.profile=Şirket verilerini düzenle
adminprofile.title=Şirket verileri
adminprofile.field.companyname=Şirket adı
adminprofile.field.companysubtitle=Şirket eki
adminprofile.field.street=Cadde ve numara
adminprofile.field.city=Posta kodu ve şehir
adminprofile.field.phone=Telefon
adminprofile.field.fax=Faks
adminprofile.field.email=E-posta
adminprofile.field.website=Web sitesi
adminprofile.field.senderline=Gönderen satırı
adminprofile.field.senderline.helper=Faturalarda alıcı adresinin üzerindeki tek satırlık gönderen bilgisi
adminprofile.field.paymentterms=Ödeme koşulları
adminprofile.field.footer=Alt bilgi
adminprofile.field.footer.helper=Yöneticiler, vergi numarası, banka bilgileri her satır faturada ayrı satır olarak görünür
adminprofile.notification.saved=Şirket verileri kaydedildi
adminprofile.notification.save.error=Kaydetme hatası: {0}
adminprofile.notification.load.error=Yükleme hatası: {0}
# Fatura oluşturma (admin)
page.title.admin.createinvoices=Fatura oluştur
admincreateinvoices.title=Fatura oluştur
admincreateinvoices.hint=Faturalar ay sonunda ödenir. Bir müşteri ay ortasında başlarsa, yalnızca ay sonuna kadar kalan günler faturalandırılır.
admincreateinvoices.column.customer=Müşteri
admincreateinvoices.column.discount=İndirim
admincreateinvoices.discount.position=İndirim (%{0})
admincreateinvoices.discount.dialog.title=İndirim gir
admincreateinvoices.discount.dialog.percent=İndirim %
admincreateinvoices.discount.dialog.reason=İndirim nedeni
admincreateinvoices.discount.dialog.apply=Uygula
admincreateinvoices.column.start=Müşteri başlangıcı
admincreateinvoices.column.days=Faturalanan günler
admincreateinvoices.column.due=Vade tarihi
admincreateinvoices.column.net=Net tutar
admincreateinvoices.days.full=Tam ay
admincreateinvoices.days.partial={0} / {1} gün
admincreateinvoices.prorata.suffix=(orantılı {0}/{1} gün)
admincreateinvoices.appusers.suffix=({0} uygulama kullanıcısı)
admincreateinvoices.button.create=Fatura oluştur
admincreateinvoices.notification.notemplate=Fatura şablonu yok. Lütfen önce fatura oluşturucuda bir şablon kaydedin.
admincreateinvoices.notification.error=Fatura oluşturulurken hata: {0}
admincreateinvoices.button.createselected=Fatura oluştur ({0})
admincreateinvoices.field.month=Fatura ayı
admincreateinvoices.filter.label=Göster
admincreateinvoices.filter.all=Tüm faturalar
admincreateinvoices.filter.open=Yalnızca açık
admincreateinvoices.filter.sent=Yalnızca gönderilen
admincreateinvoices.dialog.title=Faturaları incele
admincreateinvoices.dialog.position=Fatura {0} / {1}
admincreateinvoices.dialog.button.previous=Geri
admincreateinvoices.dialog.button.next=İleri
admincreateinvoices.dialog.button.delete=Listeden kaldır
admincreateinvoices.dialog.button.send=Faturaları gönder
admincreateinvoices.pdf.button.send=Gönder
admincreateinvoices.dialog.notification.removed=Fatura listeden kaldırıldı
admincreateinvoices.mail.subject=Faturanız {0}
admincreateinvoices.mail.body=Sayın yetkili,\n\n{1} fatura dönemine ait {0} numaralı faturanız ektedir.\n\nSaygılarımızla\n{2}
admincreateinvoices.notification.sent={0} fatura e-posta ile gönderildi
admincreateinvoices.notification.sendfailed={0} fatura gönderilemedi
admincreateinvoices.button.zugferd=E-fatura (ZIP)
admincreateinvoices.notification.zugferd.created={0} numaralı e-fatura oluşturuldu
admincreateinvoices.notification.zugferd.invalid=E-fatura oluşturuldu ancak doğrulama başarısız oldu. Ayrıntılar ZIP dosyasındaki doğrulama raporunda.
admincreateinvoices.notification.zugferd.error=E-fatura oluşturulurken hata: {0}
admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandırılabilir pozisyon yok

View File

@@ -141,8 +141,8 @@ class InvoiceComplianceValidatorTest {
void rejectsItemWithNegativeUnitPrice() { void rejectsItemWithNegativeUnitPrice() {
CustomerInvoice invoice = validInvoice(); CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00")); invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).isNotNull(); assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis")); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
} }
@@ -151,8 +151,8 @@ class InvoiceComplianceValidatorTest {
void rejectsInconsistentTotals() { void rejectsInconsistentTotals() {
CustomerInvoice invoice = validInvoice(); CustomerInvoice invoice = validInvoice();
invoice.setTotalAmount(new BigDecimal("999.99")); invoice.setTotalAmount(new BigDecimal("999.99"));
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).isNotNull(); assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht")); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
} }
@@ -164,8 +164,8 @@ class InvoiceComplianceValidatorTest {
invoice.setNetAmount(new BigDecimal("80.00")); invoice.setNetAmount(new BigDecimal("80.00"));
invoice.setVatAmount(new BigDecimal("15.20")); invoice.setVatAmount(new BigDecimal("15.20"));
invoice.setTotalAmount(new BigDecimal("95.20")); invoice.setTotalAmount(new BigDecimal("95.20"));
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).isNotNull(); assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen")); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
} }
@@ -207,8 +207,8 @@ class InvoiceComplianceValidatorTest {
// Erwartet wären 19,00 € — wir tragen 25,00 € ein. // Erwartet wären 19,00 € — wir tragen 25,00 € ein.
invoice.setVatAmount(new BigDecimal("25.00")); invoice.setVatAmount(new BigDecimal("25.00"));
invoice.setTotalAmount(new BigDecimal("125.00")); invoice.setTotalAmount(new BigDecimal("125.00"));
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).isNotNull(); assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag")); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
} }
@@ -219,8 +219,8 @@ class InvoiceComplianceValidatorTest {
invoice.setInvoiceNumber(null); invoice.setInvoiceNumber(null);
invoice.setSenderName(null); invoice.setSenderName(null);
invoice.setItems(new ArrayList<>()); invoice.setItems(new ArrayList<>());
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).isNotNull(); assertThat(ex).isNotNull();
assertThat(ex.getViolations()) assertThat(ex.getViolations())
.as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen") .as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
@@ -250,8 +250,8 @@ class InvoiceComplianceValidatorTest {
} }
private void assertSingleViolation(CustomerInvoice invoice, String fragment) { private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
InvoiceComplianceException ex = catchThrowableOfType( InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); () -> validator.validateForIssuance(invoice));
assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull(); assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
assertThat(ex.getViolations()) assertThat(ex.getViolations())
.as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations()) .as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())

View File

@@ -6,6 +6,7 @@
{ {
"_version": "9.1", "_version": "9.1",
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0",
"sourceMap": true, "sourceMap": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"inlineSources": true, "inlineSources": true,