Compare commits

..

8 Commits

Author SHA1 Message Date
d146d2f319 feat: E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen mit ZIP-Download und E-Mail-Versand, Empfänger-E-Mail und Bankdaten im Profil
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:46:30 +02:00
3d408e43c9 feat: Rechnungsversand als signierte E-Rechnung mit Sammel-ZIP-Download, Abbruch bei fehlgeschlagener Validierung (HTTP 422)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:59:52 +02:00
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
61 changed files with 4683 additions and 810 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
```
**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.
## Architecture Overview

View File

@@ -258,6 +258,40 @@ class Job {
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
List<Task> tasks = [];
if (deliveryStations.isNotEmpty) {

View File

@@ -116,9 +116,16 @@ class DatabaseService {
final jobBox = _store!.box<JobEntity>();
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();
taskStatusBox.removeAll();
final existingStatuses = {
for (final entity in taskStatusBox.getAll()) entity.taskId: entity,
};
final knownTaskIds = <String>{};
// Save new jobs
for (final job in jobs) {
@@ -134,7 +141,8 @@ class DatabaseService {
// Also persist task completion states from JSON (adopt status on load)
// Only set completed=true entries to avoid overwriting local progress with false
for (final task in normalized.tasks) {
if (task.completed) {
knownTaskIds.add(task.id);
if (task.completed && !existingStatuses.containsKey(task.id)) {
final taskStatusEntity = TaskStatusEntity(
taskId: task.id,
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(
'Jobs and task statuses saved successfully',
name: 'DatabaseService',

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'l10n/app_localizations.dart';
import 'app_state.dart';
import 'app_theme.dart';
@@ -26,11 +27,23 @@ class SettingsView extends StatefulWidget {
class _SettingsViewState extends State<SettingsView> {
late String _selectedLanguageCode;
final AppState _appState = AppState();
String _appVersion = '';
@override
void initState() {
super.initState();
_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 {
@@ -213,7 +226,7 @@ class _SettingsViewState extends State<SettingsView> {
ListTile(
leading: Icon(Icons.info_outline, color: AppColors.textMuted),
title: Text(l10n.version),
subtitle: const Text('0.9.2'),
subtitle: Text(_appVersion),
),
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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.9.16+1
version: 0.9.20+1
environment:
sdk: ^3.7.0

View File

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

Binary file not shown.

View File

@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
var elements = window.profileInvoiceState.elements;
var selectedElement = window.profileInvoiceState.selectedElement;
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 dragStart = { x: 0, y: 0 };
var elementStart = { x: 0, y: 0 };
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
var colVatX = x + colNameWidth;
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
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 calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
// Ensure background covers at least the element height or the calculated content
@@ -346,21 +391,11 @@ window.initProfileInvoiceGenerator = function() {
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
// Sample data rows (placeholder)
var sampleData = [
{ name: 'Umzugsleistung inkl. Verpackung', vat: 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;
// Draw sample rows
// Draw data rows
ctx.font = fontSize + 'px Arial';
sampleData.forEach(function(row, index) {
rows.forEach(function(row, index) {
// Draw row background (alternating)
if (index % 2 === 1) {
ctx.fillStyle = 'rgba(0,0,0,0.02)';
@@ -416,10 +451,19 @@ window.initProfileInvoiceGenerator = function() {
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
var valueX = x + w - padding; // Value column (right-aligned)
// Calculate totals from sample data
var netTotal = 655.00; // 450 + 85 + 120
var vatTotal = netTotal * vatRate;
var grossTotal = netTotal + vatTotal;
// Totals: provided with the service data or calculated from sample data
var netTotalLabel, vatTotalLabel, grossTotalLabel;
if (serviceData && serviceData.netTotal) {
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
ctx.textBaseline = 'middle';
@@ -431,7 +475,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Umsatzsteuer - label left, value right
@@ -440,7 +484,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Gesamtsumme - label left, value right
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.textAlign = 'left';
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right';
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
}
function drawSelection(el) {
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) {
var handle = getResizeHandle(x, y, selectedElement);
if (handle >= 0) {
pushUndoState();
isResizing = true;
resizeHandle = handle;
resizeStart = {
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
}
if (clickedElement) {
pushUndoState();
selectedElement = clickedElement;
isDragging = true;
dragStart = { x: x, y: y };
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
// Keyboard navigation
canvas.setAttribute('tabindex', '0');
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 (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
pushUndoState();
}
var moved = false;
switch(e.key) {
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
// Add element function
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
pushUndoState();
elementCounter++;
var id = 'element-' + elementCounter;
@@ -972,6 +1030,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementText = function(id, text) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.text = text;
draw();
}
@@ -980,6 +1039,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementPosition = function(id, x, y) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
if (x !== null) el.x = snapToGrid(x);
if (y !== null) el.y = snapToGrid(y);
draw();
@@ -989,6 +1049,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementFontSize = function(id, size) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.fontSize = size;
// Update height based on text content and new font size
if (el.type !== 'line' && el.type !== 'image') {
@@ -1004,6 +1065,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementColor = function(id, color) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.color = color;
draw();
}
@@ -1012,6 +1074,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementSize = function(id, width, height) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
if (width !== null) el.width = width;
if (height !== null) el.height = height;
draw();
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
// Load image to get dimensions
var img = new Image();
img.onload = function() {
pushUndoState();
var MAX_WIDTH = 1090;
var width = img.width;
var height = img.height;
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
window.deleteProfileElement = function(id) {
var index = elements.findIndex(function(e) { return e.id === id; });
if (index > -1) {
pushUndoState();
elements.splice(index, 1);
if (selectedElement && selectedElement.id === id) {
selectedElement = null;
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
// Clear canvas function
window.clearProfileCanvas = function() {
elements = [];
if (elements.length) {
pushUndoState();
}
// Keep the same array reference (shared with profileInvoiceState and undo)
elements.length = 0;
selectedElement = null;
window.profileInvoiceState.elements = [];
window.profileInvoiceState.selectedElement = null;
saveState();
notifyElementDeselected();
draw();
};

View File

@@ -1,124 +1,57 @@
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
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
* exposes an OpenAI-compatible API at {@code /v1/chat/completions}.
* Configuration for LLM integration via the Anthropic Claude API.
*/
@Configuration
@Slf4j
public class LlmConfig {
@Value("${app.ai.lmstudio.base-url}")
private String lmstudioBaseUrl;
@Value("${app.ai.anthropic.api-key}")
private String apiKey;
@Value("${app.ai.lmstudio.model}")
private String lmstudioModel;
@Value("${app.ai.lmstudio.htaccess-username}")
private String lmstudioHtaccessUsername;
@Value("${app.ai.lmstudio.htaccess-password}")
private String lmstudioHtaccessPassword;
@Value("${app.ai.anthropic.model:claude-opus-4-8}")
private String model;
@PostConstruct
public void logConfig() {
log.info("=== LLM Configuration ===");
log.info("Provider: lmstudio");
log.info("Base URL: {}", lmstudioBaseUrl);
log.info("Model: {}", lmstudioModel);
log.info("HTACCESS auth: {}", hasHtaccessCredentials() ? "configured" : "not configured");
testConnection(lmstudioBaseUrl, lmstudioModel);
log.info("Provider: Anthropic Claude");
log.info("Model: {}", model);
log.info("API key: {}", apiKey != null && !apiKey.isBlank() ? "configured" : "NOT configured");
testConnection();
}
private void testConnection(String baseUrl, String model) {
log.info("Testing LLM connection to: {}", baseUrl);
// Test 1: Models endpoint
testEndpoint(baseUrl + "/v1/models", "GET", null);
// Test 2: Chat completions (no streaming)
String testPayload = "{\"model\":\"" + model
+ "\",\"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) {
/**
* Validate API key and connectivity using the free count_tokens endpoint (no
* tokens are billed).
*/
private void testConnection() {
if (apiKey == null || apiKey.isBlank()) {
log.warn("Skipping Claude connection test - no API key configured");
return;
}
try {
log.info("Testing endpoint: {} {}", method, endpoint);
URL url = URI.create(endpoint).toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(5000);
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());
AnthropicClient client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
MessageTokensCount count = client.messages()
.countTokens(MessageCountTokensParams.builder().model(model).addUserMessage("ping").build());
log.info("Claude connection test -> SUCCESS (count_tokens returned {} input tokens)",
count.inputTokens());
} 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() {
return lmstudioModel;
}
public boolean hasHtaccessCredentials() {
return lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank()
&& lmstudioHtaccessPassword != null && !lmstudioHtaccessPassword.isBlank();
}
public String getLmstudioHtaccessUsername() {
return lmstudioHtaccessUsername;
}
public String getLmstudioHtaccessPassword() {
return lmstudioHtaccessPassword;
return model;
}
}

View File

@@ -1,24 +1,20 @@
package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.Message;
import com.anthropic.models.messages.MessageCreateParams;
import lombok.extern.slf4j.Slf4j;
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.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.stream.Collectors;
/**
* Direct REST client for LM Studio LLM API. Communicates via the
* OpenAI-compatible /v1/chat/completions endpoint.
* LLM client backed by the Anthropic Claude API (official Java SDK). Keeps the
* same public interface as the previous LM-Studio-based implementation so
* callers (AiStatisticsService, TranslationService) stay unchanged.
*/
@Component
@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_TAG_PATTERN = Pattern.compile("(?is)</?think>");
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final AnthropicClient client;
private final String model;
public LlmRestClient(@Value("${app.ai.lmstudio.base-url}") String lmstudioBaseUrl,
@Value("${app.ai.lmstudio.model}") String lmstudioModel,
@Value("${app.ai.lmstudio.htaccess-username}") String lmstudioHtaccessUsername,
@Value("${app.ai.lmstudio.htaccess-password}") String lmstudioHtaccessPassword, ObjectMapper objectMapper) {
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();
public LlmRestClient(@Value("${app.ai.anthropic.api-key}") String apiKey,
@Value("${app.ai.anthropic.model:claude-opus-4-8}") String model) {
this.model = model;
this.client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
log.info("LlmRestClient initialized - Provider: Anthropic Claude, Model: {}", model);
}
/**
@@ -78,32 +54,45 @@ public class LlmRestClient {
* @param userMessage
* User message/question
* @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
* Maximum tokens in response
* @return LLM response text, or null on error
*/
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
try {
Map<String, Object> request = Map.of("model", model, "messages",
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
Map.of("role", "user", "content", userMessage)),
"temperature", temperature, "max_tokens", maxTokens, "stream", false);
MessageCreateParams.Builder builder = MessageCreateParams.builder().model(model).maxTokens(maxTokens);
if (systemPrompt != null && !systemPrompt.isBlank()) {
builder.system(systemPrompt);
}
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();
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
Message response = client.messages().create(builder.build());
long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration);
log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0);
log.info("Claude response received in {}ms (stop_reason: {})", duration, response.stopReason());
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) {
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()) {
log.debug("Full stack trace:", e);
}
@@ -122,35 +111,6 @@ public class LlmRestClient {
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) {
String sanitized = THINK_BLOCK_PATTERN.matcher(content).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);
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId);
log.info("[JOBS] Found {} jobs for appUserId: {}", assignedJobs.size(), appUserId);
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId).stream()
.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<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
@@ -381,7 +383,8 @@ public class MessageController {
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
extraDataSummary);
} 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
@@ -393,7 +396,8 @@ public class MessageController {
// It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted).
} 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);
@@ -420,11 +424,17 @@ public class MessageController {
try {
emailService.sendJobCompletionNotification(jobId, completedBy);
} 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) {
// 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 {
Optional<Job> jobOpt = jobRepository.findById(jobId);
if (jobOpt.isEmpty()) {
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
return;
}
@@ -440,9 +451,10 @@ public class MessageController {
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
log.info("[JOB] Job {} marked as COMPLETED", jobId);
}
} 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) {
try {
String jobIdStr = payload.get("jobId") != null ? payload.get("jobId").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()) {
log.warn("[STATION] Job with jobNumber {} not found", jobNumber);
log.warn("[STATION] Job not found for station_completed (jobId={}, jobNumber={})", jobIdStr,
jobNumber);
return;
}
@@ -505,7 +521,8 @@ public class MessageController {
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
messageService.receiveMessageFromClient(inboundPayload);
} 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 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;
public PriceTable() {
}
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
String statisticalEvaluation) {
public PriceTable(String monthlyBasePackage, String appUserFee, String statisticalEvaluation) {
this.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense;
this.revenueParticipation = revenueParticipation;
this.appUserFee = appUserFee;
this.statisticalEvaluation = statisticalEvaluation;
}
@@ -41,20 +41,12 @@ public class PriceTable {
this.monthlyBasePackage = monthlyBasePackage;
}
public String getAppUsageLicense() {
return appUsageLicense;
public String getAppUserFee() {
return appUserFee;
}
public void setAppUsageLicense(String appUsageLicense) {
this.appUsageLicense = appUsageLicense;
}
public String getRevenueParticipation() {
return revenueParticipation;
}
public void setRevenueParticipation(String revenueParticipation) {
this.revenueParticipation = revenueParticipation;
public void setAppUserFee(String appUserFee) {
this.appUserFee = appUserFee;
}
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

@@ -66,6 +66,7 @@ public class CustomerInvoice {
private String recipientCity;
private String recipientCountry;
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
private String recipientEmail; // E-Mail für den Rechnungsversand
// Rechnungsdetails
private String description; // Beschreibung der Leistung
@@ -274,6 +275,14 @@ public class CustomerInvoice {
this.recipientVatId = recipientVatId;
}
public String getRecipientEmail() {
return recipientEmail;
}
public void setRecipientEmail(String recipientEmail) {
this.recipientEmail = recipientEmail;
}
public String getDescription() {
return description;
}

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() {
}
public static void putUnique(Map<String, Customer> target, Customer customer, String fallbackLabel) {
String label = build(customer, fallbackLabel);
String uniqueLabel = label;
int counter = 2;
while (target.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
target.put(uniqueLabel, customer);
/** Adds the customer under its full address label ("Name, Street No, Zip City"). */
public static void put(Map<String, Customer> target, Customer customer, String fallbackLabel) {
target.put(build(customer, fallbackLabel), 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) {
if (customer == null) {
return fallbackLabel;
}
String companyName = trimToNull(customer.getCompanyName());
if (companyName != null) {
return companyName;
StringBuilder label = new StringBuilder(buildName(customer, fallbackLabel));
String street = trimToNull(join(" ", customer.getStreet(), customer.getHouseNumber()));
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()));
return fullName != null ? fullName : fallbackLabel;
String name = trimToNull(customer.getCompanyName());
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) {

View File

@@ -687,7 +687,7 @@ public class DeliveryStationDialog extends Dialog {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
companyAddressOptions.clear();
for (Customer customer : customers) {
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
CustomerAddressLabelHelper.put(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed"));
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.pages.base.ui.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasElement;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
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.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService;
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
@@ -31,6 +31,7 @@ public final class AdminLayout extends AppLayout {
private Div headerRef;
private Scroller navRef;
private Component userMenuRef;
private final Div viewContainer = new Div();
public AdminLayout(SecurityService securityService) {
this.securityService = securityService;
@@ -47,12 +48,25 @@ public final class AdminLayout extends AppLayout {
userMenuRef = createUserMenu();
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();
// Re-check on attach (new UI/session) and on every navigation cycle
addAttachListener(e -> updateDrawerVisibility());
}
@Override
public void showRouterLayoutContent(HasElement content) {
viewContainer.getElement().removeAllChildren();
if (content != null) {
viewContainer.getElement().appendChild(content.getElement());
}
}
private void updateDrawerVisibility() {
boolean loggedIn = securityService.isUserLoggedIn();
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 invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator",
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 systemSettings = new SideNavItem("Systemeinstellungen",
// "admin-settings", new Icon(VaadinIcon.COG));
@@ -100,6 +116,7 @@ public final class AdminLayout extends AppLayout {
nav.addItem(dashboard);
nav.addItem(invoiceGenerator);
nav.addItem(createInvoices);
nav.addItem(priceTable);
// nav.addItem(systemSettings);
// nav.addItem(userManagement);
@@ -134,9 +151,8 @@ public final class AdminLayout extends AppLayout {
var userMenuItem = userMenu.addItem(avatar);
userMenuItem.add(userNameSpan);
// Profile display with navigation
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
// Profile display with navigation to the admin company profile
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate("admin-profile"));
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
// Update function for username and avatar

View File

@@ -1,9 +1,11 @@
package de.assecutor.votianlt.pages.domain;
import de.assecutor.votianlt.model.Customer;
import java.util.List;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
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;
}
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);
// Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
customer.setCreatedBy(currentUser.getId());
customer.setOwner(currentUser.getId());
// Keine komplett identischen Einträge anlegen
if (identicalCustomerExists(customer)) {
return false;
}
if (customer.getUsrId() == null) {
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
}
addCustomerRepository.save(customer);
return true;
}
public void addInternalCustomer(Customer customer) {
@@ -44,6 +55,28 @@ public class AddCustomerService {
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) {
if (customer == null || customer.getId() == null) {
throw new IllegalArgumentException("Kunden-ID fehlt");

View File

@@ -214,7 +214,13 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
Customer customer = new 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"),
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();
customerLabelToEntity.clear();
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()));
@@ -1415,7 +1415,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
Map<String, Customer> addressOptions = new LinkedHashMap<>();
for (Customer customer : allCustomers) {
CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
CustomerAddressLabelHelper.put(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
}
// Set items for autocomplete

View File

@@ -0,0 +1,994 @@
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 the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective
* customers. First all signed ZIPs are created and cached; only when every
* ZIP could be created and passed the validation the mails are sent.
* Afterwards a combined ZIP containing all individual invoice ZIPs is
* offered as browser download.
*/
private void sendInvoices(List<InvoicePreview> previews) {
// Phase 1: create and cache the signed ZIP of every invoice; abort
// without sending anything if a single one fails
Map<InvoicePreview, PdfToolService.SignedInvoice> signedZips = new LinkedHashMap<>();
for (InvoicePreview preview : previews) {
try {
signedZips.put(preview, pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview)));
} catch (Exception ex) {
log.error("Error creating ZUGFeRD e-invoice {} for batch send: {}", preview.invoiceNumber(),
ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.batcherror",
preview.invoiceNumber(), ex.getMessage()), 7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
}
// Invoices failing the Mustang validation stop the whole batch: no
// mail is sent as long as a single invoice is invalid
String invalidNumbers = signedZips.entrySet().stream().filter(entry -> !entry.getValue().valid())
.map(entry -> entry.getKey().invoiceNumber()).collect(java.util.stream.Collectors.joining(", "));
if (!invalidNumbers.isEmpty()) {
log.warn("Invoice batch send aborted, validation failed for: {}", invalidNumbers);
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.invalidsend", invalidNumbers),
7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
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()));
// Phase 2: all ZIPs exist, send one mail per invoice with its ZIP
int sent = 0;
int failed = 0;
for (Map.Entry<InvoicePreview, PdfToolService.SignedInvoice> entry : signedZips.entrySet()) {
InvoicePreview preview = entry.getKey();
PdfToolService.SignedInvoice signed = entry.getValue();
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),
signed.zip(), signed.fileName());
recordDispatch(preview, email);
sent++;
} catch (Exception ex) {
log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex);
failed++;
}
}
// Phase 3: bundle all individual invoice ZIPs and offer the download
if (signedZips.size() > 1) {
try {
String archiveName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
+ "-zugferd.zip";
triggerZipDownload(buildCombinedZip(signedZips.values()), archiveName);
} catch (Exception ex) {
log.error("Error creating combined invoice ZIP: {}", ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.zip.combined.error", ex.getMessage()),
7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
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));
triggerZipDownload(signed.zip(), signed.fileName());
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);
}
}
/**
* Packs the individual invoice ZIPs into one combined ZIP so all sent
* e-invoices can be archived with a single download.
*/
private byte[] buildCombinedZip(java.util.Collection<PdfToolService.SignedInvoice> signedInvoices)
throws java.io.IOException {
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zipStream = new java.util.zip.ZipOutputStream(buffer)) {
for (PdfToolService.SignedInvoice signed : signedInvoices) {
zipStream.putNextEntry(new java.util.zip.ZipEntry(signed.fileName()));
zipStream.write(signed.zip());
zipStream.closeEntry();
}
}
return buffer.toByteArray();
}
/** Offers the given ZIP bytes as browser download. */
private void triggerZipDownload(byte[] zip, String fileName) {
String base64Zip = Base64.getEncoder().encodeToString(zip);
getElement().executeJs("const link = document.createElement('a');"
+ "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
+ fileName.replace("'", "") + "';"
+ "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
}
/**
* 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;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H1;
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.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.Customer;
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.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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)
@RolesAllowed("ADMIN")
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
private final BarcodeRepository barcodeRepository;
private final SignatureRepository signatureRepository;
private final CommentRepository commentRepository;
private final CustomerRepository customerRepository;
private final Div statisticsContainer;
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository) {
SignatureRepository signatureRepository, CommentRepository commentRepository,
CustomerRepository customerRepository) {
this.jobRepository = jobRepository;
this.taskRepository = taskRepository;
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
this.barcodeRepository = barcodeRepository;
this.signatureRepository = signatureRepository;
this.commentRepository = commentRepository;
this.customerRepository = customerRepository;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
// System overview section
mainLayout.add(createSystemOverviewSection());
// Customers overview section
mainLayout.add(createCustomersSection());
// Job statistics section
mainLayout.add(createJobStatisticsSection());
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
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() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);

View File

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

@@ -24,6 +24,7 @@ import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -586,6 +587,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount);
// Pflichtangaben nach §14 UStG: Leistungsdatum, Absender, Empfänger und
// Positionen — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
: java.time.LocalDate.now());
applySenderData(invoice, user);
applyRecipientData(invoice);
invoice.setItems(buildInvoiceItems(vatRate));
invoice.setPdfData(pdfBytes);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
@@ -609,6 +618,105 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
}
}
/**
* Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten
* (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen.
*/
private void applySenderData(CustomerInvoice invoice, User user) {
invoice.setSenderName(firstNonBlank(user.getCompany(),
(safe(user.getFirstname()) + " " + safe(user.getName())).trim()));
invoice.setSenderAddress((safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
invoice.setSenderPostcode(safe(user.getZip()));
invoice.setSenderCity(safe(user.getCity()));
invoice.setSenderCountry("DE");
invoice.setSenderPhone(safe(user.getPhone()));
invoice.setSenderEmail(safe(user.getEmail()));
userInvoiceDataService.findByUserId(user.getId()).ifPresent(invoiceData -> {
invoice.setSenderVatId(safe(invoiceData.getUstId()));
invoice.setSenderTaxNumber(safe(invoiceData.getTaxNumber()));
invoice.setIban(safe(invoiceData.getIban()));
invoice.setBankAccount(safe(invoiceData.getBankName()));
invoice.setPaymentTerms(safe(invoiceData.getPaymentTerms()));
});
if (invoice.getPaymentDueDate() == null) {
invoice.setPaymentDueDate(invoice.getInvoiceDate().plusDays(14));
}
}
/**
* Empfängerdaten aus der Kundenauswahl des Auftrags übernehmen; die volle
* Anschrift und E-Mail kommen aus dem passenden Adressbuch-Eintrag.
*/
private void applyRecipientData(CustomerInvoice invoice) {
String customerSelection = currentJob.getCustomerSelection();
if (customerSelection == null || customerSelection.isBlank()) {
return;
}
// Format: "Firmenname | Vorname Nachname"
String[] parts = customerSelection.split("\\|");
String companyName = parts.length > 0 ? parts[0].trim() : "";
String contactName = parts.length > 1 ? parts[1].trim() : "";
invoice.setRecipientCompany(companyName);
invoice.setRecipientName(firstNonBlank(contactName, companyName));
Customer matchedCustomer = customerService.findAllForCurrentOwner().stream()
.filter(c -> companyName.equalsIgnoreCase(c.getCompanyName())).findFirst().orElse(null);
if (matchedCustomer != null) {
invoice.setRecipientAddress(
(safe(matchedCustomer.getStreet()) + " " + safe(matchedCustomer.getHouseNumber())).trim());
invoice.setRecipientPostcode(safe(matchedCustomer.getZip()));
invoice.setRecipientCity(safe(matchedCustomer.getCity()));
invoice.setRecipientCountry("DE");
invoice.setRecipientEmail(safe(matchedCustomer.getMail()));
}
}
/**
* Rechnungspositionen aus den ausgewählten Leistungen aufbauen — mit
* denselben Mengen-/Preisregeln wie {@link #calculateNetAmount()}, damit
* die Positionssumme exakt zum Rechnungs-Netto passt.
*/
private List<CustomerInvoiceItem> buildInvoiceItems(BigDecimal vatRate) {
List<CustomerInvoiceItem> items = new ArrayList<>();
for (ServiceRow row : getSelectedServices()) {
Service service = row.getService();
BigDecimal quantity = null;
BigDecimal unitPrice = null;
String unit = null;
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
quantity = BigDecimal.ONE;
unitPrice = service.getPrice();
unit = "pauschal";
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
&& service.getPricePerKilometer() != null && getRouteDistanceKm(row.getSelection()) != null) {
quantity = BigDecimal.valueOf(getRouteDistanceKm(row.getSelection()));
unitPrice = service.getPricePerKilometer();
unit = "km";
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
&& service.getPricePer15Minutes() != null && getTimeIn15MinUnits(row.getSelection()) != null) {
quantity = new BigDecimal(getTimeIn15MinUnits(row.getSelection()));
unitPrice = service.getPricePer15Minutes();
unit = "x 15 Min.";
}
if (quantity == null || quantity.signum() <= 0 || unitPrice == null) {
continue;
}
items.add(new CustomerInvoiceItem(quantity, unit, service.getName(), unitPrice, vatRate));
}
return items;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
/**
* Generates an invoice PDF from the template with actual job data.
*/

View File

@@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout);
// Steuer- und Bankdaten: Pflichtangaben nach §14 UStG bzw. für die
// ZUGFeRD-E-Rechnung (Absender-USt-IdNr./Steuernummer, IBAN)
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setPlaceholder("DE123456789");
ustIdField.addBlurListener(e -> saveInvoiceData());
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.addBlurListener(e -> saveInvoiceData());
bankNameField.setLabel(getTranslation("profile.billing.bankname"));
bankNameField.addBlurListener(e -> saveInvoiceData());
ibanField.setLabel(getTranslation("profile.billing.iban"));
ibanField.setPlaceholder("DE00 0000 0000 0000 0000 00");
ibanField.addBlurListener(e -> saveInvoiceData());
HorizontalLayout billingTaxLayout = new HorizontalLayout(ustIdField, taxNumberField, bankNameField, ibanField);
billingTaxLayout.setSpacing(true);
billingTaxLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingTaxLayout);
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull();
@@ -429,6 +447,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
actionLayout.setSpacing(true);
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));
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
@@ -462,7 +485,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
});
});
actionLayout.add(previewPdfButton, saveTemplateButton);
actionLayout.add(undoButton, previewPdfButton, saveTemplateButton);
billingTab.add(actionLayout);
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
@@ -486,7 +509,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Also register this view instance for JavaScript callbacks
tabSheet.addSelectedChangeListener(e -> {
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(); "
+ " console.log('Canvas initialized, now loading template...'); "
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
@@ -1466,6 +1490,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " console.log('Loading template into canvas...'); "
+ " window.profileInvoiceServiceData = null; "
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
+ " window.masterdataValues = "
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "

View File

@@ -33,11 +33,15 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
@@ -49,6 +53,7 @@ import java.util.Optional;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
@Slf4j
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
@@ -63,12 +68,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private final InvoicePermissionService invoicePermissionService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
private final UserInvoiceZugferdService userInvoiceZugferdService;
private final EmailService emailService;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService,
EmailService emailService) {
this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
@@ -77,6 +85,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
this.userInvoiceZugferdService = userInvoiceZugferdService;
this.emailService = emailService;
setSizeFull();
setPadding(true);
@@ -201,6 +211,22 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
actions.add(sentBtn);
}
// E-Rechnung (ZUGFeRD) über das externe PDF Tool — analog zu den
// System-Rechnungen der Admin-Seite
if (type == InvoiceType.INVOICE && invoice.getPdfData() != null && invoice.getPdfData().length > 0) {
Button zugferdBtn = new Button(getTranslation("invoices.action.zugferd"),
e -> downloadZugferdZip(invoice));
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(zugferdBtn);
if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) {
Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"),
e -> sendAsEInvoice(invoice));
sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
actions.add(sendBtn);
}
}
if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
@@ -318,6 +344,91 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
}
}
/**
* Wandelt die Rechnung über das externe PDF Tool in eine signierte
* ZUGFeRD-E-Rechnung um und bietet das ZIP (PDF/A-3, factur-x.xml,
* Prüfbericht) als Download an.
*/
private void downloadZugferdZip(CustomerInvoice invoice) {
try {
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
triggerZipDownload(signed.zip(), signed.fileName());
if (signed.valid()) {
Notification.show(getTranslation("invoices.notification.zugferd.created", invoice.getInvoiceNumber()),
5000, Notification.Position.BOTTOM_END);
} else {
Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000,
Notification.Position.MIDDLE);
}
} catch (Exception ex) {
log.error("Fehler beim Erstellen der ZUGFeRD-E-Rechnung {}: {}", invoice.getInvoiceNumber(),
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/**
* Versendet die Rechnung als signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail
* an den Kunden. Besteht die Rechnung die Validierung nicht (HTTP 422 des
* PDF Tools), wird der Versand abgebrochen — wie beim Sammelversand der
* System-Rechnungen. Nach erfolgreichem Versand wird der Status auf
* "Versendet" gesetzt.
*/
private void sendAsEInvoice(CustomerInvoice invoice) {
String recipientEmail = invoice.getRecipientEmail();
if (recipientEmail == null || recipientEmail.isBlank()) {
Notification.show(getTranslation("invoices.notification.email.missing"), 6000,
Notification.Position.MIDDLE);
return;
}
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
if (!signed.valid()) {
log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen",
invoice.getInvoiceNumber());
Notification.show(
getTranslation("invoices.notification.zugferd.invalidsend", invoice.getInvoiceNumber()), 7000,
Notification.Position.MIDDLE);
return;
}
emailService.sendEmailWithAttachment(recipientEmail,
getTranslation("invoices.mail.subject", invoice.getInvoiceNumber()),
getTranslation("invoices.mail.body", invoice.getInvoiceNumber(),
firstNonBlank(invoice.getSenderName(), "")),
signed.zip(), signed.fileName());
if (invoice.getStatus() == InvoiceStatus.ISSUED) {
invoiceLifecycleService.markAsSent(invoice.getId(),
"Als E-Rechnung per E-Mail versendet an " + recipientEmail);
}
Notification.show(
getTranslation("invoices.notification.emailsent", invoice.getInvoiceNumber(), recipientEmail),
5000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
log.error("Fehler beim Versand der Rechnung {} an {}: {}", invoice.getInvoiceNumber(), recipientEmail,
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/** Bietet die ZIP-Bytes als Browser-Download an. */
private void triggerZipDownload(byte[] zip, String fileName) {
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zip));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
}
private void markAsSent(CustomerInvoice invoice) {
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());

View File

@@ -31,11 +31,13 @@ import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.messaging.MessagingPublisher;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.ClientConnectionService;
import de.assecutor.votianlt.service.JobHistoryService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
@@ -47,6 +49,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed("USER")
@@ -67,6 +70,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
private final JobHistoryService jobHistoryService;
private final SecurityService securityService;
private final ServiceRepository serviceRepository;
private final ClientConnectionService clientConnectionService;
private final MessagingPublisher messagingPublisher;
private final VerticalLayout content;
private Job job;
@@ -80,11 +85,14 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
private Integer manualDurationSeconds;
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService, ServiceRepository serviceRepository) {
SecurityService securityService, ServiceRepository serviceRepository,
ClientConnectionService clientConnectionService, MessagingPublisher messagingPublisher) {
this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService;
this.securityService = securityService;
this.serviceRepository = serviceRepository;
this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher;
setSizeFull();
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"),
description, currentUser, JobHistoryType.STATUS_CHANGE);
notifyClientJobDeleted(job);
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
@@ -695,4 +705,26 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
.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.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import de.assecutor.votianlt.service.SystemInvoiceService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
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 Div emptyState = new Div();
private final SystemInvoiceService systemInvoiceService;
private final SystemCompanyProfileService systemCompanyProfileService;
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
public MyInvoicesView(SystemInvoiceService systemInvoiceService,
SystemCompanyProfileService systemCompanyProfileService) {
this.systemInvoiceService = systemInvoiceService;
this.systemCompanyProfileService = systemCompanyProfileService;
addClassName("data-view");
getStyle().set("max-width", "1100px");
getStyle().set("margin-left", "auto");
@@ -272,7 +276,7 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
}
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
SystemInvoiceData data = new SystemInvoiceData();
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
data.setInvoiceNumber(row.invoiceNumber());
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
data.setInvoiceText("Rechnung " + row.invoiceNumber());

View File

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

View File

@@ -23,4 +23,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by 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.
*/
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)
@@ -265,27 +265,6 @@ public class CustomerInvoiceService {
String invoicePrefix, BigDecimal vatRate) throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
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
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.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()) {
for (com.fasterxml.jackson.databind.JsonNode element : elements) {
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>");
}
} else if ("services.list".equals(variable)) {
// Render services list as a table
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
// Render positions from services.json if provided (e.g. system
// 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>")) {
// Multi-line text: render without nowrap so <br> tags work
htmlBuilder.append("<span>").append(text).append("</span>");
@@ -865,8 +882,11 @@ public class CustomerInvoiceService {
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(vatRateLabel)).append("</td>");
// 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%;'>")
.append(netAmount).append("</td>");
.append(amountDisplay).append("</td>");
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.JobRepository;
import de.assecutor.votianlt.repository.UserRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -378,6 +382,23 @@ public class EmailService {
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
*/

View File

@@ -1,5 +1,7 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
@@ -7,6 +9,9 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
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
@@ -17,6 +22,61 @@ public class MonthlySchedulerService {
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
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.

View File

@@ -0,0 +1,124 @@
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). A failed Mustang
* validation is reported by the tool as HTTP 422 but still carries the ZIP
* (including the validation report) in the body; it is returned as a
* {@link SignedInvoice} with {@code valid=false} instead of throwing.
*/
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);
try {
ResponseEntity<byte[]> response = restClient.post().uri("/api/invoices/sign")
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.toEntity(byte[].class);
return toSignedInvoice(response.getBody(), response.getHeaders(), metadata);
} catch (RestClientResponseException ex) {
if (ex.getStatusCode().value() == 422 && ex.getResponseBodyAsByteArray().length > 0) {
return toSignedInvoice(ex.getResponseBodyAsByteArray(), ex.getResponseHeaders(), metadata);
}
throw new IllegalStateException(extractErrorMessage(ex), ex);
}
}
/** Builds the result from the response ZIP, file name and validation header. */
private SignedInvoice toSignedInvoice(byte[] zip, org.springframework.http.HttpHeaders headers,
InvoiceMetadata metadata) {
if (zip == null || zip.length == 0) {
throw new IllegalStateException("PDF Tool returned an empty response");
}
String fileName = headers != null ? headers.getContentDisposition().getFilename() : null;
if (fileName == null || fileName.isBlank()) {
fileName = metadata.invoiceNumber() + "-zugferd.zip";
}
boolean valid = headers != null && Boolean.parseBoolean(headers.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
public class SystemInvoiceService {
private final SystemCompanyProfileService systemCompanyProfileService;
public SystemInvoiceService(SystemCompanyProfileService systemCompanyProfileService) {
this.systemCompanyProfileService = systemCompanyProfileService;
}
public byte[] generateInvoicePdfFromHtml() throws Exception {
// Read the HTML template
String htmlContent = readHtmlTemplate();
@@ -35,7 +41,7 @@ public class SystemInvoiceService {
}
public SystemInvoiceData createSampleInvoiceData() {
SystemInvoiceData data = new SystemInvoiceData();
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
// Set sample data
data.setInvoiceNumber("HHA-2021-007");

View File

@@ -0,0 +1,122 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Wandelt Nutzer-Rechnungen (Rechnungen der Systemnutzer an ihre eigenen
* Kunden) über das externe PDF Tool in signierte ZUGFeRD-E-Rechnungen um —
* dasselbe Verfahren, das {@code AdminCreateInvoicesView} für die
* System-Rechnungen verwendet. Die EN16931-Metadaten werden vollständig aus
* der persistierten {@link CustomerInvoice} aufgebaut (Absender, Empfänger,
* Positionen), die beim Erstellen der Rechnung in {@code CreateInvoiceView}
* befüllt werden.
*/
@Service
public class UserInvoiceZugferdService {
private final PdfToolService pdfToolService;
public UserInvoiceZugferdService(PdfToolService pdfToolService) {
this.pdfToolService = pdfToolService;
}
/**
* Signiert die Rechnung über das PDF Tool. Wirft eine
* {@link IllegalStateException} mit verständlicher Meldung, wenn der
* Rechnung Daten fehlen, die für eine EN16931-E-Rechnung zwingend sind.
*/
public PdfToolService.SignedInvoice sign(CustomerInvoice invoice) throws Exception {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
throw new IllegalStateException("Für diese Rechnung ist kein PDF gespeichert.");
}
return pdfToolService.signInvoice(invoice.getPdfData(), buildMetadata(invoice));
}
private PdfToolService.InvoiceMetadata buildMetadata(CustomerInvoice invoice) {
if (isBlank(invoice.getSenderName()) || isBlank(invoice.getRecipientName())) {
throw new IllegalStateException(
"Absender- oder Empfängerdaten fehlen — diese Rechnung wurde vor der E-Rechnungs-Umstellung "
+ "erstellt und kann nicht als E-Rechnung aufbereitet werden.");
}
PdfToolService.Party sender = new PdfToolService.Party(invoice.getSenderName(),
safe(invoice.getSenderAddress()), safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()),
firstNonBlank(invoice.getSenderCountry(), "DE"), blankToNull(invoice.getSenderVatId()),
safe(invoice.getSenderEmail()), safe(invoice.getSenderPhone()));
PdfToolService.Party recipient = new PdfToolService.Party(
firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName()),
safe(invoice.getRecipientAddress()), safe(invoice.getRecipientPostcode()),
safe(invoice.getRecipientCity()), firstNonBlank(invoice.getRecipientCountry(), "DE"),
blankToNull(invoice.getRecipientVatId()), safe(invoice.getRecipientEmail()), null);
List<PdfToolService.LineItem> items = buildLineItems(invoice);
if (items.isEmpty()) {
throw new IllegalStateException("Die Rechnung enthält keine Positionen mit Betrag.");
}
LocalDate issueDate = invoice.getInvoiceDate() != null ? invoice.getInvoiceDate() : LocalDate.now();
LocalDate deliveryDate = invoice.getDeliveryDate() != null ? invoice.getDeliveryDate() : issueDate;
LocalDate dueDate = invoice.getPaymentDueDate() != null ? invoice.getPaymentDueDate() : issueDate.plusDays(14);
return new PdfToolService.InvoiceMetadata(invoice.getInvoiceNumber(), issueDate, deliveryDate, dueDate, "EUR",
safe(invoice.getPaymentTerms()), null, blankToNull(invoice.getIban()), blankToNull(invoice.getBic()),
sender, recipient, items);
}
private List<PdfToolService.LineItem> buildLineItems(CustomerInvoice invoice) {
BigDecimal vatPercent = invoice.getVatRate() != null
? invoice.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: new BigDecimal("19");
List<PdfToolService.LineItem> items = new ArrayList<>();
if (invoice.getItems() != null) {
for (CustomerInvoiceItem item : invoice.getItems()) {
if (item == null || item.getQuantity() == null || item.getUnitPrice() == null) {
continue;
}
BigDecimal itemVatPercent = item.getVatRate() != null
? item.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: vatPercent;
items.add(new PdfToolService.LineItem(safe(item.getDescription()), item.getQuantity(),
item.getUnitPrice(), itemVatPercent));
}
}
// Ältere Rechnungen ohne Positionsliste: eine Sammelposition aus dem
// Rechnungs-Netto, damit sie trotzdem als E-Rechnung versendbar bleiben.
if (items.isEmpty() && invoice.getNetAmount() != null && invoice.getNetAmount().signum() > 0) {
items.add(new PdfToolService.LineItem(firstNonBlank(invoice.getDescription(), "Leistung gemäß Rechnung"),
BigDecimal.ONE, invoice.getNetAmount(), vatPercent));
}
return items;
}
private String safe(String value) {
return value != null ? value : "";
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
# Enable browser launch in development
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
logging.level.de.assecutor.votianlt=DEBUG
logging.level.root=INFO

View File

@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
# Disable browser launch in production
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
app.security.two-factor.enabled=true

View File

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

View File

@@ -241,7 +241,7 @@ page.title.dashboard=VotianLT - Dashboard
page.title.appuser.create=Neuen App-Nutzer anlegen
page.title.messages=Nachrichten
page.title.register=Bei VotianLT registrieren
page.title.customers=Kunden
page.title.customers=Adressbuch
page.title.customer.edit=Adresse bearbeiten
page.title.verwaltung=Verwaltung
page.title.company.create=Neue Firma anlegen
@@ -249,7 +249,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Profil bearbeiten
page.title.admin.dashboard=Admin Dashboard
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.jobs=Aufträge
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
# Customers
customers.title=Kunden
customers.button.add=Neuen Kunden hinzufügen
customers.hint.click=Klicken Sie auf einen Kunden, um Details zu sehen
customers.title=Adressbuch
customers.button.add=Neue Adresse anlegen
customers.hint.click=Klicken Sie auf einen Adresseintrag, um Details zu sehen
customers.column.company=Firma
customers.column.name=Name
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
# Add Customer
addcustomer.title=Neuen Kunden anlegen
addcustomer.button.submit=Kunden anlegen
addcustomer.title=Neue Adresse anlegen
addcustomer.button.submit=Adresse anlegen
addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus
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.error=Fehler: {0}
addcustomer.validation.required=Dieses Feld ist erforderlich
@@ -1051,8 +1052,123 @@ misc.retry=Erneut versuchen
# Admin Price Table
adminpricetable.title=Preistabelle
adminpricetable.field.monthly=Monatliches Basispaket
adminpricetable.field.applicense=App-Nutzungslizenz
adminpricetable.field.revenue=Umsatzbeteiligung
adminpricetable.field.appuserfee=Gebühr pro App-Nutzer
adminpricetable.field.appuserfee.helper=Wird je eingerichtetem App-Nutzer monatlich berechnet
adminpricetable.notification.saved=Preistabelle wurde gespeichert
adminpricetable.notification.save.error=Fehler beim Speichern: {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
admincreateinvoices.notification.zugferd.batcherror=E-Rechnung {0} konnte nicht erstellt werden, es wurden keine Rechnungen versendet: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet.
admincreateinvoices.notification.zip.combined.error=Fehler beim Erstellen der Sammel-ZIP-Datei: {0}
# E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen an ihre Kunden
invoices.action.zugferd=E-Rechnung (ZIP)
invoices.action.sendinvoice=Per E-Mail senden
invoices.notification.zugferd.created=E-Rechnung {0} erstellt
invoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
invoices.notification.zugferd.invalidsend=Versand abgebrochen: Die Validierung der E-Rechnung {0} ist fehlgeschlagen.
invoices.notification.zugferd.error=Fehler bei der E-Rechnung: {0}
invoices.notification.email.missing=Für den Rechnungsempfänger ist keine E-Mail-Adresse hinterlegt.
invoices.notification.emailsent=Rechnung {0} wurde als E-Rechnung an {1} versendet.
invoices.mail.subject=Ihre Rechnung {0}
invoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} als E-Rechnung (ZIP mit PDF und Rechnungsdaten).\n\nMit freundlichen Grüßen\n{1}
profile.billing.ustid=USt-IdNr.
profile.billing.taxnumber=Steuernummer
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -213,7 +213,7 @@ page.title.dashboard=VotianLT - T\u00f6\u00f6laud
page.title.appuser.create=Uue \u00e4pikasutaja loomine
page.title.messages=S\u00f5numid
page.title.register=VotianLT-sse registreerimine
page.title.customers=Kliendid
page.title.customers=Aadressiraamat
page.title.customer.edit=Kliendi muutmine
page.title.verwaltung=Haldus
page.title.company.create=Uue ettev\u00f5tte loomine
@@ -221,7 +221,7 @@ page.title.imprint=Impressum
page.title.profile.edit=Profiili muutmine
page.title.admin.dashboard=Administraatori t\u00f6\u00f6laud
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.jobs=Tellimused
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.dialog.delete.text=Kas soovite selle \u00e4pikasutaja t\u00f5esti kustutada?
editappuser.dialog.delete.confirm=Kustuta
customers.title=Kliendid
customers.button.add=Lisa uus klient
customers.hint.click=Kl\u00f5psake kliendil, et n\u00e4ha \u00fcksikasju
customers.title=Aadressiraamat
customers.button.add=Lisa uus aadress
customers.hint.click=Kl\u00f5psake aadressikirjel, et n\u00e4ha \u00fcksikasju
customers.column.company=Ettev\u00f5te
customers.column.name=Nimi
customers.column.email=E-post
@@ -309,10 +309,11 @@ editcustomer.notification.check=Palun kontrollige oma sisestusi
editcustomer.notification.deleted=Klient edukalt kustutatud
editcustomer.dialog.delete.text=Kas soovite selle kliendi t\u00f5esti kustutada?
editcustomer.dialog.delete.confirm=Kustuta
addcustomer.title=Uue kliendi loomine
addcustomer.button.submit=Loo klient
addcustomer.title=Uue aadressi loomine
addcustomer.button.submit=Loo aadress
addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad
addcustomer.notification.success=Klient edukalt loodud
addcustomer.notification.duplicate=Identne aadressiraamatu kirje on juba olemas
addcustomer.notification.check=Palun kontrollige oma sisestusi
addcustomer.notification.error=Viga: {0}
addcustomer.validation.required=See v\u00e4li on kohustuslik
@@ -881,8 +882,123 @@ misc.error=Ilmnes viga
misc.retry=Proovi uuesti
adminpricetable.title=Hinnatabel
adminpricetable.field.monthly=Igakuine p\u00f5hipakett
adminpricetable.field.applicense=\u00c4pi kasutuslitsents
adminpricetable.field.revenue=K\u00e4ibest osalus
adminpricetable.field.appuserfee=Tasu äpi kasutaja kohta
adminpricetable.field.appuserfee.helper=Arvestatakse igakuiselt iga seadistatud äpi kasutaja kohta
adminpricetable.notification.saved=Hinnatabel on salvestatud
adminpricetable.notification.save.error=Salvestamisel 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
admincreateinvoices.notification.zugferd.batcherror=E-arvet {0} ei õnnestunud luua, ühtegi arvet ei saadetud: {1}
admincreateinvoices.notification.zugferd.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud.
admincreateinvoices.notification.zip.combined.error=Viga koond-ZIP-faili loomisel: {0}
# E-arve (ZUGFeRD) kasutajate arvetele nende klientidele
invoices.action.zugferd=E-arve (ZIP)
invoices.action.sendinvoice=Saada e-postiga
invoices.notification.zugferd.created=E-arve {0} loodud
invoices.notification.zugferd.invalid=E-arve loodi, kuid valideerimine ebaõnnestus. Vaadake kontrolliaruannet ZIP-failis.
invoices.notification.zugferd.invalidsend=Saatmine katkestati: e-arve {0} valideerimine ebaõnnestus.
invoices.notification.zugferd.error=E-arve viga: {0}
invoices.notification.email.missing=Arve saajale pole e-posti aadressi salvestatud.
invoices.notification.emailsent=Arve {0} saadeti e-arvena aadressile {1}.
invoices.mail.subject=Teie arve {0}
invoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} e-arvena (ZIP koos PDF-i ja arveandmetega).\n\nLugupidamisega\n{1}
profile.billing.ustid=KMKR nr
profile.billing.taxnumber=Maksunumber
profile.billing.bankname=Pank
profile.billing.iban=IBAN

View File

@@ -241,7 +241,7 @@ page.title.dashboard=VotianLT - Dashboard
page.title.appuser.create=Create New App User
page.title.messages=Messages
page.title.register=Register at VotianLT
page.title.customers=Customers
page.title.customers=Address Book
page.title.customer.edit=Edit Customer
page.title.verwaltung=Management
page.title.company.create=Create New Company
@@ -249,7 +249,7 @@ page.title.imprint=Imprint
page.title.profile.edit=Edit Profile
page.title.admin.dashboard=Admin Dashboard
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.jobs=Jobs
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
# Customers
customers.title=Customers
customers.button.add=Add New Customer
customers.hint.click=Click on a customer to see details
customers.title=Address Book
customers.button.add=Add New Address
customers.hint.click=Click on an address entry to see details
customers.column.company=Company
customers.column.name=Name
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
# Add Customer
addcustomer.title=Create New Customer
addcustomer.button.submit=Create Customer
addcustomer.title=Create New Address
addcustomer.button.submit=Create Address
addcustomer.notification.validation=Please fill in all required fields
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.error=Error: {0}
addcustomer.validation.required=This field is required
@@ -1051,8 +1052,123 @@ misc.retry=Retry
# Admin Price Table
adminpricetable.title=Price Table
adminpricetable.field.monthly=Monthly Base Package
adminpricetable.field.applicense=App Usage License
adminpricetable.field.revenue=Revenue Share
adminpricetable.field.appuserfee=Fee per App User
adminpricetable.field.appuserfee.helper=Billed monthly per app user set up
adminpricetable.notification.saved=Price table has been saved
adminpricetable.notification.save.error=Error saving: {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
admincreateinvoices.notification.zugferd.batcherror=E-invoice {0} could not be created, no invoices were sent: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validation failed for: {0}. No invoices were sent.
admincreateinvoices.notification.zip.combined.error=Error creating the combined ZIP file: {0}
# E-invoice (ZUGFeRD) for user invoices to their customers
invoices.action.zugferd=E-invoice (ZIP)
invoices.action.sendinvoice=Send by email
invoices.notification.zugferd.created=E-invoice {0} created
invoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
invoices.notification.zugferd.invalidsend=Sending aborted: validation of e-invoice {0} failed.
invoices.notification.zugferd.error=E-invoice error: {0}
invoices.notification.email.missing=No email address is stored for the invoice recipient.
invoices.notification.emailsent=Invoice {0} was sent as an e-invoice to {1}.
invoices.mail.subject=Your invoice {0}
invoices.mail.body=Dear Sir or Madam,\n\nPlease find attached your invoice {0} as an e-invoice (ZIP with PDF and invoice data).\n\nKind regards\n{1}
profile.billing.ustid=VAT ID
profile.billing.taxnumber=Tax number
profile.billing.bankname=Bank
profile.billing.iban=IBAN

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.messages=Mensajes
page.title.register=Registrarse en VotianLT
page.title.customers=Clientes
page.title.customers=Libreta de direcciones
page.title.customer.edit=Editar cliente
page.title.verwaltung=Administraci\u00f3n
page.title.company.create=Crear nueva empresa
@@ -248,7 +248,7 @@ page.title.imprint=Aviso legal
page.title.profile.edit=Editar perfil
page.title.admin.dashboard=Panel de administraci\u00f3n
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.jobs=Pedidos
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
# Customers
customers.title=Clientes
customers.button.add=A\u00f1adir nuevo cliente
customers.hint.click=Haga clic en un cliente para ver los detalles
customers.title=Libreta de direcciones
customers.button.add=A\u00f1adir nueva direcci\u00f3n
customers.hint.click=Haga clic en una entrada de direcci\u00f3n para ver los detalles
customers.column.company=Empresa
customers.column.name=Nombre
customers.column.email=Correo electr\u00f3nico
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=\u00bfDesea realmente eliminar este cliente?
editcustomer.dialog.delete.confirm=Eliminar
# Add Customer
addcustomer.title=Crear nuevo cliente
addcustomer.button.submit=Crear cliente
addcustomer.title=Crear nueva dirección
addcustomer.button.submit=Crear dirección
addcustomer.notification.validation=Por favor, complete todos los campos obligatorios
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.error=Error: {0}
addcustomer.validation.required=Este campo es obligatorio
@@ -982,8 +983,123 @@ misc.retry=Reintentar
# Admin Price Table
adminpricetable.title=Tabla de precios
adminpricetable.field.monthly=Paquete b\u00e1sico mensual
adminpricetable.field.applicense=Licencia de uso de la app
adminpricetable.field.revenue=Participaci\u00f3n en los ingresos
adminpricetable.field.appuserfee=Tarifa por usuario de la app
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.save.error=Error al guardar: {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
admincreateinvoices.notification.zugferd.batcherror=No se pudo crear la factura electrónica {0}, no se envió ninguna factura: {1}
admincreateinvoices.notification.zugferd.invalidsend=La validación falló para: {0}. No se envió ninguna factura.
admincreateinvoices.notification.zip.combined.error=Error al crear el archivo ZIP combinado: {0}
# Factura electrónica (ZUGFeRD) para facturas de usuarios a sus clientes
invoices.action.zugferd=Factura electrónica (ZIP)
invoices.action.sendinvoice=Enviar por correo
invoices.notification.zugferd.created=Factura electrónica {0} creada
invoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
invoices.notification.zugferd.invalidsend=Envío cancelado: la validación de la factura electrónica {0} ha fallado.
invoices.notification.zugferd.error=Error en la factura electrónica: {0}
invoices.notification.email.missing=No hay una dirección de correo electrónico registrada para el destinatario de la factura.
invoices.notification.emailsent=La factura {0} se envió como factura electrónica a {1}.
invoices.mail.subject=Su factura {0}
invoices.mail.body=Estimados señores:\n\nAdjunto encontrará su factura {0} como factura electrónica (ZIP con PDF y datos de la factura).\n\nAtentamente\n{1}
profile.billing.ustid=NIF-IVA
profile.billing.taxnumber=Número fiscal
profile.billing.bankname=Banco
profile.billing.iban=IBAN

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.messages=Messages
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.verwaltung=Administration
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.admin.dashboard=Tableau de bord administrateur
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.jobs=Missions
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
# Customers
customers.title=Clients
customers.button.add=Ajouter un nouveau client
customers.hint.click=Cliquez sur un client pour voir les d\u00e9tails
customers.title=Carnet d'adresses
customers.button.add=Ajouter une nouvelle adresse
customers.hint.click=Cliquez sur une entr\u00e9e d'adresse pour voir les d\u00e9tails
customers.column.company=Entreprise
customers.column.name=Nom
customers.column.email=E-mail
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Voulez-vous vraiment supprimer ce client ?
editcustomer.dialog.delete.confirm=Supprimer
# Add Customer
addcustomer.title=Cr\u00e9er un nouveau client
addcustomer.button.submit=Cr\u00e9er le client
addcustomer.title=Cr\u00e9er une nouvelle adresse
addcustomer.button.submit=Cr\u00e9er l'adresse
addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires
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.error=Erreur : {0}
addcustomer.validation.required=Ce champ est obligatoire
@@ -982,8 +983,123 @@ misc.retry=R\u00e9essayer
# Admin Price Table
adminpricetable.title=Tableau des prix
adminpricetable.field.monthly=Forfait mensuel de base
adminpricetable.field.applicense=Licence d'utilisation de l'app
adminpricetable.field.revenue=Participation au chiffre d'affaires
adminpricetable.field.appuserfee=Frais par utilisateur de l'app
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.save.error=Erreur lors de l'enregistrement : {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
admincreateinvoices.notification.zugferd.batcherror=La facture électronique {0} n''a pas pu être créée, aucune facture n''a été envoyée : {1}
admincreateinvoices.notification.zugferd.invalidsend=La validation a échoué pour : {0}. Aucune facture n''a été envoyée.
admincreateinvoices.notification.zip.combined.error=Erreur lors de la création du fichier ZIP combiné : {0}
# Facture électronique (ZUGFeRD) pour les factures des utilisateurs à leurs clients
invoices.action.zugferd=Facture électronique (ZIP)
invoices.action.sendinvoice=Envoyer par e-mail
invoices.notification.zugferd.created=Facture électronique {0} créée
invoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Voir le rapport de validation dans le fichier ZIP.
invoices.notification.zugferd.invalidsend=Envoi annulé : la validation de la facture électronique {0} a échoué.
invoices.notification.zugferd.error=Erreur de facture électronique : {0}
invoices.notification.email.missing=Aucune adresse e-mail n'est enregistrée pour le destinataire de la facture.
invoices.notification.emailsent=La facture {0} a été envoyée en tant que facture électronique à {1}.
invoices.mail.subject=Votre facture {0}
invoices.mail.body=Madame, Monsieur,\n\nVeuillez trouver ci-joint votre facture {0} sous forme de facture électronique (ZIP avec PDF et données de facturation).\n\nCordialement\n{1}
profile.billing.ustid=N° TVA intracommunautaire
profile.billing.taxnumber=Numéro fiscal
profile.billing.bankname=Banque
profile.billing.iban=IBAN

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Valdymo skydas
page.title.appuser.create=Sukurti naują programėlės naudotoją
page.title.messages=Žinutės
page.title.register=Registracija VotianLT
page.title.customers=Klientai
page.title.customers=Adresų knyga
page.title.customer.edit=Redaguoti klientą
page.title.verwaltung=Administravimas
page.title.company.create=Sukurti naują įmonę
@@ -248,7 +248,7 @@ page.title.imprint=Informacija
page.title.profile.edit=Redaguoti profilį
page.title.admin.dashboard=Administratoriaus valdymo skydas
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.jobs=Užsakymai
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
# Customers
customers.title=Klientai
customers.button.add=Pridėti naują klientą
customers.hint.click=Spustelėkite klientą, kad pamatytumėte informaciją
customers.title=Adresų knyga
customers.button.add=Pridėti naują adresą
customers.hint.click=Spustelėkite adreso įrašą, kad pamatytumėte informaciją
customers.column.company=Įmonė
customers.column.name=Pavadinimas
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
# Add Customer
addcustomer.title=Sukurti naują klientą
addcustomer.button.submit=Sukurti klientą
addcustomer.title=Sukurti naują adresą
addcustomer.button.submit=Sukurti adresą
addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus
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.error=Klaida: {0}
addcustomer.validation.required=Šis laukas yra privalomas
@@ -984,8 +985,123 @@ misc.retry=Bandyti dar kartą
# Admin Price Table
adminpricetable.title=Kainų lentelė
adminpricetable.field.monthly=Mėnesinis bazinis paketas
adminpricetable.field.applicense=Programėlės naudojimo licencija
adminpricetable.field.revenue=Pajamų dalis
adminpricetable.field.appuserfee=Mokestis už programėlės naudotoją
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.save.error=Klaida išsaugant: {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ų
admincreateinvoices.notification.zugferd.batcherror=Nepavyko sukurti e. sąskaitos {0}, sąskaitos nebuvo išsiųstos: {1}
admincreateinvoices.notification.zugferd.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos.
admincreateinvoices.notification.zip.combined.error=Klaida kuriant bendrą ZIP failą: {0}
# E. sąskaita (ZUGFeRD) naudotojų sąskaitoms jų klientams
invoices.action.zugferd=E. sąskaita (ZIP)
invoices.action.sendinvoice=Siųsti el. paštu
invoices.notification.zugferd.created=E. sąskaita {0} sukurta
invoices.notification.zugferd.invalid=E. sąskaita sukurta, bet patikra nepavyko. Žr. patikros ataskaitą ZIP faile.
invoices.notification.zugferd.invalidsend=Siuntimas nutrauktas: e. sąskaitos {0} patikra nepavyko.
invoices.notification.zugferd.error=E. sąskaitos klaida: {0}
invoices.notification.email.missing=Sąskaitos gavėjui nėra išsaugoto el. pašto adreso.
invoices.notification.emailsent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1}.
invoices.mail.subject=Jūsų sąskaita {0}
invoices.mail.body=Gerbiamieji,\n\npridedame Jūsų sąskaitą {0} kaip e. sąskaitą (ZIP su PDF ir sąskaitos duomenimis).\n\nPagarbiai\n{1}
profile.billing.ustid=PVM mokėtojo kodas
profile.billing.taxnumber=Mokesčių mokėtojo numeris
profile.billing.bankname=Bankas
profile.billing.iban=IBAN

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Informācijas panelis
page.title.appuser.create=Izveidot jaunu lietotnes lietotāju
page.title.messages=Ziņojumi
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.verwaltung=Pārvaldība
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.admin.dashboard=Administrācijas panelis
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.jobs=Uzdevumi
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
# Customers
customers.title=Klienti
customers.button.add=Pievienot jaunu klientu
customers.hint.click=Noklikšķiniet uz klienta, lai redzētu detaļas
customers.title=Adrešu grāmata
customers.button.add=Pievienot jaunu adresi
customers.hint.click=Noklikšķiniet uz adreses ieraksta, lai redzētu detaļas
customers.column.company=Uzņēmums
customers.column.name=Nosaukums
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
# Add Customer
addcustomer.title=Izveidot jaunu klientu
addcustomer.button.submit=Izveidot klientu
addcustomer.title=Izveidot jaunu adresi
addcustomer.button.submit=Izveidot adresi
addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus
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.error=Kļūda: {0}
addcustomer.validation.required=Šis lauks ir obligāts
@@ -982,8 +983,123 @@ misc.retry=Mēģināt vēlreiz
# Admin Price Table
adminpricetable.title=Cenu tabula
adminpricetable.field.monthly=Ikmēneša pamatpakete
adminpricetable.field.applicense=Lietotnes licence
adminpricetable.field.revenue=Ieņēmumu daļa
adminpricetable.field.appuserfee=Maksa par lietotnes lietotāju
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.save.error=Kļūda saglabā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
admincreateinvoices.notification.zugferd.batcherror=Neizdevās izveidot e-rēķinu {0}, rēķini netika nosūtīti: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti.
admincreateinvoices.notification.zip.combined.error=Kļūda, veidojot apvienoto ZIP failu: {0}
# E-rēķins (ZUGFeRD) lietotāju rēķiniem viņu klientiem
invoices.action.zugferd=E-rēķins (ZIP)
invoices.action.sendinvoice=Sūtīt pa e-pastu
invoices.notification.zugferd.created=E-rēķins {0} izveidots
invoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Skatiet pārbaudes atskaiti ZIP failā.
invoices.notification.zugferd.invalidsend=Sūtīšana pārtraukta: e-rēķina {0} validācija neizdevās.
invoices.notification.zugferd.error=E-rēķina kļūda: {0}
invoices.notification.email.missing=Rēķina saņēmējam nav saglabāta e-pasta adrese.
invoices.notification.emailsent=Rēķins {0} nosūtīts kā e-rēķins uz {1}.
invoices.mail.subject=Jūsu rēķins {0}
invoices.mail.body=Cienījamie klienti!\n\nPielikumā Jūsu rēķins {0} kā e-rēķins (ZIP ar PDF un rēķina datiem).\n\nAr cieņu\n{1}
profile.billing.ustid=PVN numurs
profile.billing.taxnumber=Nodokļu maksātāja numurs
profile.billing.bankname=Banka
profile.billing.iban=IBAN

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.messages=Wiadomo\u015bci
page.title.register=Rejestracja w VotianLT
page.title.customers=Klienci
page.title.customers=Książka adresowa
page.title.customer.edit=Edytuj klienta
page.title.verwaltung=Zarz\u0105dzanie
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.admin.dashboard=Panel administracyjny
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.jobs=Zlecenia
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
# Customers
customers.title=Klienci
customers.button.add=Dodaj nowego klienta
customers.hint.click=Kliknij klienta, aby zobaczy\u0107 szczeg\u00f3\u0142y
customers.title=Ksi\u0105\u017cka adresowa
customers.button.add=Dodaj nowy adres
customers.hint.click=Kliknij wpis adresowy, aby zobaczy\u0107 szczeg\u00f3\u0142y
customers.column.company=Firma
customers.column.name=Nazwa
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
# Add Customer
addcustomer.title=Dodaj nowego klienta
addcustomer.button.submit=Dodaj klienta
addcustomer.title=Dodaj nowy adres
addcustomer.button.submit=Dodaj adres
addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola
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.error=B\u0142\u0105d: {0}
addcustomer.validation.required=To pole jest wymagane
@@ -982,8 +983,123 @@ misc.retry=Spr\u00f3buj ponownie
# Admin Price Table
adminpricetable.title=Cennik
adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy
adminpricetable.field.applicense=Licencja aplikacji
adminpricetable.field.revenue=Udzia\u0142 w przychodach
adminpricetable.field.appuserfee=Opłata za użytkownika aplikacji
adminpricetable.field.appuserfee.helper=Naliczana miesięcznie za każdego skonfigurowanego użytkownika aplikacji
adminpricetable.notification.saved=Cennik zosta\u0142 zapisany
adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {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
admincreateinvoices.notification.zugferd.batcherror=Nie udało się utworzyć e-faktury {0}, żadne faktury nie zostały wysłane: {1}
admincreateinvoices.notification.zugferd.invalidsend=Walidacja nie powiodła się dla: {0}. Żadne faktury nie zostały wysłane.
admincreateinvoices.notification.zip.combined.error=Błąd podczas tworzenia zbiorczego pliku ZIP: {0}
# E-faktura (ZUGFeRD) dla faktur użytkowników dla ich klientów
invoices.action.zugferd=E-faktura (ZIP)
invoices.action.sendinvoice=Wyślij e-mailem
invoices.notification.zugferd.created=E-faktura {0} utworzona
invoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Zobacz raport walidacji w pliku ZIP.
invoices.notification.zugferd.invalidsend=Wysyłka przerwana: walidacja e-faktury {0} nie powiodła się.
invoices.notification.zugferd.error=Błąd e-faktury: {0}
invoices.notification.email.missing=Dla odbiorcy faktury nie zapisano adresu e-mail.
invoices.notification.emailsent=Faktura {0} została wysłana jako e-faktura na adres {1}.
invoices.mail.subject=Państwa faktura {0}
invoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} jako e-fakturę (ZIP z plikiem PDF i danymi faktury).\n\nZ poważaniem\n{1}
profile.billing.ustid=NIP UE
profile.billing.taxnumber=Numer podatkowy
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Панель управления
page.title.appuser.create=Создать нового пользователя приложения
page.title.messages=Сообщения
page.title.register=Регистрация в VotianLT
page.title.customers=Клиенты
page.title.customers=Адресная книга
page.title.customer.edit=Редактирование клиента
page.title.verwaltung=Управление
page.title.company.create=Создать новую компанию
@@ -248,7 +248,7 @@ page.title.imprint=Выходные данные
page.title.profile.edit=Редактирование профиля
page.title.admin.dashboard=Панель администратора
page.title.invoice.create=Создать счёт
page.title.customer.create=Создать нового клиента
page.title.customer.create=Создать новый адрес
page.title.login=Вход в VotianLT
page.title.jobs=Заказы
page.title.appuser.edit=Редактирование пользователя приложения
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Вы действительно хотите уд
editappuser.dialog.delete.confirm=Удалить
# Customers
customers.title=Клиенты
customers.button.add=Добавить нового клиента
customers.hint.click=Нажмите на клиента, чтобы увидеть подробности
customers.title=Адресная книга
customers.button.add=Добавить новый адрес
customers.hint.click=Нажмите на запись адреса, чтобы увидеть подробности
customers.column.company=Компания
customers.column.name=Имя
customers.column.email=Эл. почта
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Вы действительно хотите у
editcustomer.dialog.delete.confirm=Удалить
# Add Customer
addcustomer.title=Создать нового клиента
addcustomer.button.submit=Создать клиента
addcustomer.title=Создать новый адрес
addcustomer.button.submit=Создать адрес
addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля
addcustomer.notification.success=Клиент успешно создан
addcustomer.notification.duplicate=Идентичная запись в адресной книге уже существует
addcustomer.notification.check=Пожалуйста, проверьте ваши данные
addcustomer.notification.error=Ошибка: {0}
addcustomer.validation.required=Это поле обязательно для заполнения
@@ -982,8 +983,123 @@ misc.retry=Повторить попытку
# Admin Price Table
adminpricetable.title=Таблица цен
adminpricetable.field.monthly=Ежемесячный базовый пакет
adminpricetable.field.applicense=Лицензия на приложение
adminpricetable.field.revenue=Участие в выручке
adminpricetable.field.appuserfee=Плата за пользователя приложения
adminpricetable.field.appuserfee.helper=Начисляется ежемесячно за каждого настроенного пользователя приложения
adminpricetable.notification.saved=Таблица цен сохранена
adminpricetable.notification.save.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=Нет позиций для выставления электронного счёта
admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1}
admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены.
admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0}
# Электронный счёт (ZUGFeRD) для счетов пользователей их клиентам
invoices.action.zugferd=Электронный счёт (ZIP)
invoices.action.sendinvoice=Отправить по эл. почте
invoices.notification.zugferd.created=Электронный счёт {0} создан
invoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. См. отчёт о проверке в ZIP-файле.
invoices.notification.zugferd.invalidsend=Отправка отменена: проверка электронного счёта {0} не пройдена.
invoices.notification.zugferd.error=Ошибка электронного счёта: {0}
invoices.notification.email.missing=Для получателя счёта не сохранён адрес электронной почты.
invoices.notification.emailsent=Счёт {0} отправлен как электронный счёт на {1}.
invoices.mail.subject=Ваш счёт {0}
invoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Ваш счёт {0} в виде электронного счёта (ZIP с PDF и данными счёта).\n\nС уважением\n{1}
profile.billing.ustid=Идентификатор плательщика НДС
profile.billing.taxnumber=Налоговый номер
profile.billing.bankname=Банк
profile.billing.iban=IBAN

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.messages=Mesajlar
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.verwaltung=Y\u00f6netim
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.admin.dashboard=Y\u00f6netici Kontrol Paneli
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.jobs=\u0130\u015fler
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
# Customers
customers.title=M\u00fc\u015fteriler
customers.button.add=Yeni M\u00fc\u015fteri Ekle
customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir m\u00fc\u015fteriye t\u0131klay\u0131n
customers.title=Adres defteri
customers.button.add=Yeni Adres Ekle
customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir adres giri\u015fine t\u0131klay\u0131n
customers.column.company=\u015eirket
customers.column.name=Ad
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
# Add Customer
addcustomer.title=Yeni M\u00fc\u015fteri Olu\u015ftur
addcustomer.button.submit=M\u00fc\u015fteri Olu\u015ftur
addcustomer.title=Yeni Adres Olu\u015ftur
addcustomer.button.submit=Adres Olu\u015ftur
addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun
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.error=Hata: {0}
addcustomer.validation.required=Bu alan gereklidir
@@ -982,8 +983,123 @@ misc.retry=Tekrar Dene
# Admin Price Table
adminpricetable.title=Fiyat Tablosu
adminpricetable.field.monthly=Ayl\u0131k Temel Paket
adminpricetable.field.applicense=Uygulama Kullan\u0131m Lisans\u0131
adminpricetable.field.revenue=Gelir Pay\u0131
adminpricetable.field.appuserfee=Uygulama kullanıcısı başına ücret
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.save.error=Kaydetme 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
admincreateinvoices.notification.zugferd.batcherror=E-fatura {0} oluşturulamadı, hiçbir fatura gönderilmedi: {1}
admincreateinvoices.notification.zugferd.invalidsend=Doğrulama başarısız oldu: {0}. Hiçbir fatura gönderilmedi.
admincreateinvoices.notification.zip.combined.error=Toplu ZIP dosyası oluşturulurken hata: {0}
# Kullanıcıların müşterilerine kestiği faturalar için e-fatura (ZUGFeRD)
invoices.action.zugferd=E-fatura (ZIP)
invoices.action.sendinvoice=E-posta ile gönder
invoices.notification.zugferd.created=E-fatura {0} oluşturuldu
invoices.notification.zugferd.invalid=E-fatura oluşturuldu, ancak doğrulama başarısız oldu. ZIP dosyasındaki doğrulama raporuna bakın.
invoices.notification.zugferd.invalidsend=Gönderim iptal edildi: {0} e-faturasının doğrulaması başarısız oldu.
invoices.notification.zugferd.error=E-fatura hatası: {0}
invoices.notification.email.missing=Fatura alıcısı için kayıtlı bir e-posta adresi yok.
invoices.notification.emailsent={0} numaralı fatura e-fatura olarak {1} adresine gönderildi.
invoices.mail.subject=Faturanız {0}
invoices.mail.body=Sayın yetkili,\n\nekte {0} numaralı faturanızı e-fatura olarak (PDF ve fatura verilerini içeren ZIP) bulabilirsiniz.\n\nSaygılarımızla\n{1}
profile.billing.ustid=KDV kimlik no
profile.billing.taxnumber=Vergi numarası
profile.billing.bankname=Banka
profile.billing.iban=IBAN

View File

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

View File

@@ -0,0 +1,161 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class UserInvoiceZugferdServiceTest {
@Mock
private PdfToolService pdfToolService;
@Captor
private ArgumentCaptor<PdfToolService.InvoiceMetadata> metadataCaptor;
private UserInvoiceZugferdService service;
@BeforeEach
void setUp() {
service = new UserInvoiceZugferdService(pdfToolService);
}
private CustomerInvoice fullyPopulatedInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber("RE-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 7, 14));
invoice.setDeliveryDate(LocalDate.of(2026, 7, 10));
invoice.setPaymentDueDate(LocalDate.of(2026, 7, 28));
invoice.setSenderName("Spedition Muster GmbH");
invoice.setSenderAddress("Musterstraße 1");
invoice.setSenderPostcode("20095");
invoice.setSenderCity("Hamburg");
invoice.setSenderVatId("DE123456789");
invoice.setSenderEmail("info@muster.de");
invoice.setSenderPhone("040 123456");
invoice.setRecipientCompany("Kunde AG");
invoice.setRecipientName("Erika Beispiel");
invoice.setRecipientAddress("Beispielweg 2");
invoice.setRecipientPostcode("10115");
invoice.setRecipientCity("Berlin");
invoice.setRecipientEmail("rechnung@kunde-ag.de");
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setIban("DE02120300000000202051");
invoice.setPaymentTerms("Zahlbar innerhalb von 14 Tagen");
invoice.setItems(List.of(
new CustomerInvoiceItem(new BigDecimal("12"), "km", "Transport", new BigDecimal("5.00"),
new BigDecimal("0.19")),
new CustomerInvoiceItem(BigDecimal.ONE, "pauschal", "Grundpreis", new BigDecimal("40.00"),
new BigDecimal("0.19"))));
invoice.setPdfData(new byte[] { 1, 2, 3 });
return invoice;
}
@Test
void signBuildsMetadataFromInvoice() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
PdfToolService.SignedInvoice signed = new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true);
when(pdfToolService.signInvoice(eq(invoice.getPdfData()), any())).thenReturn(signed);
PdfToolService.SignedInvoice result = service.sign(invoice);
assertThat(result).isSameAs(signed);
verify(pdfToolService).signInvoice(eq(invoice.getPdfData()), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.invoiceNumber()).isEqualTo("RE-2026-0001");
assertThat(metadata.issueDate()).isEqualTo(LocalDate.of(2026, 7, 14));
assertThat(metadata.deliveryDate()).isEqualTo(LocalDate.of(2026, 7, 10));
assertThat(metadata.dueDate()).isEqualTo(LocalDate.of(2026, 7, 28));
assertThat(metadata.currency()).isEqualTo("EUR");
assertThat(metadata.iban()).isEqualTo("DE02120300000000202051");
assertThat(metadata.sender().name()).isEqualTo("Spedition Muster GmbH");
assertThat(metadata.sender().vatId()).isEqualTo("DE123456789");
assertThat(metadata.recipient().name()).isEqualTo("Kunde AG");
assertThat(metadata.recipient().zip()).isEqualTo("10115");
assertThat(metadata.recipient().email()).isEqualTo("rechnung@kunde-ag.de");
assertThat(metadata.items()).hasSize(2);
assertThat(metadata.items().get(0).description()).isEqualTo("Transport");
assertThat(metadata.items().get(0).quantity()).isEqualByComparingTo("12");
assertThat(metadata.items().get(0).unitPriceNet()).isEqualByComparingTo("5.00");
assertThat(metadata.items().get(0).vatPercent()).isEqualByComparingTo("19");
}
@Test
void signFallsBackToSingleLineForLegacyInvoiceWithoutItems() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setDescription("Kurierfahrt HamburgBerlin");
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
List<PdfToolService.LineItem> items = metadataCaptor.getValue().items();
assertThat(items).hasSize(1);
assertThat(items.get(0).description()).isEqualTo("Kurierfahrt HamburgBerlin");
assertThat(items.get(0).quantity()).isEqualByComparingTo("1");
assertThat(items.get(0).unitPriceNet()).isEqualByComparingTo("100.00");
}
@Test
void signUsesDefaultDatesWhenMissing() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setDeliveryDate(null);
invoice.setPaymentDueDate(null);
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.deliveryDate()).isEqualTo(invoice.getInvoiceDate());
assertThat(metadata.dueDate()).isEqualTo(invoice.getInvoiceDate().plusDays(14));
}
@Test
void signRejectsInvoiceWithoutPdf() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setPdfData(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("kein PDF");
}
@Test
void signRejectsInvoiceWithoutParties() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setSenderName(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Absender");
}
@Test
void signRejectsInvoiceWithoutAnyAmount() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setNetAmount(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Positionen");
}
}

View File

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