FRONTEND/BACKEND: Add comprehensive error capturing and reporting functionality; implement ErrorController and FrontendErrorDTO for backend handling; introduce useErrorStore in frontend for collecting and transmitting errors; integrate error handling into PremiseEditStore, MaterialStore, and other related components; enhance DestinationListView and destination editing workflow with new modal behavior and user-defined cost handling.

This commit is contained in:
Jan 2025-08-21 14:10:35 +02:00
parent b13f5db876
commit 6eaf3d4abc
31 changed files with 1686 additions and 347 deletions

156
src/frontend/src/common.js Normal file
View file

@ -0,0 +1,156 @@
export const parseNumberFromString = (value, decimals = 2) => {
if (typeof value === 'number') return value;
if (!value || typeof value !== 'string') return 0;
/* determine decimal separator */
const lastDot = value.lastIndexOf('.');
const lastComma = value.lastIndexOf(',');
const decimalSeparator = (lastDot > lastComma) ? '.' : ',';
const hasSeperator = !(lastDot === -1 && lastComma === -1);
if (hasSeperator) {
/* remove all digit grouping */
value = (decimalSeparator === '.') ? value.replace(',', '') : value.replace('.', '');
/* remove artificial decimal separators */
const parts = value.split(decimalSeparator);
value = parts.slice(0, -1).join('') + decimalSeparator + parts[parts.length - 1];
}
/* replace ',' with '.' and remove all non-digit characters */
const normalizedValue = value.replace(',', '.').replace(/[^0-9.]/g, '');
const parsed = parseFloat(normalizedValue);
if (isNaN(parsed)) return 0;
return Math.round(parsed * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
export class UrlSafeBase64 {
constructor() {
// Standard Base64 alphabet
this.base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// URL-safe Base64 alphabet (replacing + with - and / with _)
this.urlSafeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
// Create reverse lookup table for decoding
this.decodeTable = {};
for (let i = 0; i < this.urlSafeChars.length; i++) {
this.decodeTable[this.urlSafeChars[i]] = i;
}
}
/**
* Encode a string to URL-safe Base64
* @param {string} input - The string to encode
* @returns {string} URL-safe Base64 encoded string
*/
encode(input) {
if (!input) return '';
// Convert string to bytes
const bytes = new TextEncoder().encode(input);
let result = '';
let i = 0;
// Process 3 bytes at a time
while (i < bytes.length) {
const byte1 = bytes[i++];
const byte2 = i < bytes.length ? bytes[i++] : 0;
const byte3 = i < bytes.length ? bytes[i++] : 0;
const bitmap = (byte1 << 16) | (byte2 << 8) | byte3;
// Extract 6-bit segments
const char1 = (bitmap >> 18) & 63;
const char2 = (bitmap >> 12) & 63;
const char3 = (bitmap >> 6) & 63;
const char4 = bitmap & 63;
// Add characters based on how many bytes we had
result += this.urlSafeChars[char1];
result += this.urlSafeChars[char2];
result += (i - 2) < bytes.length ? this.urlSafeChars[char3] : '=';
result += (i - 1) < bytes.length ? this.urlSafeChars[char4] : '=';
}
return result;
}
/**
* Decode a URL-safe Base64 string
* @param {string} input - The URL-safe Base64 string to decode
* @returns {string} Decoded string
*/
decode(input) {
if (!input) return '';
// Remove padding if present
input = input.replace(/=/g, '');
// Validate input
if (!/^[A-Za-z0-9\-_]*$/.test(input)) {
throw new Error('Invalid URL-safe Base64 string');
}
const bytes = [];
let i = 0;
// Process 4 characters at a time
while (i < input.length) {
const char1 = this.decodeTable[input[i++]] || 0;
const char2 = i < input.length ? this.decodeTable[input[i++]] || 0 : 0;
const char3 = i < input.length ? this.decodeTable[input[i++]] || 0 : 0;
const char4 = i < input.length ? this.decodeTable[input[i++]] || 0 : 0;
const bitmap = (char1 << 18) | (char2 << 12) | (char3 << 6) | char4;
// Extract original bytes
const byte1 = (bitmap >> 16) & 255;
const byte2 = (bitmap >> 8) & 255;
const byte3 = bitmap & 255;
bytes.push(byte1);
if (i - 3 < input.length) bytes.push(byte2);
if (i - 2 < input.length) bytes.push(byte3);
}
// Convert bytes back to string
return new TextDecoder().decode(new Uint8Array(bytes));
}
/**
* Encode without padding (optional for URLs)
* @param {string} input - The string to encode
* @returns {string} URL-safe Base64 encoded string without padding
*/
encodeNoPadding(input) {
return this.encode(input).replace(/=/g, '');
}
encodeIds(ids) {
return this.encodeNoPadding(ids.join(','));
}
decodeIds(encodedIds) {
return this.decode(encodedIds).split(',').map(id => parseInt(id));
}
/**
* Convert standard Base64 to URL-safe Base64
* @param {string} base64 - Standard Base64 string
* @returns {string} URL-safe Base64 string
*/
fromStandardBase64(base64) {
return base64.replace(/\+/g, '-').replace(/\//g, '_');
}
/**
* Convert URL-safe Base64 to standard Base64
* @param {string} urlSafeBase64 - URL-safe Base64 string
* @returns {string} Standard Base64 string
*/
toStandardBase64(urlSafeBase64) {
return urlSafeBase64.replace(/-/g, '+').replace(/_/g, '/');
}
}

View file

@ -20,4 +20,35 @@ export default {
position: relative;
padding: 1.5rem;
}
/* Fade transition for Vue.js */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
/* Optional: Add a slight delay to prevent content jumping */
.fade-enter-active {
transition-delay: 0.15s;
}
.fade-leave-active {
transition-delay: 0s;
}
/* Ensure the transition container maintains consistent height */
.destination-edit-routes {
min-height: fit-content;
}
</style>

View file

@ -1,12 +1,19 @@
<template>
<div class="list-edit-container">
<icon-button variant="blue" icon="pencil-simple" help-text="Edit all selected calculations"></icon-button>
<icon-button variant="blue" icon="trash" help-text="Delete all selected calculations"></icon-button>
<icon-button variant="blue" icon="archive" help-text="Archive all selected calculations"></icon-button>
<transition
name="list-edit-transition"
tag="div"
class="list-edit-container"
>
<div v-if="show" class="list-edit">
<icon-button variant="blue" icon="pencil-simple" help-text="Edit all selected calculations" @click="handleAction('edit')"></icon-button>
<icon-button variant="blue" icon="trash" help-text="Delete all selected calculations" @click="handleAction('delete')"></icon-button>
<icon-button variant="blue" icon="archive" help-text="Archive all selected calculations" @click="handleAction('archive')"></icon-button>
<!-- <icon-button variant="blue" icon="pencil-simple" ></icon-button>-->
<!-- <icon-button variant="blue" icon="trash" ></icon-button>-->
<!-- <icon-button variant="blue" icon="archive" ></icon-button>-->
</div>
</transition>
</template>
@ -15,12 +22,33 @@ import IconButton from "@/components/UI/IconButton.vue";
export default{
name: "ListEdit",
components: {IconButton}
components: {IconButton},
emits: ['action'],
props: {
show: {
type: Boolean,
default: false
}
},
methods: {
handleAction(action) {
this.$emit('action', action);
}
}
}
</script>
<style scoped>
.list-edit-container {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translate(-50%, 0);
z-index: 9999;
}
.list-edit {
display: flex;
justify-content: center;
align-items: center;
@ -33,4 +61,28 @@ export default{
}
/* Transition animations */
.list-edit-transition-enter-active {
transition: all 0.3s ease;
}
.list-edit-transition-leave-active {
transition: all 0.3s ease;
}
.list-edit-transition-enter-from {
opacity: 0;
transform: translate(-50%, 100%); /* beide Transforms kombinieren */
}
.list-edit-transition-leave-to {
opacity: 0;
transform: translate(-50%, 100%); /* beide Transforms kombinieren */
}
.list-edit-transition-move {
transition: transform 0.3s ease;
}
</style>

View file

@ -5,7 +5,7 @@
type="radio"
:name="name"
:value="value"
v-model="selectedValue"
:checked="isSelected"
>
<span class="radiomark"></span>
<span class="radio-label"><slot></slot></span>
@ -15,7 +15,7 @@
<script>
export default{
emits:["option-changed"],
emits:["update:modelValue", "option-changed"],
props: {
value: {
type: [String, Number, Boolean],
@ -32,31 +32,17 @@ export default{
}
},
name: "RadioOption",
data() {
return {
internalValue: this.modelValue,
}
},
computed: {
selectedValue: {
get() {
return this.internalValue;
},
set(value) {
this.internalValue = value;
this.$emit('option-changed', value);
}
}
},
watch: {
modelValue(newVal) {
this.internalValue = newVal;
isSelected() {
return this.modelValue === this.value;
}
},
methods: {
setOption(event) {
// The computed setter will handle the emit
this.selectedValue = event.target.value;
if (event.target.checked) {
this.$emit('update:modelValue', this.value);
this.$emit('option-changed', this.value);
}
}
}
}

View file

@ -1,40 +1,42 @@
// TabContainer.vue - Main tabs component
<template>
<div class="tab-container">
<!-- Tab Headers -->
<div class="tab-headers" :class="{ 'tab-headers-vertical': vertical }">
<div class="tab-headers">
<button
v-for="(tab, index) in tabs"
:key="tab.id || index"
:class="[
'tab-header',
{ 'tab-header-active': activeTab === index },
{ 'tab-header-disabled': tab.disabled }
]"
@click="selectTab(index)"
:disabled="tab.disabled"
:aria-selected="activeTab === index"
role="tab"
:key="index"
:class="['tab-header', { active: activeTab === index }]"
@click="setActiveTab(index)"
>
<component :is="tab.icon"></component>
{{ tab.title }}
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
</button>
</div>
<!-- Tab Content -->
<div class="tab-content" :class="{ 'tab-content-vertical': vertical }">
<!-- Tab Content with CSS Animations -->
<div class="tab-content">
<div
v-for="(tab, index) in tabs"
:key="tab.id || index"
v-show="activeTab === index"
class="tab-panel"
role="tabpanel"
:aria-hidden="activeTab !== index"
:key="index"
:class="['tab-pane', {
active: activeTab === index,
'slide-in': activeTab === index && hasChanged,
'slide-out': activeTab !== index && hasChanged
}]"
>
<slot :name="tab.slot || `tab-${index}`" :tab="tab" :index="index">
<div v-html="tab.content"></div>
</slot>
<!-- Render component if provided -->
<component
v-if="tab.component"
:is="tab.component"
v-bind="tab.props || {}"
/>
<!-- Render slot content if provided -->
<div v-else-if="tab.slot">
<slot :name="tab.slot"></slot>
</div>
<!-- Render HTML content if provided -->
<div v-else-if="tab.content" v-html="tab.content"></div>
<!-- Fallback text content -->
<div v-else>{{ tab.text || 'No content provided' }}</div>
</div>
</div>
</div>
@ -48,205 +50,148 @@ export default {
type: Array,
required: true,
validator(tabs) {
return tabs.every(tab => tab.title)
return tabs.every(tab => tab.title && typeof tab.title === 'string');
}
},
defaultTab: {
type: Number,
default: 0
},
vertical: {
type: Boolean,
default: false
},
animated: {
type: Boolean,
default: true
}
},
data() {
return {
activeTab: this.defaultTab
}
activeTab: this.defaultTab,
hasChanged: false
};
},
methods: {
selectTab(index) {
if (this.tabs[index] && !this.tabs[index].disabled) {
const previousTab = this.activeTab
this.activeTab = index
this.$emit('tab-changed', {
activeTab: index,
previousTab,
tab: this.tabs[index]
})
}
},
setActiveTab(index) {
this.selectTab(index)
},
getActiveTab() {
return {
index: this.activeTab,
tab: this.tabs[this.activeTab]
}
}
},
watch: {
defaultTab: {
immediate: true,
handler(newVal) {
this.activeTab = newVal
if (index >= 0 && index < this.tabs.length) {
this.hasChanged = true;
this.activeTab = index;
this.$emit('tab-changed', {
index,
tab: this.tabs[index]
});
// Reset animation flag
setTimeout(() => {
this.hasChanged = false;
}, 400);
}
}
},
mounted() {
// Emit initial tab
this.$emit('tab-changed', {
activeTab: this.activeTab,
previousTab: null,
index: this.activeTab,
tab: this.tabs[this.activeTab]
})
});
}
}
};
</script>
<style scoped>
.tab-container {
width: 100%;
font-family: Arial, sans-serif;
}
/* Horizontal Layout (default) */
.tab-headers {
display: flex;
border-bottom: 2px solid #e2e8f0;
background-color: #f8fafc;
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
background-color: transparent;
position: relative;
}
.tab-header {
padding: 12px 20px;
border: none;
background-color: transparent;
cursor: pointer;
font-size: 1.4rem;
font-weight: 500;
font-family: inherit;
color: #6b7280;
border-bottom: 3px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 2;
}
.tab-header:hover {
background-color: rgba(107, 134, 156, 0.05);
color: #6b7280;
}
.tab-header.active {
color: #002F54;
border-bottom-color: #5AF0B4;
background-color: white;
}
.tab-content {
padding: 1rem;
background-color: #ffffff;
padding: 20px;
background-color: white;
min-height: 200px;
position: relative;
overflow: hidden;
}
/* Vertical Layout */
.tab-container:has(.tab-headers-vertical) {
display: flex;
}
.tab-headers-vertical {
flex-direction: column;
border-bottom: none;
border-right: 2px solid #e2e8f0;
min-width: 200px;
}
.tab-content-vertical {
flex: 1;
}
/* Tab Header Styles */
.tab-header {
padding: 0.75rem 1.5rem;
border: none;
background: none;
cursor: pointer;
font-size: 1.4rem;
font-family: 'Poppins', sans-serif;
font-weight: 500;
color: #64748b;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.tab-headers-vertical .tab-header {
border-bottom: none;
border-right: 2px solid transparent;
justify-content: flex-start;
}
.tab-header:hover:not(.tab-header-disabled) {
color: #3b82f6;
background-color: #f1f5f9;
}
.tab-header-active {
color: #3b82f6;
border-bottom-color: #3b82f6;
background-color: #ffffff;
}
.tab-headers-vertical .tab-header-active {
border-bottom-color: transparent;
border-right-color: #3b82f6;
}
.tab-header-disabled {
color: #cbd5e1;
cursor: not-allowed;
opacity: 0.6;
}
.tab-header-disabled:hover {
background-color: transparent;
}
/* Tab Icon and Badge */
.tab-icon {
font-size: 1rem;
}
.tab-badge {
background-color: #ef4444;
color: white;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.75rem;
min-width: 1.25rem;
text-align: center;
line-height: 1;
}
.tab-header-active .tab-badge {
background-color: #3b82f6;
}
/* Tab Panel */
.tab-panel {
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
.tab-pane {
width: 100%;
position: absolute;
top: 20px;
left: 20px;
right: 20px;
opacity: 0;
transform: translateY(0.5rem);
}
to {
transform: translateY(20px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.tab-pane.active {
opacity: 1;
transform: translateY(0);
position: relative;
top: auto;
left: auto;
right: auto;
pointer-events: all;
}
.tab-pane.slide-in {
animation: slideInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.tab-pane.slide-out {
animation: slideOutUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* Keyframe Animations */
@keyframes slideInUp {
0% {
opacity: 0;
transform: translateX(-30px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
/* Responsive Design */
@media (max-width: 768px) {
.tab-headers {
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
/* Keyframe Animations */
@keyframes slideOutUp {
0% {
opacity: 1;
transform: translateX(0);
}
.tab-headers::-webkit-scrollbar {
display: none;
}
.tab-header {
flex-shrink: 0;
padding: 0.5rem 1rem;
font-size: 0.8rem;
100% {
opacity: 0;
transform: translateX(30px);
}
}
</style>

View file

@ -0,0 +1,239 @@
<template>
<teleport to="body">
<transition-group
name="toast"
tag="div"
class="toast-container"
>
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', `toast--${toast.variant}`]"
@click="removeToast(toast.id)"
>
<div v-if="toast.icon" class="toast__icon">
<component
:is="toast.icon"
weight="regular"
size="24"
class="icon-btn"
:class="toast__icon_sizing"
/>
</div>
<div class="toast__content">
<div v-if="toast.title" class="toast__title">{{ toast.title }}</div>
<div class="toast__message">{{ toast.message }}</div>
</div>
</div>
</transition-group>
</teleport>
</template>
<script>
import {PhCheck, PhFloppyDisk} from "@phosphor-icons/vue";
export default {
name: 'Toast',
components: {PhFloppyDisk, PhCheck},
data() {
return {
toasts: [],
nextId: 1
}
},
methods: {
/**
* Add a new toast notification
* @param {Object} options - Toast configuration
* @param {string} options.message - Toast message (required)
* @param {string} options.title - Toast title (optional)
* @param {string} options.variant - Toast variant: 'success', 'error', 'warning', 'info'
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
* @param {string} options.icon - Icon name (optional)
*/
addToast(options = {}) {
const toast = {
id: this.nextId++,
message: options.message || 'Notification',
title: options.title || null,
variant: options.variant || 'primary',
duration: options.duration !== undefined ? options.duration : 5000,
icon: options.icon ? `Ph${options.icon.charAt(0).toUpperCase() + options.icon.slice(1)}` : null,
};
this.toasts.push(toast)
// Auto-dismiss if duration is set
if (toast.duration > 0) {
setTimeout(() => {
this.removeToast(toast.id)
}, toast.duration)
}
return toast.id
},
/**
* Remove a toast by ID
* @param {number} id - Toast ID
*/
removeToast(id) {
const index = this.toasts.findIndex(toast => toast.id === id)
if (index > -1) {
this.toasts.splice(index, 1)
}
},
/**
* Remove all toasts
*/
clearToasts() {
this.toasts = []
},
// Convenience methods
success(message, options = {}) {
return this.addToast({ ...options, message, variant: 'primary' })
},
error(message, options = {}) {
return this.addToast({ ...options, message, variant: 'exception' })
},
warning(message, options = {}) {
return this.addToast({ ...options, message, variant: 'secondary' })
},
info(message, options = {}) {
return this.addToast({ ...options, message, variant: 'secondary' })
}
}
}
</script>
<style scoped>
.toast-container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translate(-50%, 0);
z-index: 9999;
pointer-events: none;
}
.toast {
background-color: #5AF0B4;
color: #002F54;
min-width: 30rem;
max-width: 40rem;
margin-bottom: 1.2rem;
padding: 1.6rem;
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 1.6rem;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.toast:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 0.9rem rgba(0, 0, 0, 0.1);
}
.toast__icon {
flex-shrink: 0;
width: 3.6rem;
height: 3.6rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
}
.toast__icon_sizing {
width: 3.6rem;
height: 3.6rem;
}
.toast__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
justify-content: center;
}
.toast__title {
font-weight: 600;
font-size: 1.6rem;
margin-bottom: 0.2rem;
line-height: 1.3;
}
.toast__message {
font-size: 1.4rem;
line-height: 1.4;
word-break: break-word;
}
.toast-primary {
background-color: #5AF0B4;
color: #002F54;
}
.toast--secondary {
background-color: #002F54;
color: #ffffff;
}
.toast--exception{
background-color: #BC2B72;
color: #ffffff;
}
/* Transition animations */
.toast-enter-active {
transition: all 0.3s ease;
}
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateY(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
/* Responsive design */
@media (max-width: 480px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
}
.toast {
min-width: auto;
max-width: none;
}
}
</style>

View file

@ -180,6 +180,7 @@ export default {
.tooltip {
opacity: 0;
animation: tooltipFadeIn 0.2s ease-out forwards;
font-weight: 300;
}
@keyframes tooltipFadeIn {

View file

@ -0,0 +1,119 @@
<template>
<div class="trace-view-container" :class="trace ? '' : 'trace-view-container--no-trace'">
<div class="trace-view-header">
<h3 class="sub-header">{{ code }}</h3>
<icon-button icon="x" @click="$emit('close')"></icon-button>
</div>
<div v-if="showMessage" class="trace-view-message">{{ message }}</div>
<div v-if="trace" class="trace-view">
<div class="trace-view-exception">{{ exception }}</div>
<div :class="highlightClasses(traceItem)" v-for="traceItem of trace"> at {{ fullClassName(traceItem) }} (<span
class="trace-view-file">{{ fileName(traceItem) }}</span>)
</div>
</div>
</div>
</template>
<script>
import BasicButton from "@/components/UI/BasicButton.vue";
import IconButton from "@/components/UI/IconButton.vue";
export default {
name: "TraceView",
components: {IconButton, BasicButton},
emits: ['close'],
props: {
error: {
type: Object,
required: true
}
},
computed: {
trace() {
return this.error.trace?.stackTrace;
},
exception() {
return this.error.trace?.errorMessage;
},
code() {
return this.error.code;
},
showMessage() {
return this.error.message !== this.error.trace?.errorMessage;
},
message() {
return this.error.message;
},
},
methods: {
highlightClasses(traceItem) {
return traceItem.className.includes('de.avatic.lcc') ? 'highlight-trace-item' : 'trace-item';
},
fullClassName(traceItem) {
return `${traceItem.className}.${traceItem.methodName}`;
},
fileName(traceItem) {
return `${traceItem.fileName}:${traceItem.lineNumber}`;
}
}
}
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Cal+Sans&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
.highlight-trace-item {
background-color: #c3cfdf;
color: #002F54;
}
.trace-item:hover {
background-color: rgba(107, 134, 156, 0.05);
}
.trace-view {
overflow: auto;
font-family: Roboto Mono, monospace;
font-size: 1.2rem;
flex: 1;
padding: 1.6rem;
}
.trace-view-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.trace-view-message {
font-size: 1.4rem;
color: #002F54;
}
.trace-view-file {
font-weight: 500;
text-decoration: underline;
}
.trace-view-exception {
font-family: inherit;
font-weight: 500;
}
.trace-view-container {
height: 90vh;
width: 80vw;
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.trace-view-container--no-trace {
height: auto;
width: auto;
}
</style>

View file

@ -94,7 +94,7 @@ export default {
this.premise.checked = checked;
},
editClick() {
this.$router.push({name: 'edit', params: {id: this.id}});
},
deleteClick() {

View file

@ -16,14 +16,15 @@
<!-- Header -->
<div class="destination-list-header">
<div>Destination</div>
<div>Annual purchase volume</div>
<div>Annual quantity</div>
<div>Selected route</div>
<div>Action</div>
</div>
<div v-if="premiseEditStore.selectedPremise.destinations.length !== 0">
<destination-item v-for="destination in premiseEditStore.selectedPremise.destinations" :key="destination.id"
:id="destination.id" :destination="destination" @delete="deleteDestination" @edit="editDestination"></destination-item>
:id="destination.id" :destination="destination" @delete="deleteDestination"
@edit="editDestination"></destination-item>
</div>
<div v-else class="empty-container">
<span class="space-around">No Destinations found.</span>
@ -31,7 +32,7 @@
</div>
<modal :state="editDestinationModalState">
<destination-edit></destination-edit>
<destination-edit @accept="deselectDestination(true)" @discard="deselectDestination(false)"></destination-edit>
</modal>
</div>
</template>
@ -51,13 +52,15 @@ import {usePremiseEditStore} from "@/store/premiseEdit.js";
import {useNodeStore} from "@/store/node.js";
import Modal from "@/components/UI/Modal.vue";
import DestinationEdit from "@/components/layout/edit/destination/DestinationEdit.vue";
import {UrlSafeBase64} from "@/common.js";
export default {
name: "DestinationListView",
components: {
DestinationEdit,
Modal,
DestinationItem, CalculationListItem, Checkbox, Spinner, BasicButton, AutosuggestSearchbar, IconButton},
DestinationItem, CalculationListItem, Checkbox, Spinner, BasicButton, AutosuggestSearchbar, IconButton
},
props: {
destinations: {
type: Object,
@ -66,7 +69,7 @@ export default {
},
data() {
return {
editDestinationModalState: true
editDestinationModalState: false,
}
},
computed: {
@ -80,16 +83,25 @@ export default {
resolveFlag(node) {
return node.country.iso_code;
},
addDestination(node) {
async addDestination(node) {
console.log(node)
this.premiseEditStore.addDestination(node.id);
const [id] = await this.premiseEditStore.addDestination(node.id);
console.log(id);
this.editDestination(id);
},
deleteDestination(id) {
this.premiseEditStore.deleteDestination(id);
},
editDestination(id) {
console.log("edit id: " + id + "")
editDestination(id) {
if (id && this.destinations.some(d => d.id === id)) {
this.premiseEditStore.selectDestinations([id]);
this.editDestinationModalState = true;
}
},
deselectDestination(save) {
this.premiseEditStore.deselectDestinations(save);
this.editDestinationModalState = false;
}
}
}

View file

@ -66,6 +66,7 @@ import ModalDialog from "@/components/UI/ModalDialog.vue";
import {PhArrowCounterClockwise} from "@phosphor-icons/vue";
import {useMaterialStore} from "@/store/material.js";
import {mapStores} from "pinia";
import {parseNumberFromString} from "@/common.js";
export default {
name: "MaterialEdit",
@ -125,17 +126,6 @@ export default {
this.selectedMaterial = null;
}
},
parseNumberFromString(value, decimals = 2) {
if (typeof value === 'number') return value;
if (!value || typeof value !== 'string') return 0;
const normalizedValue = value.replace(',', '.').replace(/[^0-9.]/g, '');
const parsed = parseFloat(normalizedValue);
if (isNaN(parsed)) return 0;
return Math.round(parsed * Math.pow(10, decimals)) / Math.pow(10, decimals);
},
updateInputValue(inputRef, formattedValue) {
this.$nextTick(() => {
if (this.$refs[inputRef] && this.$refs[inputRef].value !== formattedValue) {
@ -145,7 +135,7 @@ export default {
},
validateInput(type, event) {
const decimals = 2
const parsed = this.parseNumberFromString(event.target.value, decimals);
const parsed = parseNumberFromString(event.target.value, decimals);
console.log('validateInput', type, event.target.value, parsed);
this.$emit(`update:${type}`, parsed);

View file

@ -1,5 +1,5 @@
<template>
<div class="container">
<div class="container" @focusout="focusLost">
<div class="caption-column">Length</div>
<div class="input-column">
<div class="input-field-container">
@ -64,7 +64,6 @@
<checkbox :checked="mixable" @checkbox-changed="updateMixable">mixable</checkbox>
</tooltip>
</div>
</div>
</template>
@ -74,11 +73,12 @@
import Checkbox from "@/components/UI/Checkbox.vue";
import Dropdown from "@/components/UI/Dropdown.vue";
import Tooltip from "@/components/UI/Tooltip.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "PackagingEdit",
components: {Tooltip, Dropdown, Checkbox},
emits: ['update:stackable', 'update:mixable', 'update:length', 'update:width', 'update:height', 'update:weight', 'update:unitCount', 'update:weightUnit', 'update:dimensionUnit'],
emits: ['update:stackable', 'update:mixable', 'update:length', 'update:width', 'update:height', 'update:weight', 'update:unitCount', 'update:weightUnit', 'update:dimensionUnit', 'save'],
props: {
length: {
required: true,
@ -192,16 +192,10 @@ export default {
}
},
methods: {
parseNumberFromString(value, decimals = 2) {
if (typeof value === 'number') return value;
if (!value || typeof value !== 'string') return 0;
const normalizedValue = value.replace(',', '.').replace(/[^0-9.]/g, '');
const parsed = parseFloat(normalizedValue);
if (isNaN(parsed)) return 0;
return Math.round(parsed * Math.pow(10, decimals)) / Math.pow(10, decimals);
focusLost(event) {
if (!this.$el.contains(event.relatedTarget)) {
this.$emit('save', 'packaging');
}
},
updateInputValue(inputRef, formattedValue) {
this.$nextTick(() => {
@ -212,7 +206,7 @@ export default {
},
validateDimension(type, event) {
const decimals = (this.huDimensionUnitSelected === 2) ? 2 : ((this.huDimensionUnitSelected === 3) ? 4 : 0);
const parsed = this.parseNumberFromString(event.target.value, decimals);
const parsed = parseNumberFromString(event.target.value, decimals);
this.$emit(`update:${type}`, parsed);
@ -224,7 +218,7 @@ export default {
},
validateWeight(type, event) {
const decimals = (this.huWeightUnitSelected === 2) ? 4 : ((this.huWeightUnitSelected === 3) ? 8 : 0);
const parsed = this.parseNumberFromString(event.target.value, decimals);
const parsed = parseNumberFromString(event.target.value, decimals);
this.$emit('update:weight', parsed);
@ -233,7 +227,7 @@ export default {
this.updateInputValue('weightInput', formattedValue);
},
validateCount(event) {
const parsed = this.parseNumberFromString(event.target.value, 0);
const parsed = parseNumberFromString(event.target.value, 0);
this.$emit('update:unitCount', parsed);
// Force update the input field with the correctly formatted value

View file

@ -29,6 +29,7 @@
import Checkbox from "@/components/UI/Checkbox.vue";
import Tooltip from "@/components/UI/Tooltip.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "PriceEdit",
@ -57,18 +58,8 @@ export default {
}
},
methods: {
parseNumberFromString(value, decimals = 2) {
if (typeof value === 'number') return value;
if (!value || typeof value !== 'string') return 0;
const normalizedValue = value.replace(',', '.').replace(/[^0-9.]/g, '');
const parsed = parseFloat(normalizedValue);
if (isNaN(parsed)) return 0;
return Math.round(parsed * Math.pow(10, decimals)) / Math.pow(10, decimals);
},
validatePrice(event) {
const value = this.parseNumberFromString(event.target.value, 2);
const value = parseNumberFromString(event.target.value, 2);
const validatedValue = Math.max(0, value);
if (validatedValue !== this.price) {
@ -81,7 +72,7 @@ export default {
this.$emit('update:includeFcaFee', value);
},
validateOverSeaShare(event) {
const percentValue = this.parseNumberFromString(event.target.value, 4);
const percentValue = parseNumberFromString(event.target.value, 4);
const validatedPercent = Math.max(0, Math.min(100, percentValue));
const validatedDecimal = validatedPercent / 100;
@ -107,17 +98,6 @@ export default {
flex: 1 1 auto;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.input-column {
@ -130,6 +110,18 @@ export default {
flex: 1 1 auto;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.input-field-container {
display: flex;
align-items: center;

View file

@ -2,22 +2,12 @@
<div class="destination-edit-modal-container">
<h3 class="sub-header">Edit Destination</h3>
<div class="destination-edit-container">
<tab-container :tabs="basicTabs">
<template #tab-0>
<h3>Dashboard Content</h3>
<p>This is the dashboard panel with charts and metrics.</p>
</template>
<template #tab-1>
<h3>User Profile</h3>
<p>User settings and profile information go here.</p>
</template>
<template #tab-2>
<h3>Settings Panel</h3>
<p>Application settings and preferences.</p>
</template>
</tab-container>
<tab-container :tabs="tabsConfig" class="tab-container">
</tab-container >
<div class="destination-edit-actions">
<basic-button variant="primary" :show-icon="false" @click="close('accept')">OK</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="close('discard')">Cancel</basic-button>
</div>
</div>
</div>
</template>
@ -27,56 +17,66 @@
import Tab from "@/components/UI/Tab.vue";
import TabContainer from "@/components/UI/TabContainer.vue";
import DestinationEditRoutes from "@/components/layout/edit/destination/DestinationEditRoutes.vue";
import DestinationEditHandlingCost from "@/components/layout/edit/destination/DestinationEditHandlingCost.vue";
import {markRaw} from "vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
export default {
name: "DestinationEdit",
components: {TabContainer, Tab},
components: {BasicButton, TabContainer, Tab},
emits: ['accept', 'discard'],
data() {
return {
basicTabs: [
currentTab: null,
tabsConfig: [
{
id: 'dashboard',
title: 'Routes',
component: markRaw(DestinationEditRoutes),
},
{
id: 'profile',
title: 'Packaging & Handling',
}
],
advancedTabs: [
{
id: 'notifications',
title: 'Notifications',
icon: 'PhPlus',
badge: '5',
slot: 'notifications'
},
{
id: 'messages',
title: 'Messages',
icon: 'PhCheck',
badge: '12',
slot: 'messages'
},
{
id: 'analytics',
title: 'Analytics',
icon: 'PhTrash',
slot: 'analytics'
title: 'Handling & Repackaging',
component: markRaw(DestinationEditHandlingCost),
}
]
}
},
methods: {
close(action) {
this.$emit(action);
}
}
}
</script>
<style scoped>
.tab-container {
flex: 1;
overflow: auto; /* In case content overflows */
}
.destination-edit-container {
flex: 1;
display: flex;
flex-direction: column;
}
.destination-edit-modal-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
flex: 0 0 max(80vw, 80rem);
flex: 1 0 max(80vw, 80rem);
height: min(70vh, 50rem);
}
.destination-edit-actions {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
</style>

View file

@ -0,0 +1,182 @@
<template>
<div class="destination-edit-handling-cost">
<div class="destination-edit-handling-cost-info">
<ph-warning size="18px"></ph-warning>
Handling and repackaging costs can be calculated automatically.
If needed, you can overwrite these values here.
</div>
<div>
<checkbox :checked="inputFieldsActive" @checkbox-changed="activateInputFields">I want to enter handling and
repackaging costs.
</checkbox>
</div>
<div class="destination-edit-handling-cost-container" v-show="inputFieldsActive">
<div class="destination-edit-column-caption">Repackaging cost [EUR]</div>
<div class="destination-edit-column-data">
<div class="input-field-container">
<input :value="repackaging" @blur="validate('repackaging', $event)" class="input-field"
autocomplete="off"/>
</div>
</div>
<div class="destination-edit-column-caption">Handling cost [EUR]</div>
<div class="destination-edit-column-data">
<div class="input-field-container">
<input :value="handling" @blur="validate('handling', $event)" class="input-field"
autocomplete="off"/>
</div>
</div>
<div class="destination-edit-column-caption">Disposal cost [EUR]</div>
<div class="destination-edit-column-data">
<div class="input-field-container">
<input :value="disposal" @blur="validate('disposal', $event)" class="input-field"
autocomplete="off"/>
</div>
</div>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import {set} from "@vueuse/core";
import {parseNumberFromString} from "@/common.js";
export default {
name: "DestinationEditHandlingCost",
components: {Checkbox},
created() {
console.log("Destination:", this.destination)
},
computed: {
...mapStores(usePremiseEditStore),
destination() {
const [dest] = this.premiseEditStore.getSelectedDestinations;
return dest;
},
repackaging: {
get() {
return this.destination?.repackaging_costs?.toFixed(2) ?? '0.00';
},
set(value) {
return this.destination && (this.destination.repackaging_costs = value);
}
},
handling: {
get() {
return this.destination?.handling_costs?.toFixed(2) ?? '0.00';
},
set(value) {
return this.destination && (this.destination.handling_costs = value);
}
},
disposal: {
get() {
return this.destination?.disposal_costs?.toFixed(2) ?? '0.00';
},
set(value) {
return this.destination && (this.destination.disposal_costs = value);
}
},
inputFieldsActive: {
get() {
return this.destination?.userDefinedHandlingCosts ?? false;
},
set(value) {
this.destination && (this.destination.userDefinedHandlingCosts = value);
}
}
},
methods: {
activateInputFields(value) {
if (value !== this.inputFieldsActive) {
this.inputFieldsActive = value;
this.destination.userDefinedHandlingCosts = value;
}
},
validate(field, event) {
const value = parseNumberFromString(event.target.value, 2);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed(2);
if (stringified !== this[field]) {
this.destination[`${field}_costs`] = validatedValue;
}
event.target.value = stringified;
}
}
}
</script>
<style scoped>
.destination-edit-handling-cost {
display: flex;
flex-direction: column;
gap: 1.6rem;
align-items: flex-start;
}
.destination-edit-handling-cost-info {
display: flex;
align-items: center;
font-size: 1.4rem;
gap: 1.6rem;
background-color: #c3cfdf;
color: #002F54;
border-radius: 0.8rem;
padding: 1.6rem;
}
.destination-edit-column-caption {
font-size: 1.4rem;
font-weight: 500;
align-self: center;
justify-self: end;
color: #001D33;
text-wrap: nowrap;
}
.destination-edit-handling-cost-container {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(4, auto);
gap: 1.6rem;
width: 100%;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.input-field-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 1 fit-content(80rem);
}
.input-field-container:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
</style>

View file

@ -0,0 +1,224 @@
<template>
<div class="destination-edit-routes-container">
<div class="destination-edit-routes">
<div class="destination-edit-column-caption">Destination</div>
<div class="destination-edit-column-data">{{ destination.destination_node.name }}</div>
<div class="destination-edit-column-caption"><tooltip :text="tooltipAnnualAmount" position="right">Annual quantity</tooltip></div>
<div class="destination-edit-column-data">
<div class="input-field-container">
<input :value="annualAmount" @blur="validateAnnualAmount" class="input-field"
autocomplete="off"/>
</div>
</div>
<div class="destination-edit-column-caption">Transport mode</div>
<div class="destination-edit-column-data destination-edit-cell-routing">
<radio-option name="model" value="routing" v-model="calculationModel">standard routing</radio-option>
<radio-option name="model" value="d2d" v-model="calculationModel">individual rate</radio-option>
</div>
<!-- Single grid cell for caption that transitions content -->
<div class="destination-edit-column-caption">
<transition name="fade" mode="out-in">
<div v-if="showRoutes || showRouteWarning" key="routes">Routes</div>
<div v-else key="rate">D2D Rate [EUR]</div>
</transition>
</div>
<!-- Single grid cell for data that transitions content -->
<div class="destination-edit-column-data">
<transition name="fade" mode="out-in">
<div v-if="showRoutes" key="routes" class="destination-edit-cell-routes">
<destination-route v-for="route in destination.routes" :key="route.id" :route="route"
:selected="route.selected" @click="selectRoute(route.id)"></destination-route>
</div>
<div v-else-if="showRouteWarning">
<div class="destination-edit-route-warning">
<ph-warning size="18px"></ph-warning>
Unable to route from supplier to {{this.destination.destination_node.name}}
</div>
</div>
<div v-else key="rate" class="input-field-container">
<input :value="rateD2d" @blur="validateRateD2d" class="input-field"
autocomplete="off"/>
</div>
</transition>
</div>
</div>
</div>
</template>
<script>
import RadioOption from "@/components/UI/RadioOption.vue";
import DestinationRoute from "@/components/layout/edit/destination/DestinationRoute.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import {parseNumberFromString} from "@/common.js";
import Tooltip from "@/components/UI/Tooltip.vue";
export default {
name: "DestinationEditRoutes",
components: {Tooltip, DestinationRoute, RadioOption},
methods: {
selectRoute(id) {
// Your route selection logic
},
validateAnnualAmount(event) {
const value = parseNumberFromString(event.target.value, 0);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed();
this.annualAmount = validatedValue;
event.target.value = stringified;
},
validateRateD2d(event) {
const value = parseNumberFromString(event.target.value, 2);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed(2);
this.rateD2d = validatedValue;
event.target.value = stringified;
}
},
created() {
console.log("Destination:", this.destination)
},
data() {
return {
selectedCalculationModel: 'routing',
}
},
computed: {
tooltipAnnualAmount() {
return `Annual quantity that "${this.destination.destination_node.name}" will source from the supplier`
},
showRoutes() {
return !this.destination.is_d2d && this.destination.routes.length > 0;
},
showRouteWarning() {
return !this.destination.is_d2d && this.destination.routes.length === 0;
},
calculationModel: {
get() {
if (this.destination.is_d2d) {
return 'd2d';
} else {
return 'routing';
}
},
set(value) {
this.destination.is_d2d = value === 'd2d';
}
},
...mapStores(usePremiseEditStore),
destination() {
const [dest] = this.premiseEditStore.getSelectedDestinations;
return dest;
},
annualAmount: {
get() {
return this.destination.annual_amount?.toFixed() ?? '0';
},
set(value) {
this.destination && (this.destination.annual_amount = value);
},
},
rateD2d: {
get() {
return this.destination.rate_d2d?.toFixed(2) ?? '0.00';
},
set(value) {
this.destination && (this.destination.rate_d2d = value);
}
}
}
}
</script>
<style scoped>
/* Smooth fade transition */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.destination-edit-route-warning {
display: flex;
align-items: center;
font-size: 1.4rem;
gap: 1.6rem;
border-radius: 0.8rem;
padding: 1.6rem;
}
.destination-edit-cell-routing {
display: flex;
flex-direction: row;
gap: 1.6rem;
}
.destination-edit-routes {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(4, auto);
gap: 1.6rem;
flex: 1 1 auto;
}
.destination-edit-column-data {
align-self: center;
font-weight: 300;
font-size: 1.4rem;
color: #6b7280;
}
.destination-edit-column-caption {
font-size: 1.4rem;
font-weight: 500;
align-self: center;
justify-self: end;
color: #001D33;
text-wrap: nowrap;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.input-field-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 1 fit-content(80rem);
}
.input-field-container:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.destination-edit-cell-routes {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
</style>

View file

@ -41,13 +41,13 @@ export default {
return routeElem;
},
isSea() {
return this.route.type === "SEA";
return this.route.variant === "SEA";
},
isRoad() {
return this.route.type === "ROAD";
return this.route.variant === "ROAD";
},
isRail() {
return this.route.type === "RAIL";
return this.route.variant === "RAIL";
},
containerClass() {

View file

@ -3,7 +3,7 @@ import router from './router.js';
import {createApp} from 'vue'
import {createPinia} from 'pinia';
import App from './App.vue'
import { setupErrorBuffer } from './store/error.js'
import {
PhStar,
@ -26,7 +26,7 @@ import {
PhArchive,
PhFloppyDisk,
PhArrowCounterClockwise,
PhCheck
PhCheck, PhBug
} from "@phosphor-icons/vue";
const app = createApp(App);
@ -56,6 +56,7 @@ app.component('PhCloudArrowUp', PhCloudArrowUp);
app.component('PhSealCheck', PhSealCheck);
app.component('PhCalculator', PhCalculator);
app.component('PhStar', PhStar);
app.component('PhBug', PhBug)
app.use(router);
@ -63,6 +64,6 @@ app.use(pinia);
//app.component('base-button', () => import('./components/UI/BasicButton.vue'));
//app.component('base-badge', () => import('./components/UI/BasicBadge.vue'));
setupErrorBuffer()
app.mount('#app');

View file

@ -3,18 +3,20 @@
<div class="header-container">
<h2 class="page-header">Edit Calculation</h2>
<div class="header-controls">
<basic-button :show-icon="false" :disabled="premiseEditStore.selectedLoading" variant="secondary">Close
<basic-button @click="showCustomToast" :show-icon="false" :disabled="premiseEditStore.selectedLoading" variant="secondary">Close
</basic-button>
<basic-button :show-icon="true" :disabled="premiseEditStore.selectedLoading || premiseEditStore.singleSelectEmpty"
icon="Calculator" variant="primary">Calculate & close
</basic-button>
</div>
</div>
</div>
</div>
<Toast ref="toast" />
<modal :state="traceModal"><trace-view :error="premiseEditStore.error" @close="traceModal = false;"></trace-view></modal>
<notification-bar v-if="premiseEditStore.error != null" variant="exception" icon="x"
@icon-clicked="premiseEditStore.error = null">
<div class="errorCode">{{ premiseEditStore.error.code }}</div>
{{ premiseEditStore.error.message }}
{{ premiseEditStore.error.message }} <span class="trace-link" @click="trace">View Trace</span>
</notification-bar>
<div v-if="premiseEditStore.selectedLoading" class="edit-calculation-spinner-container">
@ -44,9 +46,9 @@
v-model:weight-unit="premise.handling_unit.weight_unit"
v-model:dimension-unit="premise.handling_unit.dimension_unit"
v-model:unit-count="premise.handling_unit.content_unit_count"
v-model:stackable="premise.is_stackable"
v-model:mixable="premise.is_mixable"></packaging-edit>
v-model:mixable="premise.is_mixable"
@save="save"></packaging-edit>
</box>
<box class="master-data-item">
<material-edit :part-number="premise.material.part_number"
@ -83,12 +85,32 @@ import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import Spinner from "@/components/UI/Spinner.vue";
import NotificationBar from "@/components/UI/NotificationBar.vue";
import Modal from "@/components/UI/Modal.vue";
import TraceView from "@/components/layout/TraceView.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Toast from "@/components/UI/Toast.vue";
export default {
name: "SingleEdit",
components: {
Toast,
IconButton,
TraceView,
Modal,
NotificationBar,
Spinner, DestinationListView, PriceEdit, PackagingEdit, MaterialEdit, Box, SupplierView, BasicButton
Spinner,
DestinationListView,
PriceEdit,
PackagingEdit,
MaterialEdit,
Box,
SupplierView,
BasicButton
},
data() {
return {
traceModal: false
}
},
computed: {
...mapStores(usePremiseEditStore),
@ -97,22 +119,43 @@ export default {
}
},
methods: {
save(type) {
console.log(type);
},
showCustomToast() {
this.$refs.toast.addToast({
icon: 'floppy-disk',
message: 'Changes saved.',
title: 'Success',
variant: 'primary',
duration: 3000
})
},
updateMaterial(id, action) {
console.log(id, action);
this.premiseEditStore.setMaterial(id, action === 'updateMasterData');
},
updateSupplier(data) {
this.premiseEditStore.setSupplier(data.nodeId, data.updateMasterData);
},
trace() {
this.traceModal = true;
}
},
created() {
this.premiseEditStore.selectPremise(parseInt(this.$route.params.id));
}
},
}
</script>
<style scoped>
.trace-link {
cursor: pointer;
text-decoration: underline;
}
.edit-calculation-spinner-container {
display: flex;
align-items: center;

View file

@ -40,11 +40,10 @@
</transition>
</div>
<transition name="list-edit">
<div v-if="showListEdit" class="calculation-list-edit-container">
<list-edit></list-edit>
</div>
</transition>
<list-edit :show="showListEdit" @action="handleMultiselectAction"></list-edit>
</div>
@ -88,6 +87,9 @@ export default {
this.premiseStore.setQuery({});
},
methods: {
handleMultiselectAction(action) {
this.premiseStore.selected
},
updateCheckBoxes(checked) {
this.overallCheck = checked;
this.premiseStore.premises.forEach(p => {

View file

@ -25,6 +25,7 @@ const router = createRouter({
{
path: '/edit/:id',
component: CalculationSingleEdit,
name: 'edit',
},
{
path: '/bulk',

View file

@ -0,0 +1,147 @@
import {defineStore, getActivePinia} from 'pinia'
import {config} from '@/config'
import {toRaw} from "vue";
export const useErrorStore = defineStore('error', {
state() {
return {
errors: [],
sendCache: [],
autoSubmitInterval: 30000,
autoSubmitTimer: null
}
},
getters: {
lastError: (state) => state.errors.length > 0 ? state.errors[state.errors.length - 1].error : null,
},
actions: {
async addError(errorDto, options = {}) {
const {request = null, store = null, global = false} = options;
const state = this.captureStoreState(store, global);
const error = {
error: {
code: errorDto.code ?? 'Unknown error',
message: errorDto.message ?? 'Unknown message',
trace: errorDto.trace ?? null,
},
request: request ? JSON.stringify(request) : null,
state: state ? JSON.stringify(state) : null,
timestamp: Date.now()
}
this.errors.push(error);
this.sendCache.push(error);
await this.transmitErrors();
},
async transmitErrors() {
const params = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(toRaw(this.sendCache))
};
const url = `${config.backendUrl}/error/`;
const response = await fetch(url, params
).catch(e => {
this.startAutoSubmitTimer();
});
if (response.ok) {
this.stopAutoSubmitTimer()
this.sendCache = [];
} else {
console.error("Error transmitting errors: " + url, params);
console.error(response, await response.text());
this.startAutoSubmitTimer();
}
},
startAutoSubmitTimer() {
if (this.autoSubmitTimer) return
this.autoSubmitTimer = setTimeout(() => {
this.transmitErrors()
}, this.autoSubmitInterval)
},
stopAutoSubmitTimer() {
if (this.autoSubmitTimer) {
clearTimeout(this.autoSubmitTimer)
this.autoSubmitTimer = null
}
},
captureStoreState(selectedStore = null, global = false) {
const storeState = {}
try {
if (selectedStore && selectedStore.$id) {
storeState[selectedStore.$id] = {
...toRaw(selectedStore.$state)
}
}
if (global) {
const pinia = this.$pinia || getActivePinia()
if (pinia && pinia._s) {
pinia._s.forEach((store, storeId) => {
if (storeId !== 'error' && store.$state) {
storeState[storeId] = {
...toRaw(store.$state)
}
}
})
}
}
} catch (err) {
console.warn('Failed to capture store state:', err);
return {};
}
return storeState;
},
async submitOnBeforeUnload() {
if (this.errors.length > 0) {
navigator.sendBeacon('/api/errors', JSON.stringify(
toRaw(this.sendCache)
))
}
}
},
});
// Global Error Handler Setup
export function setupErrorBuffer() {
const errorStore = useErrorStore()
// Unhandled Promise Rejections
window.addEventListener('unhandledrejection', (event) => {
const error = {
code: "Frontend error",
message: event.reason?.message || 'Unhandled Promise Rejection',
trace: event.reason?.stack,
};
errorStore.addError(error, {global: true}).then(r => {} );
})
// JavaScript Errors
window.addEventListener('error', (event) => {
const error = {
code: "Frontend error",
message: event.reason?.message || 'Unhandled Promise Rejection',
trace: event.reason?.stack,
};
errorStore.addError(error, {global: true}).then(r => {} );
})
window.addEventListener('beforeunload', () => {
errorStore.submitOnBeforeUnload();
})
}

View file

@ -1,5 +1,6 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
export const useMaterialStore = defineStore('material', {
state() {
@ -31,9 +32,18 @@ export const useMaterialStore = defineStore('material', {
const url = `${config.backendUrl}/materials/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
const response = await fetch(url).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
@ -44,13 +54,22 @@ export const useMaterialStore = defineStore('material', {
trace: null
}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.loading = false;
console.log(data);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
return;
}

View file

@ -1,5 +1,6 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
export const useNodeStore = defineStore('node', {
@ -35,10 +36,18 @@ export const useNodeStore = defineStore('node', {
params.append('include_user_node', this.query.includeUserNode);
const url = `${config.backendUrl}/nodes/search/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
console.log(url)
const response = await fetch(url).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
@ -49,13 +58,22 @@ export const useNodeStore = defineStore('node', {
trace: null
}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.loading = false;
console.log(data);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
return;
}

View file

@ -1,5 +1,6 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
export const usePremiseStore = defineStore('premise', {
state: () => ({
@ -15,6 +16,7 @@ export const usePremiseStore = defineStore('premise', {
showData: (state) => !state.loading && state.empty === false,
showLoading: (state) => state.loading,
showEmpty: (state) => !state.loading && state.empty === true,
selectedIds: state => state.premises?.filter(p => p.checked).map(p => p.id) ?? [],
},
actions: {
setQuery(query) {
@ -44,21 +46,39 @@ export const usePremiseStore = defineStore('premise', {
const that = this;
const url = `${config.backendUrl}/calculation/view/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
const response = await fetch(url).catch(e => {
that.error = { code: 'Network error.', message: "Please check your internet connection.", trace: null }
that.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
throw e;
});
const data = await response.json().catch(e => {
that.error = { code: 'Malformed response', message: "Malformed server response. Please contact support.", trace: null }
that.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
throw e;
});
if (!response.ok) {
that.error = { code: data.error.title, message: data.error.message, trace: data.error.details }
that.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
console.log(data);
return;
}

View file

@ -1,5 +1,7 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {toRaw} from "vue";
import {useErrorStore} from "@/store/error.js";
export const usePremiseEditStore = defineStore('premiseEdit', {
state() {
@ -10,13 +12,76 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
selectedLoading: false,
empty: false,
error: null,
selectedDestinations: [],
}
},
getters: {
selectedPremise: (state) => state.premisses.find(p => p.id = state.singleSelectId),
selectedPremise: (state) => state.premisses.find(p => p.id === state.singleSelectId),
singleSelectEmpty: (state) => state.selectedLoading === false && state.singleSelectId === null,
getDestinationById: (state) => (id) => state.premisses.find(p => p.id === state.singleSelectId)?.destinations.find(d => d.id === id),
getSelectedDestinations: (state) => state.selectedDestinations,
getSelectedDestinationsById: (state) => (id) => state.selectedDestinations?.find(d => d.id === id),
},
actions: {
/**
* Selects all destinations for the given ids for editing.
* This creates a copy of the destinations. They are written back as soon as the user closes the dialog.
*/
selectDestinations(ids) {
if (this.premisses === null) return;
const selected = [];
ids.forEach(id => {
const destination = this.getDestinationById(id);
if ((destination ?? null) == null) {
this.error = {
code: 'Frontend error.',
message: `Destination not found: ${id}. Please contact support.`,
trace: null
}
throw new Error("Internal frontend error: Destination not found: " + id);
}
const selectedDestination = structuredClone(toRaw(destination));
selectedDestination.userDefinedHandlingCosts = selectedDestination.handling_costs !== null || selectedDestination.disposal_costs !== null || selectedDestination.repackaging_costs !== null;
selected.push(selectedDestination);
});
this.selectedDestinations = selected;
},
deselectDestinations(save = false) {
if (this.premisses === null) return;
if (save) {
this.selectedDestinations.forEach(destination => {
const orig = this.getDestinationById(destination.id);
orig.annual_amount = destination.annual_amount;
orig.is_d2d = destination.is_d2d;
orig.rate_d2d = destination.is_d2d ? destination.rate_d2d : null;
if(destination.userDefinedHandlingCosts) {
orig.disposal_costs = destination.disposal_costs;
orig.repackaging_costs = destination.repackaging_costs;
orig.handling_costs = destination.handling_costs;
} else {
orig.disposal_costs = null;
orig.repackaging_costs = null;
orig.handling_costs = null;
}
destination.routes.forEach(route => {
const origRoute = orig.routes.find(r => r.id === route.id);
origRoute.is_selected = route.is_selected;
});
})
}
this.selectedDestinations = [];
},
async deleteDestination(id) {
if (this.premisses === null) return;
@ -45,6 +110,8 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const body = {destination_node_id: id, premise_id: toBeUpdated};
const url = `${config.backendUrl}/calculation/destination/`;
this.loading = true;
this.selectedLoading = true;
@ -61,6 +128,8 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
this.loading = false;
this.selectedLoading = false;
return destinations.map(d => d.id);
},
async setSupplier(id, updateMasterData) {
console.log("setSupplier");
@ -104,7 +173,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
async selectPremise(id) {
this.selectedLoading = true;
this.singleSelectId = null;
const toSelect = Array.isArray(id) ? id : [id];
const reload = this.premisses ? !toSelect.every((id) => this.premisses.find(d => d.id === id)) : true;
@ -118,6 +186,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
}
}
this.premisses.forEach(p => p.selected = toSelect.includes(p.id));
this.singleSelectId = Array.isArray(id) ? null : id;
this.selectedLoading = false;
@ -125,14 +194,13 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
}
,
async reloadPremisses(ids) {
const reload = this.premisses ? ids.every((id) => this.premisses.find(d => d.id === id)) : true;
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id)) : true;
if (reload) {
this.loading = true;
this.empty = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', ids.join(', '));
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
@ -146,6 +214,8 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
this.empty = data.length === 0;
this.loading = false;
}
},
async performRequest(method, url, body, expectResponse = true) {
@ -160,7 +230,8 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
params.body = JSON.stringify(body);
}
console.log("Request:", url, params);
const request = {url: url, params: params};
console.log("Request:",request);
const response = await fetch(url, params
).catch(e => {
@ -180,18 +251,31 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
message: "Malformed server response. Please contact support.",
trace: null
}
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
console.error(data);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, {store: this, request: request});
throw new Error('Internal backend error');
}
} else {
if (!response.ok) {
this.error = {code: "Return code error " + response.status, message: "Server returned wrong response code", trace: null}
console.error(data);
this.error = {
code: "Return code error " + response.status,
message: "Server returned wrong response code",
trace: null
}
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw new Error('Internal backend error');
}
}

View file

@ -0,0 +1,21 @@
package de.avatic.lcc.controller;
import de.avatic.lcc.dto.error.FrontendErrorDTO;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping({"/api/error", "/api/error/"})
public class ErrorController {
@PostMapping
public void error(@RequestBody List<FrontendErrorDTO> error) {
System.out.println(error.toString());
//TODO store in database.
}
}

View file

@ -0,0 +1,56 @@
package de.avatic.lcc.dto.error;
import java.util.Date;
public class FrontendErrorDTO {
private ErrorDTO error;
private String request;
private String state;
private Date timestamp;
public ErrorDTO getError() {
return error;
}
public void setError(ErrorDTO error) {
this.error = error;
}
public String getRequest() {
return request;
}
public void setRequest(String request) {
this.request = request;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public Date getTimestamp() {
return timestamp;
}
public void setTimestamp(Date timestamp) {
this.timestamp = timestamp;
}
@Override
public String toString() {
return "FrontendErrorDto{" +
"error=" + error +
", request='" + request + '\'' +
", state='" + state + '\'' +
", timestamp=" + timestamp +
'}';
}
}

View file

@ -0,0 +1,4 @@
package de.avatic.lcc.dto.error;
public class FrontendErrorItemDTO {
}

View file

@ -5,7 +5,6 @@ import de.avatic.lcc.dto.calculation.edit.destination.DestinationCreateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.model.nodes.Node;
import de.avatic.lcc.model.premises.route.*;
import de.avatic.lcc.model.properties.SystemPropertyMappingId;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.premise.*;
import de.avatic.lcc.repositories.properties.PropertyRepository;
@ -17,10 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -69,6 +65,10 @@ public class DestinationService {
}
}
if (premisesIdsToProcess.isEmpty())
return new HashMap<>();
var premisesToProcess = premiseRepository.getPremisesById(premisesIdsToProcess);
Node destinationNode = nodeRepository.getById(dto.getDestinationNodeId()).orElseThrow();