refactor: Projektstruktur in app/ und backend/ aufgeteilt
This commit is contained in:
32
backend/src/main/bundles/README.md
Normal file
32
backend/src/main/bundles/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
This directory is automatically generated by Vaadin and contains the pre-compiled
|
||||
frontend files/resources for your project (frontend development bundle).
|
||||
|
||||
It should be added to Version Control System and committed, so that other developers
|
||||
do not have to compile it again.
|
||||
|
||||
Frontend development bundle is automatically updated when needed:
|
||||
- an npm/pnpm package is added with @NpmPackage or directly into package.json
|
||||
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
|
||||
- Vaadin add-on with front-end customizations is added
|
||||
- Custom theme imports/assets added into 'theme.json' file
|
||||
- Exported web component is added.
|
||||
|
||||
If your project development needs a hot deployment of the frontend changes,
|
||||
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
|
||||
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
|
||||
- configure `vaadin-maven-plugin`:
|
||||
```
|
||||
<configuration>
|
||||
<frontendHotdeploy>true</frontendHotdeploy>
|
||||
</configuration>
|
||||
```
|
||||
- configure `jetty-maven-plugin`:
|
||||
```
|
||||
<configuration>
|
||||
<systemProperties>
|
||||
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
|
||||
</systemProperties>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle).
|
||||
BIN
backend/src/main/bundles/dev.bundle
Normal file
BIN
backend/src/main/bundles/dev.bundle
Normal file
Binary file not shown.
BIN
backend/src/main/bundles/prod.bundle
Normal file
BIN
backend/src/main/bundles/prod.bundle
Normal file
Binary file not shown.
23
backend/src/main/frontend/index.html
Normal file
23
backend/src/main/frontend/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
This file is auto-generated by Vaadin.
|
||||
-->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body, #outlet {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- This outlet div is where the views are rendered -->
|
||||
<div id="outlet"></div>
|
||||
</body>
|
||||
</html>
|
||||
878
backend/src/main/frontend/invoice-generator/invoice-generator.js
Normal file
878
backend/src/main/frontend/invoice-generator/invoice-generator.js
Normal file
@@ -0,0 +1,878 @@
|
||||
// Invoice Generator - Native HTML5 Canvas Implementation
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class InvoiceGenerator {
|
||||
constructor(containerId) {
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.elements = new Map();
|
||||
this.selectedElement = null;
|
||||
this.elementCounter = 0;
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.dragStart = { x: 0, y: 0 };
|
||||
this.elementStart = { x: 0, y: 0, w: 0, h: 0 };
|
||||
this.activeHandle = null;
|
||||
this.container = null;
|
||||
this.containerId = containerId || 'invoice-canvas-container';
|
||||
|
||||
// Page dimensions (A4 at 96 DPI)
|
||||
this.pageX = 50;
|
||||
this.pageY = 30;
|
||||
this.pageWidth = 794;
|
||||
this.pageHeight = 1123;
|
||||
this.scale = 1;
|
||||
|
||||
// Handle configuration
|
||||
this.handleSize = 10;
|
||||
this.handlePadding = 3;
|
||||
|
||||
// Grid configuration (Snap to Grid)
|
||||
this.gridSize = 5;
|
||||
this.showGrid = true;
|
||||
}
|
||||
|
||||
// Snap value to grid
|
||||
snapToGrid(value) {
|
||||
return Math.round(value / this.gridSize) * this.gridSize;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (!this.container) {
|
||||
console.error('Canvas container not found: ' + this.containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create canvas
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.style.display = 'block';
|
||||
this.updateCanvasSize();
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(this.canvas);
|
||||
|
||||
// Bind events
|
||||
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
||||
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
||||
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
||||
this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this));
|
||||
|
||||
// Keyboard navigation for selected element
|
||||
this.canvas.setAttribute('tabindex', '0');
|
||||
this.canvas.addEventListener('keydown', this.onKeyDown.bind(this));
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
this.updateCanvasSize();
|
||||
this.draw();
|
||||
});
|
||||
|
||||
this.draw();
|
||||
console.log('Invoice generator initialized');
|
||||
}
|
||||
|
||||
updateCanvasSize() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
|
||||
// Calculate scale to maximize page size while maintaining aspect ratio
|
||||
// Use full available space with minimal padding
|
||||
const padding = 10;
|
||||
const availableWidth = this.canvas.width - padding * 2;
|
||||
const availableHeight = this.canvas.height - padding * 2;
|
||||
|
||||
// Scale to fit the available space (can be larger than 1 if container is bigger than A4)
|
||||
this.scale = Math.min(availableWidth / this.pageWidth, availableHeight / this.pageHeight);
|
||||
|
||||
// Center the scaled page
|
||||
const scaledPageWidth = this.pageWidth * this.scale;
|
||||
const scaledPageHeight = this.pageHeight * this.scale;
|
||||
this.pageX = (this.canvas.width - scaledPageWidth) / 2;
|
||||
this.pageY = (this.canvas.height - scaledPageHeight) / 2;
|
||||
}
|
||||
|
||||
getMousePos(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
// Convert screen coordinates to canvas coordinates
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
// Convert to page coordinates (accounting for scale and offset)
|
||||
return {
|
||||
x: (canvasX - this.pageX) / this.scale,
|
||||
y: (canvasY - this.pageY) / this.scale
|
||||
};
|
||||
}
|
||||
|
||||
// Check if point is inside element
|
||||
hitTest(x, y, el) {
|
||||
const w = el.width || 150;
|
||||
const h = el.height || 30;
|
||||
return x >= el.x && x <= el.x + w && y >= el.y && y <= el.y + h;
|
||||
}
|
||||
|
||||
// Check if point is on a resize handle
|
||||
getHandleAt(x, y, el) {
|
||||
if (!el) return null;
|
||||
|
||||
const w = el.width || 150;
|
||||
const h = el.height || 30;
|
||||
const hs = this.handleSize + this.handlePadding;
|
||||
|
||||
// Define handle positions (center points)
|
||||
const handles = {
|
||||
'nw': { x: el.x, y: el.y },
|
||||
'n': { x: el.x + w / 2, y: el.y },
|
||||
'ne': { x: el.x + w, y: el.y },
|
||||
'w': { x: el.x, y: el.y + h / 2 },
|
||||
'e': { x: el.x + w, y: el.y + h / 2 },
|
||||
'sw': { x: el.x, y: el.y + h },
|
||||
's': { x: el.x + w / 2, y: el.y + h },
|
||||
'se': { x: el.x + w, y: el.y + h }
|
||||
};
|
||||
|
||||
for (const [name, pos] of Object.entries(handles)) {
|
||||
const dx = x - pos.x;
|
||||
const dy = y - pos.y;
|
||||
if (Math.abs(dx) <= hs && Math.abs(dy) <= hs) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
onMouseDown(e) {
|
||||
const pos = this.getMousePos(e);
|
||||
|
||||
// First check if clicking on a handle of selected element
|
||||
if (this.selectedElement) {
|
||||
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
|
||||
if (handle) {
|
||||
console.log('Resize handle clicked:', handle);
|
||||
this.isResizing = true;
|
||||
this.activeHandle = handle;
|
||||
this.dragStart = { x: pos.x, y: pos.y };
|
||||
this.elementStart = {
|
||||
x: this.selectedElement.x,
|
||||
y: this.selectedElement.y,
|
||||
w: this.selectedElement.width || 100,
|
||||
h: this.selectedElement.height || 30
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if clicking on an element
|
||||
let clickedElement = null;
|
||||
for (const el of Array.from(this.elements.values()).reverse()) {
|
||||
if (this.hitTest(pos.x, pos.y, el)) {
|
||||
clickedElement = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (clickedElement) {
|
||||
this.selectElement(clickedElement.id);
|
||||
this.isDragging = true;
|
||||
this.dragStart = { x: pos.x, y: pos.y };
|
||||
this.elementStart = { x: clickedElement.x, y: clickedElement.y };
|
||||
this.canvas.style.cursor = 'move';
|
||||
} else {
|
||||
this.deselectAll();
|
||||
}
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
const pos = this.getMousePos(e);
|
||||
|
||||
if (this.isResizing && this.selectedElement) {
|
||||
this.doResize(pos.x, pos.y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isDragging && this.selectedElement) {
|
||||
const dx = pos.x - this.dragStart.x;
|
||||
const dy = pos.y - this.dragStart.y;
|
||||
|
||||
let newX = this.elementStart.x + dx;
|
||||
let newY = this.elementStart.y + dy;
|
||||
|
||||
// Constrain to page (in page coordinates, page starts at 0,0)
|
||||
newX = Math.max(0, Math.min(newX,
|
||||
this.pageWidth - (this.selectedElement.width || 100)));
|
||||
newY = Math.max(0, Math.min(newY,
|
||||
this.pageHeight - (this.selectedElement.height || 30)));
|
||||
|
||||
// Snap to grid
|
||||
this.selectedElement.x = this.snapToGrid(newX);
|
||||
this.selectedElement.y = this.snapToGrid(newY);
|
||||
|
||||
this.draw();
|
||||
this.notifyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
if (this.selectedElement) {
|
||||
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
|
||||
if (handle) {
|
||||
const cursors = {
|
||||
'nw': 'nw-resize', 'ne': 'ne-resize', 'sw': 'sw-resize', 'se': 'se-resize',
|
||||
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize'
|
||||
};
|
||||
this.canvas.style.cursor = cursors[handle] || 'default';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check hover over elements
|
||||
let hovering = false;
|
||||
for (const el of Array.from(this.elements.values()).reverse()) {
|
||||
if (this.hitTest(pos.x, pos.y, el)) {
|
||||
hovering = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.canvas.style.cursor = hovering ? 'move' : 'default';
|
||||
}
|
||||
|
||||
onMouseUp(e) {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.activeHandle = null;
|
||||
this.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (!this.selectedElement) return;
|
||||
|
||||
const step = this.gridSize; // Move by grid size (5px)
|
||||
let moved = false;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowUp':
|
||||
this.selectedElement.y = Math.max(0, this.selectedElement.y - step);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
this.selectedElement.y = Math.min(
|
||||
this.pageHeight - (this.selectedElement.height || 30),
|
||||
this.selectedElement.y + step
|
||||
);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
this.selectedElement.x = Math.max(0, this.selectedElement.x - step);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
this.selectedElement.x = Math.min(
|
||||
this.pageWidth - (this.selectedElement.width || 100),
|
||||
this.selectedElement.x + step
|
||||
);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
this.draw();
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
doResize(mouseX, mouseY) {
|
||||
if (!this.selectedElement || !this.activeHandle) return;
|
||||
|
||||
const el = this.selectedElement;
|
||||
el.manuallyResized = true; // Mark as manually resized
|
||||
|
||||
const start = this.elementStart;
|
||||
const minSize = 20;
|
||||
|
||||
// Calculate new dimensions based on handle
|
||||
switch (this.activeHandle) {
|
||||
case 'se':
|
||||
el.width = Math.max(minSize, mouseX - start.x);
|
||||
el.height = Math.max(minSize, mouseY - start.y);
|
||||
break;
|
||||
|
||||
case 'sw':
|
||||
const newWsw = start.w + (start.x - mouseX);
|
||||
if (newWsw >= minSize) {
|
||||
el.x = mouseX;
|
||||
el.width = newWsw;
|
||||
}
|
||||
el.height = Math.max(minSize, mouseY - start.y);
|
||||
break;
|
||||
|
||||
case 'ne':
|
||||
el.width = Math.max(minSize, mouseX - start.x);
|
||||
const newHne = start.h + (start.y - mouseY);
|
||||
if (newHne >= minSize) {
|
||||
el.y = mouseY;
|
||||
el.height = newHne;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'nw':
|
||||
const newWnw = start.w + (start.x - mouseX);
|
||||
const newHnw = start.h + (start.y - mouseY);
|
||||
if (newWnw >= minSize) {
|
||||
el.x = mouseX;
|
||||
el.width = newWnw;
|
||||
}
|
||||
if (newHnw >= minSize) {
|
||||
el.y = mouseY;
|
||||
el.height = newHnw;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
el.width = Math.max(minSize, mouseX - start.x);
|
||||
break;
|
||||
|
||||
case 'w':
|
||||
const newWw = start.w + (start.x - mouseX);
|
||||
if (newWw >= minSize) {
|
||||
el.x = mouseX;
|
||||
el.width = newWw;
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
el.height = Math.max(minSize, mouseY - start.y);
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
const newHn = start.h + (start.y - mouseY);
|
||||
if (newHn >= minSize) {
|
||||
el.y = mouseY;
|
||||
el.height = newHn;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
notifyChange() {
|
||||
if (this.selectedElement && window.invoiceGeneratorView?.$server) {
|
||||
const el = this.selectedElement;
|
||||
window.invoiceGeneratorView.$server.updatePropertiesPanel(
|
||||
el.id, el.type, el.text || '', el.x, el.y, el.fontSize || 14, el.color || '#000000'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.ctx) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#e8e8e8';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Apply scale and offset
|
||||
ctx.translate(this.pageX, this.pageY);
|
||||
ctx.scale(this.scale, this.scale);
|
||||
|
||||
// Page shadow (drawn before scaling offset)
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.fillRect(this.pageX + 4 * this.scale, this.pageY + this.pageHeight * this.scale, this.pageWidth * this.scale, 4);
|
||||
ctx.fillRect(this.pageX + this.pageWidth * this.scale, this.pageY + 4 * this.scale, 4, this.pageHeight * this.scale);
|
||||
ctx.restore();
|
||||
|
||||
// Page
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, this.pageWidth, this.pageHeight);
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = 1 / this.scale;
|
||||
ctx.strokeRect(0, 0, this.pageWidth, this.pageHeight);
|
||||
|
||||
// Draw grid if enabled
|
||||
if (this.showGrid) {
|
||||
this.drawGrid(ctx);
|
||||
}
|
||||
|
||||
// Draw elements
|
||||
this.elements.forEach(el => this.drawElement(el));
|
||||
|
||||
// Draw selection
|
||||
if (this.selectedElement) {
|
||||
this.drawSelection(this.selectedElement);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawImagePlaceholder(ctx, el) {
|
||||
ctx.fillStyle = '#f0f0f0';
|
||||
ctx.fillRect(el.x, el.y, el.width || 100, el.height || 100);
|
||||
ctx.strokeStyle = '#999999';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Bild', el.x + (el.width || 100) / 2, el.y + (el.height || 100) / 2);
|
||||
}
|
||||
|
||||
drawElement(el) {
|
||||
const ctx = this.ctx;
|
||||
ctx.save();
|
||||
|
||||
switch (el.type) {
|
||||
case 'line':
|
||||
ctx.strokeStyle = el.color || '#333333';
|
||||
ctx.lineWidth = el.strokeWidth || 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(el.x, el.y);
|
||||
ctx.lineTo(el.x + (el.width || 200), el.y);
|
||||
ctx.stroke();
|
||||
break;
|
||||
|
||||
case 'vline':
|
||||
ctx.strokeStyle = el.color || '#333333';
|
||||
ctx.lineWidth = el.strokeWidth || 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(el.x, el.y);
|
||||
ctx.lineTo(el.x, el.y + (el.height || 200));
|
||||
ctx.stroke();
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
if (el.imageData) {
|
||||
// Draw actual image
|
||||
const img = el.imageObj;
|
||||
if (img && img.complete) {
|
||||
// Draw image scaled to fit element while maintaining aspect ratio
|
||||
const imgAspect = img.width / img.height;
|
||||
const elAspect = (el.width || 100) / (el.height || 100);
|
||||
|
||||
let drawWidth, drawHeight, drawX, drawY;
|
||||
|
||||
if (imgAspect > elAspect) {
|
||||
// Image is wider than element
|
||||
drawWidth = el.width || 100;
|
||||
drawHeight = drawWidth / imgAspect;
|
||||
drawX = el.x;
|
||||
drawY = el.y + ((el.height || 100) - drawHeight) / 2;
|
||||
} else {
|
||||
// Image is taller than element
|
||||
drawHeight = el.height || 100;
|
||||
drawWidth = drawHeight * imgAspect;
|
||||
drawX = el.x + ((el.width || 100) - drawWidth) / 2;
|
||||
drawY = el.y;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
|
||||
} else {
|
||||
// Image still loading or failed
|
||||
this.drawImagePlaceholder(ctx, el);
|
||||
}
|
||||
} else {
|
||||
// No image uploaded yet - draw placeholder
|
||||
this.drawImagePlaceholder(ctx, el);
|
||||
}
|
||||
break;
|
||||
|
||||
default: // text elements
|
||||
this.drawTextElement(el, ctx);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Wrap text to fit within maxWidth
|
||||
wrapText(ctx, text, maxWidth) {
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let currentLine = words[0];
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const width = ctx.measureText(currentLine + ' ' + word).width;
|
||||
if (width < maxWidth) {
|
||||
currentLine += ' ' + word;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
lines.push(currentLine);
|
||||
return lines;
|
||||
}
|
||||
|
||||
drawTextElement(el, ctx) {
|
||||
ctx.font = `${el.fontStyle || ''} ${el.fontSize || 14}px ${el.fontFamily || 'Arial'}`;
|
||||
ctx.fillStyle = el.color || '#333333';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const maxWidth = el.width || 150;
|
||||
const lineHeight = (el.fontSize || 14) * 1.2;
|
||||
const maxHeight = el.height || 1000;
|
||||
|
||||
// Calculate max lines that fit
|
||||
const maxLines = Math.floor(maxHeight / lineHeight);
|
||||
|
||||
// Split by explicit newlines first, then wrap each line
|
||||
const explicitLines = (el.text || '').split('\n');
|
||||
const allLines = [];
|
||||
|
||||
for (const line of explicitLines) {
|
||||
const metrics = ctx.measureText(line);
|
||||
if (metrics.width <= maxWidth) {
|
||||
allLines.push(line);
|
||||
} else {
|
||||
const wrapped = this.wrapText(ctx, line, maxWidth);
|
||||
allLines.push(...wrapped);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we need ellipsis
|
||||
const needsEllipsis = allLines.length > maxLines;
|
||||
const linesToDraw = needsEllipsis ? maxLines : allLines.length;
|
||||
|
||||
// Draw lines
|
||||
let y = el.y;
|
||||
for (let i = 0; i < linesToDraw; i++) {
|
||||
const line = allLines[i];
|
||||
|
||||
// If this is the last line and we need ellipsis
|
||||
if (needsEllipsis && i === linesToDraw - 1) {
|
||||
const ellipsis = '...';
|
||||
// Check if the line with ellipsis would fit
|
||||
if (ctx.measureText(line + ellipsis).width <= maxWidth) {
|
||||
ctx.fillText(line + ellipsis, el.x, y);
|
||||
} else {
|
||||
// Need to trim the line to fit ellipsis
|
||||
let trimmed = line;
|
||||
while (trimmed.length > 0 && ctx.measureText(trimmed + ellipsis).width > maxWidth) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
ctx.fillText(trimmed + ellipsis, el.x, y);
|
||||
}
|
||||
} else {
|
||||
ctx.fillText(line, el.x, y);
|
||||
}
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
// Update height based on actual content if not manually resized
|
||||
if (!el.manuallyResized) {
|
||||
el.height = Math.max(lineHeight, allLines.length * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
drawSelection(el) {
|
||||
const ctx = this.ctx;
|
||||
const x = el.x;
|
||||
const y = el.y;
|
||||
const w = el.width || 150;
|
||||
const h = el.height || 30;
|
||||
const hs = this.handleSize / this.scale;
|
||||
|
||||
// Selection border
|
||||
ctx.strokeStyle = '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Handles
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const positions = [
|
||||
[x - hs/2, y - hs/2], // nw
|
||||
[x + w/2 - hs/2, y - hs/2], // n
|
||||
[x + w - hs/2, y - hs/2], // ne
|
||||
[x - hs/2, y + h/2 - hs/2], // w
|
||||
[x + w - hs/2, y + h/2 - hs/2], // e
|
||||
[x - hs/2, y + h - hs/2], // sw
|
||||
[x + w/2 - hs/2, y + h - hs/2], // s
|
||||
[x + w - hs/2, y + h - hs/2] // se
|
||||
];
|
||||
|
||||
positions.forEach(([hx, hy]) => {
|
||||
ctx.fillRect(hx, hy, hs, hs);
|
||||
ctx.strokeRect(hx, hy, hs, hs);
|
||||
});
|
||||
}
|
||||
|
||||
drawGrid(ctx) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
|
||||
ctx.lineWidth = 0.5 / this.scale;
|
||||
|
||||
// Vertical lines (draw in page coordinates)
|
||||
for (let x = 0; x <= this.pageWidth; x += this.gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, this.pageHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = 0; y <= this.pageHeight; y += this.gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(this.pageWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
addElement(type, label, dropX, dropY) {
|
||||
this.elementCounter++;
|
||||
const id = `element-${this.elementCounter}`;
|
||||
|
||||
// dropX and dropY are in container coordinates, convert to page coordinates
|
||||
const pageCoordX = (dropX - this.pageX) / this.scale;
|
||||
const pageCoordY = (dropY - this.pageY) / this.scale;
|
||||
|
||||
// Snap initial position to grid (relative to page origin)
|
||||
const rawX = Math.max(10, pageCoordX - 50);
|
||||
const rawY = Math.max(10, pageCoordY - 15);
|
||||
const x = this.snapToGrid(rawX);
|
||||
const y = this.snapToGrid(rawY);
|
||||
|
||||
let el = {
|
||||
id, type, x, y,
|
||||
width: 150,
|
||||
height: 30,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Arial',
|
||||
color: '#333333'
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'text':
|
||||
el.text = 'Text eingeben...';
|
||||
el.height = 20;
|
||||
break;
|
||||
case 'header':
|
||||
el.text = 'Überschrift';
|
||||
el.fontSize = 24;
|
||||
el.fontStyle = 'bold';
|
||||
el.color = '#000000';
|
||||
el.width = 200;
|
||||
el.height = 30;
|
||||
break;
|
||||
case 'date':
|
||||
el.text = `Datum: ${new Date().toLocaleDateString('de-DE')}`;
|
||||
el.fontSize = 12;
|
||||
el.color = '#666666';
|
||||
el.height = 16;
|
||||
break;
|
||||
case 'customer':
|
||||
case 'company':
|
||||
el.text = type === 'customer' ? 'Kundenname\nStraße Nr.\nPLZ Ort' : 'Ihr Unternehmen\nIhre Straße\nIhre PLZ Ort';
|
||||
el.height = 50;
|
||||
el.fontSize = 12;
|
||||
break;
|
||||
case 'amount':
|
||||
el.text = 'Gesamtbetrag: 0,00 €';
|
||||
el.fontStyle = 'bold';
|
||||
el.width = 180;
|
||||
el.height = 20;
|
||||
break;
|
||||
case 'line':
|
||||
el.text = '';
|
||||
el.width = 200;
|
||||
el.height = 2;
|
||||
break;
|
||||
case 'vline':
|
||||
el.text = '';
|
||||
el.width = 2;
|
||||
el.height = 200;
|
||||
break;
|
||||
case 'image':
|
||||
el.text = 'Bild';
|
||||
el.width = 100;
|
||||
el.height = 100;
|
||||
break;
|
||||
default:
|
||||
el.text = label || 'Neues Element';
|
||||
el.height = 20;
|
||||
}
|
||||
|
||||
this.elements.set(id, el);
|
||||
this.selectElement(id);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
selectElement(id) {
|
||||
this.selectedElement = this.elements.get(id) || null;
|
||||
this.draw();
|
||||
if (this.selectedElement) {
|
||||
this.notifyChange();
|
||||
// Focus canvas for keyboard navigation
|
||||
if (this.canvas) {
|
||||
this.canvas.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deselectAll() {
|
||||
this.selectedElement = null;
|
||||
this.draw();
|
||||
if (window.invoiceGeneratorView?.$server) {
|
||||
window.invoiceGeneratorView.$server.resetPropertiesPanel();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementText(id, text) {
|
||||
const el = this.elements.get(id);
|
||||
if (el) {
|
||||
el.text = text;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementPosition(id, x, y) {
|
||||
const el = this.elements.get(id);
|
||||
if (el) {
|
||||
if (x !== null) el.x = this.snapToGrid(x);
|
||||
if (y !== null) el.y = this.snapToGrid(y);
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementFontSize(id, size) {
|
||||
const el = this.elements.get(id);
|
||||
if (el) {
|
||||
el.fontSize = size;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementColor(id, color) {
|
||||
const el = this.elements.get(id);
|
||||
if (el) {
|
||||
el.color = color;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementImage(id, imageDataUrl) {
|
||||
const el = this.elements.get(id);
|
||||
if (el && el.type === 'image') {
|
||||
el.imageData = imageDataUrl;
|
||||
|
||||
// Create image object
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
el.imageObj = img;
|
||||
this.draw();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load image');
|
||||
el.imageData = null;
|
||||
el.imageObj = null;
|
||||
this.draw();
|
||||
};
|
||||
img.src = imageDataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
deleteElement(id) {
|
||||
this.elements.delete(id);
|
||||
if (this.selectedElement?.id === id) {
|
||||
this.selectedElement = null;
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
clearCanvas() {
|
||||
this.elements.clear();
|
||||
this.selectedElement = null;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
getCanvasData() {
|
||||
return {
|
||||
elements: Array.from(this.elements.values())
|
||||
};
|
||||
}
|
||||
|
||||
exportTemplate() {
|
||||
const data = JSON.stringify(this.getCanvasData(), null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'template.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
exportTemplateJson() {
|
||||
// Return template data as JSON string for Java processing
|
||||
const data = this.getCanvasData();
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
generatePreview() {
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = this.pageWidth;
|
||||
temp.height = this.pageHeight;
|
||||
const tctx = temp.getContext('2d');
|
||||
|
||||
tctx.fillStyle = '#ffffff';
|
||||
tctx.fillRect(0, 0, temp.width, temp.height);
|
||||
|
||||
// Draw elements offset by page position
|
||||
const originalPageX = this.pageX;
|
||||
const originalPageY = this.pageY;
|
||||
this.pageX = 0;
|
||||
this.pageY = 0;
|
||||
|
||||
this.elements.forEach(el => {
|
||||
const ox = el.x, oy = el.y;
|
||||
el.x = ox - originalPageX;
|
||||
el.y = oy - originalPageY;
|
||||
this.drawElement.call({ ctx: tctx, elements: this.elements }, el);
|
||||
el.x = ox;
|
||||
el.y = oy;
|
||||
});
|
||||
|
||||
this.pageX = originalPageX;
|
||||
this.pageY = originalPageY;
|
||||
|
||||
const win = window.open();
|
||||
if (win) {
|
||||
win.document.write(`<img src="${temp.toDataURL()}" style="max-width:100%">`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.invoiceGenerator = new InvoiceGenerator();
|
||||
window.InvoiceGenerator = InvoiceGenerator;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
4
backend/src/main/frontend/themes/default/styles.css
Normal file
4
backend/src/main/frontend/themes/default/styles.css
Normal file
@@ -0,0 +1,4 @@
|
||||
/* Breite des linken Menüs (Drawer) */
|
||||
vaadin-app-layout {
|
||||
--vaadin-app-layout-drawer-width: 286px;
|
||||
}
|
||||
9
backend/src/main/frontend/themes/default/theme.json
Normal file
9
backend/src/main/frontend/themes/default/theme.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"lumoImports": [
|
||||
"typography",
|
||||
"color",
|
||||
"spacing",
|
||||
"badge",
|
||||
"utility"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[part="tabs-container"] {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
1894
backend/src/main/frontend/themes/votian-modern/styles.css
Normal file
1894
backend/src/main/frontend/themes/votian-modern/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"lumoImports": [
|
||||
"typography",
|
||||
"color",
|
||||
"spacing",
|
||||
"badge",
|
||||
"utility"
|
||||
]
|
||||
}
|
||||
56
backend/src/main/frontend/utils/language-cookie.ts
Normal file
56
backend/src/main/frontend/utils/language-cookie.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Cookie names for language preference
|
||||
*/
|
||||
const LANGUAGE_COOKIE_NAME = 'votianlt.language';
|
||||
const COOKIE_MAX_AGE_DAYS = 365; // Cookie gültig für 1 Jahr
|
||||
|
||||
/**
|
||||
* Sets the language cookie with the selected language code
|
||||
* @param languageCode - The language code (e.g., 'de', 'en', 'fr', 'es')
|
||||
*/
|
||||
export function setLanguageCookie(languageCode: string): void {
|
||||
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60; // Convert days to seconds
|
||||
document.cookie = `${LANGUAGE_COOKIE_NAME}=${languageCode};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the language code from the cookie
|
||||
* @returns The language code or null if not found
|
||||
*/
|
||||
export function getLanguageCookie(): string | null {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === LANGUAGE_COOKIE_NAME) {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the language cookie
|
||||
*/
|
||||
export function clearLanguageCookie(): void {
|
||||
document.cookie = `${LANGUAGE_COOKIE_NAME}=;path=/;max-age=0;SameSite=Lax`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Language enum values to locale strings
|
||||
*/
|
||||
export const languageToLocale: Record<string, string> = {
|
||||
DE: 'de',
|
||||
EN: 'en',
|
||||
FR: 'fr',
|
||||
ES: 'es',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps locale strings to Language enum values
|
||||
*/
|
||||
export const localeToLanguage: Record<string, string> = {
|
||||
de: 'DE',
|
||||
en: 'EN',
|
||||
fr: 'FR',
|
||||
es: 'ES',
|
||||
};
|
||||
30
backend/src/main/java/de/assecutor/votianlt/Application.java
Normal file
30
backend/src/main/java/de/assecutor/votianlt/Application.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package de.assecutor.votianlt;
|
||||
|
||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
||||
import com.vaadin.flow.component.page.Push;
|
||||
import com.vaadin.flow.theme.Theme;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@Theme("votian-modern")
|
||||
@Push
|
||||
public class Application implements AppShellConfigurator {
|
||||
|
||||
@Bean
|
||||
public Clock clock() {
|
||||
return Clock.systemDefaultZone(); // You can also use Clock.systemUTC()
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.assecutor.votianlt.ai.config;
|
||||
|
||||
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
|
||||
@Slf4j
|
||||
public class LlmConfig {
|
||||
|
||||
@Value("${app.ai.lmstudio.base-url}")
|
||||
private String lmstudioBaseUrl;
|
||||
|
||||
@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;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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());
|
||||
} catch (Exception e) {
|
||||
log.error(" -> 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
package de.assecutor.votianlt.ai.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.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;
|
||||
|
||||
/**
|
||||
* Direct REST client for LM Studio LLM API. Communicates via the
|
||||
* OpenAI-compatible /v1/chat/completions endpoint.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
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 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request.
|
||||
*
|
||||
* @param systemPrompt
|
||||
* System prompt for context
|
||||
* @param userMessage
|
||||
* User message/question
|
||||
* @return LLM response text, or null on error
|
||||
*/
|
||||
public String chat(String systemPrompt, String userMessage) {
|
||||
return chat(systemPrompt, userMessage, 0.7, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request with custom parameters.
|
||||
*
|
||||
* @param systemPrompt
|
||||
* System prompt for context
|
||||
* @param userMessage
|
||||
* User message/question
|
||||
* @param temperature
|
||||
* Temperature for response randomness (0.0-1.0)
|
||||
* @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);
|
||||
|
||||
log.info("Sending request to LLM (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();
|
||||
|
||||
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);
|
||||
|
||||
return extractContent(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Full stack trace:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple chat without system prompt.
|
||||
*/
|
||||
public String chat(String userMessage) {
|
||||
return chat(null, userMessage);
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
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(" ");
|
||||
sanitized = sanitized.replace("\r", "");
|
||||
sanitized = sanitized.replaceAll("[ \\t]+", " ");
|
||||
sanitized = sanitized.replaceAll("\\n{3,}", "\n\n");
|
||||
return sanitized.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import de.assecutor.votianlt.service.DemoModeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final DemoModeService demoModeService;
|
||||
|
||||
public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder,
|
||||
DemoModeService demoModeService) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.demoModeService = demoModeService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
initializeTestUsers();
|
||||
demoModeService.ensureDemoUser();
|
||||
}
|
||||
|
||||
private void initializeTestUsers() {
|
||||
log.info("Initializing test users...");
|
||||
|
||||
// Admin User
|
||||
if (!userRepository.existsByEmail("admin@votianlt.de")) {
|
||||
User adminUser = new User();
|
||||
adminUser.setEmail("admin@votianlt.de");
|
||||
adminUser.setPassword(passwordEncoder.encode("admin123"));
|
||||
adminUser.setName("Administrator");
|
||||
adminUser.setFirstname("Admin");
|
||||
adminUser.setIsActivated((byte) 1);
|
||||
adminUser.setIsEmailConfirmed((byte) 1);
|
||||
adminUser.setCreatedAt(LocalDateTime.now());
|
||||
adminUser.setUpdatedAt(LocalDateTime.now());
|
||||
adminUser.setRoles(Set.of("USER", "ADMIN"));
|
||||
adminUser.setTwoFactorEnabled(false); // 2FA deaktiviert für Admin
|
||||
|
||||
userRepository.save(adminUser);
|
||||
log.info("Created admin user: admin@votianlt.de / admin123 (2FA enabled)");
|
||||
} else {
|
||||
// Stelle sicher, dass bestehender Admin 2FA deaktiviert hat
|
||||
userRepository.findByEmail("admin@votianlt.de").ifPresent(adminUser -> {
|
||||
if (adminUser.isTwoFactorEnabled()) {
|
||||
adminUser.setTwoFactorEnabled(false);
|
||||
userRepository.save(adminUser);
|
||||
log.info("Updated admin user: 2FA disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log.info("Test users initialization completed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import com.vaadin.flow.server.VaadinServiceInitListener;
|
||||
import de.assecutor.votianlt.service.DemoModeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class DemoSessionCleanupConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DemoSessionCleanupConfig.class);
|
||||
|
||||
@Bean
|
||||
public VaadinServiceInitListener demoSessionCleanupListener(DemoModeService demoModeService) {
|
||||
return event -> event.getSource().addSessionDestroyListener(sessionDestroyEvent -> {
|
||||
try {
|
||||
var wrappedSession = sessionDestroyEvent.getSession().getSession();
|
||||
if (wrappedSession != null) {
|
||||
demoModeService.cleanupAndReleaseIfOwned(wrappedSession.getId());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Demo session destroy cleanup failed: {}", ex.getMessage(), ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* Jackson configuration for consistent JSON serialization across the
|
||||
* application. Ensures all date/time fields are serialized as ISO 8601 strings.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
/**
|
||||
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601
|
||||
* strings. This bean is used throughout the application for JSON serialization.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
// Serialize dates as ISO 8601 strings instead of timestamps/arrays
|
||||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return objectMapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import com.vaadin.flow.component.UI;
|
||||
import com.vaadin.flow.server.ServiceInitEvent;
|
||||
import com.vaadin.flow.server.VaadinServiceInitListener;
|
||||
import de.assecutor.votianlt.model.Language;
|
||||
import de.assecutor.votianlt.security.CustomUserPrincipal;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Sets the user's preferred locale on the UI BEFORE any layout or view is
|
||||
* constructed. Registered via {@code UIInitListener} →
|
||||
* {@code BeforeEnterListener}, which fires prior to the router creating the
|
||||
* layout component tree.
|
||||
*
|
||||
* For authenticated users: Uses the language preference from the user profile.
|
||||
* For anonymous users: Uses the language from the 'votianlt.language' cookie or
|
||||
* falls back to the browser's preferred locale.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class LocaleVaadinInitListener implements VaadinServiceInitListener {
|
||||
|
||||
private static final String LANGUAGE_COOKIE_NAME = "votianlt.language";
|
||||
|
||||
@Override
|
||||
public void serviceInit(ServiceInitEvent event) {
|
||||
event.getSource().addUIInitListener(uiInitEvent -> {
|
||||
UI ui = uiInitEvent.getUI();
|
||||
ui.addBeforeEnterListener(beforeEnterEvent -> applyLocale(ui));
|
||||
});
|
||||
}
|
||||
|
||||
private void applyLocale(UI ui) {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
|
||||
// Authenticated user: use profile language
|
||||
applyLocaleFromAuthenticatedUser(ui, auth);
|
||||
} else {
|
||||
// Anonymous user: use cookie or browser locale
|
||||
applyLocaleFromCookieOrBrowser(ui);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not apply locale: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void applyLocaleFromAuthenticatedUser(UI ui, Authentication auth) {
|
||||
if (!(auth.getPrincipal() instanceof CustomUserPrincipal cup)) {
|
||||
return;
|
||||
}
|
||||
Language language = cup.getUser().getLanguage();
|
||||
if (language == null) {
|
||||
return;
|
||||
}
|
||||
Locale targetLocale = getLocaleFromLanguage(language);
|
||||
if (!targetLocale.equals(ui.getLocale())) {
|
||||
ui.setLocale(targetLocale);
|
||||
log.debug("Locale set to {} for authenticated user {}", targetLocale, cup.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
private void applyLocaleFromCookieOrBrowser(UI ui) {
|
||||
Locale targetLocale = null;
|
||||
|
||||
// Try to get locale from cookie first
|
||||
String cookieLanguage = getLanguageFromCookie(ui);
|
||||
if (cookieLanguage != null) {
|
||||
targetLocale = getLocaleFromLanguageCode(cookieLanguage);
|
||||
log.debug("Using locale {} from cookie for anonymous user", targetLocale);
|
||||
}
|
||||
|
||||
// If no cookie, use browser's preferred locale if supported
|
||||
if (targetLocale == null) {
|
||||
targetLocale = getSupportedLocaleFromBrowser(ui);
|
||||
if (targetLocale != null) {
|
||||
log.debug("Using browser locale {} for anonymous user", targetLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the locale if different from current
|
||||
if (targetLocale != null && !targetLocale.equals(ui.getLocale())) {
|
||||
ui.setLocale(targetLocale);
|
||||
log.debug("Locale set to {} for anonymous user", targetLocale);
|
||||
}
|
||||
}
|
||||
|
||||
private String getLanguageFromCookie(UI ui) {
|
||||
try {
|
||||
var request = com.vaadin.flow.server.VaadinRequest.getCurrent();
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Cookie cookie : cookies) {
|
||||
if (LANGUAGE_COOKIE_NAME.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not read language cookie: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Locale getSupportedLocaleFromBrowser(UI ui) {
|
||||
// Get the browser's preferred locales
|
||||
var locale = ui.getSession().getBrowser().getLocale();
|
||||
if (locale == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the browser locale is supported
|
||||
String language = locale.getLanguage().toLowerCase();
|
||||
return switch (language) {
|
||||
case "de" -> Locale.GERMAN;
|
||||
case "en" -> Locale.ENGLISH;
|
||||
case "fr" -> Locale.FRENCH;
|
||||
case "es" -> Locale.of("es", "ES");
|
||||
default -> null; // Return null to use Vaadin's default
|
||||
};
|
||||
}
|
||||
|
||||
private Locale getLocaleFromLanguage(Language language) {
|
||||
return switch (language) {
|
||||
case DE -> Locale.GERMAN;
|
||||
case EN -> Locale.ENGLISH;
|
||||
case FR -> Locale.FRENCH;
|
||||
case ES -> Locale.of("es", "ES");
|
||||
case TR -> Locale.of("tr", "TR");
|
||||
case PL -> Locale.of("pl", "PL");
|
||||
case RU -> Locale.of("ru", "RU");
|
||||
case EE -> Locale.of("et", "EE");
|
||||
case LV -> Locale.of("lv", "LV");
|
||||
case LT -> Locale.of("lt", "LT");
|
||||
};
|
||||
}
|
||||
|
||||
private Locale getLocaleFromLanguageCode(String languageCode) {
|
||||
if (languageCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (languageCode.toUpperCase()) {
|
||||
case "DE" -> Locale.GERMAN;
|
||||
case "EN" -> Locale.ENGLISH;
|
||||
case "FR" -> Locale.FRENCH;
|
||||
case "ES" -> Locale.of("es", "ES");
|
||||
case "TR" -> Locale.of("tr", "TR");
|
||||
case "PL" -> Locale.of("pl", "PL");
|
||||
case "RU" -> Locale.of("ru", "RU");
|
||||
case "EE" -> Locale.of("et", "EE");
|
||||
case "LV" -> Locale.of("lv", "LV");
|
||||
case "LT" -> Locale.of("lt", "LT");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import de.assecutor.votianlt.model.task.*;
|
||||
import de.assecutor.votianlt.model.task.CommentTask;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.data.convert.ReadingConverter;
|
||||
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
|
||||
import org.bson.Document;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
@Configuration
|
||||
public class MongoConfig {
|
||||
|
||||
@Bean
|
||||
public MongoCustomConversions customConversions() {
|
||||
List<Converter<?, ?>> converters = new ArrayList<>();
|
||||
converters.add(new DocumentToBaseTaskConverter());
|
||||
return new MongoCustomConversions(converters);
|
||||
}
|
||||
|
||||
@ReadingConverter
|
||||
@Slf4j
|
||||
public static class DocumentToBaseTaskConverter implements Converter<Document, BaseTask> {
|
||||
|
||||
@Override
|
||||
public BaseTask convert(Document source) {
|
||||
// Debug logging to see what's in the document
|
||||
log.debug("Converting MongoDB document to BaseTask. Document keys: {}", source.keySet());
|
||||
log.debug("Full document content: {}", source.toJson());
|
||||
|
||||
// Use _class field for type discrimination (MongoDB standard)
|
||||
String className = source.getString("_class");
|
||||
if (className == null) {
|
||||
// Fallback to taskType field if _class is not present
|
||||
String taskType = source.getString("taskType");
|
||||
if (taskType == null) {
|
||||
taskType = source.getString("task_type");
|
||||
}
|
||||
// Map taskType to class name
|
||||
className = mapTaskTypeToClassName(taskType);
|
||||
}
|
||||
|
||||
log.debug("Extracted className: '{}' from document", className);
|
||||
|
||||
BaseTask task;
|
||||
switch (className) {
|
||||
case "de.assecutor.votianlt.model.task.ConfirmationTask":
|
||||
case "ConfirmationTask":
|
||||
log.debug("Creating ConfirmationTask");
|
||||
task = new ConfirmationTask();
|
||||
if (source.containsKey("button_text")) {
|
||||
((ConfirmationTask) task).setButtonText(source.getString("button_text"));
|
||||
}
|
||||
break;
|
||||
case "de.assecutor.votianlt.model.task.SignatureTask":
|
||||
case "SignatureTask":
|
||||
log.debug("Creating SignatureTask");
|
||||
task = new SignatureTask();
|
||||
break;
|
||||
case "de.assecutor.votianlt.model.task.PhotoTask":
|
||||
case "PhotoTask":
|
||||
log.debug("Creating PhotoTask");
|
||||
task = new PhotoTask();
|
||||
if (source.containsKey("min_photo_count")) {
|
||||
((PhotoTask) task).setMinPhotoCount(source.getInteger("min_photo_count"));
|
||||
}
|
||||
if (source.containsKey("max_photo_count")) {
|
||||
((PhotoTask) task).setMaxPhotoCount(source.getInteger("max_photo_count"));
|
||||
}
|
||||
break;
|
||||
case "de.assecutor.votianlt.model.task.TodoListTask":
|
||||
case "TodoListTask":
|
||||
log.debug("Creating TodoListTask");
|
||||
task = new TodoListTask();
|
||||
if (source.containsKey("todo_items")) {
|
||||
// Suppressing unchecked cast warning as MongoDB document structure is validated
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> todoItems = (List<String>) source.get("todo_items");
|
||||
((TodoListTask) task).setTodoItems(todoItems);
|
||||
}
|
||||
break;
|
||||
case "de.assecutor.votianlt.model.task.BarcodeTask":
|
||||
case "BarcodeTask":
|
||||
log.debug("Creating BarcodeTask");
|
||||
task = new BarcodeTask();
|
||||
if (source.containsKey("min_barcode_count")) {
|
||||
((BarcodeTask) task).setMinBarcodeCount(source.getInteger("min_barcode_count"));
|
||||
}
|
||||
if (source.containsKey("max_barcode_count")) {
|
||||
((BarcodeTask) task).setMaxBarcodeCount(source.getInteger("max_barcode_count"));
|
||||
}
|
||||
break;
|
||||
case "de.assecutor.votianlt.model.task.CommentTask":
|
||||
case "CommentTask":
|
||||
log.debug("Creating CommentTask");
|
||||
task = new CommentTask();
|
||||
if (source.containsKey("comment_text")) {
|
||||
((CommentTask) task).setCommentText(source.getString("comment_text"));
|
||||
}
|
||||
if (source.containsKey("required")) {
|
||||
((CommentTask) task).setRequired(source.getBoolean("required", false));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
log.warn("Unknown className '{}', falling back to ConfirmationTask", className);
|
||||
task = new ConfirmationTask(); // fallback
|
||||
break;
|
||||
}
|
||||
|
||||
// Set common fields
|
||||
if (source.containsKey("_id")) {
|
||||
task.setId(source.getObjectId("_id"));
|
||||
}
|
||||
task.setStationId(readObjectId(source, "station_id"));
|
||||
task.setJobId(readObjectId(source, "job_id"));
|
||||
if (source.containsKey("station_order")) {
|
||||
task.setStationOrder(source.getInteger("station_order"));
|
||||
}
|
||||
if (source.containsKey("task_order")) {
|
||||
task.setTaskOrder(source.getInteger("task_order", 0));
|
||||
}
|
||||
if (source.containsKey("description")) {
|
||||
task.setDescription(source.getString("description"));
|
||||
}
|
||||
if (source.containsKey("optional")) {
|
||||
task.setOptional(source.getBoolean("optional", false));
|
||||
}
|
||||
if (source.containsKey("completed")) {
|
||||
task.setCompleted(source.getBoolean("completed", false));
|
||||
}
|
||||
if (source.containsKey("completed_at") && source.get("completed_at") != null) {
|
||||
Object completedAtObj = source.get("completed_at");
|
||||
if (completedAtObj instanceof String) {
|
||||
task.setCompletedAt(
|
||||
LocalDateTime.parse((String) completedAtObj, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
} else if (completedAtObj instanceof java.util.Date) {
|
||||
task.setCompletedAt(((java.util.Date) completedAtObj).toInstant()
|
||||
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
|
||||
}
|
||||
}
|
||||
if (source.containsKey("completed_by")) {
|
||||
task.setCompletedBy(source.getString("completed_by"));
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
private ObjectId readObjectId(Document source, String key) {
|
||||
Object value = source.get(key);
|
||||
if (value instanceof ObjectId objectId) {
|
||||
return objectId;
|
||||
}
|
||||
if (value instanceof String stringValue && ObjectId.isValid(stringValue)) {
|
||||
return new ObjectId(stringValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String mapTaskTypeToClassName(String taskType) {
|
||||
if (taskType == null) {
|
||||
return "de.assecutor.votianlt.model.task.ConfirmationTask";
|
||||
}
|
||||
switch (taskType) {
|
||||
case "CONFIRMATION":
|
||||
return "de.assecutor.votianlt.model.task.ConfirmationTask";
|
||||
case "SIGNATURE":
|
||||
return "de.assecutor.votianlt.model.task.SignatureTask";
|
||||
case "PHOTO":
|
||||
return "de.assecutor.votianlt.model.task.PhotoTask";
|
||||
case "TODOLIST":
|
||||
return "de.assecutor.votianlt.model.task.TodoListTask";
|
||||
case "BARCODE":
|
||||
return "de.assecutor.votianlt.model.task.BarcodeTask";
|
||||
case "COMMENT":
|
||||
return "de.assecutor.votianlt.model.task.CommentTask";
|
||||
default:
|
||||
return "de.assecutor.votianlt.model.task.ConfirmationTask";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* Separate configuration for PasswordEncoder to avoid circular dependencies
|
||||
* with VaadinWebSecurity configuration.
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import com.vaadin.flow.i18n.I18NProvider;
|
||||
import de.assecutor.votianlt.model.Language;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.MessageFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.MissingResourceException;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.ResourceBundle.Control;
|
||||
|
||||
@Component
|
||||
public class TranslationProvider implements I18NProvider {
|
||||
|
||||
public static final String BUNDLE_PREFIX = "messages";
|
||||
private static final Locale DEFAULT_LOCALE = Locale.GERMAN;
|
||||
|
||||
// Custom Control to map language codes to file names
|
||||
private static final Control BUNDLE_CONTROL = new Control() {
|
||||
@Override
|
||||
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
|
||||
// Map Estonian "et" to "ee" file, Latvian "lv" to "lv", Lithuanian "lt" to "lt"
|
||||
String language = locale.getLanguage();
|
||||
|
||||
// Create a locale that matches our file naming convention
|
||||
Locale mappedLocale = switch (language) {
|
||||
case "et" -> Locale.of("ee"); // Estonian -> messages_ee.properties
|
||||
case "lv" -> Locale.of("lv"); // Latvian -> messages_lv.properties
|
||||
case "lt" -> Locale.of("lt"); // Lithuanian -> messages_lt.properties
|
||||
case "ru" -> Locale.of("ru"); // Russian -> messages_ru.properties
|
||||
case "pl" -> Locale.of("pl"); // Polish -> messages_pl.properties
|
||||
case "tr" -> Locale.of("tr"); // Turkish -> messages_tr.properties
|
||||
case "es" -> Locale.of("es"); // Spanish -> messages_es.properties
|
||||
case "fr" -> Locale.of("fr"); // French -> messages_fr.properties
|
||||
case "en" -> Locale.of("en"); // English -> messages_en.properties
|
||||
case "de" -> Locale.of("de"); // German -> messages_de.properties
|
||||
default -> locale;
|
||||
};
|
||||
|
||||
return super.getCandidateLocales(baseName, mappedLocale);
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public List<Locale> getProvidedLocales() {
|
||||
return Collections.unmodifiableList(Arrays.asList(Locale.GERMAN, Locale.ENGLISH, Locale.FRENCH,
|
||||
Locale.of("es", "ES"), Locale.of("tr", "TR"), Locale.of("pl", "PL"), Locale.of("ru", "RU"),
|
||||
Locale.of("et", "EE"), Locale.of("lv", "LV"), Locale.of("lt", "LT")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTranslation(String key, Locale locale, Object... params) {
|
||||
if (key == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
String value = findTranslation(key, locale);
|
||||
if (value == null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
value = MessageFormat.format(value, params);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (MissingResourceException e) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
private String findTranslation(String key, Locale locale) {
|
||||
Locale effectiveLocale = locale != null ? locale : DEFAULT_LOCALE;
|
||||
ResourceBundle localizedBundle = ResourceBundle.getBundle(BUNDLE_PREFIX, effectiveLocale, BUNDLE_CONTROL);
|
||||
if (localizedBundle.containsKey(key)) {
|
||||
return localizedBundle.getString(key);
|
||||
}
|
||||
|
||||
if (!DEFAULT_LOCALE.getLanguage().equals(effectiveLocale.getLanguage())) {
|
||||
ResourceBundle germanBundle = ResourceBundle.getBundle(BUNDLE_PREFIX, DEFAULT_LOCALE, BUNDLE_CONTROL);
|
||||
if (germanBundle.containsKey(key)) {
|
||||
return germanBundle.getString(key);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getTranslation(String key, Language language) {
|
||||
Locale locale = switch (language) {
|
||||
case DE -> Locale.GERMAN;
|
||||
case EN -> Locale.ENGLISH;
|
||||
case FR -> Locale.FRENCH;
|
||||
case ES -> Locale.of("es", "ES");
|
||||
case TR -> Locale.of("tr", "TR");
|
||||
case PL -> Locale.of("pl", "PL");
|
||||
case RU -> Locale.of("ru", "RU");
|
||||
case EE -> Locale.of("et", "EE");
|
||||
case LV -> Locale.of("lv", "LV");
|
||||
case LT -> Locale.of("lt", "LT");
|
||||
};
|
||||
return getTranslation(key, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import de.assecutor.votianlt.model.LocationPosition;
|
||||
import de.assecutor.votianlt.service.LocationService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* REST-Controller für Location-bezogene API-Endpunkte.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/location")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LocationApiController {
|
||||
|
||||
private final LocationService locationService;
|
||||
|
||||
/**
|
||||
* Gibt die aktuelle Position eines App-Nutzers zurück.
|
||||
*
|
||||
* @param appUserId
|
||||
* die ID des App-Nutzers
|
||||
* @return die aktuelle Position oder 404 wenn keine vorhanden
|
||||
*/
|
||||
@GetMapping("/{appUserId}")
|
||||
public ResponseEntity<LocationResponse> getCurrentPosition(@PathVariable String appUserId) {
|
||||
LocationPosition position = locationService.getLatestPosition(appUserId);
|
||||
|
||||
if (position == null || position.getLatitude() == null || position.getLongitude() == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
LocationResponse response = new LocationResponse();
|
||||
response.setLatitude(position.getLatitude());
|
||||
response.setLongitude(position.getLongitude());
|
||||
response.setAccuracy(position.getAccuracy());
|
||||
response.setSpeed(position.getSpeed());
|
||||
response.setTimestamp(position.getTimestamp());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class LocationResponse {
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private Double accuracy;
|
||||
private Double speed;
|
||||
private Instant timestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API controller for message operations. Provides endpoints for sending
|
||||
* messages, retrieving messages, and marking messages as read.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@Slf4j
|
||||
public class MessageApiController {
|
||||
|
||||
private final MessageService messageService;
|
||||
|
||||
public MessageApiController(MessageService messageService) {
|
||||
this.messageService = messageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a general message to a client POST /api/messages/send Body: { "content":
|
||||
* "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
|
||||
*/
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String content = request.get("content");
|
||||
String receiver = request.get("receiver");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) {
|
||||
log.warn("Invalid message request: missing required fields");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Message message = messageService.sendGeneralMessageToClient(content, receiver, contentType);
|
||||
log.info("General message sent to AppUser '{}'", receiver);
|
||||
return ResponseEntity.ok(message);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid general message request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error sending general message: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a job-related message to a client POST /api/messages/send-job-message
|
||||
* Body: { "content": "message text", "receiver": "appUserId", "jobId": "job
|
||||
* id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
|
||||
*/
|
||||
@PostMapping("/send-job-message")
|
||||
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String content = request.get("content");
|
||||
String receiver = request.get("receiver");
|
||||
String jobIdStr = request.get("jobId");
|
||||
String jobNumber = request.get("jobNumber");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null
|
||||
|| jobIdStr.isBlank()) {
|
||||
log.warn("Invalid job message request: missing required fields");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
ObjectId jobId = new ObjectId(jobIdStr);
|
||||
Message message = messageService.sendJobMessageToClient(content, receiver, contentType, jobId, jobNumber);
|
||||
log.info("Job-related message sent to AppUser '{}' for job {}", receiver, jobNumber);
|
||||
return ResponseEntity.ok(message);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid job message request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error sending job message: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages for a specific receiver GET
|
||||
* /api/messages/receiver/{username}
|
||||
*/
|
||||
@GetMapping("/receiver/{username}")
|
||||
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
|
||||
try {
|
||||
List<Message> messages = messageService.getMessagesForReceiver(username);
|
||||
return ResponseEntity.ok(messages);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving messages for receiver {}: {}", username, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unread messages for a specific receiver GET
|
||||
* /api/messages/receiver/{username}/unread
|
||||
*/
|
||||
@GetMapping("/receiver/{username}/unread")
|
||||
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
|
||||
try {
|
||||
List<Message> messages = messageService.getUnreadMessagesForReceiver(username);
|
||||
return ResponseEntity.ok(messages);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving unread messages for receiver {}: {}", username, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread message count for a specific receiver GET
|
||||
* /api/messages/receiver/{username}/unread-count
|
||||
*/
|
||||
@GetMapping("/receiver/{username}/unread-count")
|
||||
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
|
||||
try {
|
||||
long count = messageService.getUnreadMessageCount(username);
|
||||
return ResponseEntity.ok(Map.of("count", count));
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving unread message count for receiver {}: {}", username, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages related to a specific job GET /api/messages/job/{jobId}
|
||||
*/
|
||||
@GetMapping("/job/{jobId}")
|
||||
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
|
||||
try {
|
||||
ObjectId objectId = new ObjectId(jobId);
|
||||
List<Message> messages = messageService.getMessagesForJob(objectId);
|
||||
return ResponseEntity.ok(messages);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid jobId format: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving messages for job {}: {}", jobId, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages (for admin/overview) GET /api/messages/all
|
||||
*/
|
||||
@GetMapping("/all")
|
||||
public ResponseEntity<List<Message>> getAllMessages() {
|
||||
try {
|
||||
List<Message> messages = messageService.getAllMessages();
|
||||
return ResponseEntity.ok(messages);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving all messages: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages by origin (incoming/outgoing/server) GET
|
||||
* /api/messages/origin/{origin}
|
||||
*/
|
||||
@GetMapping("/origin/{origin}")
|
||||
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
|
||||
try {
|
||||
MessageOrigin messageOrigin = MessageOrigin.valueOf(origin.toUpperCase());
|
||||
List<Message> messages = messageService.getMessagesByOrigin(messageOrigin);
|
||||
return ResponseEntity.ok(messages);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid origin: {}", origin);
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving messages by origin {}: {}", origin, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as read PUT /api/messages/{messageId}/mark-read
|
||||
*/
|
||||
@PutMapping("/{messageId}/mark-read")
|
||||
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
|
||||
try {
|
||||
ObjectId objectId = new ObjectId(messageId);
|
||||
messageService.markAsRead(objectId);
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid messageId format: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error marking message as read {}: {}", messageId, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message DELETE /api/messages/{messageId}
|
||||
*/
|
||||
@DeleteMapping("/{messageId}")
|
||||
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
|
||||
try {
|
||||
ObjectId objectId = new ObjectId(messageId);
|
||||
messageService.deleteMessage(objectId);
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid messageId format: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error deleting message {}: {}", messageId, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
private MessageContentType resolveContentType(String rawValue) {
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return MessageContentType.TEXT;
|
||||
}
|
||||
|
||||
try {
|
||||
return MessageContentType.valueOf(rawValue.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Unsupported contentType: " + rawValue, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import de.assecutor.votianlt.dto.AppLoginRequest;
|
||||
import de.assecutor.votianlt.dto.AppLoginResponse;
|
||||
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.CargoItemRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import de.assecutor.votianlt.repository.BarcodeRepository;
|
||||
import de.assecutor.votianlt.repository.SignatureRepository;
|
||||
import de.assecutor.votianlt.repository.CommentRepository;
|
||||
import de.assecutor.votianlt.model.Photo;
|
||||
import de.assecutor.votianlt.model.Barcode;
|
||||
import de.assecutor.votianlt.model.Signature;
|
||||
import de.assecutor.votianlt.model.Comment;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import de.assecutor.votianlt.service.JobUpdateBroadcaster;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import de.assecutor.votianlt.service.TaskAssignmentService;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
/**
|
||||
* Message controller for handling real-time communication with apps. Provides
|
||||
* endpoints for sending and receiving messages via WebSocket.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MessageController {
|
||||
|
||||
private final MessagingPublisher messagingPublisher;
|
||||
|
||||
private final AppUserRepository appUserRepository;
|
||||
|
||||
private final AppUserService appUserService;
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
|
||||
private final CargoItemRepository cargoItemRepository;
|
||||
|
||||
private final TaskRepository taskRepository;
|
||||
private final PhotoRepository photoRepository;
|
||||
private final BarcodeRepository barcodeRepository;
|
||||
private final SignatureRepository signatureRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final JobHistoryService jobHistoryService;
|
||||
private final JobUpdateBroadcaster jobUpdateBroadcaster;
|
||||
private final EmailService emailService;
|
||||
private final MessageService messageService;
|
||||
private final TaskAssignmentService taskAssignmentService;
|
||||
|
||||
public MessageController(MessagingPublisher messagingPublisher, AppUserRepository appUserRepository,
|
||||
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
||||
MessageService messageService, TaskAssignmentService taskAssignmentService) {
|
||||
this.messagingPublisher = messagingPublisher;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.appUserService = appUserService;
|
||||
this.jobRepository = jobRepository;
|
||||
this.cargoItemRepository = cargoItemRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
this.photoRepository = photoRepository;
|
||||
this.barcodeRepository = barcodeRepository;
|
||||
this.signatureRepository = signatureRepository;
|
||||
this.commentRepository = commentRepository;
|
||||
this.jobHistoryService = jobHistoryService;
|
||||
this.jobUpdateBroadcaster = jobUpdateBroadcaster;
|
||||
this.emailService = emailService;
|
||||
this.messageService = messageService;
|
||||
this.taskAssignmentService = taskAssignmentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication endpoint for mobile app users via WebSocket. Client sends to
|
||||
* /server/login with payload { email, password }. Returns the result to the
|
||||
* caller (MessagingConfig) which handles session registration and response
|
||||
* sending.
|
||||
*/
|
||||
public AppLoginResponse handleAppLogin(AppLoginRequest request) {
|
||||
if (request == null || request.getEmail() == null || request.getPassword() == null
|
||||
|| request.getEmail().isBlank() || request.getPassword().isBlank()) {
|
||||
return new AppLoginResponse(false, "E-Mail und Passwort sind erforderlich", null);
|
||||
}
|
||||
|
||||
AppUser user = appUserRepository.findByEmail(request.getEmail());
|
||||
if (user == null) {
|
||||
return new AppLoginResponse(false, "Benutzer nicht gefunden", null);
|
||||
}
|
||||
|
||||
boolean ok = appUserService.verifyPassword(request.getPassword(), user.getPassword());
|
||||
if (!ok) {
|
||||
return new AppLoginResponse(false, "Ungültige Anmeldedaten", null);
|
||||
}
|
||||
|
||||
return new AppLoginResponse(true, "Anmeldung erfolgreich", user.getIdAsString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve jobs assigned to a specific app user with related cargo items and
|
||||
* tasks. The appUserId is determined from the authenticated WebSocket session.
|
||||
* Response is sent back on /client/jobs.
|
||||
*/
|
||||
public void handleGetAssignedJobs(String appUserId) {
|
||||
if (appUserId == null || appUserId.isBlank()) {
|
||||
log.warn("[JOBS] appUserId is null or blank, cannot retrieve jobs");
|
||||
return;
|
||||
}
|
||||
|
||||
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<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
|
||||
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
|
||||
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
|
||||
return new JobWithRelatedDataDTO(job, cargoItems, tasks);
|
||||
}).toList();
|
||||
|
||||
log.info("[JOBS] Publishing {} jobs to client {} on topic /client/jobs", jobsWithRelatedData.size(), appUserId);
|
||||
messagingPublisher.publishAsJson(appUserId, "jobs", jobsWithRelatedData);
|
||||
log.info("[JOBS] Jobs published successfully for client {}", appUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report generic task completion from apps. Client sends to /app/task/completed
|
||||
* with payload { taskId, completedBy?, note? }. Broadcasts to
|
||||
* /topic/task-updates and /topic/tasks/{taskId}. This endpoint accepts any task
|
||||
* type (fallback for GENERIC or unknown types).
|
||||
*/
|
||||
public void handleTaskCompleted(Map<String, Object> payload) {
|
||||
// Backward-compatible entry point: extract taskType from payload (if present)
|
||||
// and delegate to the overloaded handler with explicit type.
|
||||
String taskType = null;
|
||||
try {
|
||||
Object tt = payload != null ? payload.get("taskType") : null;
|
||||
if (tt != null)
|
||||
taskType = tt.toString();
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not extract taskType from payload: {}", e.getMessage());
|
||||
}
|
||||
handleTaskCompleted(payload, taskType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Central dispatcher for task_completed messages. Decides handling based on
|
||||
* taskType. PHOTO and CONFIRMATION are routed to specialized handlers; others
|
||||
* go to generic processing.
|
||||
*/
|
||||
public void handleTaskCompleted(Map<String, Object> payload, String taskType) {
|
||||
String key = taskType == null ? "" : taskType.trim().toUpperCase();
|
||||
|
||||
switch (key) {
|
||||
case "PHOTO" -> processPhotoTaskCompletion(payload);
|
||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload);
|
||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload);
|
||||
case "TODOLIST" -> processTodoListTaskCompletion(payload);
|
||||
case "BARCODE" -> processBarcodeTaskCompletion(payload);
|
||||
case "COMMENT" -> processCommentTaskCompletion(payload);
|
||||
default -> log.error("[TASK] Unknown taskType: {}", taskType);
|
||||
}
|
||||
}
|
||||
|
||||
private void processConfirmationTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Bestätigung durchgeführt");
|
||||
}
|
||||
|
||||
private void processTodoListTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt");
|
||||
}
|
||||
|
||||
private void processBarcodeTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
|
||||
String extraDataSummary = null;
|
||||
Object extra = payload.get("extraData");
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object barcodesObj = extraData.get("barcodes");
|
||||
if (barcodesObj instanceof List<?> barcodesList) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> barcodes = (List<String>) barcodesList;
|
||||
|
||||
if (!barcodes.isEmpty()) {
|
||||
for (String barcodeString : barcodes) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
|
||||
completedBy);
|
||||
barcodeRepository.save(barcodeEntry);
|
||||
}
|
||||
extraDataSummary = barcodes.size() + " Barcode(s) gescannt: "
|
||||
+ String.join(", ", barcodes.subList(0, Math.min(3, barcodes.size())))
|
||||
+ (barcodes.size() > 3 ? "..." : "");
|
||||
} else {
|
||||
extraDataSummary = "Keine Barcodes gescannt";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Barcode-Daten fehlerhaft";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Barcode completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processSignatureTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
|
||||
String extraDataSummary = null;
|
||||
Object extra = payload.get("extraData");
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object signatureSvgObj = extraData.get("signatureSvg");
|
||||
if (signatureSvgObj instanceof String signatureSvg) {
|
||||
if (!signatureSvg.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
||||
completedBy);
|
||||
signatureRepository.save(signatureEntry);
|
||||
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
|
||||
} else {
|
||||
extraDataSummary = "Leere Unterschrift";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Unterschrift-Daten fehlerhaft";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Signature completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processPhotoTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
|
||||
String extraDataSummary = null;
|
||||
Object extra = payload.get("extraData");
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object photosObj = extraData.get("photos");
|
||||
if (photosObj instanceof List<?> photosList) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> photos = (List<String>) photosList;
|
||||
|
||||
if (!photos.isEmpty()) {
|
||||
for (String photoString : photos) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||
photoRepository.save(photoEntry);
|
||||
}
|
||||
extraDataSummary = photos.size() + " Foto(s) aufgenommen";
|
||||
} else {
|
||||
extraDataSummary = "Keine Fotos aufgenommen";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Foto-Daten fehlerhaft";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Photo completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processCommentTaskCompletion(Map<String, Object> payload) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
|
||||
String extraDataSummary = null;
|
||||
Object extra = payload.get("extraData");
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object commentTextObj = extraData.get("commentText");
|
||||
if (commentTextObj instanceof String commentText && !commentText.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
|
||||
commentRepository.save(commentEntry);
|
||||
extraDataSummary = "Kommentar: " + commentText;
|
||||
} else {
|
||||
extraDataSummary = "Kommentar abgegeben (leer)";
|
||||
}
|
||||
} else {
|
||||
extraDataSummary = "Kommentar abgegeben";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Comment completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTaskWithHistory(Object tid, String extraDataSummary) {
|
||||
String taskIdStr = tid.toString();
|
||||
try {
|
||||
ObjectId taskId = new ObjectId(taskIdStr);
|
||||
var opt = taskRepository.findById(taskId);
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
task.setCompleted(true);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
|
||||
if (jobOpt.isEmpty()) {
|
||||
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
|
||||
return;
|
||||
}
|
||||
ObjectId jobId = jobOpt.get().getId();
|
||||
|
||||
// Log detailed task completion in job history
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType;
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
||||
extraDataSummary);
|
||||
} catch (Exception e) {
|
||||
// Ignore history logging errors
|
||||
}
|
||||
|
||||
// Send email notification for task completion
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
||||
checkAndHandleJobCompletion(jobId, completedBy);
|
||||
} catch (Exception e) {
|
||||
// Ignore email notification errors
|
||||
}
|
||||
|
||||
jobUpdateBroadcaster.broadcast(jobId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
|
||||
try {
|
||||
var allTasks = taskAssignmentService.findTasksForJob(jobId);
|
||||
if (allTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mandatoryTasks = allTasks.stream().filter(task -> !task.isOptional()).toList();
|
||||
if (mandatoryTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
|
||||
|
||||
if (allCompleted) {
|
||||
updateJobStatusToCompleted(jobId);
|
||||
try {
|
||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||
} catch (Exception e) {
|
||||
// Ignore email notification errors
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore job completion check errors
|
||||
}
|
||||
}
|
||||
|
||||
private void updateJobStatusToCompleted(ObjectId jobId) {
|
||||
try {
|
||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||
if (jobOpt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Job job = jobOpt.get();
|
||||
if (job.getStatus() != JobStatus.COMPLETED) {
|
||||
job.setStatus(JobStatus.COMPLETED);
|
||||
job.setUpdatedAt(LocalDateTime.now());
|
||||
jobRepository.save(job);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore job status update errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client via WebSocket. Client sends to
|
||||
* /server/message with payload: { "content": "message payload", "contentType":
|
||||
* "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number"
|
||||
* }
|
||||
*
|
||||
* The appUserId is determined from the authenticated WebSocket session.
|
||||
*/
|
||||
public void handleIncomingMessage(String appUserId, Map<String, Object> payload) {
|
||||
try {
|
||||
if (appUserId == null || appUserId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
payload.put("receiver", appUserId);
|
||||
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
|
||||
messageService.receiveMessageFromClient(inboundPayload);
|
||||
} catch (Exception e) {
|
||||
// Ignore message handling errors
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AppLoginRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AppLoginResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
/**
|
||||
* Only populated on success, for internal server-side routing. Not sent to
|
||||
* client.
|
||||
*/
|
||||
private String appUserId;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import java.util.Map;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
/**
|
||||
* Normalized payload for chat messages sent by mobile clients via WebSocket.
|
||||
* receiver = AppUser ID (clientId) extracted from topic
|
||||
*/
|
||||
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
|
||||
String jobNumber) {
|
||||
|
||||
public ChatMessageInboundPayload {
|
||||
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
||||
}
|
||||
|
||||
public static ChatMessageInboundPayload fromPayload(Map<String, Object> payload) {
|
||||
if (payload == null) {
|
||||
throw new IllegalArgumentException("payload must not be null");
|
||||
}
|
||||
|
||||
String receiver = extractRequiredString(payload, "receiver");
|
||||
String content = extractRequiredString(payload, "content");
|
||||
MessageContentType contentType = extractContentType(payload.get("contentType"));
|
||||
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
|
||||
String jobNumber = extractOptionalString(payload.get("jobNumber"));
|
||||
|
||||
return new ChatMessageInboundPayload(receiver, content, contentType, jobId, jobNumber);
|
||||
}
|
||||
|
||||
public boolean hasJobContext() {
|
||||
return jobId != null;
|
||||
}
|
||||
|
||||
private static String extractRequiredString(Map<String, Object> payload, String key) {
|
||||
Object value = payload.get(key);
|
||||
String asString = value != null ? value.toString().trim() : null;
|
||||
if (asString == null || asString.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field '%s'".formatted(key));
|
||||
}
|
||||
return asString;
|
||||
}
|
||||
|
||||
private static String extractOptionalString(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String asString = value.toString().trim();
|
||||
return asString.isEmpty() ? null : asString;
|
||||
}
|
||||
|
||||
private static ObjectId extractObjectId(Object value, String fieldName) {
|
||||
String candidate = extractOptionalString(value);
|
||||
if (candidate == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new ObjectId(candidate);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageContentType extractContentType(Object value) {
|
||||
String candidate = extractOptionalString(value);
|
||||
if (candidate == null) {
|
||||
return MessageContentType.TEXT;
|
||||
}
|
||||
try {
|
||||
return MessageContentType.valueOf(candidate.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Unsupported contentType '%s'".formatted(candidate), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Outbound chat message payload published to subscribers. The receiver is
|
||||
* implicit from the WebSocket session (/client/message)
|
||||
*/
|
||||
public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType,
|
||||
MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber,
|
||||
boolean read) {
|
||||
|
||||
public static ChatMessageOutboundPayload fromMessage(Message message) {
|
||||
return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(),
|
||||
message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(),
|
||||
message.getJobNumber(), message.isRead());
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* DTO for summarizing message conversations by client
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ClientMessageSummary {
|
||||
|
||||
private String clientId;
|
||||
private String clientName;
|
||||
private String clientEmail;
|
||||
private int totalMessages;
|
||||
private int unreadCount;
|
||||
private LocalDateTime lastMessageDate;
|
||||
private String lastMessagePreview;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
package de.assecutor.votianlt.dto;
|
||||
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO for returning job data with related cargo items and tasks. This combines
|
||||
* Job entity with its associated CargoItems and TaskEntries.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobWithRelatedDataDTO {
|
||||
private Job job;
|
||||
private List<CargoItem> cargoItems;
|
||||
private List<BaseTask> tasks;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.assecutor.votianlt.event;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Event published when message read status changes (e.g., messages marked as
|
||||
* read) This allows UI components like the sidebar badge to update accordingly
|
||||
*/
|
||||
public class MessageReadStatusChangedEvent extends ApplicationEvent {
|
||||
|
||||
public MessageReadStatusChangedEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.assecutor.votianlt.event;
|
||||
|
||||
import de.assecutor.votianlt.model.Message;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Event published when a new message is received from a client
|
||||
*/
|
||||
public class MessageReceivedEvent extends ApplicationEvent {
|
||||
|
||||
private final Message message;
|
||||
|
||||
public MessageReceivedEvent(Object source, Message message) {
|
||||
super(source);
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Message getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.assecutor.votianlt.mcp.config;
|
||||
|
||||
import de.assecutor.votianlt.mcp.tools.JobQueryTool;
|
||||
import de.assecutor.votianlt.mcp.tools.JobStatisticsTool;
|
||||
import de.assecutor.votianlt.mcp.tools.TaskCompletionTool;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Configuration for the MCP (Model Context Protocol) server. Registers all MCP
|
||||
* tools for job statistics and queries.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class McpServerConfig {
|
||||
|
||||
@Bean
|
||||
public ToolCallbackProvider jobStatisticsToolProvider(JobStatisticsTool jobStatisticsTool) {
|
||||
log.info("Registering JobStatisticsTool for MCP server");
|
||||
return MethodToolCallbackProvider.builder().toolObjects(jobStatisticsTool).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ToolCallbackProvider jobQueryToolProvider(JobQueryTool jobQueryTool) {
|
||||
log.info("Registering JobQueryTool for MCP server");
|
||||
return MethodToolCallbackProvider.builder().toolObjects(jobQueryTool).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ToolCallbackProvider taskCompletionToolProvider(TaskCompletionTool taskCompletionTool) {
|
||||
log.info("Registering TaskCompletionTool for MCP server");
|
||||
return MethodToolCallbackProvider.builder().toolObjects(taskCompletionTool).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.assecutor.votianlt.mcp.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* DTO for customer revenue results.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CustomerRevenueResult {
|
||||
|
||||
private String customer;
|
||||
private BigDecimal revenue;
|
||||
private long jobCount;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.assecutor.votianlt.mcp.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* DTO for job query results returned by MCP tools.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobQueryResult {
|
||||
|
||||
private String jobId;
|
||||
private String jobNumber;
|
||||
private String status;
|
||||
private String statusDisplayName;
|
||||
private String customer;
|
||||
private String pickupCity;
|
||||
private String deliveryCity;
|
||||
private LocalDate pickupDate;
|
||||
private LocalDate deliveryDate;
|
||||
private BigDecimal price;
|
||||
private LocalDateTime createdAt;
|
||||
private String assignedAppUser;
|
||||
private boolean digitalProcessing;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.assecutor.votianlt.mcp.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DTO for job statistics results returned by MCP tools.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobStatisticsResult {
|
||||
|
||||
private Map<String, Long> countsByStatus;
|
||||
private long totalJobs;
|
||||
private long completedJobs;
|
||||
private long cancelledJobs;
|
||||
private long inProgressJobs;
|
||||
private double completionRate;
|
||||
private BigDecimal totalRevenue;
|
||||
private LocalDateTime queryTimestamp;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.assecutor.votianlt.mcp.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for task completion statistics.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TaskCompletionResult {
|
||||
|
||||
private long totalTasks;
|
||||
private long completedTasks;
|
||||
private long pendingTasks;
|
||||
private double completionRate;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.assecutor.votianlt.mcp.tools;
|
||||
|
||||
import de.assecutor.votianlt.mcp.dto.JobQueryResult;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.service.JobStatisticsService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP Tool for querying jobs with various filters.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JobQueryTool {
|
||||
|
||||
private final JobStatisticsService statisticsService;
|
||||
|
||||
public JobQueryTool(JobStatisticsService statisticsService) {
|
||||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
|
||||
public List<JobQueryResult> queryJobs(
|
||||
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status,
|
||||
@ToolParam(description = "Optional: Customer name filter") String customer,
|
||||
@ToolParam(description = "Optional: Pickup city filter") String pickupCity,
|
||||
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
|
||||
@ToolParam(description = "Maximum results to return (default 50)") Integer limit) {
|
||||
log.info("MCP Tool: Querying jobs with filters - status: {}, customer: {}, pickupCity: {}, deliveryCity: {}",
|
||||
status, customer, pickupCity, deliveryCity);
|
||||
|
||||
int actualLimit = limit != null ? limit : 50;
|
||||
List<Job> jobs;
|
||||
|
||||
if (status != null && !status.isBlank()) {
|
||||
JobStatus jobStatus = JobStatus.valueOf(status.toUpperCase());
|
||||
jobs = statisticsService.getJobsByStatus(jobStatus);
|
||||
} else if (customer != null && !customer.isBlank()) {
|
||||
jobs = statisticsService.getJobsByCustomer(customer);
|
||||
} else if (pickupCity != null && !pickupCity.isBlank()) {
|
||||
jobs = statisticsService.getJobsByPickupCity(pickupCity);
|
||||
} else if (deliveryCity != null && !deliveryCity.isBlank()) {
|
||||
jobs = statisticsService.getJobsByDeliveryCity(deliveryCity);
|
||||
} else {
|
||||
jobs = statisticsService.getLatestJobs(actualLimit);
|
||||
}
|
||||
|
||||
return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get detailed information about a specific job by its job number")
|
||||
public JobQueryResult getJobByNumber(
|
||||
@ToolParam(description = "The job number to look up (e.g., JOB-2024-0001)") String jobNumber) {
|
||||
log.info("MCP Tool: Getting job by number: {}", jobNumber);
|
||||
|
||||
Job job = statisticsService.getJobByNumber(jobNumber);
|
||||
if (job == null) {
|
||||
return null;
|
||||
}
|
||||
return toQueryResult(job);
|
||||
}
|
||||
|
||||
@Tool(description = "Get jobs assigned to a specific mobile app user")
|
||||
public List<JobQueryResult> getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) {
|
||||
log.info("MCP Tool: Getting jobs for app user: {}", appUser);
|
||||
|
||||
return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
|
||||
public List<JobQueryResult> getLatestJobs(
|
||||
@ToolParam(description = "Number of jobs to return (default 10)") Integer limit) {
|
||||
log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
|
||||
|
||||
int actualLimit = limit != null ? limit : 10;
|
||||
return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get jobs created within a specific date range")
|
||||
public List<JobQueryResult> getJobsByDateRange(
|
||||
@ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
|
||||
@ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate,
|
||||
@ToolParam(description = "Maximum results to return (default 100)") Integer limit) {
|
||||
log.info("MCP Tool: Getting jobs for date range: {} to {}", startDate, endDate);
|
||||
|
||||
LocalDateTime start = LocalDateTime.parse(startDate);
|
||||
LocalDateTime end = LocalDateTime.parse(endDate);
|
||||
int actualLimit = limit != null ? limit : 100;
|
||||
|
||||
return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private JobQueryResult toQueryResult(Job job) {
|
||||
return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber())
|
||||
.status(job.getStatus() != null ? job.getStatus().name() : null)
|
||||
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
|
||||
.customer(job.getCustomerSelection()).pickupCity(job.getPickupCity())
|
||||
.deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate())
|
||||
.price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser())
|
||||
.digitalProcessing(job.isDigitalProcessing()).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.assecutor.votianlt.mcp.tools;
|
||||
|
||||
import de.assecutor.votianlt.mcp.dto.CustomerRevenueResult;
|
||||
import de.assecutor.votianlt.mcp.dto.JobStatisticsResult;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.service.JobStatisticsService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Month;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* MCP Tool for job statistics queries. Provides various statistics and
|
||||
* aggregations about jobs.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JobStatisticsTool {
|
||||
|
||||
private final JobStatisticsService statisticsService;
|
||||
|
||||
public JobStatisticsTool(JobStatisticsService statisticsService) {
|
||||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
@Tool(description = "Get comprehensive job statistics including counts by status, completion rates, and revenue metrics")
|
||||
public JobStatisticsResult getJobStatistics() {
|
||||
log.info("MCP Tool: Getting job statistics");
|
||||
|
||||
Map<JobStatus, Long> countsByStatus = statisticsService.getJobCountsByStatus();
|
||||
Map<String, Long> statusCounts = countsByStatus.entrySet().stream()
|
||||
.collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue));
|
||||
|
||||
long completed = countsByStatus.getOrDefault(JobStatus.COMPLETED, 0L);
|
||||
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
|
||||
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||
|
||||
return JobStatisticsResult.builder().countsByStatus(statusCounts)
|
||||
.totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled)
|
||||
.inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate())
|
||||
.totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build();
|
||||
}
|
||||
|
||||
@Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
||||
public Map<String, Long> getJobCountsByStatus() {
|
||||
log.info("MCP Tool: Getting job counts by status");
|
||||
|
||||
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
|
||||
return counts.entrySet().stream().collect(Collectors
|
||||
.toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue));
|
||||
}
|
||||
|
||||
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
|
||||
public String getCompletionRate() {
|
||||
log.info("MCP Tool: Getting completion rate");
|
||||
|
||||
double rate = statisticsService.getCompletionRate();
|
||||
return String.format("%.2f%%", rate);
|
||||
}
|
||||
|
||||
@Tool(description = "Get revenue statistics grouped by customer, sorted by revenue descending")
|
||||
public List<CustomerRevenueResult> getRevenueByCustomer(
|
||||
@ToolParam(description = "Maximum number of customers to return (default 10)") Integer limit) {
|
||||
log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
|
||||
|
||||
int actualLimit = limit != null ? limit : 10;
|
||||
return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> {
|
||||
String customer = entry.getKey();
|
||||
long jobCount = statisticsService.getJobsByCustomer(customer).size();
|
||||
return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount)
|
||||
.build();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
|
||||
public Map<String, Long> getMonthlyJobTrend(
|
||||
@ToolParam(description = "Year for the trend data (e.g., 2024)") int year) {
|
||||
log.info("MCP Tool: Getting monthly job trend for year: {}", year);
|
||||
|
||||
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
|
||||
return monthlyData.entrySet().stream()
|
||||
.collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
|
||||
}
|
||||
|
||||
@Tool(description = "Get total revenue from all jobs")
|
||||
public String getTotalRevenue() {
|
||||
log.info("MCP Tool: Getting total revenue");
|
||||
|
||||
BigDecimal revenue = statisticsService.getTotalRevenue();
|
||||
return String.format("%.2f EUR", revenue);
|
||||
}
|
||||
|
||||
@Tool(description = "Get job count for a specific date range")
|
||||
public long getJobCountByDateRange(
|
||||
@ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
|
||||
@ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate) {
|
||||
log.info("MCP Tool: Getting job count for date range: {} to {}", startDate, endDate);
|
||||
|
||||
LocalDateTime start = LocalDateTime.parse(startDate);
|
||||
LocalDateTime end = LocalDateTime.parse(endDate);
|
||||
return statisticsService.getJobCountByDateRange(start, end);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.assecutor.votianlt.mcp.tools;
|
||||
|
||||
import de.assecutor.votianlt.mcp.dto.TaskCompletionResult;
|
||||
import de.assecutor.votianlt.service.JobStatisticsService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MCP Tool for task completion statistics and data.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TaskCompletionTool {
|
||||
|
||||
private final JobStatisticsService statisticsService;
|
||||
|
||||
public TaskCompletionTool(JobStatisticsService statisticsService) {
|
||||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
@Tool(description = "Get overall task completion statistics including total, completed, pending tasks and completion rate")
|
||||
public TaskCompletionResult getTaskCompletionStats() {
|
||||
log.info("MCP Tool: Getting task completion statistics");
|
||||
|
||||
Map<String, Long> stats = statisticsService.getTaskCompletionStats();
|
||||
long total = stats.getOrDefault("total", 0L);
|
||||
long completed = stats.getOrDefault("completed", 0L);
|
||||
long pending = stats.getOrDefault("pending", 0L);
|
||||
|
||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||
|
||||
return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending)
|
||||
.completionRate(completionRate).build();
|
||||
}
|
||||
|
||||
@Tool(description = "Get a summary of task completion as a formatted string")
|
||||
public String getTaskCompletionSummary() {
|
||||
log.info("MCP Tool: Getting task completion summary");
|
||||
|
||||
Map<String, Long> stats = statisticsService.getTaskCompletionStats();
|
||||
long total = stats.getOrDefault("total", 0L);
|
||||
long completed = stats.getOrDefault("completed", 0L);
|
||||
long pending = stats.getOrDefault("pending", 0L);
|
||||
|
||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||
|
||||
return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed,
|
||||
completionRate, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package de.assecutor.votianlt.messaging;
|
||||
|
||||
import de.assecutor.votianlt.controller.MessageController;
|
||||
import de.assecutor.votianlt.dto.AppLoginRequest;
|
||||
import de.assecutor.votianlt.dto.AppLoginResponse;
|
||||
import de.assecutor.votianlt.service.ClientConnectionService;
|
||||
import de.assecutor.votianlt.service.LocationService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Configuration for the messaging system. Sets up message routing after
|
||||
* application startup.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class MessagingConfig {
|
||||
|
||||
private final WebSocketService webSocketService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MessagingConfig(WebSocketService webSocketService, ObjectMapper objectMapper) {
|
||||
this.webSocketService = webSocketService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up message routing after application startup.
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void setupMessaging(ApplicationReadyEvent event) {
|
||||
try {
|
||||
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
|
||||
ClientConnectionService clientConnectionService = event.getApplicationContext()
|
||||
.getBean(ClientConnectionService.class);
|
||||
LocationService locationService = event.getApplicationContext().getBean(LocationService.class);
|
||||
|
||||
setupSubscriptions(messageController, clientConnectionService, locationService);
|
||||
|
||||
log.info("[Messaging] Message routing configured");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[Messaging] Failed to initialize: {}", e.getMessage());
|
||||
throw new RuntimeException("Failed to initialize messaging", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup message subscriptions on the WebSocket service.
|
||||
*/
|
||||
private void setupSubscriptions(MessageController messageController,
|
||||
ClientConnectionService clientConnectionService, LocationService locationService) {
|
||||
// Login handler: authenticate and register session
|
||||
webSocketService.registerMessageHandler("login", (wsSessionId, payload) -> {
|
||||
handleLoginMessage(wsSessionId, payload, messageController, clientConnectionService);
|
||||
});
|
||||
|
||||
// Task completion handler
|
||||
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null;
|
||||
messageController.handleTaskCompleted(payloadMap, taskType);
|
||||
});
|
||||
});
|
||||
|
||||
// Chat message handler
|
||||
webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
messageController.handleIncomingMessage(appUserId, payloadMap);
|
||||
});
|
||||
});
|
||||
|
||||
// Buffer flushed handler - client is ready to receive pending messages
|
||||
webSocketService.registerMessageHandler("buffer_flushed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
int messageCount = extractMessageCount(payloadMap);
|
||||
clientConnectionService.onBufferFlushed(appUserId, messageCount);
|
||||
});
|
||||
});
|
||||
|
||||
// Location handler - client sends position updates
|
||||
webSocketService.registerMessageHandler("location", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
locationService.savePosition(appUserId, payloadMap);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login message. The wsSessionId identifies the pending WebSocket
|
||||
* session. On success, registers the session under the appUserId and sends an
|
||||
* auth response. On failure, sends an error response to the pending session.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleLoginMessage(String wsSessionId, byte[] payload, MessageController messageController,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
try {
|
||||
String json = new String(payload, StandardCharsets.UTF_8);
|
||||
Map<String, Object> payloadMap = objectMapper.readValue(json, Map.class);
|
||||
AppLoginRequest loginRequest = objectMapper.convertValue(payloadMap, AppLoginRequest.class);
|
||||
AppLoginResponse response = messageController.handleAppLogin(loginRequest);
|
||||
|
||||
if (response.isSuccess()) {
|
||||
String appUserId = response.getAppUserId();
|
||||
log.info("[Messaging] Login successful for appUserId: {}", appUserId);
|
||||
|
||||
webSocketService.registerAuthenticatedSession(wsSessionId, appUserId);
|
||||
|
||||
// Send success response to the now-authenticated session
|
||||
// locationTrackingEnabled: true = client should send position updates
|
||||
// appUserId: wird an den Client gesendet für Referenz
|
||||
Map<String, Object> authResponse = Map.of("success", true, "message", response.getMessage(),
|
||||
"locationTrackingEnabled", true, "appUserId", appUserId);
|
||||
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
|
||||
log.info("[Messaging] Sending auth response to appUserId: {}", appUserId);
|
||||
webSocketService.sendToClient(appUserId, "auth", responseBytes);
|
||||
|
||||
// Register client - pending messages and jobs will be sent after
|
||||
// client confirms buffer_flushed
|
||||
clientConnectionService.registerClient(appUserId);
|
||||
} else {
|
||||
log.warn("[Messaging] Login failed: {}", response.getMessage());
|
||||
// Send failure response to the pending session
|
||||
Map<String, Object> authResponse = Map.of("success", false, "message", response.getMessage());
|
||||
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
|
||||
webSocketService.sendToSessionById(wsSessionId, "/client/auth", responseBytes);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Messaging] Login handling error: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract message count from buffer_flushed payload.
|
||||
*/
|
||||
private int extractMessageCount(Map<String, Object> payloadMap) {
|
||||
try {
|
||||
Object countObj = payloadMap.get("messageCount");
|
||||
if (countObj instanceof Number) {
|
||||
return ((Number) countObj).intValue();
|
||||
}
|
||||
return 0;
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse payload bytes to a Map and pass to the consumer.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handlePayload(byte[] payload, java.util.function.Consumer<Map<String, Object>> handler) {
|
||||
try {
|
||||
String json = new String(payload, StandardCharsets.UTF_8);
|
||||
Map<String, Object> payloadMap = objectMapper.readValue(json, Map.class);
|
||||
handler.accept(payloadMap);
|
||||
} catch (Exception e) {
|
||||
log.error("[Messaging] Error parsing payload: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.assecutor.votianlt.messaging;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Publishing helper to send JSON payloads to clients via WebSocket.
|
||||
*/
|
||||
public interface MessagingPublisher {
|
||||
void publishAsJson(String clientId, String messageType, Object payload);
|
||||
}
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
class MessagingPublisherImpl implements MessagingPublisher {
|
||||
|
||||
private final WebSocketService webSocketService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper) {
|
||||
this.webSocketService = webSocketService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publishAsJson(String clientId, String messageType, Object payload) {
|
||||
try {
|
||||
// Prüfen ob Client verbunden ist
|
||||
boolean isConnected = webSocketService.isClientConnected(clientId);
|
||||
log.debug("[Messaging] Publishing to {}/{} - connected: {}", clientId, messageType, isConnected);
|
||||
|
||||
if (!isConnected) {
|
||||
log.warn("[Messaging] Client {} is not connected, cannot send {}", clientId, messageType);
|
||||
return;
|
||||
}
|
||||
|
||||
String json = objectMapper.writeValueAsString(payload);
|
||||
byte[] data = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
webSocketService.sendToClient(clientId, messageType, data).thenRun(() -> {
|
||||
log.debug("[Messaging] Successfully sent {}/{} to client {}", messageType, clientId);
|
||||
}).exceptionally(ex -> {
|
||||
log.error("[Messaging] Failed to deliver to {}/{}: {}", clientId, messageType, ex.getMessage(), ex);
|
||||
return null;
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[Messaging] Failed to publish to {}/{}: {}", clientId, messageType, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.assecutor.votianlt.messaging;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
|
||||
|
||||
/**
|
||||
* WebSocket configuration that registers the WebSocketService as a handler on
|
||||
* the configured endpoint.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final WebSocketService webSocketService;
|
||||
|
||||
@Value("${app.messaging.websocket.path:/ws/messaging}")
|
||||
private String wsPath;
|
||||
|
||||
@Value("${app.messaging.websocket.allowed-origins:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
public WebSocketConfig(WebSocketService webSocketService) {
|
||||
this.webSocketService = webSocketService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
|
||||
.addInterceptors(new HttpSessionHandshakeInterceptor());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package de.assecutor.votianlt.messaging;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* WebSocket service for direct bidirectional communication with mobile clients.
|
||||
*
|
||||
* Wire Protocol: Each WebSocket message is a JSON document with a "topic" and
|
||||
* "payload" field:
|
||||
*
|
||||
* <pre>
|
||||
* {
|
||||
* "topic": "/server/login",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* Topic Structure:
|
||||
* <ul>
|
||||
* <li>Server to Client: /client/{messageType}</li>
|
||||
* <li>Client to Server: /server/{messageType}</li>
|
||||
* <li>Login (special): /server/login (unauthenticated)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WebSocketService extends TextWebSocketHandler {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MessageHandler {
|
||||
void onMessageReceived(String clientId, byte[] payload);
|
||||
}
|
||||
|
||||
private static final String TOPIC_TO_CLIENT = "/client/%s";
|
||||
private static final long PENDING_SESSION_TIMEOUT_MS = 30_000;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// appUserId -> WebSocketSession
|
||||
private final ConcurrentHashMap<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
|
||||
|
||||
// sessionId -> appUserId (reverse lookup for cleanup on disconnect)
|
||||
private final ConcurrentHashMap<String, String> sessionToClient = new ConcurrentHashMap<>();
|
||||
|
||||
// sessionId -> PendingSession (connected but not yet logged in)
|
||||
private final ConcurrentHashMap<String, PendingSession> pendingSessions = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<String, MessageHandler> messageHandlers = new ConcurrentHashMap<>();
|
||||
private volatile boolean initialized = false;
|
||||
|
||||
private ScheduledExecutorService pendingSessionCleanup;
|
||||
|
||||
public WebSocketService(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Lifecycle
|
||||
// ==========================================
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
pendingSessionCleanup = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "ws-pending-cleanup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
pendingSessionCleanup.scheduleAtFixedRate(this::cleanupPendingSessions, 30, 30, TimeUnit.SECONDS);
|
||||
|
||||
initialized = true;
|
||||
log.info("[WebSocket] Service initialized on endpoint /ws/messaging");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
if (pendingSessionCleanup != null) {
|
||||
pendingSessionCleanup.shutdownNow();
|
||||
}
|
||||
|
||||
for (var entry : clientSessions.entrySet()) {
|
||||
try {
|
||||
WebSocketSession session = entry.getValue();
|
||||
if (session.isOpen()) {
|
||||
session.close(CloseStatus.GOING_AWAY);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[WebSocket] Error closing session for client {}: {}", entry.getKey(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
for (var entry : pendingSessions.entrySet()) {
|
||||
try {
|
||||
if (entry.getValue().session.isOpen()) {
|
||||
entry.getValue().session.close(CloseStatus.GOING_AWAY);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
clientSessions.clear();
|
||||
sessionToClient.clear();
|
||||
pendingSessions.clear();
|
||||
messageHandlers.clear();
|
||||
initialized = false;
|
||||
|
||||
log.info("[WebSocket] Service shut down");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Public API
|
||||
// ==========================================
|
||||
|
||||
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload) {
|
||||
WebSocketSession session = clientSessions.get(clientId);
|
||||
if (session == null) {
|
||||
log.warn("[WebSocket] No session found for client {}", clientId);
|
||||
return CompletableFuture.failedFuture(new IOException("No WebSocket session for client: " + clientId));
|
||||
}
|
||||
|
||||
if (!session.isOpen()) {
|
||||
log.warn("[WebSocket] Session for client {} is closed", clientId);
|
||||
// Session aus der Map entfernen
|
||||
clientSessions.remove(clientId);
|
||||
sessionToClient.remove(session.getId());
|
||||
return CompletableFuture.failedFuture(new IOException("WebSocket session closed for client: " + clientId));
|
||||
}
|
||||
|
||||
try {
|
||||
String topic = String.format(TOPIC_TO_CLIENT, messageType);
|
||||
String payloadJson = new String(payload, StandardCharsets.UTF_8);
|
||||
|
||||
ObjectNode wireMessage = objectMapper.createObjectNode();
|
||||
wireMessage.put("topic", topic);
|
||||
wireMessage.set("payload", objectMapper.readTree(payloadJson));
|
||||
|
||||
String wireJson = objectMapper.writeValueAsString(wireMessage);
|
||||
log.info("[WebSocket OUT] {} to client {} (session open: {})", topic, clientId, session.isOpen());
|
||||
log.debug("[WebSocket OUT] {} -> {}", topic, wireJson);
|
||||
|
||||
sendToSession(session, wireJson);
|
||||
log.debug("[WebSocket] Message sent successfully to client {}", clientId);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Exception e) {
|
||||
log.error("[WebSocket] Failed to send to client {}: {}", clientId, e.getMessage(), e);
|
||||
return CompletableFuture.failedFuture(new IOException("Failed to send WebSocket message", e));
|
||||
}
|
||||
}
|
||||
|
||||
public void registerMessageHandler(String messageType, MessageHandler handler) {
|
||||
messageHandlers.put(messageType, handler);
|
||||
log.debug("[WebSocket] Registered handler for messageType: {}", messageType);
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
public boolean isClientConnected(String clientId) {
|
||||
WebSocketSession session = clientSessions.get(clientId);
|
||||
return session != null && session.isOpen();
|
||||
}
|
||||
|
||||
public int getConnectedClientCount() {
|
||||
return clientSessions.size();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WebSocket handler methods
|
||||
// ==========================================
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) {
|
||||
pendingSessions.put(session.getId(), new PendingSession(session, Instant.now()));
|
||||
log.info("[WebSocket] New connection: sessionId={}, remote={}", session.getId(), session.getRemoteAddress());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
try {
|
||||
String json = message.getPayload();
|
||||
JsonNode wireMessage = objectMapper.readTree(json);
|
||||
|
||||
JsonNode topicNode = wireMessage.get("topic");
|
||||
JsonNode payloadNode = wireMessage.get("payload");
|
||||
|
||||
if (topicNode == null || payloadNode == null) {
|
||||
log.warn("[WebSocket] Invalid message format (missing topic or payload): {}", json);
|
||||
return;
|
||||
}
|
||||
|
||||
String topic = topicNode.asText();
|
||||
byte[] payloadBytes = objectMapper.writeValueAsBytes(payloadNode);
|
||||
|
||||
log.info("[WebSocket IN] {} <- {}", topic, json);
|
||||
|
||||
// Login message (special: unauthenticated)
|
||||
if ("/server/login".equals(topic)) {
|
||||
handleLoginMessage(session, payloadBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular client message: /server/{messageType}
|
||||
if (topic.startsWith("/server/")) {
|
||||
// Verify session is authenticated
|
||||
String appUserId = sessionToClient.get(session.getId());
|
||||
if (appUserId == null) {
|
||||
log.warn("[WebSocket] Unauthenticated session {} tried to send: {}", session.getId(), topic);
|
||||
return;
|
||||
}
|
||||
handleClientMessage(topic, appUserId, payloadBytes);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[WebSocket] Error handling message: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
String sessionId = session.getId();
|
||||
|
||||
// Remove from pending sessions
|
||||
pendingSessions.remove(sessionId);
|
||||
|
||||
// Remove from authenticated sessions
|
||||
String clientId = sessionToClient.remove(sessionId);
|
||||
if (clientId != null) {
|
||||
clientSessions.remove(clientId, session);
|
||||
log.info("[WebSocket] Client disconnected: clientId={}, reason={}", clientId, status);
|
||||
} else {
|
||||
log.info("[WebSocket] Unauthenticated session closed: sessionId={}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
||||
log.error("[WebSocket] Transport error for session {}: {}", session.getId(), exception.getMessage());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Internal message routing
|
||||
// ==========================================
|
||||
|
||||
private void handleLoginMessage(WebSocketSession session, byte[] payloadBytes) {
|
||||
MessageHandler handler = messageHandlers.get("login");
|
||||
if (handler != null) {
|
||||
handler.onMessageReceived(session.getId(), payloadBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a pending session as authenticated under the given appUserId. Called
|
||||
* by MessagingConfig after successful login.
|
||||
*/
|
||||
public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
|
||||
PendingSession pending = pendingSessions.get(wsSessionId);
|
||||
if (pending == null) {
|
||||
log.warn("[WebSocket] No pending session for wsSessionId={}", wsSessionId);
|
||||
return;
|
||||
}
|
||||
registerClientSession(appUserId, pending.session());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a wire-format message directly to a session by its WebSocket sessionId.
|
||||
* Used for sending login responses to pending (not yet authenticated) sessions.
|
||||
*/
|
||||
public void sendToSessionById(String wsSessionId, String topic, byte[] payload) {
|
||||
try {
|
||||
// Check pending sessions first
|
||||
PendingSession pending = pendingSessions.get(wsSessionId);
|
||||
WebSocketSession session = pending != null ? pending.session() : null;
|
||||
|
||||
// Fallback: check authenticated sessions via reverse lookup
|
||||
if (session == null) {
|
||||
String appUserId = sessionToClient.get(wsSessionId);
|
||||
if (appUserId != null) {
|
||||
session = clientSessions.get(appUserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (session == null || !session.isOpen()) {
|
||||
log.warn("[WebSocket] Cannot send to session {}: not found or closed", wsSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
String payloadJson = new String(payload, StandardCharsets.UTF_8);
|
||||
ObjectNode wireMessage = objectMapper.createObjectNode();
|
||||
wireMessage.put("topic", topic);
|
||||
wireMessage.set("payload", objectMapper.readTree(payloadJson));
|
||||
|
||||
String wireJson = objectMapper.writeValueAsString(wireMessage);
|
||||
log.info("[WebSocket OUT] {} -> {}", topic, wireJson);
|
||||
|
||||
sendToSession(session, wireJson);
|
||||
} catch (Exception e) {
|
||||
log.error("[WebSocket] Error sending to session {}: {}", wsSessionId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClientMessage(String topic, String appUserId, byte[] payload) {
|
||||
String[] parts = topic.split("/");
|
||||
|
||||
// Handle /server/{messageType} where messageType can contain slashes
|
||||
if (parts.length >= 3) {
|
||||
String messageType = String.join("/", Arrays.copyOfRange(parts, 2, parts.length));
|
||||
|
||||
MessageHandler handler = messageHandlers.get(messageType);
|
||||
if (handler != null) {
|
||||
handler.onMessageReceived(appUserId, payload);
|
||||
} else {
|
||||
log.warn("[WebSocket] No handler registered for messageType: {}", messageType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Session management
|
||||
// ==========================================
|
||||
|
||||
private void registerClientSession(String clientId, WebSocketSession session) {
|
||||
// Close old session if same clientId reconnects
|
||||
WebSocketSession oldSession = clientSessions.put(clientId, session);
|
||||
if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) {
|
||||
try {
|
||||
String oldSessionId = oldSession.getId();
|
||||
sessionToClient.remove(oldSessionId);
|
||||
oldSession.close(CloseStatus.NORMAL.withReason("Replaced by new connection"));
|
||||
log.info("[WebSocket] Closed old session for clientId={} (replaced)", clientId);
|
||||
} catch (IOException e) {
|
||||
log.warn("[WebSocket] Error closing old session for client {}: {}", clientId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
sessionToClient.put(session.getId(), clientId);
|
||||
pendingSessions.remove(session.getId());
|
||||
|
||||
log.info("[WebSocket] Client registered: clientId={}, sessionId={}", clientId, session.getId());
|
||||
}
|
||||
|
||||
private void cleanupPendingSessions() {
|
||||
Instant cutoff = Instant.now().minusMillis(PENDING_SESSION_TIMEOUT_MS);
|
||||
pendingSessions.entrySet().removeIf(entry -> {
|
||||
if (entry.getValue().connectedAt.isBefore(cutoff)) {
|
||||
try {
|
||||
WebSocketSession session = entry.getValue().session;
|
||||
if (session.isOpen()) {
|
||||
session.close(CloseStatus.POLICY_VIOLATION.withReason("Login timeout"));
|
||||
}
|
||||
log.info("[WebSocket] Closed pending session (login timeout): sessionId={}", entry.getKey());
|
||||
} catch (IOException e) {
|
||||
log.warn("[WebSocket] Error closing pending session: {}", e.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Utility methods
|
||||
// ==========================================
|
||||
|
||||
private void sendToSession(WebSocketSession session, String message) throws IOException {
|
||||
synchronized (session) {
|
||||
if (session.isOpen()) {
|
||||
session.sendMessage(new TextMessage(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Internal types
|
||||
// ==========================================
|
||||
|
||||
private record PendingSession(WebSocketSession session, Instant connectedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Speichert das Ergebnis einer Adressvalidierung. Wird verwendet, um zu merken,
|
||||
* ob eine Adresse bereits validiert wurde und ob sie gültig ist.
|
||||
*/
|
||||
@Data
|
||||
public class AddressValidationResult {
|
||||
|
||||
private final String addressType; // "pickup" oder "delivery"
|
||||
private final String street;
|
||||
private final String houseNumber;
|
||||
private final String zip;
|
||||
private final String city;
|
||||
|
||||
private boolean valid;
|
||||
private String formattedAddress;
|
||||
private double latitude;
|
||||
private double longitude;
|
||||
private String validationMessage;
|
||||
|
||||
public AddressValidationResult(String addressType, String street, String houseNumber, String zip, String city) {
|
||||
this.addressType = addressType;
|
||||
this.street = street;
|
||||
this.houseNumber = houseNumber;
|
||||
this.zip = zip;
|
||||
this.city = city;
|
||||
this.valid = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen eindeutigen Schlüssel für diese Adresse
|
||||
*/
|
||||
public String getAddressKey() {
|
||||
return String.format("%s|%s|%s|%s|%s", addressType, normalize(street), normalize(houseNumber), normalize(zip),
|
||||
normalize(city));
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value != null ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob diese Validierung für die angegebenen Adressdaten gilt
|
||||
*/
|
||||
public boolean matches(String street, String houseNumber, String zip, String city) {
|
||||
return normalize(this.street).equals(normalize(street))
|
||||
&& normalize(this.houseNumber).equals(normalize(houseNumber))
|
||||
&& normalize(this.zip).equals(normalize(zip)) && normalize(this.city).equals(normalize(city));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Document(collection = "app_user")
|
||||
public class AppUser {
|
||||
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
@Field("bezeichnung")
|
||||
private String bezeichnung;
|
||||
|
||||
@Field("vorname")
|
||||
private String vorname;
|
||||
|
||||
@Field("nachname")
|
||||
private String nachname;
|
||||
|
||||
@Field("telefon")
|
||||
private String telefon;
|
||||
|
||||
@Field("app_code")
|
||||
private String appCode;
|
||||
|
||||
@Field("email")
|
||||
@org.springframework.data.mongodb.core.index.Indexed(unique = true)
|
||||
private String email;
|
||||
|
||||
@Field("password")
|
||||
private String password;
|
||||
|
||||
// Reset-Token und Zeitstempel
|
||||
@Field("password_code")
|
||||
private String passwordCode;
|
||||
|
||||
@Field("password_timestamp")
|
||||
private LocalDateTime passwordTimestamp;
|
||||
|
||||
@Field("geraet")
|
||||
private String geraet;
|
||||
|
||||
@Field("owner")
|
||||
private ObjectId owner;
|
||||
|
||||
@Field("erstellt_am")
|
||||
private LocalDateTime erstelltAm;
|
||||
|
||||
@Field("erstellt_von")
|
||||
private ObjectId erstelltVon;
|
||||
|
||||
@Field("aktualisiert_am")
|
||||
private LocalDateTime aktualisiertAm;
|
||||
|
||||
@Field("aktualisiert_von")
|
||||
private ObjectId aktualisiertVon;
|
||||
|
||||
public AppUser() {
|
||||
this.erstelltAm = LocalDateTime.now();
|
||||
this.aktualisiertAm = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||
* app user id is returned as a string when users are retrieved via API.
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Barcode entity for storing barcode data from task completions. References the
|
||||
* task ObjectId and stores barcode strings.
|
||||
*/
|
||||
@Data
|
||||
@Document(collection = "barcodes")
|
||||
public class Barcode {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
private ObjectId taskId;
|
||||
private String barcode;
|
||||
private LocalDateTime createdAt;
|
||||
private String completedBy;
|
||||
|
||||
// Default constructor
|
||||
public Barcode() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructor with parameters
|
||||
public Barcode(ObjectId taskId, String barcode, String completedBy) {
|
||||
this();
|
||||
this.taskId = taskId;
|
||||
this.barcode = barcode;
|
||||
this.completedBy = completedBy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "cargo_items")
|
||||
public class CargoItem {
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
@Field("job_id")
|
||||
private ObjectId jobId;
|
||||
|
||||
@Field("description")
|
||||
private String description;
|
||||
|
||||
@Field("quantity")
|
||||
private Integer quantity;
|
||||
|
||||
@Field("weight_kg")
|
||||
private Double weightKg;
|
||||
|
||||
@Field("length_mm")
|
||||
private Double lengthMm;
|
||||
|
||||
@Field("width_mm")
|
||||
private Double widthMm;
|
||||
|
||||
@Field("height_mm")
|
||||
private Double heightMm;
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||
* cargo item id is returned as a string when items are retrieved via API.
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Document(collection = "comments")
|
||||
public class Comment {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
@Field("task_id")
|
||||
private ObjectId taskId;
|
||||
|
||||
@Field("comment_text")
|
||||
private String commentText;
|
||||
|
||||
@Field("completed_by")
|
||||
private String completedBy;
|
||||
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public Comment(ObjectId taskId, String commentText, String completedBy) {
|
||||
this.taskId = taskId;
|
||||
this.commentText = commentText;
|
||||
this.completedBy = completedBy;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
@Data
|
||||
public class Company {
|
||||
private ObjectId id;
|
||||
|
||||
private String name;
|
||||
private String street;
|
||||
private String houseNumber;
|
||||
private String addressAddition;
|
||||
private String zip;
|
||||
private String city;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@Document(collection = "customers")
|
||||
public class Customer {
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
@Field("title")
|
||||
private String title;
|
||||
|
||||
@Field("company_name")
|
||||
private String companyName;
|
||||
|
||||
@Field("firstname")
|
||||
private String firstname;
|
||||
|
||||
@Field("last_name")
|
||||
private String lastName;
|
||||
|
||||
@Field("telephone")
|
||||
private String telephone;
|
||||
|
||||
@Field("fax")
|
||||
private String fax;
|
||||
|
||||
@Field("mail")
|
||||
private String mail;
|
||||
|
||||
@Field("street")
|
||||
private String street;
|
||||
|
||||
@Field("house_number")
|
||||
private String houseNumber;
|
||||
|
||||
@Field("address_addition")
|
||||
private String addressAddition;
|
||||
|
||||
@Field("zip")
|
||||
private String zip;
|
||||
|
||||
@Field("city")
|
||||
private String city;
|
||||
|
||||
@Field("created_by")
|
||||
private ObjectId createdBy;
|
||||
|
||||
@Field("owner")
|
||||
private ObjectId owner;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Embedded delivery station within a Job. Each job can have up to 25 delivery
|
||||
* stations. This is NOT a standalone MongoDB document - it is stored as part of
|
||||
* the Job document.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DeliveryStation {
|
||||
|
||||
@Field("station_id")
|
||||
@JsonIgnore
|
||||
private ObjectId stationId;
|
||||
|
||||
@Field("station_order")
|
||||
private int stationOrder;
|
||||
|
||||
@Field("company")
|
||||
private String company;
|
||||
|
||||
@Field("salutation")
|
||||
private String salutation;
|
||||
|
||||
@Field("first_name")
|
||||
private String firstName;
|
||||
|
||||
@Field("last_name")
|
||||
private String lastName;
|
||||
|
||||
@Field("phone")
|
||||
private String phone;
|
||||
|
||||
@Field("street")
|
||||
private String street;
|
||||
|
||||
@Field("house_number")
|
||||
private String houseNumber;
|
||||
|
||||
@Field("address_addition")
|
||||
private String addressAddition;
|
||||
|
||||
@Field("zip")
|
||||
private String zip;
|
||||
|
||||
@Field("city")
|
||||
private String city;
|
||||
|
||||
@Field("delivery_date")
|
||||
private LocalDate deliveryDate;
|
||||
|
||||
@Field("delivery_time")
|
||||
private LocalTime deliveryTime;
|
||||
|
||||
@Field("tasks")
|
||||
private List<BaseTask> tasks = new ArrayList<>();
|
||||
|
||||
@JsonGetter("stationId")
|
||||
public String getStationIdAsString() {
|
||||
return stationId != null ? stationId.toHexString() : null;
|
||||
}
|
||||
|
||||
public ObjectId ensureStationId() {
|
||||
if (stationId == null) {
|
||||
stationId = new ObjectId();
|
||||
}
|
||||
return stationId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Stores invoice template data for a user. Contains the JSON representation of
|
||||
* the canvas elements.
|
||||
*/
|
||||
@Document(collection = "invoice_templates")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvoiceTemplate {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* The user ID this template belongs to
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* Template name (optional, for future use if multiple templates are supported)
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* JSON string containing the template data (canvas elements)
|
||||
*/
|
||||
private String templateData;
|
||||
|
||||
/**
|
||||
* When the template was created
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* When the template was last updated
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Version for optimistic locking
|
||||
*/
|
||||
private Long version;
|
||||
|
||||
public InvoiceTemplate(String userId, String name, String templateData) {
|
||||
this.userId = userId;
|
||||
this.name = name;
|
||||
this.templateData = templateData;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void updateTemplate(String templateData) {
|
||||
this.templateData = templateData;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
234
backend/src/main/java/de/assecutor/votianlt/model/Job.java
Normal file
234
backend/src/main/java/de/assecutor/votianlt/model/Job.java
Normal file
@@ -0,0 +1,234 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Data
|
||||
@Document(collection = "jobs")
|
||||
public class Job {
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
// Metadaten
|
||||
@Field("job_number")
|
||||
private String jobNumber; // Eindeutige Auftragsnummer
|
||||
|
||||
@Field("status")
|
||||
private JobStatus status = JobStatus.CREATED; // Status des Auftrags
|
||||
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Field("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Field("created_by")
|
||||
private String createdBy; // Benutzer, der den Auftrag erstellt hat
|
||||
|
||||
@Field("is_draft")
|
||||
private boolean isDraft = false; // Kennzeichnet Entwürfe
|
||||
|
||||
// Auftraggeber/Rechnungsempfänger
|
||||
@Field("customer_selection")
|
||||
private String customerSelection; // Kunde01 | KOTVor K01Nach
|
||||
|
||||
// Abholadresse
|
||||
@Field("pickup_company")
|
||||
private String pickupCompany;
|
||||
|
||||
@Field("pickup_salutation")
|
||||
private String pickupSalutation;
|
||||
|
||||
@Field("pickup_first_name")
|
||||
private String pickupFirstName;
|
||||
|
||||
@Field("pickup_last_name")
|
||||
private String pickupLastName;
|
||||
|
||||
@Field("pickup_phone")
|
||||
private String pickupPhone;
|
||||
|
||||
@Field("pickup_street")
|
||||
private String pickupStreet;
|
||||
|
||||
@Field("pickup_house_number")
|
||||
private String pickupHouseNumber;
|
||||
|
||||
@Field("pickup_address_addition")
|
||||
private String pickupAddressAddition;
|
||||
|
||||
@Field("pickup_zip")
|
||||
private String pickupZip;
|
||||
|
||||
@Field("pickup_city")
|
||||
private String pickupCity;
|
||||
|
||||
// Lieferadresse
|
||||
@Field("delivery_company")
|
||||
private String deliveryCompany;
|
||||
|
||||
@Field("delivery_salutation")
|
||||
private String deliverySalutation;
|
||||
|
||||
@Field("delivery_first_name")
|
||||
private String deliveryFirstName;
|
||||
|
||||
@Field("delivery_last_name")
|
||||
private String deliveryLastName;
|
||||
|
||||
@Field("delivery_phone")
|
||||
private String deliveryPhone;
|
||||
|
||||
@Field("delivery_street")
|
||||
private String deliveryStreet;
|
||||
|
||||
@Field("delivery_house_number")
|
||||
private String deliveryHouseNumber;
|
||||
|
||||
@Field("delivery_address_addition")
|
||||
private String deliveryAddressAddition;
|
||||
|
||||
@Field("delivery_zip")
|
||||
private String deliveryZip;
|
||||
|
||||
@Field("delivery_city")
|
||||
private String deliveryCity;
|
||||
|
||||
// Digitale Abwicklung per App
|
||||
@Field("digital_processing")
|
||||
private boolean digitalProcessing;
|
||||
|
||||
@Field("app_user")
|
||||
private String appUser;
|
||||
|
||||
// Termine
|
||||
@Field("pickup_date")
|
||||
private LocalDate pickupDate;
|
||||
|
||||
@Field("pickup_time")
|
||||
private LocalTime pickupTime;
|
||||
|
||||
@Field("delivery_date")
|
||||
private LocalDate deliveryDate;
|
||||
|
||||
@Field("delivery_time")
|
||||
private LocalTime deliveryTime;
|
||||
|
||||
// Bemerkung
|
||||
@Field("remark")
|
||||
private String remark;
|
||||
|
||||
// Preis (netto)
|
||||
@Field("price")
|
||||
private BigDecimal price;
|
||||
|
||||
// Gefahrene Kilometer für Rechnungsstellung
|
||||
@Field("kilometers_driven")
|
||||
private Integer kilometersDriven;
|
||||
|
||||
// Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung
|
||||
@Field("time_in_15min_units")
|
||||
private Integer timeIn15MinUnits;
|
||||
|
||||
// Service-IDs für die Rechnung
|
||||
@Field("service_ids")
|
||||
private List<String> serviceIds;
|
||||
|
||||
// Ausgewählte Leistungen inkl. zugeordneter Lieferstation und Berechnungsbasis
|
||||
@Field("selected_services")
|
||||
private List<JobServiceSelection> selectedServices = new ArrayList<>();
|
||||
|
||||
// Streckeninformation für die Rechnung (in km)
|
||||
@Field("route_distance_km")
|
||||
private Double routeDistanceKm;
|
||||
|
||||
// Fahrtzeit in Sekunden für die Rechnung
|
||||
@Field("route_duration_seconds")
|
||||
private Integer routeDurationSeconds;
|
||||
|
||||
// Referenz auf die erstellte Rechnung
|
||||
@Field("invoice_id")
|
||||
private String invoiceId;
|
||||
|
||||
// Lieferstationen (bis zu 25)
|
||||
@Field("delivery_stations")
|
||||
private List<DeliveryStation> deliveryStations = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||
* job id is returned as a string when jobs are retrieved via API.
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first delivery station's city. Falls back to the flat
|
||||
* deliveryCity field for backward compatibility with old jobs.
|
||||
*/
|
||||
public String getFirstDeliveryCity() {
|
||||
if (deliveryStations != null && !deliveryStations.isEmpty()) {
|
||||
return deliveryStations.get(0).getCity();
|
||||
}
|
||||
return deliveryCity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last delivery station's city for route display.
|
||||
*/
|
||||
public String getLastDeliveryCity() {
|
||||
if (deliveryStations != null && !deliveryStations.isEmpty()) {
|
||||
return deliveryStations.get(deliveryStations.size() - 1).getCity();
|
||||
}
|
||||
return deliveryCity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all delivery cities joined with arrows for display (e.g. "Berlin →
|
||||
* Dresden → München").
|
||||
*/
|
||||
public String getDeliveryCitiesDisplay() {
|
||||
if (deliveryStations != null && !deliveryStations.isEmpty()) {
|
||||
return deliveryStations.stream().map(DeliveryStation::getCity).filter(c -> c != null && !c.isBlank())
|
||||
.collect(Collectors.joining(" \u2192 "));
|
||||
}
|
||||
return deliveryCity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the flat delivery fields from the first delivery station for
|
||||
* backward compatibility. Call this before saving when using delivery stations.
|
||||
*/
|
||||
public void syncFlatDeliveryFieldsFromStations() {
|
||||
if (deliveryStations != null && !deliveryStations.isEmpty()) {
|
||||
DeliveryStation first = deliveryStations.get(0);
|
||||
this.deliveryCompany = first.getCompany();
|
||||
this.deliverySalutation = first.getSalutation();
|
||||
this.deliveryFirstName = first.getFirstName();
|
||||
this.deliveryLastName = first.getLastName();
|
||||
this.deliveryPhone = first.getPhone();
|
||||
this.deliveryStreet = first.getStreet();
|
||||
this.deliveryHouseNumber = first.getHouseNumber();
|
||||
this.deliveryAddressAddition = first.getAddressAddition();
|
||||
this.deliveryZip = first.getZip();
|
||||
this.deliveryCity = first.getCity();
|
||||
this.deliveryDate = first.getDeliveryDate();
|
||||
this.deliveryTime = first.getDeliveryTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Job History entity for tracking all changes made to a job. Each entry
|
||||
* represents a single change or action performed on a job.
|
||||
*/
|
||||
@Data
|
||||
@Document(collection = "job_history")
|
||||
public class JobHistory {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
/**
|
||||
* Reference to the job this history entry belongs to
|
||||
*/
|
||||
private ObjectId jobId;
|
||||
|
||||
/**
|
||||
* Timestamp when the change occurred
|
||||
*/
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
/**
|
||||
* Reason for the change (e.g., "Status Update", "User Edit", "System Update")
|
||||
*/
|
||||
private String reason;
|
||||
|
||||
/**
|
||||
* Description of what was changed (e.g., "Status changed from CREATED to
|
||||
* IN_PROGRESS")
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* User who made the change (can be null for system changes)
|
||||
*/
|
||||
private String changedBy;
|
||||
|
||||
/**
|
||||
* Additional details about the change (optional)
|
||||
*/
|
||||
private String details;
|
||||
|
||||
/**
|
||||
* Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
|
||||
*/
|
||||
private JobHistoryType changeType;
|
||||
|
||||
/**
|
||||
* Old value (for comparison, stored as JSON string if complex)
|
||||
*/
|
||||
private String oldValue;
|
||||
|
||||
/**
|
||||
* New value (for comparison, stored as JSON string if complex)
|
||||
*/
|
||||
private String newValue;
|
||||
|
||||
// Default constructor
|
||||
public JobHistory() {
|
||||
this.timestamp = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructor for basic history entry
|
||||
public JobHistory(ObjectId jobId, String reason, String description, String changedBy) {
|
||||
this();
|
||||
this.jobId = jobId;
|
||||
this.reason = reason;
|
||||
this.description = description;
|
||||
this.changedBy = changedBy;
|
||||
}
|
||||
|
||||
// Constructor for detailed history entry
|
||||
public JobHistory(ObjectId jobId, String reason, String description, String changedBy, JobHistoryType changeType,
|
||||
String oldValue, String newValue) {
|
||||
this(jobId, reason, description, changedBy);
|
||||
this.changeType = changeType;
|
||||
this.oldValue = oldValue;
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
// Getter for ID as String
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toHexString() : null;
|
||||
}
|
||||
|
||||
// Getter for Job ID as String
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toHexString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Enumeration of different types of job history changes
|
||||
*/
|
||||
public enum JobHistoryType {
|
||||
/**
|
||||
* Job was created
|
||||
*/
|
||||
CREATE,
|
||||
|
||||
/**
|
||||
* Job data was updated
|
||||
*/
|
||||
UPDATE,
|
||||
|
||||
/**
|
||||
* Job status was changed
|
||||
*/
|
||||
STATUS_CHANGE,
|
||||
|
||||
/**
|
||||
* Job was assigned to a user
|
||||
*/
|
||||
ASSIGNMENT,
|
||||
|
||||
/**
|
||||
* Task was completed within the job
|
||||
*/
|
||||
TASK_COMPLETED,
|
||||
|
||||
/**
|
||||
* Job was exported or shared
|
||||
*/
|
||||
EXPORT,
|
||||
|
||||
/**
|
||||
* Job was deleted or archived
|
||||
*/
|
||||
DELETE,
|
||||
|
||||
/**
|
||||
* System-generated change
|
||||
*/
|
||||
SYSTEM,
|
||||
|
||||
/**
|
||||
* Comment or note was added
|
||||
*/
|
||||
COMMENT,
|
||||
|
||||
/**
|
||||
* Other type of change
|
||||
*/
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
public class JobServiceSelection {
|
||||
|
||||
@Field("service_id")
|
||||
private String serviceId;
|
||||
|
||||
@Field("delivery_station_order")
|
||||
private Integer deliveryStationOrder;
|
||||
|
||||
@Field("route_distance_km")
|
||||
private Double routeDistanceKm;
|
||||
|
||||
@Field("route_duration_seconds")
|
||||
private Integer routeDurationSeconds;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Status-Enum für Aufträge
|
||||
*/
|
||||
public enum JobStatus {
|
||||
CREATED("Erstellt"),
|
||||
IN_PROGRESS("In Bearbeitung"),
|
||||
PICKUP_SCHEDULED("Abholung geplant"),
|
||||
PICKED_UP("Abgeholt"),
|
||||
IN_TRANSIT("Unterwegs"),
|
||||
DELIVERED("Zugestellt"),
|
||||
COMPLETED("Abgeschlossen"),
|
||||
CANCELLED("Storniert");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
JobStatus(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
public enum Language {
|
||||
DE("Deutsch"),
|
||||
EN("English"),
|
||||
FR("Français"),
|
||||
ES("Español"),
|
||||
TR("Türkçe"),
|
||||
PL("Polski"),
|
||||
RU("Русский"),
|
||||
EE("Eesti"),
|
||||
LV("Latviešu"),
|
||||
LT("Lietuvių");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
Language(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public static Language fromString(String text) {
|
||||
for (Language language : Language.values()) {
|
||||
if (language.name().equalsIgnoreCase(text)) {
|
||||
return language;
|
||||
}
|
||||
}
|
||||
return DE; // Default to German
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
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.Instant;
|
||||
|
||||
/**
|
||||
* Represents a GPS position reported by a mobile client.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Document(collection = "location_positions")
|
||||
public class LocationPosition {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
/**
|
||||
* AppUser ID (clientId) - the user who sent this position
|
||||
*/
|
||||
@Field("app_user_id")
|
||||
@Indexed
|
||||
private String appUserId;
|
||||
|
||||
/**
|
||||
* Latitude in decimal degrees
|
||||
*/
|
||||
@Field("latitude")
|
||||
private Double latitude;
|
||||
|
||||
/**
|
||||
* Longitude in decimal degrees
|
||||
*/
|
||||
@Field("longitude")
|
||||
private Double longitude;
|
||||
|
||||
/**
|
||||
* Accuracy of the position in meters
|
||||
*/
|
||||
@Field("accuracy")
|
||||
private Double accuracy;
|
||||
|
||||
/**
|
||||
* Altitude in meters above sea level (optional)
|
||||
*/
|
||||
@Field("altitude")
|
||||
private Double altitude;
|
||||
|
||||
/**
|
||||
* Speed in meters per second (optional)
|
||||
*/
|
||||
@Field("speed")
|
||||
private Double speed;
|
||||
|
||||
/**
|
||||
* Heading in degrees (0-360) (optional)
|
||||
*/
|
||||
@Field("heading")
|
||||
private Double heading;
|
||||
|
||||
/**
|
||||
* Timestamp when the position was reported (from client)
|
||||
*/
|
||||
@Field("timestamp")
|
||||
private Instant timestamp;
|
||||
|
||||
/**
|
||||
* Timestamp when the position was received by the server
|
||||
*/
|
||||
@Field("received_at")
|
||||
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
|
||||
private Instant receivedAt;
|
||||
|
||||
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude,
|
||||
Double speed, Double heading, Instant timestamp) {
|
||||
this.appUserId = appUserId;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
this.accuracy = accuracy;
|
||||
this.altitude = altitude;
|
||||
this.speed = speed;
|
||||
this.heading = heading;
|
||||
this.timestamp = timestamp;
|
||||
this.receivedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
190
backend/src/main/java/de/assecutor/votianlt/model/Message.java
Normal file
190
backend/src/main/java/de/assecutor/votianlt/model/Message.java
Normal file
@@ -0,0 +1,190 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Represents a message that can be sent between the server and clients.
|
||||
* Messages can be either job-related or general messages.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Document(collection = "messages")
|
||||
public class Message {
|
||||
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
/**
|
||||
* Content of the message, either plain text or base64 encoded media
|
||||
*/
|
||||
@Field("content")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* Declares how to interpret the content payload
|
||||
*/
|
||||
@Field("content_type")
|
||||
private MessageContentType contentType = MessageContentType.TEXT;
|
||||
|
||||
/**
|
||||
* AppUser ID (clientId) - the AppUser to whom this message belongs
|
||||
*/
|
||||
@Field("receiver")
|
||||
private String receiver;
|
||||
|
||||
/**
|
||||
* Timestamp when the message was created
|
||||
*/
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Origin of the message: INCOMING (from client), OUTGOING (to client), or
|
||||
* SERVER (from server)
|
||||
*/
|
||||
@Field("origin")
|
||||
private MessageOrigin origin;
|
||||
|
||||
/**
|
||||
* Type of message: JOB_RELATED or GENERAL
|
||||
*/
|
||||
@Field("message_type")
|
||||
private MessageType messageType;
|
||||
|
||||
/**
|
||||
* Optional reference to a job (only for job-related messages)
|
||||
*/
|
||||
@Field("job_id")
|
||||
private ObjectId jobId;
|
||||
|
||||
/**
|
||||
* Optional job number for easier reference (denormalized)
|
||||
*/
|
||||
@Field("job_number")
|
||||
private String jobNumber;
|
||||
|
||||
/**
|
||||
* Whether the message has been read by the receiver
|
||||
*/
|
||||
@Field("is_read")
|
||||
private boolean isRead;
|
||||
|
||||
/**
|
||||
* Timestamp when the message was read
|
||||
*/
|
||||
@Field("read_at")
|
||||
private LocalDateTime readAt;
|
||||
|
||||
/**
|
||||
* Delivery status: NOTSEND (failed to deliver), SEND (successfully delivered)
|
||||
*/
|
||||
@Field("delivery_status")
|
||||
private MessageDeliveryStatus deliveryStatus;
|
||||
|
||||
/**
|
||||
* Constructor for general messages
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin) {
|
||||
this(content, receiver, origin, MessageContentType.TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for general messages with explicit content type
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
|
||||
initializeBaseFields(content, receiver, origin, contentType);
|
||||
this.messageType = MessageType.GENERAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for job-related messages
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
|
||||
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for job-related messages with explicit content type
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
initializeBaseFields(content, receiver, origin, contentType);
|
||||
this.messageType = MessageType.JOB_RELATED;
|
||||
this.jobId = jobId;
|
||||
this.jobNumber = jobNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the message as read
|
||||
*/
|
||||
public void markAsRead() {
|
||||
this.isRead = true;
|
||||
this.readAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the message as sent (successfully delivered)
|
||||
*/
|
||||
public void markAsSent() {
|
||||
this.deliveryStatus = MessageDeliveryStatus.SEND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the message as not sent (delivery failed)
|
||||
*/
|
||||
public void markAsNotSent() {
|
||||
this.deliveryStatus = MessageDeliveryStatus.NOTSEND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message was successfully delivered
|
||||
*/
|
||||
public boolean isDelivered() {
|
||||
return deliveryStatus == MessageDeliveryStatus.SEND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the job ObjectId as string for JSON serialization
|
||||
*/
|
||||
@JsonGetter("jobId")
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure callers always receive a non-null content type to simplify rendering
|
||||
*/
|
||||
public MessageContentType getContentType() {
|
||||
return contentType != null ? contentType : MessageContentType.TEXT;
|
||||
}
|
||||
|
||||
private void initializeBaseFields(String content, String receiver, MessageOrigin origin,
|
||||
MessageContentType contentType) {
|
||||
this.content = content;
|
||||
this.receiver = receiver;
|
||||
this.origin = origin;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.isRead = false;
|
||||
this.contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
||||
// Server messages start as NOTSEND until confirmed delivered
|
||||
this.deliveryStatus = (origin == MessageOrigin.SERVER) ? MessageDeliveryStatus.NOTSEND : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Supported content variants for chat messages.
|
||||
*/
|
||||
public enum MessageContentType {
|
||||
TEXT, IMAGE
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Delivery status for messages sent to clients. Tracks whether a message was
|
||||
* successfully delivered via WebSocket.
|
||||
*/
|
||||
public enum MessageDeliveryStatus {
|
||||
NOTSEND("Nicht gesendet"), SEND("Gesendet");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
MessageDeliveryStatus(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Enum representing the origin of a message
|
||||
*/
|
||||
public enum MessageOrigin {
|
||||
/**
|
||||
* Message received from a client (app user)
|
||||
*/
|
||||
CLIENT,
|
||||
|
||||
/**
|
||||
* Message sent from the server
|
||||
*/
|
||||
SERVER
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Enum representing the type of message
|
||||
*/
|
||||
public enum MessageType {
|
||||
/**
|
||||
* General message not related to a specific job
|
||||
*/
|
||||
GENERAL,
|
||||
|
||||
/**
|
||||
* Message related to a specific job
|
||||
*/
|
||||
JOB_RELATED
|
||||
}
|
||||
38
backend/src/main/java/de/assecutor/votianlt/model/Photo.java
Normal file
38
backend/src/main/java/de/assecutor/votianlt/model/Photo.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Photo entity for storing photo data from task completions. References the job
|
||||
* ObjectId and stores base64 encoded photos.
|
||||
*/
|
||||
@Data
|
||||
@Document(collection = "photos")
|
||||
public class Photo {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
private ObjectId taskId;
|
||||
private String photo; // base64 encoded photos
|
||||
private LocalDateTime createdAt;
|
||||
private String completedBy;
|
||||
|
||||
// Default constructor
|
||||
public Photo() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructor with parameters
|
||||
public Photo(ObjectId taskId, String photo, String completedBy) {
|
||||
this();
|
||||
this.taskId = taskId;
|
||||
this.photo = photo;
|
||||
this.completedBy = completedBy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
@Document(collection = "price_table")
|
||||
public class PriceTable {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String monthlyBasePackage;
|
||||
private String appUsageLicense;
|
||||
private String revenueParticipation;
|
||||
private String statisticalEvaluation;
|
||||
|
||||
public PriceTable() {
|
||||
}
|
||||
|
||||
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
|
||||
String statisticalEvaluation) {
|
||||
this.monthlyBasePackage = monthlyBasePackage;
|
||||
this.appUsageLicense = appUsageLicense;
|
||||
this.revenueParticipation = revenueParticipation;
|
||||
this.statisticalEvaluation = statisticalEvaluation;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getMonthlyBasePackage() {
|
||||
return monthlyBasePackage;
|
||||
}
|
||||
|
||||
public void setMonthlyBasePackage(String monthlyBasePackage) {
|
||||
this.monthlyBasePackage = monthlyBasePackage;
|
||||
}
|
||||
|
||||
public String getAppUsageLicense() {
|
||||
return appUsageLicense;
|
||||
}
|
||||
|
||||
public void setAppUsageLicense(String appUsageLicense) {
|
||||
this.appUsageLicense = appUsageLicense;
|
||||
}
|
||||
|
||||
public String getRevenueParticipation() {
|
||||
return revenueParticipation;
|
||||
}
|
||||
|
||||
public void setRevenueParticipation(String revenueParticipation) {
|
||||
this.revenueParticipation = revenueParticipation;
|
||||
}
|
||||
|
||||
public String getStatisticalEvaluation() {
|
||||
return statisticalEvaluation;
|
||||
}
|
||||
|
||||
public void setStatisticalEvaluation(String statisticalEvaluation) {
|
||||
this.statisticalEvaluation = statisticalEvaluation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Speichert das Ergebnis einer Routenberechnung zwischen zwei Adressen.
|
||||
*/
|
||||
@Data
|
||||
public class RouteCalculationResult {
|
||||
|
||||
private boolean valid;
|
||||
private double distanceKm;
|
||||
private int durationSeconds;
|
||||
private String formattedDistance;
|
||||
private String formattedDuration;
|
||||
private String routeMessage;
|
||||
|
||||
public RouteCalculationResult() {
|
||||
this.valid = false;
|
||||
this.distanceKm = 0.0;
|
||||
this.durationSeconds = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Dauer in Minuten zurück
|
||||
*/
|
||||
public int getDurationMinutes() {
|
||||
return durationSeconds / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Dauer formatiert zurück (z.B. "1 Std. 30 Min." oder "45 Min.")
|
||||
*/
|
||||
public String getFormattedDurationLong() {
|
||||
int hours = durationSeconds / 3600;
|
||||
int minutes = (durationSeconds % 3600) / 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return String.format("%d Std. %d Min.", hours, minutes);
|
||||
} else {
|
||||
return String.format("%d Min.", minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
backend/src/main/java/de/assecutor/votianlt/model/Service.java
Normal file
136
backend/src/main/java/de/assecutor/votianlt/model/Service.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Document(collection = "services")
|
||||
public class Service {
|
||||
public static final BigDecimal FIXED_VAT_RATE = new BigDecimal("0.19");
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String userId;
|
||||
private String name;
|
||||
private CalculationBasis calculationBasis;
|
||||
private BigDecimal price; // For FLAT_RATE services
|
||||
private BigDecimal pricePerKilometer; // For DISTANCE services - price per kilometer
|
||||
private BigDecimal pricePer15Minutes; // For TIME services - price per 15 minutes
|
||||
private boolean mandatory;
|
||||
|
||||
public enum CalculationBasis {
|
||||
DISTANCE, TIME, FLAT_RATE
|
||||
}
|
||||
|
||||
public Service() {
|
||||
}
|
||||
|
||||
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price,
|
||||
BigDecimal vatRate) {
|
||||
this(userId, name, calculationBasis, price, vatRate, false);
|
||||
}
|
||||
|
||||
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price, BigDecimal vatRate,
|
||||
boolean mandatory) {
|
||||
this.userId = userId;
|
||||
this.name = name;
|
||||
this.calculationBasis = calculationBasis;
|
||||
this.mandatory = mandatory;
|
||||
|
||||
// Set the appropriate price field based on calculation basis
|
||||
switch (calculationBasis) {
|
||||
case DISTANCE:
|
||||
this.pricePerKilometer = price;
|
||||
break;
|
||||
case TIME:
|
||||
this.pricePer15Minutes = price;
|
||||
break;
|
||||
case FLAT_RATE:
|
||||
this.price = price;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public CalculationBasis getCalculationBasis() {
|
||||
return calculationBasis;
|
||||
}
|
||||
|
||||
public void setCalculationBasis(CalculationBasis calculationBasis) {
|
||||
this.calculationBasis = calculationBasis;
|
||||
}
|
||||
|
||||
public BigDecimal getVatRate() {
|
||||
return FIXED_VAT_RATE;
|
||||
}
|
||||
|
||||
public void setVatRate(BigDecimal vatRate) {
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public BigDecimal getPricePerKilometer() {
|
||||
return pricePerKilometer;
|
||||
}
|
||||
|
||||
public void setPricePerKilometer(BigDecimal pricePerKilometer) {
|
||||
this.pricePerKilometer = pricePerKilometer;
|
||||
}
|
||||
|
||||
public BigDecimal getPricePer15Minutes() {
|
||||
return pricePer15Minutes;
|
||||
}
|
||||
|
||||
public void setPricePer15Minutes(BigDecimal pricePer15Minutes) {
|
||||
this.pricePer15Minutes = pricePer15Minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate price based on calculation basis
|
||||
*/
|
||||
public BigDecimal getEffectivePrice() {
|
||||
return switch (calculationBasis) {
|
||||
case DISTANCE -> pricePerKilometer;
|
||||
case TIME -> pricePer15Minutes;
|
||||
case FLAT_RATE -> price;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isMandatory() {
|
||||
return mandatory;
|
||||
}
|
||||
|
||||
public void setMandatory(boolean mandatory) {
|
||||
this.mandatory = mandatory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Signature entity for storing signature SVG data from task completions.
|
||||
* References the task ObjectId and stores SVG signature strings.
|
||||
*/
|
||||
@Data
|
||||
@Document(collection = "signatures")
|
||||
public class Signature {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
private ObjectId taskId;
|
||||
private String signatureSvg;
|
||||
private LocalDateTime createdAt;
|
||||
private String completedBy;
|
||||
|
||||
// Default constructor
|
||||
public Signature() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructor with parameters
|
||||
public Signature(ObjectId taskId, String signatureSvg, String completedBy) {
|
||||
this();
|
||||
this.taskId = taskId;
|
||||
this.signatureSvg = signatureSvg;
|
||||
this.completedBy = completedBy;
|
||||
}
|
||||
}
|
||||
121
backend/src/main/java/de/assecutor/votianlt/model/TaskEntry.java
Normal file
121
backend/src/main/java/de/assecutor/votianlt/model/TaskEntry.java
Normal file
@@ -0,0 +1,121 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "tasks")
|
||||
public class TaskEntry {
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
@Field("station_id")
|
||||
@JsonIgnore
|
||||
private ObjectId stationId;
|
||||
|
||||
@Field("job_id")
|
||||
@JsonIgnore
|
||||
private ObjectId jobId;
|
||||
|
||||
@Field("task_type")
|
||||
private TaskType taskType = TaskType.CONFIRMATION;
|
||||
|
||||
// Task-specific configuration data
|
||||
@Field("configuration")
|
||||
private TaskConfiguration configuration;
|
||||
|
||||
// Completion tracking
|
||||
@Field("completed")
|
||||
private boolean completed = false;
|
||||
|
||||
@Field("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Field("completed_by")
|
||||
private String completedBy;
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||
* task id is returned as a string when jobs are retrieved via API.
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the station ObjectId as string for JSON serialization.
|
||||
*/
|
||||
@JsonGetter("stationId")
|
||||
public String getStationIdAsString() {
|
||||
return stationId != null ? stationId.toHexString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the legacy job ObjectId as string for internal fallback handling.
|
||||
*/
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for different task types
|
||||
*/
|
||||
public enum TaskType {
|
||||
CONFIRMATION(
|
||||
"Bestätigung"),
|
||||
SIGNATURE("Unterschrift"),
|
||||
TODOLIST("To-Do Liste"),
|
||||
PHOTO("Foto"),
|
||||
BARCODE("Barcode");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
TaskType(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration data for different task types
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class TaskConfiguration {
|
||||
// For CONFIRMATION: button text
|
||||
private String buttonText;
|
||||
|
||||
// For TODOLIST: list of todo items
|
||||
private List<String> todoItems;
|
||||
|
||||
// For PHOTO: min and max photo count
|
||||
private Integer minPhotoCount;
|
||||
private Integer maxPhotoCount;
|
||||
|
||||
// For BARCODE: min and max barcode count
|
||||
private Integer minBarcodeCount;
|
||||
private Integer maxBarcodeCount;
|
||||
|
||||
// Generic configuration map for future extensions
|
||||
private Map<String, Object> additionalConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Document(collection = "task_templates")
|
||||
public class TaskTemplate {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
@Indexed
|
||||
private ObjectId userId;
|
||||
|
||||
private String templateName;
|
||||
private List<BaseTask> tasks;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public TaskTemplate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void updateTimestamp() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
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;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MongoDB document for caching LLM translations. Stores the original text, all
|
||||
* translations keyed by language code, and the insertion timestamp.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Document(collection = "translation_cache")
|
||||
public class TranslationCacheEntry {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
/**
|
||||
* The original text that was translated.
|
||||
*/
|
||||
@Indexed(unique = true)
|
||||
@Field("source_text")
|
||||
private String sourceText;
|
||||
|
||||
/**
|
||||
* Translations keyed by language code (e.g. "de", "en", "fr").
|
||||
*/
|
||||
@Field("translations")
|
||||
private Map<String, String> translations;
|
||||
|
||||
/**
|
||||
* When this entry was inserted into the cache.
|
||||
*/
|
||||
@Indexed
|
||||
@Field("inserted_at")
|
||||
private LocalDateTime insertedAt;
|
||||
|
||||
public TranslationCacheEntry(String sourceText, Map<String, String> translations) {
|
||||
this.sourceText = sourceText;
|
||||
this.translations = translations;
|
||||
this.insertedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
71
backend/src/main/java/de/assecutor/votianlt/model/User.java
Normal file
71
backend/src/main/java/de/assecutor/votianlt/model/User.java
Normal file
@@ -0,0 +1,71 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Document(collection = "users")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
private int usrId;
|
||||
private String title;
|
||||
private String name; // Nachname
|
||||
private String firstname; // Vorname
|
||||
|
||||
// Firmen-/Adressdaten
|
||||
private String company; // Firma
|
||||
private String companyAddition; // Firmenzusatz
|
||||
private String street; // Straße
|
||||
private String houseNumber; // Hausnr
|
||||
private String addressAddition; // Adresszusatz (optional)
|
||||
private String zip; // Postleitzahl
|
||||
private String city; // Stadt
|
||||
|
||||
// Abweichende Rechnungsadresse
|
||||
private boolean diffInvoiceAddress; // Checkbox für abweichende Rechnungsadresse
|
||||
private String invCompany; // Rechnungsadresse: Firma
|
||||
private String invCompanyAddition; // Rechnungsadresse: Firmenzusatz
|
||||
private String invFirstname; // Rechnungsadresse: Vorname
|
||||
private String invLastname; // Rechnungsadresse: Nachname
|
||||
private String invStreet; // Rechnungsadresse: Straße
|
||||
private String invHouseNumber; // Rechnungsadresse: Hausnr
|
||||
private String invAddressAddition; // Rechnungsadresse: Adresszusatz
|
||||
private String invZip; // Rechnungsadresse: Postleitzahl
|
||||
private String invCity; // Rechnungsadresse: Stadt
|
||||
|
||||
@Indexed(unique = true)
|
||||
private String email;
|
||||
|
||||
private String phone;
|
||||
private String phone2;
|
||||
private String fax;
|
||||
private String password;
|
||||
private byte isActivated;
|
||||
private byte isEmailConfirmed;
|
||||
private String passwordCode;
|
||||
private LocalDateTime passwordTimestamp;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private Set<String> roles;
|
||||
|
||||
// Digitale Abwicklung und App-Nutzer Ortung
|
||||
private boolean digitalProcessingEnabled = true; // Digitale Abwicklung per App
|
||||
private boolean locationTrackingEnabled = true; // App-Nutzer orten
|
||||
|
||||
// 2-Faktor-Authentifizierung (standardmäßig aktiviert für neue Nutzer)
|
||||
private boolean twoFactorEnabled = true;
|
||||
|
||||
// Spracheinstellung (standardmäßig Deutsch)
|
||||
@Field("language")
|
||||
private Language language = Language.DE;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Document(collection = "user_invoice_data")
|
||||
public class UserInvoiceData {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
@Indexed
|
||||
private ObjectId userId;
|
||||
|
||||
private boolean billingEnabled;
|
||||
private String prefix;
|
||||
private String ustId;
|
||||
private String taxNumber;
|
||||
private String bankName;
|
||||
private String iban;
|
||||
private String taxRate;
|
||||
private String introText;
|
||||
private String paymentTerms;
|
||||
|
||||
private long nextInvoiceNumber = 0;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public UserInvoiceData() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void updateTimestamp() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Document(collection = "customerInvoices")
|
||||
public class CustomerInvoice {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||
private LocalDate invoiceDate; // Rechnungsdatum
|
||||
private LocalDate deliveryDate; // Leistungsdatum
|
||||
|
||||
// Rechnungssteller (Sender)
|
||||
private String senderName;
|
||||
private String senderAddress;
|
||||
private String senderPostcode;
|
||||
private String senderCity;
|
||||
private String senderCountry;
|
||||
private String senderTaxNumber; // Steuernummer
|
||||
private String senderVatId; // USt-IdNr.
|
||||
private String senderPhone;
|
||||
private String senderEmail;
|
||||
private String senderWebsite;
|
||||
|
||||
// Rechnungsempfänger (Recipient)
|
||||
private String recipientName;
|
||||
private String recipientCompany;
|
||||
private String recipientAddress;
|
||||
private String recipientPostcode;
|
||||
private String recipientCity;
|
||||
private String recipientCountry;
|
||||
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
|
||||
|
||||
// Rechnungsdetails
|
||||
private String description; // Beschreibung der Leistung
|
||||
private List<CustomerInvoiceItem> items;
|
||||
|
||||
// Beträge
|
||||
private BigDecimal netAmount; // Nettobetrag
|
||||
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal totalAmount; // Bruttobetrag
|
||||
|
||||
// Zahlungsdetails
|
||||
private String paymentTerms; // Zahlungsbedingungen
|
||||
private LocalDate paymentDueDate; // Fälligkeitsdatum
|
||||
private String bankAccount; // Bankverbindung
|
||||
private String iban;
|
||||
private String bic;
|
||||
|
||||
// Zusätzliche rechtliche Angaben
|
||||
private String legalNotes; // Rechtliche Hinweise
|
||||
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
|
||||
|
||||
// Verknüpfung mit Auftrag und Benutzer
|
||||
private String jobId; // Referenz auf den Auftrag
|
||||
private String userId; // Referenz auf den Benutzer (Rechnungsersteller)
|
||||
|
||||
// Gespeicherte PDF-Daten (Base64-kodiert)
|
||||
private byte[] pdfData;
|
||||
|
||||
// Constructors
|
||||
public CustomerInvoice() {
|
||||
}
|
||||
|
||||
public CustomerInvoice(String invoiceNumber, LocalDate invoiceDate, String description) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
this.invoiceDate = invoiceDate;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getInvoiceNumber() {
|
||||
return invoiceNumber;
|
||||
}
|
||||
|
||||
public void setInvoiceNumber(String invoiceNumber) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
}
|
||||
|
||||
public LocalDate getInvoiceDate() {
|
||||
return invoiceDate;
|
||||
}
|
||||
|
||||
public void setInvoiceDate(LocalDate invoiceDate) {
|
||||
this.invoiceDate = invoiceDate;
|
||||
}
|
||||
|
||||
public LocalDate getDeliveryDate() {
|
||||
return deliveryDate;
|
||||
}
|
||||
|
||||
public void setDeliveryDate(LocalDate deliveryDate) {
|
||||
this.deliveryDate = deliveryDate;
|
||||
}
|
||||
|
||||
public String getSenderName() {
|
||||
return senderName;
|
||||
}
|
||||
|
||||
public void setSenderName(String senderName) {
|
||||
this.senderName = senderName;
|
||||
}
|
||||
|
||||
public String getSenderAddress() {
|
||||
return senderAddress;
|
||||
}
|
||||
|
||||
public void setSenderAddress(String senderAddress) {
|
||||
this.senderAddress = senderAddress;
|
||||
}
|
||||
|
||||
public String getSenderPostcode() {
|
||||
return senderPostcode;
|
||||
}
|
||||
|
||||
public void setSenderPostcode(String senderPostcode) {
|
||||
this.senderPostcode = senderPostcode;
|
||||
}
|
||||
|
||||
public String getSenderCity() {
|
||||
return senderCity;
|
||||
}
|
||||
|
||||
public void setSenderCity(String senderCity) {
|
||||
this.senderCity = senderCity;
|
||||
}
|
||||
|
||||
public String getSenderCountry() {
|
||||
return senderCountry;
|
||||
}
|
||||
|
||||
public void setSenderCountry(String senderCountry) {
|
||||
this.senderCountry = senderCountry;
|
||||
}
|
||||
|
||||
public String getSenderTaxNumber() {
|
||||
return senderTaxNumber;
|
||||
}
|
||||
|
||||
public void setSenderTaxNumber(String senderTaxNumber) {
|
||||
this.senderTaxNumber = senderTaxNumber;
|
||||
}
|
||||
|
||||
public String getSenderVatId() {
|
||||
return senderVatId;
|
||||
}
|
||||
|
||||
public void setSenderVatId(String senderVatId) {
|
||||
this.senderVatId = senderVatId;
|
||||
}
|
||||
|
||||
public String getSenderPhone() {
|
||||
return senderPhone;
|
||||
}
|
||||
|
||||
public void setSenderPhone(String senderPhone) {
|
||||
this.senderPhone = senderPhone;
|
||||
}
|
||||
|
||||
public String getSenderEmail() {
|
||||
return senderEmail;
|
||||
}
|
||||
|
||||
public void setSenderEmail(String senderEmail) {
|
||||
this.senderEmail = senderEmail;
|
||||
}
|
||||
|
||||
public String getSenderWebsite() {
|
||||
return senderWebsite;
|
||||
}
|
||||
|
||||
public void setSenderWebsite(String senderWebsite) {
|
||||
this.senderWebsite = senderWebsite;
|
||||
}
|
||||
|
||||
public String getRecipientName() {
|
||||
return recipientName;
|
||||
}
|
||||
|
||||
public void setRecipientName(String recipientName) {
|
||||
this.recipientName = recipientName;
|
||||
}
|
||||
|
||||
public String getRecipientCompany() {
|
||||
return recipientCompany;
|
||||
}
|
||||
|
||||
public void setRecipientCompany(String recipientCompany) {
|
||||
this.recipientCompany = recipientCompany;
|
||||
}
|
||||
|
||||
public String getRecipientAddress() {
|
||||
return recipientAddress;
|
||||
}
|
||||
|
||||
public void setRecipientAddress(String recipientAddress) {
|
||||
this.recipientAddress = recipientAddress;
|
||||
}
|
||||
|
||||
public String getRecipientPostcode() {
|
||||
return recipientPostcode;
|
||||
}
|
||||
|
||||
public void setRecipientPostcode(String recipientPostcode) {
|
||||
this.recipientPostcode = recipientPostcode;
|
||||
}
|
||||
|
||||
public String getRecipientCity() {
|
||||
return recipientCity;
|
||||
}
|
||||
|
||||
public void setRecipientCity(String recipientCity) {
|
||||
this.recipientCity = recipientCity;
|
||||
}
|
||||
|
||||
public String getRecipientCountry() {
|
||||
return recipientCountry;
|
||||
}
|
||||
|
||||
public void setRecipientCountry(String recipientCountry) {
|
||||
this.recipientCountry = recipientCountry;
|
||||
}
|
||||
|
||||
public String getRecipientVatId() {
|
||||
return recipientVatId;
|
||||
}
|
||||
|
||||
public void setRecipientVatId(String recipientVatId) {
|
||||
this.recipientVatId = recipientVatId;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public List<CustomerInvoiceItem> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public void setItems(List<CustomerInvoiceItem> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public BigDecimal getNetAmount() {
|
||||
return netAmount;
|
||||
}
|
||||
|
||||
public void setNetAmount(BigDecimal netAmount) {
|
||||
this.netAmount = netAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getVatRate() {
|
||||
return vatRate;
|
||||
}
|
||||
|
||||
public void setVatRate(BigDecimal vatRate) {
|
||||
this.vatRate = vatRate;
|
||||
}
|
||||
|
||||
public BigDecimal getVatAmount() {
|
||||
return vatAmount;
|
||||
}
|
||||
|
||||
public void setVatAmount(BigDecimal vatAmount) {
|
||||
this.vatAmount = vatAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public void setTotalAmount(BigDecimal totalAmount) {
|
||||
this.totalAmount = totalAmount;
|
||||
}
|
||||
|
||||
public String getPaymentTerms() {
|
||||
return paymentTerms;
|
||||
}
|
||||
|
||||
public void setPaymentTerms(String paymentTerms) {
|
||||
this.paymentTerms = paymentTerms;
|
||||
}
|
||||
|
||||
public LocalDate getPaymentDueDate() {
|
||||
return paymentDueDate;
|
||||
}
|
||||
|
||||
public void setPaymentDueDate(LocalDate paymentDueDate) {
|
||||
this.paymentDueDate = paymentDueDate;
|
||||
}
|
||||
|
||||
public String getBankAccount() {
|
||||
return bankAccount;
|
||||
}
|
||||
|
||||
public void setBankAccount(String bankAccount) {
|
||||
this.bankAccount = bankAccount;
|
||||
}
|
||||
|
||||
public String getIban() {
|
||||
return iban;
|
||||
}
|
||||
|
||||
public void setIban(String iban) {
|
||||
this.iban = iban;
|
||||
}
|
||||
|
||||
public String getBic() {
|
||||
return bic;
|
||||
}
|
||||
|
||||
public void setBic(String bic) {
|
||||
this.bic = bic;
|
||||
}
|
||||
|
||||
public String getLegalNotes() {
|
||||
return legalNotes;
|
||||
}
|
||||
|
||||
public void setLegalNotes(String legalNotes) {
|
||||
this.legalNotes = legalNotes;
|
||||
}
|
||||
|
||||
public String getReverseChargeNote() {
|
||||
return reverseChargeNote;
|
||||
}
|
||||
|
||||
public void setReverseChargeNote(String reverseChargeNote) {
|
||||
this.reverseChargeNote = reverseChargeNote;
|
||||
}
|
||||
|
||||
public String getJobId() {
|
||||
return jobId;
|
||||
}
|
||||
|
||||
public void setJobId(String jobId) {
|
||||
this.jobId = jobId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public byte[] getPdfData() {
|
||||
return pdfData;
|
||||
}
|
||||
|
||||
public void setPdfData(byte[] pdfData) {
|
||||
this.pdfData = pdfData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.math.BigDecimal;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class CustomerInvoiceData {
|
||||
|
||||
private String invoiceNumber;
|
||||
private LocalDate invoiceDate;
|
||||
private LocalDate deliveryDate;
|
||||
private String description;
|
||||
|
||||
// Rechnungssteller
|
||||
private String senderName;
|
||||
private String senderAddress;
|
||||
private String senderPostcode;
|
||||
private String senderCity;
|
||||
private String senderCountry;
|
||||
private String senderTaxNumber;
|
||||
private String senderVatId;
|
||||
private String senderPhone;
|
||||
private String senderEmail;
|
||||
private String senderWebsite;
|
||||
|
||||
// Rechnungsempfänger
|
||||
private String recipientName;
|
||||
private String recipientCompany;
|
||||
private String recipientAddress;
|
||||
private String recipientPostcode;
|
||||
private String recipientCity;
|
||||
private String recipientCountry;
|
||||
private String recipientVatId;
|
||||
|
||||
private List<CustomerInvoiceItem> items;
|
||||
|
||||
private BigDecimal netAmount;
|
||||
private BigDecimal vatRate;
|
||||
private BigDecimal vatAmount;
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
// Zahlungsdetails
|
||||
private String paymentTerms;
|
||||
private LocalDate paymentDueDate;
|
||||
private String bankAccount;
|
||||
private String iban;
|
||||
private String bic;
|
||||
|
||||
// Rechtliche Angaben
|
||||
private String legalNotes;
|
||||
private String reverseChargeNote;
|
||||
|
||||
// Number formatter for German locale
|
||||
private static final NumberFormat CURRENCY_FORMAT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
|
||||
// Constructors
|
||||
public CustomerInvoiceData() {
|
||||
}
|
||||
|
||||
public CustomerInvoiceData(String invoiceNumber, LocalDate invoiceDate, String description) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
this.invoiceDate = invoiceDate;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getInvoiceNumber() {
|
||||
return invoiceNumber;
|
||||
}
|
||||
|
||||
public void setInvoiceNumber(String invoiceNumber) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
}
|
||||
|
||||
public LocalDate getInvoiceDate() {
|
||||
return invoiceDate;
|
||||
}
|
||||
|
||||
public void setInvoiceDate(LocalDate invoiceDate) {
|
||||
this.invoiceDate = invoiceDate;
|
||||
}
|
||||
|
||||
public LocalDate getDeliveryDate() {
|
||||
return deliveryDate;
|
||||
}
|
||||
|
||||
public void setDeliveryDate(LocalDate deliveryDate) {
|
||||
this.deliveryDate = deliveryDate;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getSenderName() {
|
||||
return senderName;
|
||||
}
|
||||
|
||||
public void setSenderName(String senderName) {
|
||||
this.senderName = senderName;
|
||||
}
|
||||
|
||||
public String getSenderAddress() {
|
||||
return senderAddress;
|
||||
}
|
||||
|
||||
public void setSenderAddress(String senderAddress) {
|
||||
this.senderAddress = senderAddress;
|
||||
}
|
||||
|
||||
public String getSenderPostcode() {
|
||||
return senderPostcode;
|
||||
}
|
||||
|
||||
public void setSenderPostcode(String senderPostcode) {
|
||||
this.senderPostcode = senderPostcode;
|
||||
}
|
||||
|
||||
public String getSenderCity() {
|
||||
return senderCity;
|
||||
}
|
||||
|
||||
public void setSenderCity(String senderCity) {
|
||||
this.senderCity = senderCity;
|
||||
}
|
||||
|
||||
public String getSenderCountry() {
|
||||
return senderCountry;
|
||||
}
|
||||
|
||||
public void setSenderCountry(String senderCountry) {
|
||||
this.senderCountry = senderCountry;
|
||||
}
|
||||
|
||||
public String getSenderTaxNumber() {
|
||||
return senderTaxNumber;
|
||||
}
|
||||
|
||||
public void setSenderTaxNumber(String senderTaxNumber) {
|
||||
this.senderTaxNumber = senderTaxNumber;
|
||||
}
|
||||
|
||||
public String getSenderVatId() {
|
||||
return senderVatId;
|
||||
}
|
||||
|
||||
public void setSenderVatId(String senderVatId) {
|
||||
this.senderVatId = senderVatId;
|
||||
}
|
||||
|
||||
public String getSenderPhone() {
|
||||
return senderPhone;
|
||||
}
|
||||
|
||||
public void setSenderPhone(String senderPhone) {
|
||||
this.senderPhone = senderPhone;
|
||||
}
|
||||
|
||||
public String getSenderEmail() {
|
||||
return senderEmail;
|
||||
}
|
||||
|
||||
public void setSenderEmail(String senderEmail) {
|
||||
this.senderEmail = senderEmail;
|
||||
}
|
||||
|
||||
public String getSenderWebsite() {
|
||||
return senderWebsite;
|
||||
}
|
||||
|
||||
public void setSenderWebsite(String senderWebsite) {
|
||||
this.senderWebsite = senderWebsite;
|
||||
}
|
||||
|
||||
public String getRecipientName() {
|
||||
return recipientName;
|
||||
}
|
||||
|
||||
public void setRecipientName(String recipientName) {
|
||||
this.recipientName = recipientName;
|
||||
}
|
||||
|
||||
public String getRecipientCompany() {
|
||||
return recipientCompany;
|
||||
}
|
||||
|
||||
public void setRecipientCompany(String recipientCompany) {
|
||||
this.recipientCompany = recipientCompany;
|
||||
}
|
||||
|
||||
public String getRecipientAddress() {
|
||||
return recipientAddress;
|
||||
}
|
||||
|
||||
public void setRecipientAddress(String recipientAddress) {
|
||||
this.recipientAddress = recipientAddress;
|
||||
}
|
||||
|
||||
public String getRecipientPostcode() {
|
||||
return recipientPostcode;
|
||||
}
|
||||
|
||||
public void setRecipientPostcode(String recipientPostcode) {
|
||||
this.recipientPostcode = recipientPostcode;
|
||||
}
|
||||
|
||||
public String getRecipientCity() {
|
||||
return recipientCity;
|
||||
}
|
||||
|
||||
public void setRecipientCity(String recipientCity) {
|
||||
this.recipientCity = recipientCity;
|
||||
}
|
||||
|
||||
public String getRecipientCountry() {
|
||||
return recipientCountry;
|
||||
}
|
||||
|
||||
public void setRecipientCountry(String recipientCountry) {
|
||||
this.recipientCountry = recipientCountry;
|
||||
}
|
||||
|
||||
public String getRecipientVatId() {
|
||||
return recipientVatId;
|
||||
}
|
||||
|
||||
public void setRecipientVatId(String recipientVatId) {
|
||||
this.recipientVatId = recipientVatId;
|
||||
}
|
||||
|
||||
public List<CustomerInvoiceItem> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public void setItems(List<CustomerInvoiceItem> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public BigDecimal getNetAmount() {
|
||||
return netAmount;
|
||||
}
|
||||
|
||||
public void setNetAmount(BigDecimal netAmount) {
|
||||
this.netAmount = netAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getVatRate() {
|
||||
return vatRate;
|
||||
}
|
||||
|
||||
public void setVatRate(BigDecimal vatRate) {
|
||||
this.vatRate = vatRate;
|
||||
}
|
||||
|
||||
public BigDecimal getVatAmount() {
|
||||
return vatAmount;
|
||||
}
|
||||
|
||||
public void setVatAmount(BigDecimal vatAmount) {
|
||||
this.vatAmount = vatAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public void setTotalAmount(BigDecimal totalAmount) {
|
||||
this.totalAmount = totalAmount;
|
||||
}
|
||||
|
||||
public String getPaymentTerms() {
|
||||
return paymentTerms;
|
||||
}
|
||||
|
||||
public void setPaymentTerms(String paymentTerms) {
|
||||
this.paymentTerms = paymentTerms;
|
||||
}
|
||||
|
||||
public LocalDate getPaymentDueDate() {
|
||||
return paymentDueDate;
|
||||
}
|
||||
|
||||
public void setPaymentDueDate(LocalDate paymentDueDate) {
|
||||
this.paymentDueDate = paymentDueDate;
|
||||
}
|
||||
|
||||
public String getBankAccount() {
|
||||
return bankAccount;
|
||||
}
|
||||
|
||||
public void setBankAccount(String bankAccount) {
|
||||
this.bankAccount = bankAccount;
|
||||
}
|
||||
|
||||
public String getIban() {
|
||||
return iban;
|
||||
}
|
||||
|
||||
public void setIban(String iban) {
|
||||
this.iban = iban;
|
||||
}
|
||||
|
||||
public String getBic() {
|
||||
return bic;
|
||||
}
|
||||
|
||||
public void setBic(String bic) {
|
||||
this.bic = bic;
|
||||
}
|
||||
|
||||
public String getLegalNotes() {
|
||||
return legalNotes;
|
||||
}
|
||||
|
||||
public void setLegalNotes(String legalNotes) {
|
||||
this.legalNotes = legalNotes;
|
||||
}
|
||||
|
||||
public String getReverseChargeNote() {
|
||||
return reverseChargeNote;
|
||||
}
|
||||
|
||||
public void setReverseChargeNote(String reverseChargeNote) {
|
||||
this.reverseChargeNote = reverseChargeNote;
|
||||
}
|
||||
|
||||
// Formatting methods for PDF generation
|
||||
public String getFormattedInvoiceDate() {
|
||||
return invoiceDate != null ? invoiceDate.format(DATE_FORMAT) : "";
|
||||
}
|
||||
|
||||
public String getFormattedDeliveryDate() {
|
||||
return deliveryDate != null ? deliveryDate.format(DATE_FORMAT) : "";
|
||||
}
|
||||
|
||||
public String getFormattedPaymentDueDate() {
|
||||
return paymentDueDate != null ? paymentDueDate.format(DATE_FORMAT) : "";
|
||||
}
|
||||
|
||||
public String getFormattedNetAmount() {
|
||||
return netAmount != null ? CURRENCY_FORMAT.format(netAmount) : "0,00 €";
|
||||
}
|
||||
|
||||
public String getFormattedVatAmount() {
|
||||
return vatAmount != null ? CURRENCY_FORMAT.format(vatAmount) : "0,00 €";
|
||||
}
|
||||
|
||||
public String getFormattedTotalAmount() {
|
||||
return totalAmount != null ? CURRENCY_FORMAT.format(totalAmount) : "0,00 €";
|
||||
}
|
||||
|
||||
public String getFormattedVatRate() {
|
||||
if (vatRate != null) {
|
||||
return String.format("%.0f%%", vatRate.multiply(new BigDecimal("100")));
|
||||
}
|
||||
return "19%";
|
||||
}
|
||||
|
||||
// Legacy methods for backward compatibility
|
||||
public String getDate() {
|
||||
return getFormattedInvoiceDate();
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public String getRecipientDepartment() {
|
||||
return recipientCompany;
|
||||
}
|
||||
|
||||
public String getRecipientStreet() {
|
||||
return recipientAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class CustomerInvoiceItem {
|
||||
|
||||
private BigDecimal quantity;
|
||||
private String unit; // Einheit (Stk., h, kg, etc.)
|
||||
private String description;
|
||||
private BigDecimal unitPrice; // Einzelpreis netto
|
||||
private BigDecimal netTotal; // Gesamtpreis netto
|
||||
private BigDecimal vatRate; // Steuersatz
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal grossTotal; // Gesamtpreis brutto
|
||||
|
||||
// Constructors
|
||||
public CustomerInvoiceItem() {
|
||||
}
|
||||
|
||||
public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice,
|
||||
BigDecimal vatRate) {
|
||||
this.quantity = quantity;
|
||||
this.unit = unit;
|
||||
this.description = description;
|
||||
this.unitPrice = unitPrice;
|
||||
this.vatRate = vatRate;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
private void calculateTotals() {
|
||||
if (quantity != null && unitPrice != null) {
|
||||
this.netTotal = quantity.multiply(unitPrice);
|
||||
if (vatRate != null) {
|
||||
this.vatAmount = netTotal.multiply(vatRate);
|
||||
this.grossTotal = netTotal.add(vatAmount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public BigDecimal getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(BigDecimal quantity) {
|
||||
this.quantity = quantity;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
public String getUnit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public void setUnit(String unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public BigDecimal getUnitPrice() {
|
||||
return unitPrice;
|
||||
}
|
||||
|
||||
public void setUnitPrice(BigDecimal unitPrice) {
|
||||
this.unitPrice = unitPrice;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
public BigDecimal getNetTotal() {
|
||||
return netTotal;
|
||||
}
|
||||
|
||||
public void setNetTotal(BigDecimal netTotal) {
|
||||
this.netTotal = netTotal;
|
||||
}
|
||||
|
||||
public BigDecimal getVatRate() {
|
||||
return vatRate;
|
||||
}
|
||||
|
||||
public void setVatRate(BigDecimal vatRate) {
|
||||
this.vatRate = vatRate;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
public BigDecimal getVatAmount() {
|
||||
return vatAmount;
|
||||
}
|
||||
|
||||
public void setVatAmount(BigDecimal vatAmount) {
|
||||
this.vatAmount = vatAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getGrossTotal() {
|
||||
return grossTotal;
|
||||
}
|
||||
|
||||
public void setGrossTotal(BigDecimal grossTotal) {
|
||||
this.grossTotal = grossTotal;
|
||||
}
|
||||
|
||||
// Legacy methods for backward compatibility
|
||||
public String getPrice() {
|
||||
return unitPrice != null ? unitPrice.toString() : "0.00";
|
||||
}
|
||||
|
||||
public String getTotal() {
|
||||
return grossTotal != null ? grossTotal.toString() : "0.00";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SystemInvoice {
|
||||
private String id;
|
||||
private String kunde;
|
||||
private LocalDate datum;
|
||||
private double betrag;
|
||||
private String beschreibung;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SystemInvoiceData {
|
||||
private String companyName = "Assecutor";
|
||||
private String companySubtitle = "Data Service GmbH";
|
||||
private String companyStreet = "Gerhart-Hauptmann-Weg 14";
|
||||
private String companyCity = "21502 Geesthacht";
|
||||
private String companyPhone = "040-181237710";
|
||||
private String companyFax = "040-181237719";
|
||||
private String companyEmail = "info@assecutor.de";
|
||||
private String companyWebsite = "www.assecutor.de";
|
||||
|
||||
private String invoiceNumber;
|
||||
private String invoiceDate;
|
||||
private String invoiceText;
|
||||
|
||||
private String senderLine = "Assecutor Data Service GmbH · Gerhart-Hauptmann-Weg 14 · 21502 Geesthacht";
|
||||
private String recipientName;
|
||||
private String recipientDepartment;
|
||||
private String recipientStreet;
|
||||
private String recipientCity;
|
||||
|
||||
private List<SystemInvoiceItem> systemInvoiceItems;
|
||||
private String netAmount;
|
||||
private String vatRate = "19";
|
||||
private String vatAmount;
|
||||
private String totalAmount;
|
||||
|
||||
private String paymentTerms = "Zahlungsbedingungen: Gesamtbetrag bis spätestens zum 10. Werktag nach Rechnungserhalt auf unser u. g. Konto.";
|
||||
|
||||
private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht<br>"
|
||||
+ "Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>"
|
||||
+ "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
|
||||
|
||||
public SystemInvoiceData() {
|
||||
}
|
||||
|
||||
public String getCompanyName() {
|
||||
return companyName;
|
||||
}
|
||||
|
||||
public void setCompanyName(String companyName) {
|
||||
this.companyName = companyName;
|
||||
}
|
||||
|
||||
public String getCompanySubtitle() {
|
||||
return companySubtitle;
|
||||
}
|
||||
|
||||
public void setCompanySubtitle(String companySubtitle) {
|
||||
this.companySubtitle = companySubtitle;
|
||||
}
|
||||
|
||||
public String getCompanyStreet() {
|
||||
return companyStreet;
|
||||
}
|
||||
|
||||
public void setCompanyStreet(String companyStreet) {
|
||||
this.companyStreet = companyStreet;
|
||||
}
|
||||
|
||||
public String getCompanyCity() {
|
||||
return companyCity;
|
||||
}
|
||||
|
||||
public void setCompanyCity(String companyCity) {
|
||||
this.companyCity = companyCity;
|
||||
}
|
||||
|
||||
public String getCompanyPhone() {
|
||||
return companyPhone;
|
||||
}
|
||||
|
||||
public void setCompanyPhone(String companyPhone) {
|
||||
this.companyPhone = companyPhone;
|
||||
}
|
||||
|
||||
public String getCompanyFax() {
|
||||
return companyFax;
|
||||
}
|
||||
|
||||
public void setCompanyFax(String companyFax) {
|
||||
this.companyFax = companyFax;
|
||||
}
|
||||
|
||||
public String getCompanyEmail() {
|
||||
return companyEmail;
|
||||
}
|
||||
|
||||
public void setCompanyEmail(String companyEmail) {
|
||||
this.companyEmail = companyEmail;
|
||||
}
|
||||
|
||||
public String getCompanyWebsite() {
|
||||
return companyWebsite;
|
||||
}
|
||||
|
||||
public void setCompanyWebsite(String companyWebsite) {
|
||||
this.companyWebsite = companyWebsite;
|
||||
}
|
||||
|
||||
public String getInvoiceNumber() {
|
||||
return invoiceNumber;
|
||||
}
|
||||
|
||||
public void setInvoiceNumber(String invoiceNumber) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
}
|
||||
|
||||
public String getInvoiceDate() {
|
||||
return invoiceDate;
|
||||
}
|
||||
|
||||
public void setInvoiceDate(String invoiceDate) {
|
||||
this.invoiceDate = invoiceDate;
|
||||
}
|
||||
|
||||
public String getInvoiceText() {
|
||||
return invoiceText;
|
||||
}
|
||||
|
||||
public void setInvoiceText(String invoiceText) {
|
||||
this.invoiceText = invoiceText;
|
||||
}
|
||||
|
||||
public String getSenderLine() {
|
||||
return senderLine;
|
||||
}
|
||||
|
||||
public void setSenderLine(String senderLine) {
|
||||
this.senderLine = senderLine;
|
||||
}
|
||||
|
||||
public String getRecipientName() {
|
||||
return recipientName;
|
||||
}
|
||||
|
||||
public void setRecipientName(String recipientName) {
|
||||
this.recipientName = recipientName;
|
||||
}
|
||||
|
||||
public String getRecipientDepartment() {
|
||||
return recipientDepartment;
|
||||
}
|
||||
|
||||
public void setRecipientDepartment(String recipientDepartment) {
|
||||
this.recipientDepartment = recipientDepartment;
|
||||
}
|
||||
|
||||
public String getRecipientStreet() {
|
||||
return recipientStreet;
|
||||
}
|
||||
|
||||
public void setRecipientStreet(String recipientStreet) {
|
||||
this.recipientStreet = recipientStreet;
|
||||
}
|
||||
|
||||
public String getRecipientCity() {
|
||||
return recipientCity;
|
||||
}
|
||||
|
||||
public void setRecipientCity(String recipientCity) {
|
||||
this.recipientCity = recipientCity;
|
||||
}
|
||||
|
||||
public List<SystemInvoiceItem> getInvoiceItems() {
|
||||
return systemInvoiceItems;
|
||||
}
|
||||
|
||||
public void setInvoiceItems(List<SystemInvoiceItem> systemInvoiceItems) {
|
||||
this.systemInvoiceItems = systemInvoiceItems;
|
||||
}
|
||||
|
||||
public String getNetAmount() {
|
||||
return netAmount;
|
||||
}
|
||||
|
||||
public void setNetAmount(String netAmount) {
|
||||
this.netAmount = netAmount;
|
||||
}
|
||||
|
||||
public String getVatRate() {
|
||||
return vatRate;
|
||||
}
|
||||
|
||||
public void setVatRate(String vatRate) {
|
||||
this.vatRate = vatRate;
|
||||
}
|
||||
|
||||
public String getVatAmount() {
|
||||
return vatAmount;
|
||||
}
|
||||
|
||||
public void setVatAmount(String vatAmount) {
|
||||
this.vatAmount = vatAmount;
|
||||
}
|
||||
|
||||
public String getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public void setTotalAmount(String totalAmount) {
|
||||
this.totalAmount = totalAmount;
|
||||
}
|
||||
|
||||
public String getPaymentTerms() {
|
||||
return paymentTerms;
|
||||
}
|
||||
|
||||
public void setPaymentTerms(String paymentTerms) {
|
||||
this.paymentTerms = paymentTerms;
|
||||
}
|
||||
|
||||
public String getFooterText() {
|
||||
return footerText;
|
||||
}
|
||||
|
||||
public void setFooterText(String footerText) {
|
||||
this.footerText = footerText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
public class SystemInvoiceItem {
|
||||
private String quantity;
|
||||
private String description;
|
||||
private String unitPrice;
|
||||
private String totalPrice;
|
||||
|
||||
public SystemInvoiceItem() {
|
||||
}
|
||||
|
||||
public SystemInvoiceItem(String quantity, String description, String unitPrice, String totalPrice) {
|
||||
this.quantity = quantity;
|
||||
this.description = description;
|
||||
this.unitPrice = unitPrice;
|
||||
this.totalPrice = totalPrice;
|
||||
}
|
||||
|
||||
public String getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(String quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getUnitPrice() {
|
||||
return unitPrice;
|
||||
}
|
||||
|
||||
public void setUnitPrice(String unitPrice) {
|
||||
this.unitPrice = unitPrice;
|
||||
}
|
||||
|
||||
public String getTotalPrice() {
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void setTotalPrice(String totalPrice) {
|
||||
this.totalPrice = totalPrice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BarcodeTask extends BaseTask {
|
||||
|
||||
@Field("min_barcode_count")
|
||||
private Integer minBarcodeCount;
|
||||
|
||||
@Field("max_barcode_count")
|
||||
private Integer maxBarcodeCount;
|
||||
|
||||
public BarcodeTask(Integer minBarcodeCount, Integer maxBarcodeCount) {
|
||||
this.minBarcodeCount = minBarcodeCount;
|
||||
this.maxBarcodeCount = maxBarcodeCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "BARCODE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.BARCODE.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
public Integer minBarcodeCount = BarcodeTask.this.minBarcodeCount;
|
||||
public Integer maxBarcodeCount = BarcodeTask.this.maxBarcodeCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Document(collection = "tasks")
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "taskType")
|
||||
@JsonSubTypes({ @JsonSubTypes.Type(value = ConfirmationTask.class, name = "CONFIRMATION"),
|
||||
@JsonSubTypes.Type(value = SignatureTask.class, name = "SIGNATURE"),
|
||||
@JsonSubTypes.Type(value = TodoListTask.class, name = "TODOLIST"),
|
||||
@JsonSubTypes.Type(value = PhotoTask.class, name = "PHOTO"),
|
||||
@JsonSubTypes.Type(value = BarcodeTask.class, name = "BARCODE"),
|
||||
@JsonSubTypes.Type(value = CommentTask.class, name = "COMMENT") })
|
||||
public abstract class BaseTask {
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private ObjectId id;
|
||||
|
||||
@Field("station_id")
|
||||
@JsonIgnore
|
||||
private ObjectId stationId;
|
||||
|
||||
@Field("job_id")
|
||||
@JsonIgnore
|
||||
private ObjectId jobId;
|
||||
|
||||
@Field("station_order")
|
||||
private Integer stationOrder;
|
||||
|
||||
@Field("task_order")
|
||||
private Integer taskOrder = 0;
|
||||
|
||||
@Field("description")
|
||||
private String description;
|
||||
|
||||
@Field("optional")
|
||||
private boolean optional = false;
|
||||
|
||||
@Field("completed")
|
||||
private boolean completed = false;
|
||||
|
||||
@Field("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Field("completed_by")
|
||||
private String completedBy;
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization.
|
||||
*/
|
||||
@JsonGetter("id")
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the station ObjectId as string for JSON serialization.
|
||||
*/
|
||||
@JsonGetter("stationId")
|
||||
public String getStationIdAsString() {
|
||||
return stationId != null ? stationId.toHexString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the legacy job ObjectId as string for internal fallback handling.
|
||||
*/
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the task type as string for JSON serialization.
|
||||
*/
|
||||
@JsonGetter("taskType")
|
||||
public abstract String getTaskType();
|
||||
|
||||
/**
|
||||
* Returns the display name for this task type.
|
||||
*/
|
||||
public abstract String getDisplayName();
|
||||
|
||||
/**
|
||||
* Returns task-specific data for JSON serialization.
|
||||
*/
|
||||
@JsonGetter("taskSpecificData")
|
||||
public abstract Object getTaskSpecificData();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CommentTask extends BaseTask {
|
||||
|
||||
@Field("comment_text")
|
||||
private String commentText;
|
||||
|
||||
@Field("required")
|
||||
private boolean required = false;
|
||||
|
||||
public CommentTask(String commentText, boolean required) {
|
||||
this.commentText = commentText;
|
||||
this.required = required;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "COMMENT";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.COMMENT.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
public String commentText = CommentTask.this.commentText;
|
||||
public boolean required = CommentTask.this.required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ConfirmationTask extends BaseTask {
|
||||
|
||||
@Field("button_text")
|
||||
private String buttonText;
|
||||
|
||||
public ConfirmationTask(String buttonText) {
|
||||
this.buttonText = buttonText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "CONFIRMATION";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.CONFIRMATION.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
public String buttonText = ConfirmationTask.this.buttonText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PhotoTask extends BaseTask {
|
||||
|
||||
@Field("min_photo_count")
|
||||
private Integer minPhotoCount;
|
||||
|
||||
@Field("max_photo_count")
|
||||
private Integer maxPhotoCount;
|
||||
|
||||
public PhotoTask(Integer minPhotoCount, Integer maxPhotoCount) {
|
||||
this.minPhotoCount = minPhotoCount;
|
||||
this.maxPhotoCount = maxPhotoCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "PHOTO";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.PHOTO.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
public Integer minPhotoCount = PhotoTask.this.minPhotoCount;
|
||||
public Integer maxPhotoCount = PhotoTask.this.maxPhotoCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class SignatureTask extends BaseTask {
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "SIGNATURE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.SIGNATURE.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
// No specific data for signature task
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import com.vaadin.flow.component.UI;
|
||||
|
||||
public enum TaskType {
|
||||
CONFIRMATION("CONFIRMATION"),
|
||||
SIGNATURE("SIGNATURE"),
|
||||
TODOLIST("TODOLIST"),
|
||||
PHOTO("PHOTO"),
|
||||
BARCODE("BARCODE"),
|
||||
COMMENT("COMMENT");
|
||||
|
||||
private final String translationKey;
|
||||
|
||||
TaskType(String translationKey) {
|
||||
this.translationKey = translationKey;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
// Fallback to German if UI not available
|
||||
try {
|
||||
if (UI.getCurrent() != null) {
|
||||
return UI.getCurrent().getTranslation("tasktype." + translationKey);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Fallback to German if translation fails
|
||||
}
|
||||
|
||||
// Fallback to German translations
|
||||
return switch (this) {
|
||||
case CONFIRMATION -> "Bestätigung";
|
||||
case SIGNATURE -> "Unterschrift";
|
||||
case TODOLIST -> "To-Do Liste";
|
||||
case PHOTO -> "Foto";
|
||||
case BARCODE -> "Barcode";
|
||||
case COMMENT -> "Kommentar";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class TodoListTask extends BaseTask {
|
||||
|
||||
@Field("todo_items")
|
||||
private List<String> todoItems;
|
||||
|
||||
public TodoListTask(List<String> todoItems) {
|
||||
this.todoItems = todoItems;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTaskType() {
|
||||
return "TODOLIST";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return TaskType.TODOLIST.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTaskSpecificData() {
|
||||
return new TaskSpecificData();
|
||||
}
|
||||
|
||||
public class TaskSpecificData {
|
||||
public String taskType = getTaskType();
|
||||
public List<String> todoItems = TodoListTask.this.todoItems;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
package de.assecutor.votianlt.pages.base.ui.component;
|
||||
|
||||
import com.vaadin.flow.component.Component;
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.html.H3;
|
||||
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.orderedlayout.FlexComponent;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.component.textfield.TextField;
|
||||
import de.assecutor.votianlt.model.Customer;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A self-contained tile for one delivery station in the AddJob form. Contains
|
||||
* all address fields, delivery date/time, and a save-address checkbox.
|
||||
*/
|
||||
public class DeliveryStationTile extends VerticalLayout {
|
||||
|
||||
public interface ChangeListener {
|
||||
void onChanged();
|
||||
}
|
||||
|
||||
public interface DeleteListener {
|
||||
void onDelete(DeliveryStationTile tile);
|
||||
}
|
||||
|
||||
public interface CollapseListener {
|
||||
void onCollapseChanged(boolean collapsed);
|
||||
}
|
||||
|
||||
private final int stationNumber;
|
||||
|
||||
private final ComboBox<String> company;
|
||||
private final ComboBox<String> salutation;
|
||||
private final TextField firstName;
|
||||
private final TextField lastName;
|
||||
private final TextField phone;
|
||||
private final TextField street;
|
||||
private final TextField houseNumber;
|
||||
private final TextField addressAddition;
|
||||
private final TextField zip;
|
||||
private final TextField city;
|
||||
private final Checkbox saveAddress;
|
||||
private final H3 title;
|
||||
|
||||
private ChangeListener changeListener;
|
||||
private DeleteListener deleteListener;
|
||||
private CollapseListener collapseListener;
|
||||
|
||||
private boolean collapsed = false;
|
||||
private Button collapseButton;
|
||||
private VerticalLayout collapsedContent;
|
||||
private List<Component> expandedOnlyComponents;
|
||||
|
||||
public DeliveryStationTile(int stationNumber, boolean removable, List<Customer> customers,
|
||||
TranslationHelper translationHelper) {
|
||||
this.stationNumber = stationNumber;
|
||||
|
||||
setSpacing(true);
|
||||
setPadding(true);
|
||||
setWidthFull();
|
||||
addClassName("delivery-station-card");
|
||||
getStyle().set("min-width", "0");
|
||||
getStyle().set("box-sizing", "border-box");
|
||||
|
||||
// Header with title, collapse button and delete button on one line
|
||||
title = new H3(translationHelper.getTranslation("addjob.station.delivery", stationNumber));
|
||||
title.getStyle().set("margin", "0").set("flex-grow", "1");
|
||||
|
||||
collapseButton = new Button(new Icon(VaadinIcon.ANGLE_DOUBLE_LEFT));
|
||||
collapseButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
|
||||
collapseButton.getStyle().set("cursor", "pointer");
|
||||
collapseButton.addClickListener(e -> toggleCollapse());
|
||||
|
||||
Button deleteButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
|
||||
deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
||||
if (removable) {
|
||||
deleteButton.addClickListener(e -> {
|
||||
if (deleteListener != null) {
|
||||
deleteListener.onDelete(this);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
deleteButton.getStyle().set("visibility", "hidden");
|
||||
}
|
||||
|
||||
HorizontalLayout titleLayout = new HorizontalLayout();
|
||||
titleLayout.setWidthFull();
|
||||
titleLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
|
||||
titleLayout.add(title, collapseButton, deleteButton);
|
||||
|
||||
add(titleLayout);
|
||||
|
||||
// Company with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
||||
company.setAllowCustomValue(true);
|
||||
company.setWidthFull();
|
||||
setupCompanyAutocomplete(company, customers, translationHelper);
|
||||
add(company);
|
||||
|
||||
// Salutation
|
||||
salutation = new ComboBox<>(translationHelper.getTranslation("addjob.address.salutation"));
|
||||
salutation.setItems(translationHelper.getTranslation("addjob.salutation.mr"),
|
||||
translationHelper.getTranslation("addjob.salutation.ms"),
|
||||
translationHelper.getTranslation("addjob.salutation.other"));
|
||||
salutation.setPlaceholder(translationHelper.getTranslation("addjob.address.salutation.placeholder"));
|
||||
salutation.setWidthFull();
|
||||
add(salutation);
|
||||
|
||||
// Name fields
|
||||
firstName = new TextField(translationHelper.getTranslation("profile.firstname"));
|
||||
firstName.setPlaceholder(translationHelper.getTranslation("profile.firstname"));
|
||||
firstName.setRequiredIndicatorVisible(true);
|
||||
firstName.setWidthFull();
|
||||
add(firstName);
|
||||
|
||||
lastName = new TextField(translationHelper.getTranslation("profile.lastname"));
|
||||
lastName.setPlaceholder(translationHelper.getTranslation("profile.lastname"));
|
||||
lastName.setRequiredIndicatorVisible(true);
|
||||
lastName.setWidthFull();
|
||||
add(lastName);
|
||||
|
||||
// Phone
|
||||
phone = new TextField(translationHelper.getTranslation("profile.phone"));
|
||||
phone.setPlaceholder(translationHelper.getTranslation("profile.phone"));
|
||||
phone.setWidthFull();
|
||||
add(phone);
|
||||
|
||||
// Street + house number
|
||||
street = new TextField(translationHelper.getTranslation("profile.street"));
|
||||
street.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.street.placeholder"));
|
||||
street.setRequiredIndicatorVisible(true);
|
||||
|
||||
houseNumber = new TextField(translationHelper.getTranslation("profile.housenr"));
|
||||
houseNumber.setPlaceholder(translationHelper.getTranslation("addjob.address.housenumber"));
|
||||
houseNumber.setRequiredIndicatorVisible(true);
|
||||
|
||||
HorizontalLayout streetLayout = new HorizontalLayout();
|
||||
streetLayout.setWidthFull();
|
||||
streetLayout.setSpacing(true);
|
||||
streetLayout.getStyle().set("flex-wrap", "wrap");
|
||||
street.getStyle().set("flex", "3 1 150px").set("min-width", "0");
|
||||
houseNumber.getStyle().set("flex", "1 1 60px").set("min-width", "0");
|
||||
streetLayout.add(street, houseNumber);
|
||||
add(streetLayout);
|
||||
|
||||
// Address addition
|
||||
addressAddition = new TextField(translationHelper.getTranslation("profile.addressadd"));
|
||||
addressAddition
|
||||
.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.addition.placeholder"));
|
||||
addressAddition.setWidthFull();
|
||||
add(addressAddition);
|
||||
|
||||
// Zip + city
|
||||
zip = new TextField(translationHelper.getTranslation("profile.zip"));
|
||||
zip.setPlaceholder(translationHelper.getTranslation("profile.zip"));
|
||||
zip.setRequiredIndicatorVisible(true);
|
||||
|
||||
city = new TextField(translationHelper.getTranslation("addjob.address.city"));
|
||||
city.setPlaceholder(translationHelper.getTranslation("addjob.address.city.placeholder.delivery"));
|
||||
city.setRequiredIndicatorVisible(true);
|
||||
|
||||
HorizontalLayout zipCityLayout = new HorizontalLayout();
|
||||
zipCityLayout.setWidthFull();
|
||||
zipCityLayout.setSpacing(true);
|
||||
zipCityLayout.getStyle().set("flex-wrap", "wrap");
|
||||
zip.getStyle().set("flex", "1 1 80px").set("min-width", "0");
|
||||
city.getStyle().set("flex", "3 1 150px").set("min-width", "0");
|
||||
zipCityLayout.add(zip, city);
|
||||
add(zipCityLayout);
|
||||
|
||||
// Save address checkbox
|
||||
saveAddress = new Checkbox(translationHelper.getTranslation("addjob.address.save"));
|
||||
saveAddress.setValue(true);
|
||||
saveAddress.setWidthFull();
|
||||
add(saveAddress);
|
||||
|
||||
// Register change listeners on all fields
|
||||
setupChangeListeners();
|
||||
|
||||
// Store references to expanded-mode components (excluding titleLayout which
|
||||
// stays visible)
|
||||
expandedOnlyComponents = getChildren().filter(c -> c != titleLayout).toList();
|
||||
|
||||
getStyle().set("transition", "opacity 0.2s ease");
|
||||
|
||||
// Collapsed content (initially hidden)
|
||||
collapsedContent = new VerticalLayout();
|
||||
collapsedContent.setPadding(false);
|
||||
collapsedContent.setSpacing(false);
|
||||
collapsedContent.getStyle().set("gap", "var(--lumo-space-xs)");
|
||||
collapsedContent.setVisible(false);
|
||||
add(collapsedContent);
|
||||
}
|
||||
|
||||
private void setupChangeListeners() {
|
||||
TextField[] textFields = { firstName, lastName, street, houseNumber, zip, city, phone, addressAddition };
|
||||
for (TextField field : textFields) {
|
||||
field.addValueChangeListener(e -> {
|
||||
updateFieldStyling(field);
|
||||
fireChanged();
|
||||
});
|
||||
}
|
||||
|
||||
company.addValueChangeListener(e -> fireChanged());
|
||||
salutation.addValueChangeListener(e -> fireChanged());
|
||||
}
|
||||
|
||||
private void fireChanged() {
|
||||
if (changeListener != null) {
|
||||
changeListener.onChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
||||
TranslationHelper translationHelper) {
|
||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
||||
|
||||
companyField.setItems(companyNames);
|
||||
|
||||
companyField.addValueChangeListener(event -> {
|
||||
String selectedCompany = event.getValue();
|
||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Customer> matchingCustomer = customers.stream()
|
||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
||||
|
||||
if (matchingCustomer.isPresent()) {
|
||||
Customer customer = matchingCustomer.get();
|
||||
if (customer.getTitle() != null
|
||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||
salutation.setValue(customer.getTitle());
|
||||
}
|
||||
if (customer.getFirstname() != null)
|
||||
firstName.setValue(customer.getFirstname());
|
||||
if (customer.getLastName() != null)
|
||||
lastName.setValue(customer.getLastName());
|
||||
if (customer.getTelephone() != null)
|
||||
phone.setValue(customer.getTelephone());
|
||||
if (customer.getStreet() != null)
|
||||
street.setValue(customer.getStreet());
|
||||
if (customer.getHouseNumber() != null)
|
||||
houseNumber.setValue(customer.getHouseNumber());
|
||||
if (customer.getAddressAddition() != null)
|
||||
addressAddition.setValue(customer.getAddressAddition());
|
||||
if (customer.getZip() != null)
|
||||
zip.setValue(customer.getZip());
|
||||
if (customer.getCity() != null)
|
||||
city.setValue(customer.getCity());
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
companyField.addCustomValueSetListener(event -> {
|
||||
companyField.setValue(event.getDetail());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the station number displayed in the title.
|
||||
*/
|
||||
public void updateStationNumber(int newNumber) {
|
||||
title.setText(getTranslation("addjob.station.delivery", newNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all field values into a DeliveryStation object.
|
||||
*/
|
||||
public DeliveryStation getDeliveryStation() {
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setCompany(company.getValue());
|
||||
station.setSalutation(salutation.getValue());
|
||||
station.setFirstName(firstName.getValue());
|
||||
station.setLastName(lastName.getValue());
|
||||
station.setPhone(phone.getValue());
|
||||
station.setStreet(street.getValue());
|
||||
station.setHouseNumber(houseNumber.getValue());
|
||||
station.setAddressAddition(addressAddition.getValue());
|
||||
station.setZip(zip.getValue());
|
||||
station.setCity(city.getValue());
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the tile fields from an existing DeliveryStation.
|
||||
*/
|
||||
public void setDeliveryStation(DeliveryStation station) {
|
||||
if (station == null)
|
||||
return;
|
||||
if (station.getCompany() != null)
|
||||
company.setValue(station.getCompany());
|
||||
if (station.getSalutation() != null)
|
||||
salutation.setValue(station.getSalutation());
|
||||
if (station.getFirstName() != null)
|
||||
firstName.setValue(station.getFirstName());
|
||||
if (station.getLastName() != null)
|
||||
lastName.setValue(station.getLastName());
|
||||
if (station.getPhone() != null)
|
||||
phone.setValue(station.getPhone());
|
||||
if (station.getStreet() != null)
|
||||
street.setValue(station.getStreet());
|
||||
if (station.getHouseNumber() != null)
|
||||
houseNumber.setValue(station.getHouseNumber());
|
||||
if (station.getAddressAddition() != null)
|
||||
addressAddition.setValue(station.getAddressAddition());
|
||||
if (station.getZip() != null)
|
||||
zip.setValue(station.getZip());
|
||||
if (station.getCity() != null)
|
||||
city.setValue(station.getCity());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all required address fields are filled.
|
||||
*/
|
||||
public boolean hasValidationErrors() {
|
||||
return isFieldEmpty(firstName) || isFieldEmpty(lastName) || isFieldEmpty(street) || isFieldEmpty(houseNumber)
|
||||
|| isFieldEmpty(zip) || isFieldEmpty(city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies error styling to empty required fields.
|
||||
*/
|
||||
public void highlightErrors() {
|
||||
TextField[] required = { firstName, lastName, street, houseNumber, zip, city };
|
||||
for (TextField field : required) {
|
||||
updateFieldStyling(field);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all fields in this tile.
|
||||
*/
|
||||
public void clearFields() {
|
||||
company.clear();
|
||||
salutation.clear();
|
||||
firstName.clear();
|
||||
lastName.clear();
|
||||
phone.clear();
|
||||
street.clear();
|
||||
houseNumber.clear();
|
||||
addressAddition.clear();
|
||||
zip.clear();
|
||||
city.clear();
|
||||
saveAddress.setValue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street value for address validation.
|
||||
*/
|
||||
public String getStreetValue() {
|
||||
return getValueOrEmpty(street);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the house number value for address validation.
|
||||
*/
|
||||
public String getHouseNumberValue() {
|
||||
return getValueOrEmpty(houseNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zip value for address validation.
|
||||
*/
|
||||
public String getZipValue() {
|
||||
return getValueOrEmpty(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the city value for address validation.
|
||||
*/
|
||||
public String getCityValue() {
|
||||
return getValueOrEmpty(city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the delivery address has enough data for validation.
|
||||
*/
|
||||
public boolean hasAddressForValidation() {
|
||||
return !getStreetValue().isEmpty() && !getZipValue().isEmpty() && !getCityValue().isEmpty();
|
||||
}
|
||||
|
||||
public void setChangeListener(ChangeListener listener) {
|
||||
this.changeListener = listener;
|
||||
}
|
||||
|
||||
public void setDeleteListener(DeleteListener listener) {
|
||||
this.deleteListener = listener;
|
||||
}
|
||||
|
||||
public void setCollapseListener(CollapseListener listener) {
|
||||
this.collapseListener = listener;
|
||||
}
|
||||
|
||||
public boolean isCollapsed() {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user wants to save this address as a customer.
|
||||
*/
|
||||
public boolean isSaveAddressChecked() {
|
||||
return saveAddress.getValue();
|
||||
}
|
||||
|
||||
public int getStationNumber() {
|
||||
return stationNumber;
|
||||
}
|
||||
|
||||
private boolean isFieldEmpty(TextField field) {
|
||||
String value = field.getValue();
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String getValueOrEmpty(TextField field) {
|
||||
return field.getValue() != null ? field.getValue().trim() : "";
|
||||
}
|
||||
|
||||
private void updateFieldStyling(TextField field) {
|
||||
boolean isEmpty = isFieldEmpty(field);
|
||||
if (isEmpty && field.isRequiredIndicatorVisible()) {
|
||||
field.getStyle().set("--vaadin-input-field-background", "rgba(255, 0, 0, 0.1)");
|
||||
field.getStyle().set("--vaadin-input-field-border-color", "rgba(255, 0, 0, 0.3)");
|
||||
} else {
|
||||
field.getStyle().remove("--vaadin-input-field-background");
|
||||
field.getStyle().remove("--vaadin-input-field-border-color");
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleCollapse() {
|
||||
collapsed = !collapsed;
|
||||
if (collapsed) {
|
||||
updateCollapsedContent();
|
||||
expandedOnlyComponents.forEach(c -> c.setVisible(false));
|
||||
collapsedContent.setVisible(true);
|
||||
collapseButton.setIcon(new Icon(VaadinIcon.ANGLE_DOUBLE_RIGHT));
|
||||
} else {
|
||||
expandedOnlyComponents.forEach(c -> c.setVisible(true));
|
||||
collapsedContent.setVisible(false);
|
||||
collapseButton.setIcon(new Icon(VaadinIcon.ANGLE_DOUBLE_LEFT));
|
||||
}
|
||||
if (collapseListener != null) {
|
||||
collapseListener.onCollapseChanged(collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCollapsedContent() {
|
||||
collapsedContent.removeAll();
|
||||
|
||||
addCollapsedLine(company.getValue());
|
||||
|
||||
String name = (getValueOrEmpty(firstName) + " " + getValueOrEmpty(lastName)).trim();
|
||||
addCollapsedLine(name);
|
||||
|
||||
String streetLine = (getValueOrEmpty(street) + " " + getValueOrEmpty(houseNumber)).trim();
|
||||
addCollapsedLine(streetLine);
|
||||
|
||||
String zipCityLine = (getValueOrEmpty(zip) + " " + getValueOrEmpty(city)).trim();
|
||||
addCollapsedLine(zipCityLine);
|
||||
}
|
||||
|
||||
private void addCollapsedLine(String text) {
|
||||
if (text != null && !text.trim().isEmpty()) {
|
||||
Span span = new Span(text);
|
||||
span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("word-break", "break-word").set("color",
|
||||
"var(--lumo-secondary-text-color)");
|
||||
collapsedContent.add(span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional interface for accessing translations from the parent view.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TranslationHelper {
|
||||
String getTranslation(String key, Object... params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.assecutor.votianlt.pages.base.ui.component;
|
||||
|
||||
import com.vaadin.flow.component.Component;
|
||||
import com.vaadin.flow.component.dialog.Dialog;
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
|
||||
/**
|
||||
* Shared helper for dialogs that should use the station-dialog shell.
|
||||
*/
|
||||
public final class DialogStylingHelper {
|
||||
|
||||
private DialogStylingHelper() {
|
||||
}
|
||||
|
||||
public static Dialog createStyledDialog(String title, String width) {
|
||||
Dialog dialog = new Dialog();
|
||||
apply(dialog, title, width);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
public static void apply(Dialog dialog, String title, String width) {
|
||||
if (title != null && !title.isBlank()) {
|
||||
dialog.setHeaderTitle(title);
|
||||
}
|
||||
if (width != null && !width.isBlank()) {
|
||||
dialog.setWidth(width);
|
||||
}
|
||||
dialog.setMaxWidth("95vw");
|
||||
dialog.getElement().setAttribute("theme", "no-inner-card");
|
||||
}
|
||||
|
||||
public static Component wrapContent(Component content) {
|
||||
Div frame = new Div();
|
||||
frame.getStyle().set("border", "10px solid transparent");
|
||||
frame.getStyle().set("border-radius", "0");
|
||||
frame.getStyle().set("box-sizing", "border-box");
|
||||
frame.setWidthFull();
|
||||
|
||||
Div whiteCard = new Div();
|
||||
whiteCard.getStyle().set("background", "white");
|
||||
whiteCard.getStyle().set("border-radius", "24px");
|
||||
whiteCard.getStyle().set("overflow", "auto");
|
||||
whiteCard.setWidthFull();
|
||||
whiteCard.add(content);
|
||||
|
||||
frame.add(whiteCard);
|
||||
return frame;
|
||||
}
|
||||
|
||||
public static VerticalLayout createContentLayout(String maxWidth) {
|
||||
return createContentLayout(maxWidth, FlexComponent.Alignment.STRETCH);
|
||||
}
|
||||
|
||||
public static VerticalLayout createContentLayout(String maxWidth, FlexComponent.Alignment alignment) {
|
||||
VerticalLayout content = new VerticalLayout();
|
||||
content.setPadding(true);
|
||||
content.setSpacing(true);
|
||||
content.setWidthFull();
|
||||
if (maxWidth != null && !maxWidth.isBlank()) {
|
||||
content.setMaxWidth(maxWidth);
|
||||
}
|
||||
content.setDefaultHorizontalComponentAlignment(alignment);
|
||||
content.getStyle().set("margin", "0 auto");
|
||||
return content;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user