Merge pull request 'dev: Refactoring Massedit, Bugfixing, Dependency updates.' (#67) from dev into main

Reviewed-on: #67
This commit is contained in:
Jan Weber 2025-12-08 13:20:40 +00:00
commit 8f0986c7d8
92 changed files with 5713 additions and 1287 deletions

View file

@ -118,8 +118,9 @@ jobs:
echo "Pushing latest and main tags..."
docker push ${IMAGE_BASE}:latest
docker push ${IMAGE_BASE}:main
else
echo "Skipping latest/main tags - not on main branch (current: ${{ gitea.ref_name }})"
elif [ "${{ gitea.ref_name }}" = "dev" ]; then
echo "Pushing dev tag..."
docker push ${IMAGE_BASE}:dev
fi
- name: Create and push git tag

22
pom.xml
View file

@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<version>3.5.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>de.avatic</groupId>
@ -28,9 +28,9 @@
</scm>
<properties>
<java.version>23</java.version>
<spring-cloud-azure.version>5.23.0</spring-cloud-azure.version>
<mockito.version>5.18.0</mockito.version>
<flyway.version>11.1.0</flyway.version>
<spring-cloud-azure.version>5.24.0</spring-cloud-azure.version>
<mockito.version>5.20.0</mockito.version>
<flyway.version>11.18.0</flyway.version>
</properties>
<dependencies>
<dependency>
@ -108,27 +108,27 @@
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.5.0</version>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>org.dhatim</groupId>
<artifactId>fastexcel</artifactId>
<version>0.17.0</version>
<version>0.19.0</version>
</dependency>
<dependency>
<groupId>org.dhatim</groupId>
<artifactId>fastexcel-reader</artifactId>
<version>0.17.0</version>
<version>0.19.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.5.0</version>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>org.dhatim</groupId>
<artifactId>fastexcel</artifactId>
<version>0.18.4</version>
<version>0.19.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -208,12 +208,12 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>versions-maven-plugin</artifactId>
<version>2.18.0</version>
<version>2.20.1</version>
</plugin>
<plugin>
<groupId>org.jvnet.jaxb</groupId>
<artifactId>jaxb-maven-plugin</artifactId>
<version>4.0.11</version>
<version>4.0.12</version>
<executions>
<execution>
<goals>

View file

@ -4,7 +4,13 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LCC</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="MyWebSite" />
<link rel="manifest" href="/site.webmanifest" />
<title>Logistics Cost Calculation Tool</title>
</head>
<body>
<div id="app"></div>

View file

@ -14,7 +14,6 @@
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"@vueuse/core": "^13.6.0",
"azure-maps-control": "^3.6.1",
"chart.js": "^4.5.0",
"leaflet": "^1.9.4",
"loglevel": "^1.9.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1184)"><rect width="1000" height="1000" fill="#ffffff"></rect><g transform="matrix(2.028221249963782,0,0,2.028221249963782,198.37307681163614,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="297.43" height="345.13"><svg id="Ebene_1_Kopie" data-name="Ebene 1 Kopie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 297.43 345.13">
<defs>
<style>
.cls-1 {
fill: #5af0b4;
}
.cls-2 {
fill: #002f54;
}
</style>
<clipPath id="SvgjsClipPath1184"><rect width="1000" height="1000" x="0" y="0" rx="200" ry="200"></rect></clipPath></defs>
<polygon class="cls-1" points="201.84 201.83 201.84 257.02 201.85 257.02 249.64 229.43 249.64 229.42 297.43 201.83 297.43 257.02 249.65 284.61 249.64 284.61 201.84 312.21 154.05 339.8 154.05 174.24 201.84 146.64 249.64 119.05 297.43 91.46 297.43 146.64 249.64 174.23 249.64 174.24 201.84 201.83"></polygon>
<polygon class="cls-2" points="289.44 82.78 289.44 82.79 241.65 110.38 193.85 137.97 146.06 165.57 98.27 137.97 50.47 110.38 2.68 82.79 2.68 82.78 50.47 55.19 98.26 27.6 98.27 27.6 98.27 27.59 146.06 0 193.85 27.59 193.85 27.6 146.06 55.19 98.27 82.78 98.27 82.79 146.06 110.38 193.85 82.79 193.86 82.79 241.65 55.19 241.66 55.19 289.44 82.78"></polygon>
<polygon class="cls-2" points="143.38 289.94 143.38 345.13 95.59 317.54 47.79 289.94 0 262.35 0 96.79 47.79 124.38 47.79 234.76 95.58 262.35 95.59 262.35 143.38 289.94"></polygon>
</svg></svg></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -29,6 +29,14 @@ export default {
padding: 0;
}
html.modal-open {
background: #f8fafc;
}
.page-header {
font-weight: normal;
margin-bottom: 3rem;
@ -44,7 +52,7 @@ export default {
html {
font-size: 62.5%;
font-family: 'Poppins', sans-serif;
font-family: 'Arial', sans-serif;
}
body {

View file

@ -76,6 +76,7 @@ import Spinner from "@/components/UI/Spinner.vue";
import Flag from "@/components/UI/Flag.vue";
import {useDebounceFn} from "@vueuse/core";
import BasicBadge from "@/components/UI/BasicBadge.vue";
import logger from "@/logger.js";
export default {
name: 'AutosuggestSearchbar',
@ -182,7 +183,7 @@ export default {
this.highlightedIndex = -1
this.$emit('suggestions-loaded', this.suggestions)
} catch (error) {
console.error('Error fetching suggestions:', error)
logger.error('Error fetching suggestions:', error)
this.suggestions = []
this.hideSuggestions()
this.$emit('error', error)

View file

@ -23,6 +23,11 @@ export default{
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'grey', 'exception', 'skeleton'].includes(value)
},
size: {
type: String,
default: 'default',
validator: (value) => ['default', 'compact'].includes(value)
}
},
computed: {
@ -31,7 +36,8 @@ export default{
},
batchClasses() {
return [
`batch--${this.variant}`
`batch--${this.variant}`,
`batch--${this.size}`
]
},
iconComponent() {
@ -65,6 +71,12 @@ export default{
height: 2.4rem;
}
.batch-container.batch--compact {
padding: 0.2rem 0.4rem;
gap: 0.4rem;
height: 2rem;
}
.batch--primary {
background-color: #5AF0B4;
color: #002F54;

View file

@ -1,23 +1,24 @@
<template>
<div class="checkbox-container">
<label class="checkbox-item" :class="{ disabled: disabled }" @change="setFilter">
<label class="checkbox-item" :class="{ disabled: disabled }">
<input
@keydown.enter="$emit('enter', $event)"
type="checkbox"
:checked="isChecked"
:checked="internalChecked"
:disabled="disabled"
:indeterminate.prop="isIndeterminate"
v-model="isChecked"
:indeterminate.prop="internalIndeterminate"
@change="handleChange"
ref="checkboxInput"
>
<span class="checkmark" :class="{ indeterminate: isIndeterminate }"></span>
<span class="checkmark" :class="{ indeterminate: internalIndeterminate }"></span>
<span class="checkbox-label"><slot></slot></span>
</label>
</div>
</template>
<script>
export default{
emits:["checkbox-changed"],
export default {
emits: ["checkbox-changed", "enter"],
props: {
checked: {
type: Boolean,
@ -39,42 +40,49 @@ export default{
data() {
return {
internalChecked: this.checked,
internalIndeterminate: this.indeterminate,
}
},
computed: {
isChecked: {
get() {
return this.internalChecked;
},
set(value) {
if (this.disabled) return; // Prevent changes when disabled
this.internalChecked = value;
this.internalIndeterminate = false;
this.$emit('checkbox-changed', value);
}
},
isIndeterminate() {
return this.internalIndeterminate && !this.internalChecked;
internalIndeterminate: this.indeterminate && !this.checked,
}
},
watch: {
checked(newVal) {
this.internalChecked = newVal;
this.updateIndeterminateState(this.internalIndeterminate);
// Wenn checked true ist, dann indeterminate deaktivieren
if (newVal) {
this.internalIndeterminate = false;
this.updateIndeterminateState(false);
}
},
indeterminate(newVal) {
this.internalIndeterminate = newVal;
this.updateIndeterminateState(newVal);
// Indeterminate nur setzen, wenn checked false ist
this.internalIndeterminate = newVal && !this.internalChecked;
this.updateIndeterminateState(this.internalIndeterminate);
}
},
mounted() {
this.updateIndeterminateState(this.isIndeterminate);
// Beim Mount: checked hat Priorität über indeterminate
if (this.internalChecked) {
this.internalIndeterminate = false;
}
this.updateIndeterminateState(this.internalIndeterminate);
},
methods: {
setFilter(event) {
focus() {
this.$refs.checkboxInput?.focus();
},
handleChange(event) {
if (this.disabled) return;
this.isChecked = event.target.checked;
const newValue = event.target.checked;
const valueChanged = this.internalChecked !== newValue;
// Bei User-Interaktion: indeterminate zurücksetzen
this.internalIndeterminate = false;
this.internalChecked = newValue;
this.updateIndeterminateState(false);
if (valueChanged) {
this.$emit('checkbox-changed', newValue);
}
},
updateIndeterminateState(value) {
if (this.$refs.checkboxInput) {
@ -84,8 +92,8 @@ export default{
}
}
</script>
<style>
<style>
.checkbox-container {
display: flex;
gap: 2rem;

View file

@ -0,0 +1,127 @@
<template>
<div class="circle-badge" :class="circleClasses">
<component
:is="iconComponent"
weight="bold"
:size="iconSize"
class="circle-icon"
/>
</div>
</template>
<script>
export default{
name: "CircleBadge",
props:{
icon: {
type: String,
default: 'check'
},
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'grey', 'exception', 'skeleton', 'skeleton-grey'].includes(value)
},
size: {
type: String,
default: 'small',
validator: (value) => ['small', 'medium', 'large'].includes(value)
}
},
computed: {
circleClasses() {
return [
`circle-badge--${this.variant}`,
`circle-badge--${this.size}`
]
},
iconComponent() {
const iconName = this.icon.charAt(0).toUpperCase() + this.icon.slice(1);
return `Ph${iconName}`;
},
iconSize() {
const sizes = {
small: 12,
medium: 16,
large: 20
};
return sizes[this.size];
}
}
}
</script>
<style scoped>
.circle-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 0.2rem solid transparent;
transition: all 0.2s ease-in-out;
outline: none;
user-select: none;
flex-shrink: 0;
}
/* Größen */
.circle-badge--small {
width: 2.4rem;
height: 2.4rem;
}
.circle-badge--medium {
width: 3.2rem;
height: 3.2rem;
}
.circle-badge--large {
width: 4rem;
height: 4rem;
}
/* Varianten */
.circle-badge--primary {
background-color: #5AF0B4;
color: #002F54;
}
.circle-badge--secondary {
background-color: #c3cfdf;
color: #002F54;
}
.circle-badge--exception {
background-color: #BC2B72;
color: #ffffff;
}
.circle-badge--grey {
background-color: #DCDCDC;
color: #002F54;
}
.circle-badge--skeleton {
border: 0.1rem solid #002F54;
background-color: transparent;
color: #002F54;
}
.circle-badge--skeleton-grey {
border: 0.1rem solid #6b7280;
background-color: transparent;
color: #6b7280;
}
.circle-badge--skeleton-primary {
border: 0.1rem solid #5AF0B4;
color: #5AF0B4;
}
.circle-icon {
flex-shrink: 0;
}
</style>

View file

@ -4,7 +4,7 @@
ref="trigger"
class="dropdown-trigger"
:class="{ 'dropdown-trigger--open': isOpen}"
@click="toggleDropdown"
@click.stop="toggleDropdown"
@keydown="handleTriggerKeydown"
:disabled="disabled"
>
@ -143,6 +143,7 @@ export default {
return this.modelValue === option[this.valueKey]
},
handleClickOutside(event) {
if (!this.$refs.dropdown?.contains(event.target)) {
this.closeDropdown()
}

View file

@ -66,13 +66,10 @@ export default {
<style scoped>
.tree-container {
backdrop-filter: blur(10px);
overflow-y: auto;
/* Remove any fixed height constraints */
min-height: fit-content;
height: auto;
height: 100%; /* Nimmt die volle Höhe des Parent-Containers */
max-height: 100%; /* Verhindert Überlauf */
}
.tree-container::-webkit-scrollbar {

View file

@ -3,14 +3,19 @@
name="list-edit-transition"
tag="div"
class="list-edit-container"
>
<div v-if="show" class="list-edit">
<div class="icon-container"><ph-pencil-simple size="24" /><span class="number-circle">{{selectCount}}</span></div>
<div class="list-edit-button" @click="handleAction('material')">Material</div>
<div class="list-edit-button" @click="handleAction('price')">Price</div>
<div class="list-edit-button" @click="handleAction('packaging')">Packaging</div>
<div class="list-edit-button" @click="handleAction('destinations')">Destinations & Routes</div>
<div class="icon-container"><ph-selection size="24"/><span class="number-circle">{{selectCount}}</span></div>
<basic-button icon="package" @click="handleAction('material')">Material</basic-button>
<basic-button icon="tag" @click="handleAction('price')">Price</basic-button>
<basic-button icon="vectorThree" @click="handleAction('packaging')">Packaging</basic-button>
<basic-button icon="stack" @click="handleAction('amount')">Annual quantity</basic-button>
<basic-button icon="MapPin" @click="handleAction('routes')">Routes</basic-button>
<basic-button icon="X" @click="handleAction('deselect')">Cancel</basic-button>
</div>
</transition>
@ -19,11 +24,12 @@
<script>
import IconButton from "@/components/UI/IconButton.vue";
import {PhPencilSimple} from "@phosphor-icons/vue";
import {PhPencilSimple, PhSelection} from "@phosphor-icons/vue";
import BasicButton from "@/components/UI/BasicButton.vue";
export default{
name: "MassEditDialog",
components: {PhPencilSimple, IconButton},
components: {BasicButton, PhSelection, PhPencilSimple, IconButton},
emits: ['action'],
props: {
show: {
@ -70,7 +76,7 @@ export default{
display: flex;
justify-content: center;
align-items: center;
gap: 3.6rem;
gap: 1.2rem;
background-color: #5AF0B4;
border-radius: 0.8rem;
flex: 0 0 auto;

View file

@ -11,7 +11,12 @@
:style="modalAddStyle"
>
<div class="modal-container">
<box @click.stop class="modal-box">
<box
@click.stop
class="modal-box"
@mouseenter="onModalMouseEnter"
@mouseleave="onModalMouseLeave"
>
<slot></slot>
</box>
</div>
@ -70,6 +75,11 @@ export default {
}
}
},
data() {
return {
preventScroll: null
}
},
mounted() {
if (this.isVisible) {
this.handleOpen();
@ -83,6 +93,20 @@ export default {
this.$emit('close');
},
handleOpen() {
// Prevent scroll via event listener
this.preventScroll = (e) => {
// Allow scrolling when mouse is over modal
if (this.isMouseOverModal) {
return;
}
e.preventDefault();
};
// Prevent scroll, touch events and keyboard scrolling
document.addEventListener('wheel', this.preventScroll, { passive: false });
document.addEventListener('touchmove', this.preventScroll, { passive: false });
document.addEventListener('keydown', this.preventScrollKeys, { passive: false });
this.$nextTick(() => {
if (this.$refs.modalOverlay) {
this.$refs.modalOverlay.focus();
@ -90,6 +114,31 @@ export default {
});
},
handleClose() {
// Re-enable scrolling
if (this.preventScroll) {
document.removeEventListener('wheel', this.preventScroll);
document.removeEventListener('touchmove', this.preventScroll);
document.removeEventListener('keydown', this.preventScrollKeys);
}
this.isMouseOverModal = false;
},
preventScrollKeys(e) {
// Check if focus is inside modal
if (this.$refs.modalOverlay && this.$refs.modalOverlay.contains(document.activeElement)) {
return; // Allow keyboard scrolling inside modal
}
// Prevent scrolling via keyboard (arrow keys, space, page up/down)
const scrollKeys = [32, 33, 34, 35, 36, 37, 38, 39, 40];
if (scrollKeys.includes(e.keyCode)) {
e.preventDefault();
}
},
onModalMouseEnter() {
this.isMouseOverModal = true;
},
onModalMouseLeave() {
this.isMouseOverModal = false;
}
}
};
@ -100,8 +149,8 @@ export default {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw; /* Statt right: 0 */
height: 100vh; /* Statt bottom: 0 */
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);

View file

@ -0,0 +1,507 @@
<template>
<div class="route-dropdown" ref="dropdown">
<button
ref="trigger"
class="route-dropdown-trigger"
:class="{ 'route-dropdown-trigger--open': isOpen}"
@click="toggleDropdown"
@keydown="handleTriggerKeydown"
:disabled="disabled"
>
<span class="route-dropdown-trigger-content">
<component
v-if="selectedIcon"
:is="selectedIcon"
:size="16"
class="route-icon"
/>
<span class="route-dropdown-trigger-text">
{{ selectedDisplayText }}
</span>
</span>
<span class="route-dropdown-trigger-warning"><ph-warning-circle v-if="warnD2D" weight="fill"
size="16"></ph-warning-circle></span>
<svg
class="route-dropdown-trigger-icon"
:class="{ 'route-dropdown-trigger-icon--rotated': isOpen }"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
<transition name="route-dropdown-fade">
<ul
v-if="isOpen"
ref="menu"
class="route-dropdown-menu"
@keydown="handleMenuKeydown"
tabindex="-1"
>
<!-- D2D Routing Option -->
<li
class="route-dropdown-option"
:class="{
'route-dropdown-option--selected': isD2DSelected,
'route-dropdown-option--focused': focusedIndex === -1
}"
@click="selectD2D"
@mouseenter="focusedIndex = -1"
>
<ph-shipping-container :size="16" class="route-icon"/>
<span>D2D routing</span>
</li>
<!-- Divider -->
<li v-if="options.length > 0" class="route-dropdown-divider"></li>
<!-- Regular Route Options -->
<li
v-for="(option, index) in options"
:key="option[valueKey]"
class="route-dropdown-option"
:class="{
'route-dropdown-option--selected': isSelected(option),
'route-dropdown-option--focused': focusedIndex === index
}"
@click="selectOption(option, $event)"
@mouseenter="focusedIndex = index"
>
<component
:is="getIconForType(option.type)"
:size="16"
class="route-icon"
/>
<span>{{ option[displayKey] }}</span>
</li>
<li v-if="options.length === 0" class="route-dropdown-option route-dropdown-option--empty">
{{ emptyText }}
</li>
</ul>
</transition>
</div>
</template>
<script>
import {PhTruck, PhBoat, PhTrain, PhShippingContainer, PhWarningCircle} from '@phosphor-icons/vue'
export default {
name: 'RouteDropdown',
components: {
PhWarningCircle,
PhTruck,
PhBoat,
PhTrain,
PhShippingContainer
},
props: {
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number, Object],
default: null
},
placeholder: {
type: String,
default: 'Select a route'
},
displayKey: {
type: String,
default: 'routeDisplayString'
},
valueKey: {
type: String,
default: 'routeCompareString'
},
emptyText: {
type: String,
default: 'No routes available'
},
disabled: {
type: Boolean,
default: false
},
showD2dWarn: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'change'],
data() {
return {
isOpen: false,
focusedIndex: -1,
D2D_VALUE: 'D2D_ROUTING'
}
},
computed: {
warnD2D() {
return this.isD2DSelected && this.showD2dWarn;
},
selectedOption() {
if (!this.modelValue || this.modelValue === this.D2D_VALUE) return null
return this.options?.find(option =>
option[this.valueKey] === this.modelValue
) ?? null
},
isD2DSelected() {
return this.modelValue === this.D2D_VALUE
},
selectedDisplayText() {
if (this.isD2DSelected) return 'D2D routing'
if (this.selectedOption) return this.selectedOption[this.displayKey]
return this.placeholder
},
selectedIcon() {
if (this.isD2DSelected) return 'ph-shipping-container'
if (this.selectedOption) return this.getIconForType(this.selectedOption.type)
return null
}
},
watch: {
isOpen(newVal) {
if (newVal) {
// Warte einen Tick, damit das Menü gerendert ist
this.$nextTick(() => {
document.addEventListener('click', this.handleClickOutside, true)
})
} else {
document.removeEventListener('click', this.handleClickOutside, true)
}
}
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside, true)
},
methods: {
getIconForType(type) {
const iconMap = {
'ROAD': 'ph-truck',
'SEA': 'ph-boat',
'RAIL': 'ph-train'
}
return iconMap[type] || null
},
toggleDropdown(event) {
if (this.disabled) return
event.stopPropagation()
this.isOpen = !this.isOpen
if (this.isOpen) {
this.$nextTick(() => {
this.$refs.menu?.focus()
if (this.isD2DSelected) {
this.focusedIndex = -1
} else if (this.selectedOption) {
this.focusedIndex = this.options.findIndex(option =>
option[this.valueKey] === this.modelValue
)
} else {
this.focusedIndex = -1
}
})
} else {
this.focusedIndex = -1
}
},
selectD2D(event) {
event.stopPropagation()
this.$emit('update:modelValue', this.D2D_VALUE)
this.$emit('change', {type: 'D2D', value: this.D2D_VALUE})
this.closeDropdown()
this.$refs.trigger.focus()
},
selectOption(option, event) {
event.stopPropagation()
this.$emit('update:modelValue', option[this.valueKey])
this.$emit('change', option)
this.closeDropdown()
this.$refs.trigger.focus()
},
closeDropdown() {
this.isOpen = false
this.focusedIndex = -1
},
isSelected(option) {
return this.modelValue === option[this.valueKey]
},
handleClickOutside(event) {
if (!this.$refs.dropdown?.contains(event.target)) {
this.closeDropdown()
}
},
handleTriggerKeydown(event) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
if (!this.isOpen) {
this.toggleDropdown(event)
}
break
case 'ArrowUp':
event.preventDefault()
if (!this.isOpen) {
this.toggleDropdown(event)
}
break
case 'Escape':
if (this.isOpen) {
event.preventDefault()
this.closeDropdown()
}
break
}
},
handleMenuKeydown(event) {
const totalOptions = this.options.length
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (this.focusedIndex === -1) {
this.focusedIndex = totalOptions > 0 ? 0 : -1
} else {
this.focusedIndex = Math.min(this.focusedIndex + 1, totalOptions - 1)
}
break
case 'ArrowUp':
event.preventDefault()
if (this.focusedIndex === 0) {
this.focusedIndex = -1
} else if (this.focusedIndex === -1) {
this.focusedIndex = -1
} else {
this.focusedIndex = Math.max(this.focusedIndex - 1, 0)
}
break
case 'Enter':
event.preventDefault()
if (this.focusedIndex === -1) {
this.selectD2D(event)
} else if (this.focusedIndex >= 0 && this.options[this.focusedIndex]) {
this.selectOption(this.options[this.focusedIndex], event)
}
break
case 'Escape':
event.preventDefault()
this.closeDropdown()
this.$refs.trigger.focus()
break
case 'Tab':
this.closeDropdown()
break
}
}
}
}
</script>
<style scoped>
.route-dropdown {
position: relative;
display: flex;
flex: 1 0 auto;
}
.route-dropdown-trigger {
display: flex;
align-items: center;
justify-content: space-between;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
font: inherit;
cursor: pointer;
}
.route-dropdown-trigger:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.route-dropdown-trigger--open {
border: 0.2rem solid #8DB3FE;
background: #EEF4FF;
}
.route-dropdown-trigger-content {
display: flex;
align-items: center;
gap: 0.8rem;
flex: 1;
}
.route-dropdown-trigger-text {
color: #2d3748;
font: inherit;
letter-spacing: -0.05em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-dropdown-trigger-warning {
display: flex;
align-items: center;
color: #BC2B72;
}
.route-icon {
color: #6b7280;
flex-shrink: 0;
}
.route-dropdown-trigger-icon {
transition: transform 0.2s ease;
color: #718096;
flex-shrink: 0;
margin-left: 0.8rem;
}
.route-dropdown-trigger-icon--rotated {
transform: rotate(180deg);
}
.route-dropdown-menu {
font: inherit;
outline: none;
list-style: none;
color: #2d3748;
position: absolute;
top: 100%;
left: 0;
right: 0;
padding: 0;
background: white;
border: 0.1rem solid #E3EDFF;
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
z-index: 1000;
max-height: 50rem;
overflow-y: auto;
margin-top: 0.4rem;
}
.route-dropdown-option {
font: inherit;
padding: 1.2rem 1.6rem;
cursor: pointer;
border-bottom: 0.16rem solid #f3f4f6;
transition: background-color 0.2s ease;
color: #2d3748;
background: none;
display: flex;
align-items: center;
gap: 0.8rem;
letter-spacing: -0.05em;
}
.route-dropdown-option:hover,
.route-dropdown-option--focused {
background-color: rgba(107, 134, 156, 0.05);
}
.route-dropdown-option--selected {
color: #2d3748;
background: none;
}
.route-dropdown-option--selected:hover,
.route-dropdown-option--selected.route-dropdown-option--focused {
color: #2d3748;
background-color: rgba(107, 134, 156, 0.05);
}
.route-dropdown-option--empty {
color: #001D33;
cursor: default;
justify-content: center;
}
.route-dropdown-option--empty:hover {
background-color: transparent;
}
.route-dropdown-divider {
height: 0.1rem;
background-color: #e5e7eb;
border-bottom: none; /* Diese Zeile hinzufügen */
}
.route-dropdown-option:has(+ .route-dropdown-divider) {
border-bottom: none;
}
/* Transition animations */
.route-dropdown-fade-enter-active,
.route-dropdown-fade-leave-active {
transition: all 0.15s ease;
}
.route-dropdown-fade-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.route-dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* Disabled state */
.route-dropdown-trigger:disabled {
background: white;
cursor: not-allowed;
border: 0.2rem solid rgba(227, 237, 255, 0.5);
color: rgba(0, 47, 84, 0.3);
}
.route-dropdown-trigger:disabled:hover {
border: 0.2rem solid rgba(227, 237, 255, 0.5);
transform: none;
}
.route-dropdown-trigger:disabled .route-dropdown-trigger-text {
color: rgba(0, 47, 84, 0.5);
}
.route-dropdown-trigger:disabled .route-dropdown-trigger-icon,
.route-dropdown-trigger:disabled .route-icon {
color: rgba(113, 128, 150, 0.5);
}
/* Responsive adjustments */
@media (max-width: 640px) {
.route-dropdown-trigger {
padding: 10px 12px;
font-size: 14px;
}
.route-dropdown-option {
padding: 10px 12px;
}
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<button
@click="$emit('click')"
class="sort-button"
:class="{ 'active': active }"
>
<PhCaretUp weight="fill"
:size="16"
class="sort-icon"
:class="{ 'rotate': direction === 'asc' }"
/>
</button>
</template>
<script>
import {PhArrowCircleUp, PhCaretUp, PhFunnelSimple} from '@phosphor-icons/vue';
export default {
name: "SortButton",
components: {
PhCaretUp,
PhArrowCircleUp,
PhFunnelSimple
},
emits: ['click'],
props: {
active: {
type: Boolean,
default: false,
},
direction: {
type: String,
default: 'desc',
validator: (d) => (d === 'desc' || d === 'asc'),
}
}
}
</script>
<style scoped>
.sort-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
}
.sort-icon {
color: #6B869C;
transition: transform 0.3s ease, color 0.2s ease;
}
.sort-button.active .sort-icon {
color: #002F54;
}
.sort-icon.rotate {
transform: rotate(180deg);
}
</style>

View file

@ -138,17 +138,18 @@ export default {
}
.tab-content {
padding: 20px;
background-color: white;
flex: 1; /* Take remaining space */
min-height: 0; /* Allow shrinking */
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.tab-pane {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
opacity: 0;
transform: translateY(20px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);

View file

@ -1,149 +1,260 @@
<template>
<div class="bulk-edit-row">
<div class="edit-calculation-checkbox-cell">
<div class="bulk-edit-row" @wheel="handleWheel">
<div class="bulk-edit-row__checkbox">
<checkbox :checked="isSelected" @checkbox-changed="updateSelected"></checkbox>
</div>
<div class="edit-calculation-cell-container">
<div class="edit-calculation-cell copyable-cell" @click="action('material')">
<div class="edit-calculation-cell-line">{{ premise.material.part_number }}</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="premise.hs_code">
HS Code:
{{ premise.hs_code }}
<div class="bulk-edit-row__cell-container">
<div
class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable bulk-edit-row__cell--filterable edit-calculation-list-header-cell--copyable"
@click="action($event,'material')"
@mousedown="handleMouseDown">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__line">
<ph-package size="16"/>
{{ premise.material.part_number ?? 'N/A' }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline"
v-if="(premise.tariff_rate ?? null) !== null">
Tariff rate:
{{ toPercent(premise.tariff_rate) }}&nbsp;
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
HS:
{{ premise.hs_code ?? 'N/A' }}
</div>
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<ph-hand-coins size="16"/>
{{ toPercent(premise.tariff_rate) ?? 'N/A' }}
</div>
</div>
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="materialCheck && showMaterialCheck" :key="'check-' + id" variant="primary" icon="check"
class="badge--check"></circle-badge>
<circle-badge v-else-if="!materialCheck" :key="'error-' + id" variant="exception"
icon="exclamation-mark"></circle-badge>
</transition>
</div>
</div>
</div>
<div class="edit-calculation-cell-container">
<div class="edit-calculation-cell copyable-cell" @click="action('price')" v-if="showPrice">
<div class="edit-calculation-cell-line">{{ toFixed(premise.material_cost) }} EUR</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline">Oversea share:
{{ toPercent(premise.oversea_share) }} %
<div class="bulk-edit-row__cell-container">
<div
class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable edit-calculation-list-header-cell--copyable"
@click="action($event,'price')">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<ph-tag size="16"/>
{{ toFixed(premise.material_cost, '€') }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="premise.is_fca_enabled">
<basic-badge icon="plus" variant="primary">FCA FEE</basic-badge>
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<ph-chart-pie-slice size="16"/>
{{ toPercent(premise.oversea_share) }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="showPriceIncomplete">
<basic-badge variant="exception" icon="warning">INCOMPLETE</basic-badge>
<div class="bulk-edit-row__line bulk-edit-row__line--sub" v-if="premise.is_fca_enabled">
<basic-badge icon="plus" size="compact" variant="primary">FCA</basic-badge>
</div>
</div>
<div class="edit-calculation-empty copyable-cell" v-else @click="action('price')">
<basic-badge variant="exception" icon="warning">INCOMPLETE</basic-badge>
</div>
</div>
<div class="edit-calculation-cell-container">
<div v-if="showHu" class="edit-calculation-cell copyable-cell"
@click="action('packaging')">
<div class="edit-calculation-cell-line">
<PhVectorThree/>
{{ premise.handling_unit.length }} x
{{ premise.handling_unit.width }} x
{{ premise.handling_unit.height }} {{ premise.handling_unit.dimension_unit }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline">
<PhBarbell/>
<span>{{ premise.handling_unit.weight }} {{ premise.handling_unit.weight_unit }}</span>
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline">
<PhHash/>
{{ premise.handling_unit.content_unit_count }} pcs.
</div>
<div class="edit-calculation-packaging-badges">
<basic-badge v-if="premise.is_stackable" variant="primary" icon="stack">STACKABLE</basic-badge>
<basic-badge v-if="premise.is_mixable" variant="skeleton" icon="shuffle">MIXABLE</basic-badge>
</div>
</div>
<div class="edit-calculation-empty copyable-cell" v-else
@click="action('packaging')">
<basic-badge variant="exception" icon="warning">INCOMPLETE</basic-badge>
</div>
</div>
<div class="edit-calculation-cell-container">
<div class="edit-calculation-cell" v-if="premise.supplier">
<div class="calculation-list-supplier-data">
<div class="edit-calculation-cell-line">{{ premise.supplier.name }}</div>
<div class="edit-calculation-cell-subline"> {{ premise.supplier.address }}</div>
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="priceCheck && showPriceCheck" :key="'check-price-' + id" variant="primary"
icon="check" class="badge--check"></circle-badge>
<circle-badge v-else-if="!priceCheck" :key="'error-price-' + id" variant="exception"
icon="exclamation-mark"></circle-badge>
</transition>
</div>
</div>
</div>
<div class="edit-calculation-cell-container">
<div class="edit-calculation-cell copyable-cell" v-if="showDestinations"
@click="action('destinations')">
<div class="edit-calculation-cell-line">
<span class="number-circle"> {{ destinationsCount }} </span> Destinations
<div class="bulk-edit-row__cell-container">
<div
class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable edit-calculation-list-header-cell--copyable"
@click="action($event,'packaging')">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<PhVectorThree size="16"/>
{{ toDimension(premise.handling_unit) }}
</div>
<div class="edit-calculation-cell-subline" v-for="name in destinationNames"> {{ name }}</div>
<div class="edit-calculation-cell-subline" v-if="showDestinationIncomplete">
<basic-badge variant="exception" icon="warning">INCOMPLETE</basic-badge>
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<PhBarbell size="16"/>
<span>{{ toFixed(premise.handling_unit.weight, premise.handling_unit.weight_unit, 0) }}</span>
</div>
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<PhHash size="16"/>
{{ toFixed(premise.handling_unit.content_unit_count, 'pcs.', 0) }}
</div>
<div class="bulk-edit-row__badges">
<basic-badge v-if="premise.is_stackable" size="compact" variant="primary" icon="stack">STACKABLE
</basic-badge>
<basic-badge v-if="premise.is_mixable" size="compact" variant="secondary" icon="shuffle">MIXABLE
</basic-badge>
</div>
</div>
<div class="edit-calculation-empty" v-else-if="showMassEdit">
<spinner></spinner>
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="packagingCheck && showPackagingCheck" :key="'check-packaging-' + id" variant="primary"
icon="check" class="badge--check"></circle-badge>
<circle-badge v-else-if="!packagingCheck" :key="'error-packaging-' + id" variant="exception"
icon="exclamation-mark"></circle-badge>
</transition>
</div>
<div class="edit-calculation-empty copyable-cell" v-else
@click="action('destinations')">
<basic-badge variant="exception" icon="warning">INCOMPLETE</basic-badge>
</div>
</div>
<div class="bulk-edit-row__cell-container">
<div class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--filterable"
@click="action($event,'supplier')"
@mousedown="handleMouseDown">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__line bulk-edit-row__line--sub">
<ph-factory style="display: inline-block; vertical-align: middle;" size="16"/>
{{ premise.supplier.name }}
</div>
</div>
</div>
</div>
<div class="bulk-edit-row__cell-container">
<div
class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable bulk-edit-row__cell--destinations"
@click="action($event,'amount')">
<div class="bulk-edit-row__data bulk-edit-row__data--destinations">
<div class="bulk-edit-row__dest-line"
v-for="(destination, index) in destinations.slice(0, 3)"
:key="index">
<div>
<ph-stack size="16"/>
</div>
<div>{{ toFixed(destination.annual_amount, 'pcs.', 0) }}</div>
<div>
<basic-badge size="compact" variant="secondary">{{ toDestination(destination) }}</basic-badge>
</div>
</div>
<div class="bulk-edit-row__dest-line" v-if="destinations.length > 3">
<div></div>
<div> more ...</div>
<div></div>
</div>
</div>
<div class="edit-calculation-actions-cell">
<!-- Expanded destinations overlay -->
<div class="bulk-edit-row__destinations-expanded" v-if="destinations.length > 3">
<div class="bulk-edit-row__dest-line"
v-for="(destination, index) in destinations"
:key="index">
<div>
<ph-stack size="16"/>
</div>
<div>{{ toFixed(destination.annual_amount, 'pcs.', 0) }}</div>
<div>
<basic-badge size="compact" variant="secondary">{{ toDestination(destination) }}</basic-badge>
</div>
</div>
</div>
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="destinationCheck && showDestinationCheck" :key="'check-dest-' + id" variant="primary"
icon="check" class="badge--check"></circle-badge>
<circle-badge v-else-if="!destinationCheck" :key="'error-dest-' + id" variant="exception"
icon="exclamation-mark"></circle-badge>
</transition>
</div>
</div>
</div>
<div class="bulk-edit-row__cell-container">
<div
class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable bulk-edit-row__cell--destinations"
@click="action($event,'routes')">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__route-line"
v-for="(destination, index) in destinations.slice(0, 3)"
:key="index">
<div>
<component :is="toRouteIcon(destination)" size="16"></component>
</div>
<div>{{ toRoute(destination) }}</div>
<div>
<basic-badge size="compact" variant="secondary">{{ toDestination(destination) }}</basic-badge>
</div>
</div>
<div class="bulk-edit-row__route-line" v-if="destinations.length > 3">
<div></div>
<div> more ...</div>
<div></div>
</div>
</div>
<!-- Expanded destinations overlay -->
<div class="bulk-edit-row__destinations-expanded" v-if="destinations.length > 3">
<div class="bulk-edit-row__route-line"
v-for="(destination, index) in destinations"
:key="index">
<div>
<component :is="toRouteIcon(destination)" size="16"></component>
</div>
<div>{{ toRoute(destination) }}</div>
<div>
<basic-badge size="compact" variant="secondary">{{ toDestination(destination, 15) }}</basic-badge>
</div>
</div>
</div>
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="routeCheck && showRouteCheck" :key="'check-route-' + id" variant="primary"
icon="check" class="badge--check"></circle-badge>
<circle-badge v-else-if="!routeCheck" :key="'error-route-' + id" variant="exception"
icon="exclamation-mark"></circle-badge>
</transition>
</div>
</div>
</div>
<div class="bulk-edit-row__actions">
<icon-button icon="pencil-simple" help-text="Edit this calculation" help-text-position="left"
@click="editSingle"></icon-button>
<icon-button icon="x" help-text="Remove from mass edit" help-text-position="left" @click="remove"></icon-button>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Flag from "@/components/UI/Flag.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import BasicBadge from "@/components/UI/BasicBadge.vue";
import {
PhBarbell,
PhBarcode,
PhEmpty,
PhBarbell, PhBoat,
PhChartPieSlice,
PhFactory,
PhHandCoins,
PhHash,
PhMapPin,
PhPercent,
PhVectorThree,
PhVectorTwo
PhMapPinLine,
PhPackage, PhPath,
PhTag, PhTrain, PhTruck,
PhVectorThree
} from "@phosphor-icons/vue";
import {UrlSafeBase64} from "@/common.js";
import Spinner from "@/components/UI/Spinner.vue";
import CircleBadge from "@/components/UI/CircleBadge.vue";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
export default {
name: "BulkEditRow",
emits: ['remove', 'action'],
emits: ['remove', 'action', 'select'],
components: {
Spinner,
PhMapPin,
PhPath,
PhMapPinLine,
PhPackage,
PhTag,
PhChartPieSlice,
PhHandCoins,
CircleBadge,
PhFactory,
PhPercent,
PhBarcode, PhBarbell, PhHash, PhVectorThree, PhVectorTwo, PhEmpty, BasicBadge, Flag, IconButton, Checkbox
PhBarbell,
PhHash,
PhVectorThree,
BasicBadge,
IconButton,
Checkbox
},
props: {
id: {
@ -155,70 +266,245 @@ export default {
required: true,
}
},
computed: {
destinationsCount() {
return this.premise.destinations?.length ?? 0;
},
destinationsText() {
return this.premise.destinations.map(d => d.destination_node.name).join(', ');
},
destinationNames() {
const spliceCnt = ((this.premise.destinations.length === 4) ? 4 : 3) - (this.showDestinationIncomplete ? 1 : 0);
const names = this.premise.destinations.map(d => d.destination_node.name).slice(0, spliceCnt);
if (this.premise.destinations.length > names.length) {
names.push('and more ...');
data() {
return {
// Flags to show check badges only on transition
showMaterialCheck: false,
showPriceCheck: false,
showPackagingCheck: false,
showDestinationCheck: false,
showRouteCheck: false,
// Flag to track if component has been initialized
isInitialized: false,
// Store the initial state to prevent false triggers on mount
initialCheckStates: null,
}
return names;
},
showDestinationIncomplete() {
return this.premise.destinations.some(p => (
(((p.annual_amount ?? null) === null) || p.annual_amount === 0 ||
((p.routes?.every(r => !r.is_selected) && !p.is_d2d) ||
(p.is_d2d && ((p.rate_d2d ?? null) === null || p.rate_d2d === 0 || (p.lead_time_d2d ?? null) === null || p.lead_time_d2d === 0))
)
)));
computed: {
materialCheck() {
return (this.premise?.material.part_number != null && this.premise?.tariff_rate != null)
},
showDestinations() {
return (this.destinationsCount > 0);
},
showMassEdit() {
return this.premiseEditStore.showProcessingModal;
},
showHu() {
return (this.hu.width && this.hu.length && this.hu.height && this.hu.weight && this.hu.content_unit_count)
},
showPrice() {
return !(this.premise.material_cost === null) || (this.premise.material_cost === 0)
},
showPriceIncomplete() {
return (this.premise.oversea_share === null)
},
isSelected() {
return this.premise.selected;
priceCheck() {
return (this.premise?.material_cost != null && this.premise?.oversea_share != null);
},
hu() {
return this.premise.handling_unit;
},
...mapStores(usePremiseEditStore),
packagingCheck() {
return this.hu?.length != null
&& this.hu?.width != null
&& this.hu?.height != null
&& this.hu?.weight != null
&& this.hu?.content_unit_count != null;
},
destinationCheck() {
if (((this.destinations ?? null) === null) || this.destinations.length === 0)
return true;
return !this.destinations?.some(d => !d.annual_amount);
},
routeCheck() {
if (((this.destinations ?? null) === null) || this.destinations.length === 0)
return true;
return this.destinations?.every(d => (d.is_d2d && d.rate_d2d && d.lead_time_d2d) || (!d.is_d2d && d.routes?.some((route) => route.is_selected)));
},
isSelected() {
return this.premiseEditStore.isChecked(this.id);
},
destinations() {
return this.destinationEditStore.getByPremiseId(this.id) ?? [];
},
...mapStores(usePremiseEditStore, useDestinationEditStore),
},
watch: {
materialCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.material !== true) {
this.showMaterialCheck = true;
// Reset initial state after first valid transition
if (this.initialCheckStates) {
this.initialCheckStates.material = true;
}
}
},
priceCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.price !== true) {
this.showPriceCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.price = true;
}
}
},
packagingCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.packaging !== true) {
this.showPackagingCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.packaging = true;
}
}
},
destinationCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.destination === false) { // Hier war !== true, sollte === false sein
this.showDestinationCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.destination = true;
}
}
},
routeCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.route === false) { // Hier war !== true, sollte === false sein
this.showRouteCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.route = true;
}
}
},
},
mounted() {
// Capture initial states BEFORE setting isInitialized
// This prevents the watchers from triggering on mount
this.initialCheckStates = {
material: this.materialCheck,
price: this.priceCheck,
packaging: this.packagingCheck,
destination: this.destinationCheck,
route: this.routeCheck,
};
this.$nextTick(() => {
this.isInitialized = true;
});
},
methods: {
toFixed(value) {
return value !== null ? (value).toFixed(2) : '0.00';
toDestination(destination, limit = 15) {
return this.toNode(destination.destination_node, limit);
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
toDimension(handlingUnit) {
if (((handlingUnit ?? null) == null)
|| ((handlingUnit.length ?? null) == null)
|| ((handlingUnit.width ?? null) == null)
|| ((handlingUnit.height ?? null) == null)
) return 'N/A';
return `${this.toFixed(handlingUnit.length, null, 0)} x ${this.toFixed(handlingUnit.width, null, 0)} x ${this.toFixed(handlingUnit.height, null, 0)} ${handlingUnit.dimension_unit}`;
},
toRoute(destination, limit = 32) {
const route = destination?.routes?.find((route) => route.is_selected) ?? null;
if (destination.is_d2d)
return 'D2D Routing';
if (!route)
return 'N/A';
const nodes = route.transit_nodes?.map((node) => this.toNode(node)) ?? [];
if (nodes.length === 0)
return 'N/A';
const separator = " > ";
let fullString = nodes.join(separator);
if (fullString.length <= limit)
return fullString;
const front = nodes[0].concat(separator).concat("...").concat(separator);
let back = [];
for (const node of nodes.slice().reverse()) {
back.unshift(node);
const temp = front.concat(back.join(separator));
if (temp.length > limit) {
return front.concat(back.slice(1).join(separator));
}
}
},
toRouteIcon(destination) {
const route = destination?.routes?.find((route) => route.is_selected) ?? null;
if (destination.is_d2d)
return 'PhShippingContainer';
if (route?.type === 'SEA')
return 'PhBoat';
if (route?.type === 'RAIL')
return 'PhTrain';
if (route?.type === 'ROAD')
return 'PhTruck';
return 'PhEmpty';
},
toFixed(value, unit = null, decimals = 2) {
return value !== null ? `${(value).toFixed(decimals)} ${unit ?? ''}` : 'N/A';
},
toPercent(value) {
return value !== null ? (value * 100).toFixed(2) : '0.00';
},
updateSelected(value) {
this.premiseEditStore.setSelectTo([this.id], value);
return value !== null ? `${(value * 100).toFixed(2)} %` : 'N/A';
},
editSingle() {
const bulkQuery = this.$route.params.ids;
const urlStr = new UrlSafeBase64().encodeIds([this.id]);
this.$router.push({name: 'bulk-single-edit', params: {id: urlStr, ids: bulkQuery}});
},
action(action) {
handleMouseDown(event) {
if (event.shiftKey || event.ctrlKey) {
event.preventDefault();
}
},
handleWheel(event) {
if (event.ctrlKey) {
event.preventDefault();
window.scrollBy(0, event.deltaY);
}
},
action(event, action) {
if (event.ctrlKey && !event.shiftKey && (action === 'material' || action === 'supplier')) {
this.$emit('action', {id: this.id, action: action.concat('-filter')});
} else if (event.ctrlKey && event.shiftKey && (action === 'material' || action === 'supplier')) {
this.$emit('action', {id: this.id, action: action.concat('-append')});
} else if (action !== 'supplier') {
this.$emit('action', {id: this.id, action: action});
}
},
updateSelected(value) {
this.$emit("select", {id: this.id, checked: value});
},
remove() {
this.premiseEditStore.removePremise(this.id);
@ -229,127 +515,231 @@ export default {
</script>
<style scoped>
.edit-calculation-cell-container {
display: flex;
flex-direction: column;
align-self: stretch;
justify-self: stretch;
}
.edit-calculation-cell {
flex: 1 1 auto;
margin: 1.6rem 0;
padding: 0.8rem;
}
.edit-calculation-empty {
flex: 1 1 auto;
margin: 1.6rem 0;
padding: 0.8rem;
}
.copyable-cell {
border-radius: 0.8rem;
}
/* Standard hover ohne copy mode */
.copyable-cell:hover {
cursor: pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Main container */
.bulk-edit-row {
display: grid;
grid-template-columns: 6rem 1fr 1fr 1.5fr 1.5fr 1.5fr 10rem;
grid-template-columns: 6rem 0.8fr 0.7fr 1fr 1fr 1.2fr 2fr 10rem;
gap: 1.6rem;
padding: 0 2.4rem;
border-bottom: 0.16rem solid #f3f4f6;
align-items: center;
align-items: stretch;
transition: background-color 0.2s ease;
font-size: 1.2rem;
font-weight: 500;
height: 14rem;
overflow: hidden;
overflow: visible;
position: relative;
z-index: 1;
}
.bulk-edit-row:last-child {
border-bottom: none;
}
.edit-calculation-checkbox-cell {
/* Erhöhe z-index der gesamten Row beim Hover über destinations */
.bulk-edit-row:has(.bulk-edit-row__destinations-expanded:hover) {
z-index: 50;
}
/* Checkbox cell */
.bulk-edit-row__checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.edit-calculation-cell--price {
/* Cell container */
.bulk-edit-row__cell-container {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-self: stretch;
justify-content: center;
}
.edit-calculation-cell--supplier {
display: flex;
gap: 1.2rem;
height: 90%;
/* Cell */
.bulk-edit-row__cell {
flex: 1 1 auto;
margin: 1.6rem 0;
padding: 0.8rem;
border-radius: 0.8rem;
}
.edit-calculation-cell--supplier-container {
.bulk-edit-row__cell--status {
display: flex;
flex-direction: row;
gap: 0.8rem;
}
.edit-calculation-cell--supplier-flag {
display: flex;
align-items: center;
.bulk-edit-row__cell--clickable:hover {
cursor: pointer;
background-color: #f8fafc;
}
.edit-calculation-cell--packaging, .edit-calculation-cell--material, .edit-calculation-cell--destination {
.bulk-edit-row__cell--destinations {
position: relative;
}
/* Cell data */
.bulk-edit-row__data {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1;
}
.edit-calculation-cell-line {
.bulk-edit-row__data--destinations {
position: relative;
z-index: 1;
}
/* Cell status */
.bulk-edit-row__status {
display: flex;
align-items: center;
flex: 0 0 auto;
}
/* Badge Transition Animation */
.badge-transition-enter-active {
animation: badge-enter 0.3s ease-in both;
}
.badge-transition-leave-active {
animation: badge-leave 0.3s ease-in;
}
/* Check badge fade-out Animation - wird NACH der Enter-Animation ausgeführt */
.badge--check {
animation: badge-enter 0.3s ease-out 0.1s both,
badge-zoom-fade-out 0.6s ease-out both;
}
@keyframes badge-enter {
0% {
transform: scale(1);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes badge-leave {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0;
}
}
@keyframes badge-zoom-fade-out {
0% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(3);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
/* Lines */
.bulk-edit-row__line {
display: flex;
align-items: flex-start;
gap: 0.8rem;
}
.edit-calculation-cell-subline {
.bulk-edit-row__line--sub {
font-size: 1.2rem;
font-weight: 400;
color: #6b7280;
}
.edit-calculation-packaging-badges {
/* Destination lines */
.bulk-edit-row__dest-line {
display: grid;
grid-template-columns: auto 1fr 2fr;
gap: 0.4rem;
font-size: 1.2rem;
font-weight: 400;
color: #6b7280;
}
/* Destination lines */
.bulk-edit-row__route-line {
display: grid;
grid-template-columns: auto 2fr 1fr;
gap: 0.4rem;
font-size: 1.2rem;
font-weight: 400;
color: #6b7280;
}
/* Badges */
.bulk-edit-row__badges {
display: flex;
gap: 0.8rem;
}
.bulk-edit-row__warning-badge {
position: absolute;
top: 0.8rem;
right: 0.8rem;
z-index: 1;
}
/* Expanded destinations overlay */
.bulk-edit-row__destinations-expanded {
position: absolute;
top: 0;
left: 0;
right: 0;
background: white;
border: 0.1rem solid #E3EDFF;
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem rgba(0, 0, 0, 0.1);
padding: 0.8rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0;
transform: translateY(-0.4rem);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
max-height: 0;
overflow: hidden;
}
.edit-calculation-actions-cell {
.bulk-edit-row__cell--destinations:hover .bulk-edit-row__destinations-expanded {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
max-height: 50rem;
}
.bulk-edit-row__cell--destinations:hover .bulk-edit-row__data--destinations {
opacity: 0;
}
.bulk-edit-row__cell--destinations:not(:has(.bulk-edit-row__destinations-expanded)):hover .bulk-edit-row__data--destinations {
opacity: 1;
}
/* Actions */
.bulk-edit-row__actions {
display: flex;
align-items: center;
gap: 1.6rem;
}
.number-circle {
display: inline-block;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background-color: #002F54;
color: white;
text-align: center;
line-height: 1.6rem;
font-size: 1.2rem;
}
</style>

View file

@ -11,9 +11,10 @@
</div>
<div class="bulk-operation-status">
<div v-if="operation.state === 'EXCEPTION'">
<tooltip min-width="500px" :text="shortend(operation.error.message)" position="left">
<tooltip v-if="operation.error" min-width="500px" :text="shortend(operation.error?.message)" position="left">
<basic-badge variant="exception">ERROR</basic-badge>
</tooltip>
<basic-badge v-else variant="exception">ERROR</basic-badge>
</div>
<basic-badge v-else-if="operation.state === 'COMPLETED'">COMPLETED</basic-badge>
<basic-badge v-else-if="operation.state === 'SCHEDULED'" variant="skeleton">SCHEDULED</basic-badge>

View file

@ -181,8 +181,9 @@ export default {
.calculation-list-supplier-data {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.4rem;
height: 90%
}
.supplier-name {

View file

@ -1,5 +1,5 @@
<template>
<div class="apps-container">
<div class="app-list-header">
<div>App</div>
@ -15,6 +15,8 @@
<add-app @close="closeModal"></add-app>
</modal>
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
</div>
</template>
@ -65,6 +67,10 @@ export default {
<style scoped>
.apps-container {
padding: 2.4rem;
}
.app-list-header {
display: grid;
grid-template-columns: 1fr 2fr 0.5fr;

View file

@ -295,6 +295,7 @@ export default {
}
.bulk-operations-container {
margin: 2.4rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;

View file

@ -0,0 +1,148 @@
<template>
<div class="dump-container">
<table-view :columns="columns" :data-source="fetch" :page-size="pageSize" :page="pagination.page"
:page-count="pagination.pageCount" :total-count="pagination.totalCount" @row-click="showDump"
:mouse-over="true"></table-view>
<modal :state="showModal">
<div class="single-dump-container">
<div class="button-container">
<basic-button @click="showModal = false" :show-icon="false" variant="secondary">Close</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="expand = !expand"> {{ expand === true ? 'Collapse all items' : 'Expand all items' }}</basic-button>
</div>
<json-tree-viewer :data="dump" :all-expanded="expand"></json-tree-viewer>
</div>
</modal>
</div>
</template>
<script>
import TableView from "@/components/UI/TableView.vue";
import performRequest from "@/backend.js";
import {config} from "@/config.js";
import Modal from "@/components/UI/Modal.vue";
import JsonTreeViewer from "@/components/UI/JsonTreeViewer.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
export default {
name: "CalculationDumps",
components: {BasicButton, JsonTreeViewer, Modal, TableView},
methods: {
showDump(dump) {
this.dump = dump;
this.showModal = true;
},
async fetch(query) {
const params = new URLSearchParams();
if (query?.searchTerm && query.searchTerm !== '')
params.append('filter', query.searchTerm);
if (query?.periodId)
params.append('valid', query.periodId);
if (query?.page)
params.append('page', query.page);
if (query?.pageSize)
params.append('limit', query.pageSize);
const resp = await performRequest(null, "GET", `${config.backendUrl}/dumps/dump/${params.size === 0 ? '' : '?'}${params.toString()}`, null);
this.dump = resp.data;
this.pagination = {
page: parseInt(resp.headers.get('X-Current-Page')),
pageCount: parseInt(resp.headers.get('X-Page-Count')),
totalCount: parseInt(resp.headers.get('X-Total-Count'))
};
return this.dump;
}
},
data() {
return {
showModal: false,
dump: null,
expand: false,
pageSize: 20,
pagination: {page: 1, pageCount: 1, totalCount: 1},
columns: [
{
key: 'id',
label: 'ID',
},
{
key: 'calculation_date',
label: 'Calculation date',
},
{
key: 'job_state',
label: 'State',
badgeResolver: (value) => {
if (value === 'VALID')
return [{text: value, variant: "primary"}];
if (value === 'EXCEPTION')
return [{text: value, variant: "exception"}]
return [{text: value, variant: "secondary"}]
}
},
{
key: 'user_id',
label: 'User ID',
},
{
key: 'premise.material.part_number',
label: 'Material',
},
{
key: 'premise.supplier.name',
label: 'Supplier',
},
{
key: 'premise.destinations',
label: 'Destinations',
formatter: (value) => {
return value.map(v => v.destination_node.name).join(', ');
}
},
],
}
}
}
</script>
<style scoped>
.button-container {
display: flex;
gap: 1rem;
}
.dump-container {
padding: 2.4rem;
}
.single-dump-container {
height: 80vh;
width: 80vw;
display: flex;
flex-direction: column;
overflow: hidden;
}
.single-dump-container > :first-child {
flex-shrink: 0; /* Button behält seine Größe */
}
.single-dump-container > :last-child {
flex: 1;
min-height: 0; /* Wichtig für Flex-Children mit overflow */
overflow-y: auto;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="materials-container">
<table-view ref="tableViewRef" :data-source="fetch" :columns="materialColumns" :page="pagination.page"
:page-size="pageSize" :page-count="pagination.pageCount"
:total-count="pagination.totalCount"></table-view>
@ -74,5 +74,8 @@ export default {
<style scoped>
.materials-container {
padding: 2.4rem;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="nodes-container">
<table-view ref="tableViewRef" :data-source="fetch" :columns="nodeColumns" :page="pagination.page"
:page-size="pageSize" :page-count="pagination.pageCount"
:total-count="pagination.totalCount"></table-view>
@ -91,4 +91,8 @@ export default {
<style scoped>
.nodes-container {
padding: 2.4rem;
}
</style>

View file

@ -166,6 +166,9 @@ export default {
width: fit-content;
}
.properties-container {
padding: 2.4rem;
}
.property-item-enter-from {
opacity: 0;

View file

@ -236,6 +236,7 @@ export default {
.container-rate-container {
display: flex;
flex-direction: column;
padding: 1.6rem;
}
.container-rate-header {

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="users-container">
<div class="user-list">
<table-view ref="tableViewRef" :searchbar="false" :columns="columns" :data-source="fetch" @row-click="selectUser"
:mouse-over="true"></table-view>
@ -119,9 +119,9 @@ export default {
badgeResolver: (value) => {
const formattedValues = []
value.slice(0,5).forEach(v => formattedValues.push({text: v, variant: "secondary"}));
value.slice(0, 5).forEach(v => formattedValues.push({text: v, variant: "secondary"}));
if(value.length > 5)
if (value.length > 5)
formattedValues.push({text: "...", variant: "secondary"});
return formattedValues;
@ -141,6 +141,11 @@ export default {
<style scoped>
.users-container {
padding: 2.4rem;
}
.user-list {
margin-bottom: 2.4rem;
}

View file

@ -35,7 +35,7 @@ export default {
components: {Box, BasicButton, ToggleSwitch, JsonTreeViewer},
async created() {
const resp = await performRequest(null, "GET", `${config.backendUrl}/dev/dump/${this.$route.params.id}`, null);
const resp = await performRequest(null, "GET", `${config.backendUrl}/dumps/dump/${this.$route.params.id}`, null);
this.dump = resp.data;
},

View file

@ -36,7 +36,7 @@ export default {
if(query?.pageSize)
params.append('limit', query.pageSize);
const resp = await performRequest(null, "GET", `${config.backendUrl}/dev/dump/${params.size === 0 ? '' : '?'}${params.toString()}`, null);
const resp = await performRequest(null, "GET", `${config.backendUrl}/dumps/dump/${params.size === 0 ? '' : '?'}${params.toString()}`, null);
this.dump = resp.data;
this.pagination = { page: parseInt(resp.headers.get('X-Current-Page')), pageCount: parseInt(resp.headers.get('X-Page-Count')), totalCount: parseInt(resp.headers.get('X-Total-Count'))};
@ -92,4 +92,5 @@ export default {
<style scoped>
</style>

View file

@ -98,8 +98,6 @@ export default {
},
editDestination(id) {
logger.log(id);
if (id) {
const destination = this.premiseSingleEditStore.getDestinationById(id);
logger.log(destination);

View file

@ -2,7 +2,7 @@
<div class="outer-container">
<div class="container" :class="{ 'responsive': responsive }" @focusout="focusLost">
<!-- Wiederholende Felder als Array definieren und mit v-for rendern -->
<template v-for="field in displayFields" :key="field.name">
<div class="caption-column">{{ field.label }}</div>
<div class="input-column">
@ -15,6 +15,7 @@
@blur="field.onBlur"
class="input-field"
autocomplete="off"
:placeholder="fromMassEdit ? '<keep>' : ''"
/>
</div>
</div>
@ -28,7 +29,7 @@
<div>
<div>Automatic tariff rate determination was ambiguous</div>
<div class="tariff-rate-info-text">
Please contact a customs expert to obtain correct HS code and tariff rate.
Please correct tariff rate or continue with default value.
</div>
</div>
</div>
@ -92,6 +93,10 @@ export default {
responsive: {
type: Boolean,
default: true,
},
fromMassEdit: {
type: Boolean,
default: false,
}
},
computed: {

View file

@ -6,6 +6,7 @@
<div class="text-container">
<input ref="lengthInput" :value="huLength" @blur="validateDimension('length', $event)"
@keydown.enter="handleEnter('lengthInput', $event)" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -20,6 +21,7 @@
<div class="text-container">
<input ref="widthInput" :value="huWidth" @blur="validateDimension('width', $event)"
@keydown.enter="handleEnter('widthInput', $event)" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -32,6 +34,7 @@
<div class="text-container">
<input ref="heightInput" :value="huHeight" @blur="validateDimension('height', $event)"
@keydown.enter="handleEnter('heightInput', $event)" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -44,6 +47,7 @@
<div class="text-container">
<input ref="weightInput" :value="huWeight" @blur="validateWeight('weight', $event)"
@keydown.enter="handleEnter('weightInput', $event)" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -57,6 +61,7 @@
<div class="text-container">
<input ref="unitCountInput" :value="huUnitCount" @blur="validateCount"
@keydown.enter="handleEnter('unitCountInput', $event)" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -84,7 +89,7 @@ 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', 'save'],
emits: ['update:stackable', 'update:mixable', 'update:length', 'update:width', 'update:height', 'update:weight', 'update:unitCount', 'update:weightUnit', 'update:dimensionUnit', 'save', 'accept'],
props: {
length: {
required: true,
@ -125,6 +130,10 @@ export default {
responsive: {
type: Boolean,
default: true,
},
fromMassEdit: {
type: Boolean,
default: false,
}
},
computed: {
@ -204,6 +213,13 @@ export default {
const inputOrder = ['lengthInput', 'widthInput', 'heightInput', 'weightInput', 'unitCountInput'];
const currentIndex = inputOrder.indexOf(currentRef);
if(currentIndex >= inputOrder.length - 1) {
this.validateCount(event);
this.$emit('accept');
return;
}
if (currentIndex !== -1 && currentIndex < inputOrder.length - 1) {
const nextRef = inputOrder[currentIndex + 1];
this.$nextTick(() => {

View file

@ -5,7 +5,8 @@
<div class="caption-column">MEK_A [EUR]</div>
<div class="input-column">
<div class="text-container">
<input :value="priceFormatted" @blur="validatePrice" class="input-field"
<input ref="priceInput" @keydown.enter="handleEnter('priceInput', $event)" :value="priceFormatted" @blur="validatePrice" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -15,7 +16,8 @@
<div class="caption-column">Oversea share [%]</div>
<div class="input-column">
<div class="text-container">
<input :value="overSeaSharePercent" @blur="validateOverSeaShare" class="input-field"
<input ref="overseaShareInput" @keydown.enter="handleEnter('overseaShareInput', $event)" :value="overSeaSharePercent" @blur="validateOverSeaShare" class="input-field"
:placeholder="fromMassEdit ? '<keep>' : ''"
autocomplete="off"/>
</div>
</div>
@ -25,7 +27,7 @@
<div class="caption-column">Include FCA Fee</div>
<div class="input-column">
<tooltip text="Select if a additional FCA has to be added during calculation">
<checkbox :checked="includeFcaFee" @checkbox-changed="updateIncludeFcaFee"></checkbox>
<checkbox ref="fcaInput" @enter="handleEnter('fcaInput', $event)" @keydown.enter="handleEnter('fcaInput', $event)" :checked="includeFcaFee" @checkbox-changed="updateIncludeFcaFee"></checkbox>
</tooltip>
</div>
</div>
@ -62,6 +64,10 @@ export default {
responsive: {
type: Boolean,
default: true,
},
fromMassEdit: {
type: Boolean,
default: false,
}
},
computed: {
@ -73,6 +79,29 @@ export default {
}
},
methods: {
handleEnter(currentRef, event) {
event.preventDefault();
// Define the navigation order
const inputOrder = ['priceInput', 'overseaShareInput', 'fcaInput'];
const currentIndex = inputOrder.indexOf(currentRef);
if(currentIndex >= inputOrder.length - 1) {
this.$emit('accept');
return;
}
if (currentIndex !== -1 && currentIndex < inputOrder.length - 1) {
const nextRef = inputOrder[currentIndex + 1];
this.$nextTick(() => {
if (this.$refs[nextRef]) {
this.$refs[nextRef].focus();
// this.$refs[nextRef].select();
}
});
}
},
focusLost(event) {
if (!this.$el.contains(event.relatedTarget)) {
this.$emit('save', 'price');

View file

@ -118,6 +118,7 @@ export default {
flex-direction: column;
gap: 1.6rem;
align-items: flex-start;
margin: 1.6rem;
}
.destination-edit-handling-cost-info {

View file

@ -164,7 +164,7 @@ export default {
},
rateD2d: {
get() {
return this.destination.rate_d2d?.toFixed(2) ?? '0.00';
return this.destination.rate_d2d?.toFixed(2) ?? null;
},
set(value) {
this.destination && (this.destination.rate_d2d = value);
@ -172,7 +172,7 @@ export default {
},
leadtimeD2d: {
get() {
return this.destination.lead_time_d2d?.toFixed() ?? '0';
return this.destination.lead_time_d2d === 0 ? null : (this.destination.lead_time_d2d?.toFixed() ?? null);
},
set(value) {
this.destination && (this.destination.lead_time_d2d = value);
@ -197,6 +197,7 @@ export default {
flex-direction: column;
height: 100%;
min-height: 0; /* Important for flexbox shrinking */
margin: 1.6rem;
}
.destination-edit-route-warning {

View file

@ -61,7 +61,7 @@ export default {
return this.route.is_fastest;
},
routeElements() {
const routeElem = this.route.transit_nodes.map(n => n.external_mapping_id);
const routeElem = this.route.transit_nodes.map(n => n.external_mapping_id.replace("_", " "));
return routeElem;
},
isSea() {

View file

@ -0,0 +1,224 @@
<template>
<div class="dest-mass-create-container">
<autosuggest-searchbar @selected="selectedNode" placeholder="Search and add destination ..."
no-results-text='No destination found for "{query}".' :fetch-suggestions="fetch"
variant="flags" :reset-on-select="true"
:flag-resolver="resolveFlag" title-resolver="name"
subtitle-resolver="address"></autosuggest-searchbar>
<div class="dest-mass-create-table-wrapper">
<div class="dest-mass-create-table-header">
<div class="dest-mass-create-table-header-material">Material</div>
<div class="dest-mass-create-table-header-supplier">Supplier</div>
<div class="dest-mass-create-table-header-dest"
:key="`${dest.id}-${dest.overallCheck}-${dest.overallIndeterminate}`"
v-for="dest in destPool">
<checkbox :checked="dest.overallCheck" :indeterminate="dest.overallIndeterminate"
@checkbox-changed="setOverallCheck($event, dest.id)"></checkbox>
{{ toNode(dest, 6) }}
</div>
</div>
<div class="dest-mass-create-table">
<dest-mass-create-row @update-selected="updateCheck" :row="row" :key="row.id"
v-for="row in destMatrix"></dest-mass-create-row>
</div>
</div>
</div>
</template>
<script>
import AutosuggestSearchbar from "@/components/UI/AutoSuggestSearchBar.vue";
import {mapStores} from "pinia";
import {useNodeStore} from "@/store/node.js";
import DestMassCreateRow from "@/components/layout/edit/destination/mass/DestMassCreateRow.vue";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import Checkbox from "@/components/UI/Checkbox.vue";
export default {
name: "DestinationMassCreate",
components: {Checkbox, DestMassCreateRow, AutosuggestSearchbar},
computed: {
...mapStores(useNodeStore, useDestinationEditStore, usePremiseEditStore),
premises() {
return this.premiseEditStore.getPremisses;
},
},
created() {
this.buildMatrix();
},
data() {
return {
destPool: [],
destMatrix: null
};
},
methods: {
getDestinationChanges() {
return this.destMatrix;
},
buildMatrix() {
this.destPool = [];
const destIds = new Set();
this.premises.forEach(p => {
this.destinationEditStore.getByPremiseId(p.id)?.forEach(d => {
const destId = d.destination_node.id;
if (!destIds.has(destId)) {
destIds.add(destId);
this.destPool.push({...d.destination_node, overallCheck: false, overallIndeterminate: false});
}
});
});
// Build matrix
this.destMatrix = this.premises
.filter(p => p)
.map(premise => {
const existingDestIds = new Set(
this.destinationEditStore.getByPremiseId(premise.id)
?.map(d => d.destination_node.id) ?? []
);
return {
id: premise.id,
material: premise.material.part_number,
supplier: this.toNode(premise.supplier, 30),
destinations: this.destPool.map(dest => ({
...dest,
selected: existingDestIds.has(dest.id)
}))
};
});
// set overall checkboxes
this.destPool.forEach(dest => {
const selectedCount = this.destMatrix.filter(r =>
r.destinations.some(d => d.id === dest.id && d.selected)
).length;
const totalCount = this.destMatrix.length;
dest.overallCheck = selectedCount === totalCount;
dest.overallIndeterminate = selectedCount > 0 && selectedCount < totalCount;
});
},
updateCheck(data) {
const dest = this.destPool.find(d => d.id === data.dest);
const selectedCount = this.destMatrix.filter(r =>
r.destinations.some(d => d.id === data.dest && d.selected)
).length;
const totalCount = this.destMatrix.length;
dest.overallCheck = selectedCount === totalCount;
dest.overallIndeterminate = selectedCount > 0 && !dest.overallCheck;
this.$forceUpdate();
},
selectedNode(destination) {
if (destination && !this.destPool.find(d => d.id === destination.id)) {
this.destPool.push({
...destination,
overallCheck: false,
overallIndeterminate: false
});
this.destMatrix.forEach(p => p.destinations.push({...destination, selected: false}));
}
},
setOverallCheck(newValue, id) {
const header = this.destPool.find(d => d.id === id);
header.overallCheck = newValue;
header.overallIndeterminate = false;
this.destMatrix.forEach(r => r.destinations.find(d => d.id === id).selected = newValue);
this.$forceUpdate();
},
async fetch(query) {
const supplierQuery = {searchTerm: query, includeUserNode: true, nodeType: "DESTINATION"};
await this.nodeStore.setSearch(supplierQuery);
return this.nodeStore.nodes;
},
resolveFlag(node) {
return node.country.iso_code;
},
toNode(node, limit = 15) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
}
}
</script>
<style scoped>
.dest-mass-create-container {
min-width: 100rem;
width: 90vw;
display: flex;
flex-direction: column;
max-height: 80vh; /* Begrenzt die Gesamthöhe */
}
.dest-mass-create-table-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0; /* Wichtig für Firefox */
}
.dest-mass-create-table-header {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 2.4rem;
justify-content: flex-start;
background-color: #ffffff;
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
flex-shrink: 0; /* Header bleibt fixiert */
}
.dest-mass-create-table {
overflow-y: auto;
margin: 0;
padding-bottom: 2.4rem;
}
.dest-mass-create-table-header-material {
width: 14rem;
}
.dest-mass-create-table-header-supplier {
width: 24rem;
}
.dest-mass-create-table-header-dest {
display: flex;
justify-content: flex-start;
width: 15rem;
}
</style>

View file

@ -0,0 +1,110 @@
<template>
<div class="dest-mass-create-row-container">
<div class="dest-mass-create-row-material"><ph-package size="24"/> {{ row.material }}</div>
<div class="dest-mass-create-row-supplier">
<ph-factory size="24"/>{{ row.supplier }}
</div>
<div v-for="dest in row.destinations" class="dest-mass-create-row-dest" :key="dest.id">
<checkbox :checked="dest.selected" @checkbox-changed="updateCheckbox($event, dest.id)">
</checkbox>
<!-- <basic-badge variant="secondary">{{ toNode(dest, 15) }}</basic-badge>-->
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import {PhFactory, PhPackage} from "@phosphor-icons/vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
import {mapStores} from "pinia";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
export default {
name: "DestMassCreateRow",
components: {PhFactory, BasicBadge, PhPackage, Checkbox},
emits: ['update-selected'],
props: {
row: {
type: Object,
required: true
},
},
computed: {
...mapStores(useDestinationEditStore)
},
methods: {
updateCheckbox(value, destId) {
this.row.destinations.find(d => d.id === destId).selected = value;
this.$emit('update-selected',{id: this.row.id, dest: destId, selected: value});
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
}
}
</script>
<style scoped>
.dest-mass-create-row-container {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.6rem 2.4rem;
justify-content: flex-start;
border-bottom: 0.16rem solid #f3f4f6;
transition: background-color 0.2s ease;
}
.dest-mass-create-row-container:hover {
background-color: rgba(107, 134, 156, 0.05);
}
.dest-mass-create-row-material {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 14rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-create-row-supplier {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 24rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-create-row-dest {
width: 15rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
</style>

View file

@ -0,0 +1,137 @@
<template>
<div class="destination-edit-container">
<tab-container :default-tab="defaultTab" :tabs="tabsConfig" class="tab-container">
</tab-container>
</div>
</template>
<script>
import TabContainer from "@/components/UI/TabContainer.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import {markRaw} from "vue";
import DestinationMassQuantity from "@/components/layout/edit/destination/mass/DestinationMassQuantity.vue";
import DestinationMassRoute from "@/components/layout/edit/destination/mass/DestinationMassRoute.vue";
import DestinationMassHandlingCost from "@/components/layout/edit/destination/mass/DestinationMassHandlingCost.vue";
import {mapStores} from "pinia";
import {useNotificationStore} from "@/store/notification.js";
export default {
name: "DestinationMassEdit",
components: {BasicButton, TabContainer},
props: {
premiseIds: {
type: Array,
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
currentTab: null,
isLoading: [false, false, false],
}
},
created() {
this.updateSpinner();
},
methods: {
handleTabLoadingQuantity(loading) {
this.isLoading[0] = loading;
this.updateSpinner();
},
handleTabLoadingHandling(loading) {
this.isLoading[1] = loading;
this.updateSpinner();
},
handleTabLoadingRoutes(loading) {
this.isLoading[2] = loading;
this.updateSpinner();
},
updateSpinner() {
if (this.isLoading[0] || this.isLoading[1] || this.isLoading[2]) {
this.notificationStore.setSpinner("Processing ...");
}
else {
this.notificationStore.clearSpinner();
}
}
},
computed: {
...mapStores(useNotificationStore),
defaultTab() {
return this.tabsConfig.indexOf(this.tabsConfig.find(t => t.matchType === this.type)) ?? 0;
},
tabsConfig() {
return [
{
title: 'Annual quantity',
component: markRaw(DestinationMassQuantity),
props: {premiseIds: this.premiseIds, onLoadingChange: this.handleTabLoadingQuantity},
matchType: 'amount',
},
{
title: 'Handling & Repackaging',
component: markRaw(DestinationMassHandlingCost),
props: {premiseIds: this.premiseIds, onLoadingChange: this.handleTabLoadingHandling},
matchType: 'handling',
},
{
title: 'Routes',
component: markRaw(DestinationMassRoute),
props: {premiseIds: this.premiseIds, onLoadingChange: this.handleTabLoadingRoutes},
matchType: 'routes',
},
]
}
}
}
</script>
<style scoped>
.tab-container {
flex: 1;
min-height: 0; /* Critical: allows flex child to shrink below content size */
display: flex;
flex-direction: column;
}
.destination-edit-container {
display: flex;
flex-direction: column;
min-width: 100rem;
width: 90vw;
height: 70vh; /* Feste Höhe statt max-height */
min-height: 50rem; /* Mindesthöhe für kleine Bildschirme */
max-height: 90rem; /* Maximale Höhe für große Bildschirme */
}
.destination-edit-modal-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
flex: 1 0 min(60vw, 120rem);
height: min(60vh, 120rem);
min-height: 0;
}
.destination-edit-actions {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,466 @@
<template>
<div class="dest-mass-handling-container"
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
<div>
<div class="destination-mass-handling-cost-info">
<ph-warning size="18px"></ph-warning>
Handling and repackaging costs are calculated automatically.
If needed, you can overwrite these values here.
</div>
</div>
<div class="destination-mass-handling-checkbox">
<checkbox :checked="handlingCostActive" @checkbox-changed="activateInputFields">I want to enter handling and
repackaging costs manually.
</checkbox>
</div>
<div class="dest-mass-handling-table-wrapper" v-if="handlingCostActive">
<div class="dest-mass-handling-table-header">
<div class="dest-mass-handling-table-header-checkbox">
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate"></checkbox>
</div>
<div class="dest-mass-handling-table-header-material">Material</div>
<div class="dest-mass-handling-table-header-supplier">Supplier</div>
<div class="dest-mass-handling-table-header-destination">Destination</div>
<div class="dest-mass-handling-table-header-applier">
<icon-button icon="check" :disabled="!someChecked" @click="updateOverallValue"></icon-button>
<icon-button icon="x" :disabled="!someChecked" @click="dismissChecked"></icon-button>
</div>
<div class="dest-mass-handling-table-header-costs">
<div>Handling costs</div>
<div class="text-container" :class="{disabled: !someChecked}">
<input class="input-field"
v-model="overallHandlingCostValue"
autocomplete="off"
@blur="validateHandlingCost($event, 'handling')"
:disabled="!someChecked"/>
</div>
</div>
<div class="dest-mass-handling-table-header-costs">
<div>Repackaging cost</div>
<div class="text-container" :class="{disabled: !someChecked}">
<input class="input-field"
v-model="overallRepackagingCostValue"
autocomplete="off"
@blur="validateHandlingCost($event, 'repackaging')"
:disabled="!someChecked"/>
</div>
</div>
<div class="dest-mass-handling-table-header-costs">
<div>Disposal costs</div>
<div class="text-container" :class="{disabled: !someChecked}">
<input class="input-field"
v-model="overallDisposalCostValue"
autocomplete="off"
@blur="validateHandlingCost($event, 'disposal')"
:disabled="!someChecked"/>
</div>
</div>
</div>
<div class="dest-mass-handling-table">
<destination-mass-handling-cost-row @action="onClickAction" @update-selected="updateCheckBox" :row="row"
:key="row.id"
:disabled="someChecked"
v-for="row in rows"></destination-mass-handling-cost-row>
</div>
</div>
</div>
</template>
<script>
import {mapStores} from "pinia";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import DestinationMassQuantityRow from "@/components/layout/edit/destination/mass/DestinationMassQuantityRow.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import IconButton from "@/components/UI/IconButton.vue";
import DestinationMassHandlingCostRow
from "@/components/layout/edit/destination/mass/DestinationMassHandlingCostRow.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "DestinationMassHandlingCost",
components: {DestinationMassHandlingCostRow, IconButton, Checkbox, DestinationMassQuantityRow},
props: {
premiseIds: {
type: Array,
required: true
},
onLoadingChange: {
type: Function,
default: () => {
}
}
},
data() {
return {
handlingCostActive: false,
overallDisposalCostValue: null,
overallRepackagingCostValue: null,
overallHandlingCostValue: null,
overallCheck: false,
overallIndeterminate: false,
isCtrlPressed: false,
isShiftPressed: false,
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore),
rows() {
return this.destinationEditStore.getHandlingCostMatrix ?? [];
},
allChecked() {
return this.rows.every(r => r.selected);
},
someChecked() {
return this.rows.some(r => r.selected);
},
hasSelection() {
return !this.addAll && !this.applyFilter && this.someChecked;
},
applyFilter() {
return this.isCtrlPressed && this.isShiftPressed;
},
addAll() {
return this.isCtrlPressed && !this.isShiftPressed;
}
},
async created() {
this.onLoadingChange(true);
try {
await new Promise(resolve => setTimeout(() => {
this.buildMatrix();
resolve();
}, 10));
} finally {
this.onLoadingChange(false);
}
},
mounted() {
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
activateInputFields(value) {
this.handlingCostActive = value;
},
validateHandlingCost(event, type) {
const value = event.target.value == null ? null : parseNumberFromString(event.target.value, 2);
const validatedValue = value == null ? null : Math.max(0, value);
const stringified = validatedValue === null ? '' : validatedValue.toFixed();
if (type === 'handling')
this.overallHandlingCostValue = validatedValue;
else if (type === 'repackaging')
this.overallRepackagingCostValue = validatedValue;
else if (type === 'disposal')
this.overallDisposalCostValue = validatedValue;
event.target.value = stringified;
},
onClickAction(data) {
this.rows.forEach(d => {
d.selected = ((data.column === 'material' && d.material === data.row.material)
|| (data.column === 'supplier' && d.supplierId === data.row.supplierId)
|| (data.column === 'destination' && d.destinationNodeId === data.row.destinationNodeId)
|| (data.action === 'append' && d.selected));
});
this.updateOverallCheckBox();
},
/* key down/up handler */
handleKeyDown(event) {
if (event.key === 'Control') {
this.isCtrlPressed = true;
} else if (event.key === 'Shift') {
this.isShiftPressed = true;
}
if (event.key === 'Escape') {
this.fillData(this.modalType);
this.modalType = null;
}
},
handleKeyUp(event) {
if (event.key === 'Control') {
this.isCtrlPressed = false;
} else if (event.key === 'Shift') {
this.isShiftPressed = false;
}
},
updateOverallValue() {
if (this.overallHandlingCostValue !== null || this.overallDisposalCostValue !== null || this.overallRepackagingCostValue !== null) {
this.rows
.filter(row => row.selected)
.forEach(row => {
row.handling_costs = this.overallHandlingCostValue ?? row.handling_costs;
row.disposal_costs = this.overallDisposalCostValue ?? row.disposal_costs;
row.repackaging_costs = this.overallRepackagingCostValue ?? row.repackaging_costs;
});
this.overallHandlingCostValue = null;
this.overallRepackagingCostValue = null;
this.overallDisposalCostValue = null;
this.$forceUpdate();
}
this.dismissChecked();
},
dismissChecked() {
this.rows.forEach(row => row.selected = false);
this.updateOverallCheckBox();
},
/* checkbox handling */
updateCheckBox(data) { // data = {id: this.row.id, selected: value}
// update global (rest is done in row)
this.updateOverallCheckBox();
},
updateCheckBoxes(value) {
this.rows?.forEach(r => r.selected = value);
this.updateOverallCheckBox();
},
updateOverallCheckBox() {
this.overallCheck = this.rows.every(r => r.selected);
if (!this.overallCheck)
this.overallIndeterminate = this.rows.some(r => r.selected);
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
async buildMatrix() {
const handlingCostMatrix = [];
for (const pId of this.premiseIds) {
const premise = this.premiseEditStore.getById(pId);
const destinations = this.destinationEditStore.getByPremiseId(pId);
if (!destinations) continue;
for (const d of destinations) {
handlingCostMatrix.push({
id: premise.id,
material: premise.material.part_number,
supplier: this.toNode(premise.supplier, 15),
supplierId: premise.supplier.id,
supplierIso: premise.supplier.country.iso_code,
destinationId: d.id,
destinationNodeId: d.destination_node.id,
destination: this.toNode(d.destination_node, 15),
repackaging_costs: d.repackaging_costs,
handling_costs: d.handling_costs,
disposal_costs: d.disposal_costs,
selected: false
});
this.handlingCostActive = ((d.handling_costs !== null) || d.repackaging_costs !== null || d.disposal_costs !== null) || this.handlingCostActive;
}
}
this.destinationEditStore.setHandlingCostMatrix(handlingCostMatrix);
await this.$nextTick();
}
}
}
</script>
<style scoped>
.dest-mass-handling-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
gap: 2.4rem;
}
.destination-mass-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;
margin: 1.6rem 1.6rem 0 1.6rem;
}
.destination-mass-handling-checkbox {
display: flex;
align-items: center;
gap: 1.6rem;
padding: 0 1.6rem;
}
/* Global style für copy-mode cursor */
.dest-mass-handling-container.has-selection :deep(.dest-mass-handling-row__cell--copyable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDExMi41MWgtMi4yNGMtMS4yNCwwLTIuMjQtMS0yLjI0LTIuMjR2LTIuMjQiLz48bGluZSBjbGFzcz0iYyIgeDE9IjcwLjU3IiB5MT0iNzYuNjciIHgyPSI2My44NSIgeTI9Ijc2LjY3Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI3MC41NyIgeTE9IjExMi41MSIgeDI9IjY2LjA5IiB5Mj0iMTEyLjUxIi8+PGxpbmUgY2xhc3M9ImMiIHgxPSI4Ni4yNSIgeTE9Ijk5LjA3IiB4Mj0iODYuMjUiIHkyPSI5Mi4zNSIvPjxsaW5lIGNsYXNzPSJjIiB4MT0iNTAuNDEiIHkxPSI5Ni44MyIgeDI9IjUwLjQxIiB5Mj0iOTIuMzUiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTgxLjc3LDExMi41MWgyLjI0YzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0yLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS43Nyw3Ni42N2gyLjI0YzEuMjQsMCwyLjI0LDEsMi4yNCwyLjI0djIuMjQiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDc2LjY3aC0yLjI0Yy0xLjI0LDAtMi4yNCwxLTIuMjQsMi4yNHYyLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04Ni4yNSw5OS4wN2gxMS4yYzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0zMS4zNmMwLTEuMjQtMS0yLjI0LTIuMjQtMi4yNGgtMzEuMzZjLTEuMjQsMC0yLjI0LDEtMi4yNCwyLjI0djExLjIiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJkIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjwvc3ZnPg==") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-handling-container.add-all :deep(.dest-mass-handling-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48bGluZSBjbGFzcz0iYyIgeDE9IjczLjAzIiB5MT0iNzUuNTUiIHgyPSI2Ni4zOCIgeTI9Ijc1LjU1Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI2Ni4zOCIgeTE9IjExMi4xNiIgeDI9IjczLjAzIiB5Mj0iMTEyLjE2Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSw3NS41NWg0Ljk5Yy45MiwwLDEuNjYuNzUsMS42NiwxLjY2djQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9Ijg4LjAxIiB5MT0iOTcuMTgiIHgyPSI4OC4wMSIgeTI9IjkwLjUzIi8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSwxMTIuMTZoNC45OWMuOTIsMCwxLjY2LS43NSwxLjY2LTEuNjZ2LTQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9IjUxLjQiIHkxPSI5MC41MyIgeDI9IjUxLjQiIHkyPSI5Ny4xOCIvPjxwYXRoIGNsYXNzPSJjIiBkPSJtNTguMDUsMTEyLjE2aC00Ljk5Yy0uOTIsMC0xLjY2LS43NS0xLjY2LTEuNjZ2LTQuOTkiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU4LjA1LDc1LjU1aC00Ljk5Yy0uOTIsMC0xLjY2Ljc1LTEuNjYsMS42NnY0Ljk5Ii8+PC9nPjxnIGlkPSJiIj48cGF0aCBjbGFzcz0iZCIgZD0ibTQ0LjA4LDQ0LjA3bDMyLjk0LTkuMmMxLjY5LS41MiwyLjY0LTIuMzEsMi4xMi00LS4zLS45OC0xLjA1LTEuNzUtMi4wMS0yLjA5TDYuNzMsMi42N2MtMS42Ny0uNTctMy40OS4zMy00LjA2LDItLjIzLjY2LS4yMywxLjM4LDAsMi4wNWwyNi4xMSw3MC40Yy41OCwxLjY3LDIuNCwyLjU2LDQuMDcsMS45OC45Ny0uMzMsMS43MS0xLjExLDIuMDEtMi4wOWw5LjIyLTMyLjk0WiIvPjwvZz48L3N2Zz4=") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-handling-container.apply-filter :deep(.dest-mass-handling-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uZHtzdHJva2U6IzAwMDt9LmQsLmV7ZmlsbDpub25lO30uZCwuZSwuZntzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLXdpZHRoOjVweDt9LmUsLmZ7c3Ryb2tlOiMwMTAxMDE7fS5me2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9ImEiPjxsaW5lIGNsYXNzPSJlIiB4MT0iNzMuMDMiIHkxPSI3NS41NSIgeDI9IjY2LjM4IiB5Mj0iNzUuNTUiLz48bGluZSBjbGFzcz0iZSIgeDE9IjY2LjM4IiB5MT0iMTEyLjE2IiB4Mj0iNzMuMDMiIHkyPSIxMTIuMTYiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDc1LjU1aDQuOTljLjkyLDAsMS42Ni43NSwxLjY2LDEuNjZ2NC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iODguMDEiIHkxPSI5Ny4xOCIgeDI9Ijg4LjAxIiB5Mj0iOTAuNTMiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDExMi4xNmg0Ljk5Yy45MiwwLDEuNjYtLjc1LDEuNjYtMS42NnYtNC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iNTEuNCIgeTE9IjkwLjUzIiB4Mj0iNTEuNCIgeTI9Ijk3LjE4Ii8+PHBhdGggY2xhc3M9ImUiIGQ9Im01OC4wNSwxMTIuMTZoLTQuOTljLS45MiwwLTEuNjYtLjc1LTEuNjYtMS42NnYtNC45OSIvPjxwYXRoIGNsYXNzPSJlIiBkPSJtNTguMDUsNzUuNTVoLTQuOTljLS45MiwwLTEuNjYuNzUtMS42NiwxLjY2djQuOTkiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJmIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjxnIGlkPSJjIj48bGluZSBjbGFzcz0iZCIgeDE9Ijk5LjM4IiB5MT0iOTQuMTkiIHgyPSIxMjYuMDEiIHkyPSI5NC4xOSIvPjxsaW5lIGNsYXNzPSJkIiB4MT0iMTEyLjY5IiB5MT0iODAuODciIHgyPSIxMTIuNjkiIHkyPSIxMDcuNTEiLz48L2c+PC9zdmc+") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.text-container:hover:not(.disabled) {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
max-width: 6rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
max-width: 8rem;
}
.dest-mass-handling-table-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0; /* Wichtig für Firefox */
}
.dest-mass-handling-table-header {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.6rem 2.4rem;
justify-content: flex-start;
background-color: #ffffff;
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
flex-shrink: 0; /* Header bleibt fixiert */
}
.dest-mass-handling-table {
overflow-y: auto;
margin: 0;
padding-bottom: 2.4rem;
}
.dest-mass-handling-table-header-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 6rem;
}
.dest-mass-handling-table-header-material {
width: 14rem;
display: flex;
align-items: center;
}
.dest-mass-handling-table-header-supplier {
width: 18rem;
display: flex;
align-items: center;
}
.dest-mass-handling-table-header-destination {
width: 18rem;
display: flex;
align-items: center;
}
.dest-mass-handling-table-header-applier {
display: flex;
align-items: center;
width: 7rem;
gap: 0.8rem;
}
.dest-mass-handling-table-header-costs {
display: flex;
justify-content: flex-start;
align-items: center;
width: 25rem;
gap: 0.8rem;
}
</style>

View file

@ -0,0 +1,294 @@
<template>
<div class="dest-mass-handling-row-container" @wheel="handleWheel">
<div class="dest-mass-handling-row-checkbox">
<checkbox :checked="row.selected" @checkbox-changed="updateCheckbox">
</checkbox>
</div>
<div class="dest-mass-handling-row-material dest-mass-handling-row__cell--filterable"
@click="action($event,'material')"
@mousedown="handleMouseDown">
<ph-package size="24"/>
{{ row.material }}
</div>
<div class="dest-mass-handling-row-supplier dest-mass-handling-row__cell--filterable"
@click="action($event,'supplier')"
@mousedown="handleMouseDown">
<flag :iso="row.supplierIso" />
{{ row.supplier }}
</div>
<div class="dest-mass-handling-row-destination dest-mass-handling-row__cell--filterable"
@click="action($event,'destination')"
@mousedown="handleMouseDown">
<ph-map-pin size="24"/>
{{ row.destination }}
</div>
<div class="dest-mass-handling-row-applier"></div>
<div class="dest-mass-handling-row-costs">
<div class="text-container" :class="{disabled: disabled}">
<input class="input-field"
v-model="handling"
@blur="validateHandlingCost($event, 'handling')"
autocomplete="off"
:disabled="disabled"/>
</div>
<div>[EUR/HU]</div>
</div>
<div class="dest-mass-handling-row-costs">
<div class="text-container" :class="{disabled: disabled}">
<input class="input-field"
v-model="repackaging"
@blur="validateHandlingCost($event, 'repackaging')"
autocomplete="off"
:disabled="disabled"/>
</div>
<div>[EUR/HU]</div>
</div>
<div class="dest-mass-handling-row-costs">
<div class="text-container" :class="{disabled: disabled}">
<input class="input-field"
v-model="disposal"
@blur="validateHandlingCost($event, 'disposal')"
autocomplete="off"
:disabled="disabled"/>
</div>
<div>[EUR/HU]</div>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import {PhFactory, PhMapPin} from "@phosphor-icons/vue";
import {parseNumberFromString} from "@/common.js";
import Flag from "@/components/UI/Flag.vue";
export default {
name: "DestinationMassHandlingCostRow",
components: {Flag, PhMapPin, PhFactory, Checkbox},
emits: ['action', 'update-selected'],
props: {
row: {
type: Object,
required: true
},
disabled: {
type: Boolean,
required: true
}
},
computed: {
repackaging: {
get() {
return this.row.repackaging_costs?.toFixed(2) ?? '';
},
set(value) {
this.row.repackaging_costs && (this.row.repackaging_costs = value);
},
},
handling: {
get() {
return this.row.handling_costs?.toFixed(2) ?? '';
},
set(value) {
this.row.handling_costs && (this.row.handling_costs = value);
},
},
disposal: {
get() {
return this.row.disposal_costs?.toFixed(2) ?? '';
},
set(value) {
this.row.disposal_costs && (this.row.disposal_costs = value);
},
}
},
methods: {
handleMouseDown(event) {
if (event.shiftKey || event.ctrlKey) {
event.preventDefault();
}
},
handleWheel(event) {
if (event.ctrlKey) {
event.preventDefault();
window.scrollBy(0, event.deltaY);
}
},
action(event, column) {
if (event.ctrlKey && !event.shiftKey && (column === 'material' || column === 'supplier' || column === 'destination')) {
this.$emit('action', {row: this.row, column: column, action: 'filter'});
} else if (event.ctrlKey && event.shiftKey && (column === 'material' || column === 'supplier' || column === 'destination')) {
this.$emit('action', {row: this.row, column: column, action: 'append'});
}
},
updateCheckbox(value) {
this.row.selected = value;
this.$emit('update-selected', {id: this.row.id, selected: value});
},
validateHandlingCost(event, type) {
const value = event.target.value == null ? null : parseNumberFromString(event.target.value, 2);
const validatedValue = value == null ? null : Math.max(0, value);
const stringified = validatedValue === null ? '' : validatedValue.toFixed();
if(type === 'handling')
this.row.handling_costs = validatedValue;
else if(type === 'repackaging')
this.row.repackaging_costs = validatedValue;
else if(type === 'disposal')
this.row = validatedValue;
event.target.value = stringified;
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
}
}
</script>
<style scoped>
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
max-width: 6rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
max-width: 8rem;
}
.text-container:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
/*transform: translateY(2px);*/
transform: scale(1.01);
}
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.text-container:hover:not(.disabled) {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.dest-mass-handling-row-container {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.2rem 2.4rem;
justify-content: flex-start;
border-bottom: 0.16rem solid #f3f4f6;
transition: background-color 0.2s ease;
}
.dest-mass-handling-row-container:hover {
background-color: rgba(107, 134, 156, 0.05);
}
.dest-mass-handling-row-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 6rem;
}
.dest-mass-handling-row-material {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 14rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-handling-row-applier {
width: 7rem;
}
.dest-mass-handling-row-supplier {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 18rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-handling-row-destination {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 18rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-handling-row-costs {
width: 25rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
font-size: 1.4rem;
font-weight: 400;
}
</style>

View file

@ -0,0 +1,428 @@
<template>
<div class="dest-mass-quantity-container"
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
<div class="dest-mass-quantity-table-wrapper">
<div class="dest-mass-quantity-table-header">
<div class="dest-mass-quantity-table-header-checkbox">
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate"></checkbox>
</div>
<div class="dest-mass-quantity-table-header-material">Material</div>
<div class="dest-mass-quantity-table-header-supplier">Supplier</div>
<div class="dest-mass-quantity-table-header-applier">
<icon-button icon="check" :disabled="!someChecked" @click="updateOverallValue"></icon-button>
<icon-button icon="x" :disabled="!someChecked" @click="dismissChecked"></icon-button>
</div>
<div class="dest-mass-quantity-table-header-dest"
:key="`${dest.id}`"
v-for="dest in destPool">
<div>{{ toNode(dest, 6) }}</div>
<div class="text-container" :class="{disabled: !someChecked}">
<input class="input-field"
v-model="dest.overallValue"
autocomplete="off"
@blur="validateAnnualAmount($event, dest)"
:disabled="!someChecked"/>
</div>
</div>
</div>
<div class="dest-mass-quantity-table">
<destination-mass-quantity-row @action="onClickAction" @update-selected="updateCheckBox" :row="row"
:key="row.id"
:disabled="someChecked"
v-for="row in rows"></destination-mass-quantity-row>
</div>
</div>
</div>
</template>
<script>
import {useDestinationEditStore} from "@/store/destinationEdit.js";
import {mapStores} from "pinia";
import Checkbox from "@/components/UI/Checkbox.vue";
import DestMassCreateRow from "@/components/layout/edit/destination/mass/DestMassCreateRow.vue";
import DestinationMassQuantityRow from "@/components/layout/edit/destination/mass/DestinationMassQuantityRow.vue";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import BulkEditRow from "@/components/layout/bulkedit/BulkEditRow.vue";
import {toRaw} from "vue";
import IconButton from "@/components/UI/IconButton.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "DestinationMassQuantity",
components: {BasicButton, IconButton, BulkEditRow, DestinationMassQuantityRow, DestMassCreateRow, Checkbox},
props: {
premiseIds: {
type: Array,
required: true
},
onLoadingChange: {
type: Function,
default: () => {
}
}
},
data() {
return {
destPool: null,
overallCheck: false,
overallIndeterminate: false,
isCtrlPressed: false,
isShiftPressed: false,
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore),
rows() {
return this.destinationEditStore.getQuantityMatrix ?? [];
},
allChecked() {
return this.rows.every(r => r.selected);
},
someChecked() {
return this.rows.some(r => r.selected);
},
hasSelection() {
return !this.addAll && !this.applyFilter && this.someChecked;
},
applyFilter() {
return this.isCtrlPressed && this.isShiftPressed;
},
addAll() {
return this.isCtrlPressed && !this.isShiftPressed;
},
},
async created() {
this.onLoadingChange(true);
try {
await new Promise(resolve => setTimeout(() => {
this.buildMatrix();
resolve();
}, 10));
} finally {
this.onLoadingChange(false);
}
},
mounted() {
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
/* key down/up handler */
handleKeyDown(event) {
if (event.key === 'Control') {
this.isCtrlPressed = true;
} else if (event.key === 'Shift') {
this.isShiftPressed = true;
}
if (event.key === 'Escape') {
this.fillData(this.modalType);
this.modalType = null;
}
},
handleKeyUp(event) {
if (event.key === 'Control') {
this.isCtrlPressed = false;
} else if (event.key === 'Shift') {
this.isShiftPressed = false;
}
},
onClickAction(data) {
this.rows.forEach(d => {
d.selected = ((data.column === 'material' && d.material === data.row.material)
|| (data.column === 'supplier' && d.supplier === data.row.supplier)
|| (data.action === 'append' && d.selected));
});
this.updateOverallCheckBox();
},
validateAnnualAmount(event, dest) {
const value = event.target.value == null ? null : parseNumberFromString(event.target.value, 0);
const validatedValue = value == null ? null : Math.max(0, value);
const stringified = validatedValue === null ? '' : validatedValue.toFixed();
dest.overallValue = validatedValue;
event.target.value = stringified;
},
updateOverallValue() {
const updates = this.destPool
.filter(d => d.overallValue !== null && d.overallValue !== '')
.map(d => ({
nodeId: d.id,
value: d.overallValue
}));
if (updates.length > 0) {
this.rows
.filter(row => row.selected)
.forEach(row => {
updates.forEach(update => {
const d = row.destinations.find(rd => rd.nodeId === update.nodeId);
if (d && d.id !== null) {
d.annual_amount = update.value;
}
});
});
this.destPool.forEach(d => d.overallValue = null);
this.$forceUpdate();
}
this.dismissChecked();
},
dismissChecked() {
this.rows.forEach(row => row.selected = false);
this.updateOverallCheckBox();
},
/* checkbox handling */
updateCheckBox(data) { // data = {id: this.row.id, selected: value}
// update global (rest is done in row)
this.updateOverallCheckBox();
},
updateCheckBoxes(value) {
this.rows?.forEach(r => r.selected = value);
this.updateOverallCheckBox();
},
updateOverallCheckBox() {
this.overallCheck = this.rows.every(r => r.selected);
if (!this.overallCheck)
this.overallIndeterminate = this.rows.some(r => r.selected);
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
async buildMatrix() {
// destPool aufbauen
const destMap = new Map();
for (const pId of this.premiseIds) {
const destinations = this.destinationEditStore.getByPremiseId(pId);
if (!destinations) continue;
for (const d of destinations) {
const destId = d.destination_node.id;
if (!destMap.has(destId)) {
destMap.set(destId, {
...d.destination_node,
overallValue: null,
overallCheck: false,
overallIndeterminate: false
});
}
}
}
this.destPool = Array.from(destMap.values());
const quantityMatrix = this.premiseIds
.filter(p => p)
.map(p => this.premiseEditStore.getById(p))
.map(premise => {
const destRaw = this.destinationEditStore.getByPremiseId(premise.id);
// Map für schnelleren Lookup erstellen
const destLookup = new Map();
if (destRaw) {
for (const dr of destRaw) {
destLookup.set(dr.destination_node.id, dr);
}
}
return {
id: premise.id,
material: premise.material.part_number,
supplier: this.toNode(premise.supplier, 30),
destinations: this.destPool.map(dest => {
const match = destLookup.get(dest.id);
return {
annual_amount: match?.annual_amount ?? null,
id: match?.id ?? null,
nodeId: dest.id,
};
}),
selected: false
};
});
this.destinationEditStore.setQuantityMatrix(quantityMatrix);
await this.$nextTick();
}
}
}
</script>
<style scoped>
/* Global style für copy-mode cursor */
.dest-mass-quantity-container.has-selection :deep(.dest-mass-quantity-row__cell--copyable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDExMi41MWgtMi4yNGMtMS4yNCwwLTIuMjQtMS0yLjI0LTIuMjR2LTIuMjQiLz48bGluZSBjbGFzcz0iYyIgeDE9IjcwLjU3IiB5MT0iNzYuNjciIHgyPSI2My44NSIgeTI9Ijc2LjY3Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI3MC41NyIgeTE9IjExMi41MSIgeDI9IjY2LjA5IiB5Mj0iMTEyLjUxIi8+PGxpbmUgY2xhc3M9ImMiIHgxPSI4Ni4yNSIgeTE9Ijk5LjA3IiB4Mj0iODYuMjUiIHkyPSI5Mi4zNSIvPjxsaW5lIGNsYXNzPSJjIiB4MT0iNTAuNDEiIHkxPSI5Ni44MyIgeDI9IjUwLjQxIiB5Mj0iOTIuMzUiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTgxLjc3LDExMi41MWgyLjI0YzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0yLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS43Nyw3Ni42N2gyLjI0YzEuMjQsMCwyLjI0LDEsMi4yNCwyLjI0djIuMjQiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDc2LjY3aC0yLjI0Yy0xLjI0LDAtMi4yNCwxLTIuMjQsMi4yNHYyLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04Ni4yNSw5OS4wN2gxMS4yYzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0zMS4zNmMwLTEuMjQtMS0yLjI0LTIuMjQtMi4yNGgtMzEuMzZjLTEuMjQsMC0yLjI0LDEtMi4yNCwyLjI0djExLjIiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJkIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjwvc3ZnPg==") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-quantity-container.add-all :deep(.dest-mass-quantity-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48bGluZSBjbGFzcz0iYyIgeDE9IjczLjAzIiB5MT0iNzUuNTUiIHgyPSI2Ni4zOCIgeTI9Ijc1LjU1Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI2Ni4zOCIgeTE9IjExMi4xNiIgeDI9IjczLjAzIiB5Mj0iMTEyLjE2Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSw3NS41NWg0Ljk5Yy45MiwwLDEuNjYuNzUsMS42NiwxLjY2djQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9Ijg4LjAxIiB5MT0iOTcuMTgiIHgyPSI4OC4wMSIgeTI9IjkwLjUzIi8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSwxMTIuMTZoNC45OWMuOTIsMCwxLjY2LS43NSwxLjY2LTEuNjZ2LTQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9IjUxLjQiIHkxPSI5MC41MyIgeDI9IjUxLjQiIHkyPSI5Ny4xOCIvPjxwYXRoIGNsYXNzPSJjIiBkPSJtNTguMDUsMTEyLjE2aC00Ljk5Yy0uOTIsMC0xLjY2LS43NS0xLjY2LTEuNjZ2LTQuOTkiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU4LjA1LDc1LjU1aC00Ljk5Yy0uOTIsMC0xLjY2Ljc1LTEuNjYsMS42NnY0Ljk5Ii8+PC9nPjxnIGlkPSJiIj48cGF0aCBjbGFzcz0iZCIgZD0ibTQ0LjA4LDQ0LjA3bDMyLjk0LTkuMmMxLjY5LS41MiwyLjY0LTIuMzEsMi4xMi00LS4zLS45OC0xLjA1LTEuNzUtMi4wMS0yLjA5TDYuNzMsMi42N2MtMS42Ny0uNTctMy40OS4zMy00LjA2LDItLjIzLjY2LS4yMywxLjM4LDAsMi4wNWwyNi4xMSw3MC40Yy41OCwxLjY3LDIuNCwyLjU2LDQuMDcsMS45OC45Ny0uMzMsMS43MS0xLjExLDIuMDEtMi4wOWw5LjIyLTMyLjk0WiIvPjwvZz48L3N2Zz4=") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-quantity-container.apply-filter :deep(.dest-mass-quantity-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uZHtzdHJva2U6IzAwMDt9LmQsLmV7ZmlsbDpub25lO30uZCwuZSwuZntzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLXdpZHRoOjVweDt9LmUsLmZ7c3Ryb2tlOiMwMTAxMDE7fS5me2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9ImEiPjxsaW5lIGNsYXNzPSJlIiB4MT0iNzMuMDMiIHkxPSI3NS41NSIgeDI9IjY2LjM4IiB5Mj0iNzUuNTUiLz48bGluZSBjbGFzcz0iZSIgeDE9IjY2LjM4IiB5MT0iMTEyLjE2IiB4Mj0iNzMuMDMiIHkyPSIxMTIuMTYiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDc1LjU1aDQuOTljLjkyLDAsMS42Ni43NSwxLjY2LDEuNjZ2NC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iODguMDEiIHkxPSI5Ny4xOCIgeDI9Ijg4LjAxIiB5Mj0iOTAuNTMiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDExMi4xNmg0Ljk5Yy45MiwwLDEuNjYtLjc1LDEuNjYtMS42NnYtNC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iNTEuNCIgeTE9IjkwLjUzIiB4Mj0iNTEuNCIgeTI9Ijk3LjE4Ii8+PHBhdGggY2xhc3M9ImUiIGQ9Im01OC4wNSwxMTIuMTZoLTQuOTljLS45MiwwLTEuNjYtLjc1LTEuNjYtMS42NnYtNC45OSIvPjxwYXRoIGNsYXNzPSJlIiBkPSJtNTguMDUsNzUuNTVoLTQuOTljLS45MiwwLTEuNjYuNzUtMS42NiwxLjY2djQuOTkiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJmIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjxnIGlkPSJjIj48bGluZSBjbGFzcz0iZCIgeDE9Ijk5LjM4IiB5MT0iOTQuMTkiIHgyPSIxMjYuMDEiIHkyPSI5NC4xOSIvPjxsaW5lIGNsYXNzPSJkIiB4MT0iMTEyLjY5IiB5MT0iODAuODciIHgyPSIxMTIuNjkiIHkyPSIxMDcuNTEiLz48L2c+PC9zdmc+") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.text-container:hover:not(.disabled) {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
max-width: 6rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
max-width: 8rem;
}
.dest-mass-quantity-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.dest-mass-quantity-table-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0;
}
.dest-mass-quantity-table-header {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.6rem 2.4rem;
justify-content: flex-start;
background-color: #ffffff;
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
flex-shrink: 0;
}
.dest-mass-quantity-table {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
margin: 0;
padding-bottom: 2.4rem;
}
.dest-mass-quantity-table-header-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 6rem;
}
.dest-mass-quantity-table-header-material {
width: 14rem;
display: flex;
align-items: center;
}
.dest-mass-quantity-table-header-supplier {
width: 24rem;
display: flex;
align-items: center;
}
.dest-mass-quantity-table-header-applier {
display: flex;
align-items: center;
width: 7rem;
gap: 0.8rem;
}
.dest-mass-quantity-table-header-dest {
display: flex;
justify-content: flex-start;
align-items: center;
width: 15rem;
gap: 0.8rem;
}
</style>

View file

@ -0,0 +1,216 @@
<template>
<div class="dest-mass-quantity-row-container" @wheel="handleWheel">
<div class="dest-mass-quantity-row-checkbox">
<checkbox :checked="row.selected" @checkbox-changed="updateCheckbox">
</checkbox>
</div>
<div class="dest-mass-quantity-row-material dest-mass-quantity-row__cell--filterable" @click="action($event,'material')"
@mousedown="handleMouseDown">
<ph-package size="24"/>
{{ row.material }}
</div>
<div class="dest-mass-quantity-row-supplier dest-mass-quantity-row__cell--filterable" @click="action($event,'supplier')"
@mousedown="handleMouseDown">
<ph-factory size="24"/>
{{ row.supplier }}
</div>
<div class="dest-mass-quantity-row-applier"></div>
<div v-for="dest in row.destinations" class="dest-mass-quantity-row-dest" :key="dest.id">
<ph-stack size="24"></ph-stack>
<div class="text-container" :class="{disabled: disabled || dest.id === null}">
<input class="input-field"
v-model="dest.annual_amount"
@blur="validateAnnualAmount($event, dest)"
autocomplete="off"
:disabled="disabled || dest.id === null"/>
</div>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import {PhFactory, PhPackage, PhStack} from "@phosphor-icons/vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
import InputField from "@/components/UI/InputField.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "DestinationMassQuantityRow",
components: {PhStack, InputField, PhFactory, Checkbox},
emits: ['update-selected', 'action'],
props: {
row: {
type: Object,
required: true
},
disabled: {
type: Boolean,
required: true
}
},
methods: {
validateAnnualAmount(event, dest) {
const value = event.target.value == null ? null : parseNumberFromString(event.target.value, 0);
const validatedValue = value == null ? null : Math.max(0, value);
const stringified = validatedValue === null ? '' : validatedValue.toFixed();
dest.annual_amount = validatedValue;
event.target.value = stringified;
},
handleMouseDown(event) {
if (event.shiftKey || event.ctrlKey) {
event.preventDefault();
}
},
handleWheel(event) {
if (event.ctrlKey) {
event.preventDefault();
window.scrollBy(0, event.deltaY);
}
},
action(event, column) {
if (event.ctrlKey && !event.shiftKey && (column === 'material' || column === 'supplier')) {
this.$emit('action', {row: this.row, column: column, action: 'filter'});
} else if (event.ctrlKey && event.shiftKey && (column === 'material' || column === 'supplier')) {
this.$emit('action', {row: this.row, column: column, action: 'append'});
}
},
updateCheckbox(value) {
this.row.selected = value;
this.$emit('update-selected', {id: this.row.id, selected: value});
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
}
}
</script>
<style scoped>
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
max-width: 6rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
max-width: 8rem;
}
.text-container:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
/*transform: translateY(2px);*/
transform: scale(1.01);
}
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.text-container:hover:not(.disabled) {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.dest-mass-quantity-row-container {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.2rem 2.4rem;
justify-content: flex-start;
border-bottom: 0.16rem solid #f3f4f6;
transition: background-color 0.2s ease;
}
.dest-mass-quantity-row-container:hover {
background-color: rgba(107, 134, 156, 0.05);
}
.dest-mass-quantity-row-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 6rem;
}
.dest-mass-quantity-row-material {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 14rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-quantity-row-applier {
width: 7rem;
}
.dest-mass-quantity-row-supplier {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 24rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
}
.dest-mass-quantity-row-dest {
width: 15rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
}
</style>

View file

@ -0,0 +1,476 @@
<template>
<div class="dest-mass-route-container">
<div v-if="generalError">
<div class="destination-mass-route-info">
<ph-warning size="18px"></ph-warning>
The routing data is faulty. Please contact support.
You can try to solve the problem by first deleting all destinations and then creating them again.
</div>
</div>
<div v-else class="dest-mass-route-table-wrapper">
<div class="dest-mass-route-table-header-wrapper"
ref="headerWrapper"
@scroll="syncScroll('header')">
<div class="dest-mass-route-table-header">
<div class="dest-mass-route-table-header-supplier"></div>
<div class="dest-mass-route-table-header-dest"
:key="`${dest.id}`"
v-for="dest in destPool">
<div></div>
<div>{{ toNode(dest.destination_node, 6) }}</div>
</div>
</div>
</div>
<div class="dest-mass-route-table"
ref="tableBody"
@scroll="syncScroll('body')">
<destination-mass-route-row :row="row"
:key="row.id"
v-for="row in rows"></destination-mass-route-row>
</div>
</div>
</div>
</template>
<script>
import DestinationMassQuantityRow from "@/components/layout/edit/destination/mass/DestinationMassQuantityRow.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import IconButton from "@/components/UI/IconButton.vue";
import {mapStores} from "pinia";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import {toRaw} from "vue";
import DestinationMassRouteRow from "@/components/layout/edit/destination/mass/DestinationMassRouteRow.vue";
import Flag from "@/components/UI/Flag.vue";
import logger from "@/logger.js";
export default {
name: "DestinationMassRoute",
components: {Flag, DestinationMassRouteRow, IconButton, Checkbox, DestinationMassQuantityRow},
props: {
premiseIds: {
type: Array,
required: true
},
onLoadingChange: {
type: Function,
default: () => {}
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore),
rows() {
return this.destinationEditStore.getRouteMatrix ?? [];
}
},
data() {
return {
destPool: null,
generalError: false,
isScrollingSyncronized: false,
}
},
async created() {
this.onLoadingChange(true);
try {
await new Promise(resolve => setTimeout(() => {
this.buildMatrix();
resolve();
}, 10));
} finally {
this.onLoadingChange(false);
}
},
methods: {
syncScroll(source) {
if (this.isScrollingSyncronized) {
this.isScrollingSyncronized = false;
return;
}
this.isScrollingSyncronized = true;
if (source === 'body') {
this.$refs.headerWrapper.scrollLeft = this.$refs.tableBody.scrollLeft;
} else if (source === 'header') {
this.$refs.tableBody.scrollLeft = this.$refs.headerWrapper.scrollLeft;
}
},
toNode(node, limit = 5) {
if (!node)
return 'N/A';
const name = node.name;
const mappingId = node.external_mapping_id;
const needsShortName = name.length > limit;
const useMappingId = ((mappingId ?? null) !== null) && ((name ?? null) === null || needsShortName);
const shortName = name?.substring(0, limit).concat("...") ?? 'N/A';
return `${useMappingId ? mappingId.replace("_", " ") : (needsShortName ? shortName : name)}`;
},
async buildMatrix() {
const columnHeadersMap = new Map();
const supplierToDestinationsMap = new Map();
for (const pId of this.premiseIds) {
const curPremise = this.premiseEditStore.getById(pId);
const destOfCurPremise = this.destinationEditStore.getByPremiseId(pId);
if (!destOfCurPremise) continue;
/* supplier map collects all destinations for one supplier.
* if there is more than one instance of a destination for one supplier
* (more than one part number), a destination instance is chosen by the following priority list:
* 1. instances with d2d rate
* 2. instances with selected routes
* 3. all other instances.
*/
if (!supplierToDestinationsMap.has(curPremise.supplier.id)) {
supplierToDestinationsMap.set(curPremise.supplier.id, {
destinations: [...destOfCurPremise]
});
} else {
const mapEntry = supplierToDestinationsMap.get(curPremise.supplier.id);
const exDs = mapEntry.destinations;
destOfCurPremise.forEach(d => {
const exD = exDs.find(ex => ex.destination_node.id === d.destination_node.id) ?? null;
if (!exD) {
exDs.push(d);
} else {
if ((!exD.routes?.some(r => r.is_selected) && d.routes?.some(r => r.is_selected) && !exD.is_d2d) || (!exD.is_d2d && d.is_d2d)) {
const idx = exDs.indexOf(exD);
exDs.splice(idx, 1);
exDs.push(d);
}
}
})
}
/* Collects all destinations over all
* suppliers and part numbers for the table headers
*/
for (const d of destOfCurPremise) {
const destId = d.destination_node.id;
if (!columnHeadersMap.has(destId)) {
columnHeadersMap.set(destId, {
destination_node: {...d.destination_node},
destinationNodeId: d.destination_node.id,
destinationNodeName: d.destination_node.name
});
}
}
}
this.destPool = Array.from(columnHeadersMap.values());
const premiseMap = new Map();
this.premiseIds.forEach(pId => {
const curPremise = this.premiseEditStore.getById(pId);
const destOfCurPremise = this.destinationEditStore.getByPremiseId(pId);
if (!premiseMap.has(curPremise.supplier.id)) {
premiseMap.set(curPremise.supplier.id, {
ids: [],
supplierNodeId: curPremise.supplier.id,
supplier: curPremise.supplier,
destinations: this.buildDestinations(columnHeadersMap, supplierToDestinationsMap.get(curPremise.supplier.id)?.destinations ?? [])
});
}
const row = premiseMap.get(curPremise.supplier.id);
if (row) {
row.ids.push(curPremise.id);
this.addDestinationsToRow(row.destinations, destOfCurPremise)
}
});
const destMatrix = Array.from(premiseMap.values());
this.generalError = destMatrix.some(r => !r.destinations.every(d => d.valid));
this.destinationEditStore.setRouteMatrix(destMatrix)
await this.$nextTick();
},
buildDestinations(allDestinationsMap, assignedDestinations) {
return Array.from(allDestinationsMap.values()).map(d => {
const assignedDest = assignedDestinations.find(dest => dest.destination_node.id === d.destinationNodeId);
const builtRoutes = this.buildRoutes(assignedDest?.routes);
const selectedBuildRoute = builtRoutes?.find(r => r.selected)?.routeCompareString ?? null;
return {
ids: [],
disabled: true,
valid: true,
destinationNodeId: d.destinationNodeId,
destinationName: d.destinationNodeName,
routes: builtRoutes,
isD2d: assignedDest?.is_d2d ?? false,
rateD2d: assignedDest?.rate_d2d ?? null,
leadTimeD2d: assignedDest?.lead_time_d2d === 0 ? null : (assignedDest?.lead_time_d2d ?? null),
selectedRoute: selectedBuildRoute
}
});
},
addDestinationsToRow(rowDestinations, destOfPremises) {
destOfPremises.forEach(curDestOfPremise => {
/* rowDestinations contains all here known destinations that are shown
*
*
*/
let existingDest = rowDestinations.find(rowD => rowD.destinationNodeId === curDestOfPremise.destination_node.id) ?? null;
if (existingDest) {
existingDest.disabled = false;
if(existingDest.ids.includes(curDestOfPremise.id))
logger.log("Duplicate id: ", curDestOfPremise.id);
existingDest.ids.push(curDestOfPremise.id);
/* add route ids to routes */
this.verifyRoutes(existingDest, curDestOfPremise)
}
});
},
verifyRoutes(rowDest, premiseDest) {
const premiseRoutes = premiseDest.routes;
if (rowDest.routes.length !== premiseRoutes.length) {
logger.log("length mismatch ", toRaw(rowDest), toRaw(premiseDest));
rowDest.valid = false;
return
}
premiseRoutes.forEach(route => {
const routeString = JSON.stringify(route.transit_nodes.map(n => n.external_mapping_id)); //.join(" > ").replace("_", " ");
const rowRoute = rowDest.routes.find(r => r.routeCompareString === routeString && r.type === route.type);
if (!rowRoute) {
logger.log("no matching route ", routeString, rowDest);
rowDest.valid = false;
} else {
rowRoute.ids.push(route.id);
}
});
},
buildRoutes(routes) {
return routes?.map(r => {
return {
ids: [],
type: r.type,
selected: r.is_selected,
transitNodes: r.transit_nodes.map(n => n.external_mapping_id),
routeCompareString: JSON.stringify(r.transit_nodes.map(n => n.external_mapping_id)), //.join(" > ").replace("_", " ")
routeDisplayString: this.toRoute(r)
}
}) ?? [];
},
toRoute(route, limit = 48) {
if (!route)
return 'N/A';
const nodes = route.transit_nodes?.map((node) => this.toNode(node)) ?? [];
if (nodes.length === 0)
return 'N/A';
const separator = " > ";
let fullString = nodes.join(separator);
if (fullString.length <= limit)
return fullString;
const front = nodes[0].concat(separator).concat("...").concat(separator);
let back = [];
for (const node of nodes.slice().reverse()) {
back.unshift(node);
const temp = front.concat(back.join(separator));
if (temp.length > limit) {
return front.concat(back.slice(1).join(separator));
}
}
},
}
}
</script>
<style scoped>
.destination-mass-route-info {
display: flex;
align-items: center;
font-size: 1.4rem;
gap: 1.6rem;
background-color: #BC2B72;
color: #ffffff;
border-radius: 0.8rem;
padding: 1.6rem;
margin: 1.6rem 1.6rem 0 1.6rem;
}
/* Global style für copy-mode cursor */
.dest-mass-route-container.has-selection :deep(.dest-mass-route-row__cell--copyable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDExMi41MWgtMi4yNGMtMS4yNCwwLTIuMjQtMS0yLjI0LTIuMjR2LTIuMjQiLz48bGluZSBjbGFzcz0iYyIgeDE9IjcwLjU3IiB5MT0iNzYuNjciIHgyPSI2My44NSIgeTI9Ijc2LjY3Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI3MC41NyIgeTE9IjExMi41MSIgeDI9IjY2LjA5IiB5Mj0iMTEyLjUxIi8+PGxpbmUgY2xhc3M9ImMiIHgxPSI4Ni4yNSIgeTE9Ijk5LjA3IiB4Mj0iODYuMjUiIHkyPSI5Mi4zNSIvPjxsaW5lIGNsYXNzPSJjIiB4MT0iNTAuNDEiIHkxPSI5Ni44MyIgeDI9IjUwLjQxIiB5Mj0iOTIuMzUiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTgxLjc3LDExMi41MWgyLjI0YzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0yLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS43Nyw3Ni42N2gyLjI0YzEuMjQsMCwyLjI0LDEsMi4yNCwyLjI0djIuMjQiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDc2LjY3aC0yLjI0Yy0xLjI0LDAtMi4yNCwxLTIuMjQsMi4yNHYyLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04Ni4yNSw5OS4wN2gxMS4yYzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0zMS4zNmMwLTEuMjQtMS0yLjI0LTIuMjQtMi4yNGgtMzEuMzZjLTEuMjQsMC0yLjI0LDEtMi4yNCwyLjI0djExLjIiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJkIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjwvc3ZnPg==") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-route-container.add-all :deep(.dest-mass-route-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48bGluZSBjbGFzcz0iYyIgeDE9IjczLjAzIiB5MT0iNzUuNTUiIHgyPSI2Ni4zOCIgeTI9Ijc1LjU1Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI2Ni4zOCIgeTE9IjExMi4xNiIgeDI9IjczLjAzIiB5Mj0iMTEyLjE2Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSw3NS41NWg0Ljk5Yy45MiwwLDEuNjYuNzUsMS42NiwxLjY2djQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9Ijg4LjAxIiB5MT0iOTcuMTgiIHgyPSI4OC4wMSIgeTI9IjkwLjUzIi8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSwxMTIuMTZoNC45OWMuOTIsMCwxLjY2LS43NSwxLjY2LTEuNjZ2LTQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9IjUxLjQiIHkxPSI5MC41MyIgeDI9IjUxLjQiIHkyPSI5Ny4xOCIvPjxwYXRoIGNsYXNzPSJjIiBkPSJtNTguMDUsMTEyLjE2aC00Ljk5Yy0uOTIsMC0xLjY2LS43NS0xLjY2LTEuNjZ2LTQuOTkiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU4LjA1LDc1LjU1aC00Ljk5Yy0uOTIsMC0xLjY2Ljc1LTEuNjYsMS42NnY0Ljk5Ii8+PC9nPjxnIGlkPSJiIj48cGF0aCBjbGFzcz0iZCIgZD0ibTQ0LjA4LDQ0LjA3bDMyLjk0LTkuMmMxLjY5LS41MiwyLjY0LTIuMzEsMi4xMi00LS4zLS45OC0xLjA1LTEuNzUtMi4wMS0yLjA5TDYuNzMsMi42N2MtMS42Ny0uNTctMy40OS4zMy00LjA2LDItLjIzLjY2LS4yMywxLjM4LDAsMi4wNWwyNi4xMSw3MC40Yy41OCwxLjY3LDIuNCwyLjU2LDQuMDcsMS45OC45Ny0uMzMsMS43MS0xLjExLDIuMDEtMi4wOWw5LjIyLTMyLjk0WiIvPjwvZz48L3N2Zz4=") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.dest-mass-route-container.apply-filter :deep(.dest-mass-route-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uZHtzdHJva2U6IzAwMDt9LmQsLmV7ZmlsbDpub25lO30uZCwuZSwuZntzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLXdpZHRoOjVweDt9LmUsLmZ7c3Ryb2tlOiMwMTAxMDE7fS5me2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9ImEiPjxsaW5lIGNsYXNzPSJlIiB4MT0iNzMuMDMiIHkxPSI3NS41NSIgeDI9IjY2LjM4IiB5Mj0iNzUuNTUiLz48bGluZSBjbGFzcz0iZSIgeDE9IjY2LjM4IiB5MT0iMTEyLjE2IiB4Mj0iNzMuMDMiIHkyPSIxMTIuMTYiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDc1LjU1aDQuOTljLjkyLDAsMS42Ni43NSwxLjY2LDEuNjZ2NC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iODguMDEiIHkxPSI5Ny4xOCIgeDI9Ijg4LjAxIiB5Mj0iOTAuNTMiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDExMi4xNmg0Ljk5Yy45MiwwLDEuNjYtLjc1LDEuNjYtMS42NnYtNC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iNTEuNCIgeTE9IjkwLjUzIiB4Mj0iNTEuNCIgeTI9Ijk3LjE4Ii8+PHBhdGggY2xhc3M9ImUiIGQ9Im01OC4wNSwxMTIuMTZoLTQuOTljLS45MiwwLTEuNjYtLjc1LTEuNjYtMS42NnYtNC45OSIvPjxwYXRoIGNsYXNzPSJlIiBkPSJtNTguMDUsNzUuNTVoLTQuOTljLS45MiwwLTEuNjYuNzUtMS42NiwxLjY2djQuOTkiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJmIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjxnIGlkPSJjIj48bGluZSBjbGFzcz0iZCIgeDE9Ijk5LjM4IiB5MT0iOTQuMTkiIHgyPSIxMjYuMDEiIHkyPSI5NC4xOSIvPjxsaW5lIGNsYXNzPSJkIiB4MT0iMTEyLjY5IiB5MT0iODAuODciIHgyPSIxMTIuNjkiIHkyPSIxMDcuNTEiLz48L2c+PC9zdmc+") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.text-container:hover:not(.disabled) {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
max-width: 6rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
max-width: 8rem;
}
.dest-mass-route-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.dest-mass-route-table-wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0;
}
.dest-mass-route-table-header-wrapper {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
/* Scrollbar verstecken aber Funktionalität behalten */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.dest-mass-route-table-header-wrapper::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
.dest-mass-route-table {
flex: 1;
overflow-y: auto;
overflow-x: auto;
min-height: 0;
margin: 0;
padding-bottom: 2.4rem;
}
.dest-mass-route-table-header {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.6rem 2.4rem;
justify-content: flex-start;
background-color: #ffffff;
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
min-width: fit-content;
}
.dest-mass-route-table-header-supplier {
width: 24rem;
display: flex;
align-items: center;
flex-shrink: 0;
}
.dest-mass-route-table-header-dest {
display: flex;
justify-content: flex-start;
align-items: center;
width: 35rem;
gap: 0.8rem;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,229 @@
<template>
<div class="dest-mass-route-cell">
<div class="dest-mass-route-dropdown">
<route-dropdown
placeholder="No route selected"
empty-text="No routes"
:disabled="destination.disabled"
:show-d2d-warn="showD2DWarn"
v-model:model-value="this.selectedRoute"
:options="destination.routes"
display-key="routeDisplayString"
value-key="routeCompareString"
@update:modelValue="updateSelectedRoute"
/>
</div>
<div>
<icon-button :disabled="destination.disabled || this.selectedRoute !== 'D2D_ROUTING'" icon="pencilSimple" @click="openD2DModal">icon</icon-button>
</div>
<modal :state="modalState" @close="modalState = false">
<div class="destination-route-modal">
<div>
<div>D2D Rate [EUR]</div>
</div>
<div>
<div class="text-container">
<input :value="this.rateD2d" @blur="validateRateD2d" class="input-field" ref="rate" @keydown.enter="handleEnter('rate', $event)"
autocomplete="off"/>
</div>
</div>
<div>
<div> Lead time [days]</div>
</div>
<div>
<div class="text-container">
<input :value="this.leadTimeD2d" @blur="validateLeadTimeD2d" class="input-field" ref="leadTime" @keydown.enter="handleEnter('leadTime', $event)"
autocomplete="off"/>
</div>
</div>
<div></div>
<div class="destination-route-modal-footer">
<basic-button :show-icon="false" @click="applyD2D">OK</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="dismissD2D">Cancel</basic-button>
</div>
</div>
</modal>
</div>
</template>
<script>
import RouteDropdown from "@/components/UI/RouteDropdown.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Modal from "@/components/UI/Modal.vue";
import DestinationRoute from "@/components/layout/edit/destination/DestinationRoute.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import {parseNumberFromString} from "@/common.js";
export default {
name: "DestinationMassRouteCell",
components: {BasicButton, DestinationRoute, Modal, IconButton, RouteDropdown},
props: {
destination: {
type: Object,
required: true
}
},
data() {
return {
selectedRoute: null,
modalState: false,
rateD2d: null,
leadTimeD2d: null
}
},
computed: {
showD2DWarn() {
return (this.destination.rateD2d === null || this.destination.leadTimeD2d === null || this.destination.rateD2d === 0 || this.destination.leadTimeD2d === 0);
},
},
created() {
if(this.destination.isD2d) {
this.selectedRoute = 'D2D_ROUTING'
} else {
this.selectedRoute = this.destination.selectedRoute;
}
},
methods: {
handleEnter(currentRef, event) {
event.preventDefault();
// Define the navigation order
const inputOrder = ['rate', 'leadTime'];
const currentIndex = inputOrder.indexOf(currentRef);
if(currentIndex >= inputOrder.length - 1) {
this.validateLeadTimeD2d(event);
this.applyD2D();
return;
}
if (currentIndex !== -1 && currentIndex < inputOrder.length - 1) {
const nextRef = inputOrder[currentIndex + 1];
this.$nextTick(() => {
if (this.$refs[nextRef]) {
this.$refs[nextRef].focus();
this.$refs[nextRef].select();
}
});
}
},
updateSelectedRoute(route) {
if(route === 'D2D_ROUTING') {
this.destination.selectedRoute = null;
this.destination.isD2d = true;
} else {
this.destination.selectedRoute = route;
this.destination.isD2d = false;
}
},
applyD2D() {
this.destination.rateD2d = this.rateD2d;
this.destination.leadTimeD2d = this.leadTimeD2d;
this.dismissD2D()
},
dismissD2D() {
this.modalState = false;
},
openD2DModal() {
this.rateD2d = this.destination.rateD2d;
this.leadTimeD2d = this.destination.leadTimeD2d;
this.modalState = true;
},
validateRateD2d(event) {
const value = parseNumberFromString(event.target.value, 2);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed(2);
this.rateD2d = validatedValue === 0 ? null : validatedValue;
event.target.value = stringified;
},
validateLeadTimeD2d(event) {
const value = parseNumberFromString(event.target.value, 0);
const validatedValue = Math.max(0, value);
const stringified = validatedValue.toFixed();
this.leadTimeD2d = validatedValue === 0 ? null : validatedValue;
event.target.value = stringified;
}
}
}
</script>
<style scoped>
.destination-route-modal {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.6rem;
font-size: 1.4rem;
font-weight: 400;
align-items: center;
}
.destination-route-modal-footer {
display: flex;
gap: 0.8rem;
justify-content: flex-end;
}
.dest-mass-route-cell {
width: 35rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
font-size: 1.2rem;
font-weight: 400;
gap: 0.8rem;
flex-shrink: 0;
}
.dest-mass-route-dropdown {
width: 30rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
font-size: 1.4rem;
font-weight: 400;
gap: 0.8rem;
flex-shrink: 0;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.text-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);
}
.text-container:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
}
</style>

View file

@ -0,0 +1,75 @@
<template>
<div class="dest-mass-route-row-container">
<div class="dest-mass-route-row-supplier">
<flag :iso="row.supplier.country.iso_code"/>
{{ row.supplier.name }}
</div>
<destination-mass-route-cell :destination="dest" v-for="dest in row.destinations" class="dest-mass-route-row-dest"
:key="dest.id">
</destination-mass-route-cell>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import Flag from "@/components/UI/Flag.vue";
import RouteDropdown from "@/components/UI/RouteDropdown.vue";
import DestinationMassRouteCell from "@/components/layout/edit/destination/mass/DestinationMassRouteCell.vue";
export default {
name: "DestinationMassRouteRow",
components: {DestinationMassRouteCell, RouteDropdown, Flag, Checkbox},
props: {
row: {
type: Object,
required: true
}
},
computed: {
routes() {
},
},
methods: {
toggleDropdown() {
}
}
}
</script>
<style scoped>
.dest-mass-route-row-container {
display: flex;
flex-direction: row;
gap: 1.6rem;
padding: 1.2rem 2.4rem;
justify-content: flex-start;
border-bottom: 0.16rem solid #f3f4f6;
transition: background-color 0.2s ease;
min-width: fit-content;
}
.dest-mass-route-row-container:hover {
background-color: rgba(107, 134, 156, 0.05);
}
.dest-mass-route-row-supplier {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
width: 24rem;
font-size: 1.4rem;
font-weight: 400;
color: #6b7280;
flex-shrink: 0;
}
</style>

View file

@ -1,7 +1,7 @@
import log from 'loglevel'
if (process.env.NODE_ENV === 'production') {
log.setLevel('debug') //TODO change back to 'silent'
log.setLevel('silent')
} else {
log.setLevel('debug')
}

View file

@ -1,5 +1,4 @@
import router from './router.js';
//import store from './store/index.js';
import {setupErrorBuffer} from './store/notification.js'
import {createApp} from 'vue'
import {createPinia} from 'pinia';
@ -34,7 +33,8 @@ import {
PhTruckTrailer,
PhUpload,
PhWarning,
PhX
PhX,
PhExclamationMark, PhMapPin, PhEmpty, PhShippingContainer, PhPackage, PhVectorThree, PhTag
} from "@phosphor-icons/vue";
import {setupSessionRefresh} from "@/store/activeuser.js";
@ -61,6 +61,8 @@ app.component('PhTruckTrailer', PhTruckTrailer);
app.component('PhTruck', PhTruck);
app.component('PhBoat', PhBoat);
app.component('PhTrain', PhTrain);
app.component('PhEmpty', PhEmpty);
app.component('PhShippingContainer', PhShippingContainer);
app.component('PhPencilSimple', PhPencilSimple);
app.component('PhX', PhX);
app.component('PhCloudArrowUp', PhCloudArrowUp);
@ -74,6 +76,12 @@ app.component('PhFile', PhFile);
app.component("PhDesktop", PhDesktop );
app.component("PhHardDrives", PhHardDrives );
app.component("PhClipboard", PhClipboard );
app.component("PhExclamationMark", PhExclamationMark );
app.component("PhMapPin", PhMapPin);
app.component("PhPackage", PhPackage);
app.component("PhVectorThree", PhVectorThree);
app.component("PhTag", PhTag);
app.use(router);

View file

@ -1,92 +1,145 @@
<template>
<div class="edit-calculation-container" :class="{ 'has-selection': hasSelection }">
<div class="edit-calculation-container"
:class="{ 'has-selection': hasSelection, 'apply-filter': applyFilter, 'add-all': addAll }">
<div class="header-container">
<h2 class="page-header">Mass edit calculation</h2>
<div class="header-controls">
<basic-button :show-icon="false"
:disabled="premiseEditStore.selectedLoading"
variant="secondary"
@click="closeMassEdit"
>Close
<basic-button :show-icon="true"
:disabled="disableButtons"
icon="MapPin" variant="primary"
@click="destMgmt"
>Destination manager
</basic-button>
<basic-button :show-icon="true"
:disabled="premiseEditStore.selectedLoading"
:disabled="disableButtons"
icon="Calculator" variant="primary"
@click="startCalculation"
@click="calculate"
>Calculate & close
</basic-button>
<basic-button :show-icon="false"
:disabled="disableButtons"
variant="secondary"
@click="close"
>Close
</basic-button>
</div>
</div>
<transition name="list-edit-container" tag="div">
<transition-group name="list-edit" mode="out-in" class="edit-calculation-list-container" tag="div">
<div class="edit-calculation-list-header" key="header">
<div class="edit-calculation-list-container">
<div class="edit-calculation-list-header">
<div>
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"></checkbox>
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate" :disabled="!showData"></checkbox>
</div>
<div>Material</div>
<div>Price</div>
<div>Packaging</div>
<div>Supplier</div>
<div>Destinations & routes</div>
<div>Actions</div>
<div class="edit-calculation-list-header-cell edit-calculation-list-header-cell--clickable"
:class="{'edit-calculation-list-header-cell--selected': premiseEditStore.activeSort === 'material'}"
@click="premiseEditStore.sort('material')">Material
<sort-button :active="premiseEditStore.activeSort === 'material'"
:direction="premiseEditStore.directionSort('material')"/>
</div>
<div class="edit-calculation-list-header-cell">Price</div>
<div class="edit-calculation-list-header-cell">Packaging</div>
<div class="edit-calculation-list-header-cell edit-calculation-list-header-cell--clickable"
:class="{'edit-calculation-list-header-cell--selected': premiseEditStore.activeSort === 'supplier'}"
@click="premiseEditStore.sort('supplier')">Supplier
<sort-button :active="premiseEditStore.activeSort === 'supplier'"
:direction="premiseEditStore.directionSort('supplier')"/>
</div>
<div class="edit-calculation-list-header-cell">Annual Quantity</div>
<div class="edit-calculation-list-header-cell">Routes</div>
<div class="edit-calculation-list-header-cell">Actions</div>
</div>
<div v-if="showLoading" class="spinner-container" key="spinner">
<!-- Loading Spinner - außerhalb der TransitionGroup -->
<div v-if="showLoading" class="spinner-container">
<spinner class="space-around"></spinner>
</div>
<div v-else-if="showEmpty" class="empty-container" key="empty">
<!-- Empty State - außerhalb der TransitionGroup -->
<div v-else-if="showEmpty" class="empty-container">
<span class="space-around">No Calculations found.</span>
</div>
<bulk-edit-row v-else class="edit-calculation-list-item" v-for="premise of this.premiseEditStore.getPremisses"
:key="premise.id" :id="premise.id" :premise="premise" @action="onClickAction"
<!-- Rows mit Sort-Animation -->
<transition-group
v-else
name="sort-list"
tag="div"
class="edit-calculation-list-body"
@before-enter="onBeforeEnter"
@enter="onEnter"
>
<bulk-edit-row
v-for="(premise, index) of premises"
:key="premise.id"
:id="premise.id"
:premise="premise"
:data-index="index"
class="edit-calculation-list-item"
@action="onClickAction"
@select="updateCheckBox"
@remove="updateUrl">
</bulk-edit-row>
</transition-group>
</transition>
</div>
<mass-edit-dialog v-if="showData" :show="showMultiselectAction" @action="multiselectAction"
<mass-edit-dialog v-if="showData" :show="showMultiselectAction" @action="onToolbarAction"
:select-count="selectCount"></mass-edit-dialog>
<modal :z-index="2000" :state="showEditModal">
<modal-dialog title="Missing destinations" :state="modalDialogShow"
message="Some of the selected calculations have no destinations set. Would you like to edit the destinations first?"
accept-text="Yes" :deny-text="denyText" @click="modalDialogClick"></modal-dialog>
<modal :z-index="2000" :state="modalShow">
<div class="modal-content-container">
<h3 class="sub-header">{{ modalTitle }}</h3>
<component
:is="componentType"
v-model:partNumber="componentProps.partNumber"
v-model:hsCode="componentProps.hsCode"
v-model:tariffRate="componentProps.tariffRate"
v-model:tariffUnlocked="componentProps.tariffUnlocked"
v-model:description="componentProps.description"
v-model:price="componentProps.price"
v-model:overSeaShare="componentProps.overSeaShare"
v-model:includeFcaFee="componentProps.includeFcaFee"
v-model:length="componentProps.length"
v-model:width="componentProps.width"
v-model:height="componentProps.height"
v-model:weight="componentProps.weight"
v-model:weightUnit="componentProps.weightUnit"
v-model:dimensionUnit="componentProps.dimensionUnit"
v-model:unitCount="componentProps.unitCount"
v-model:mixable="componentProps.mixable"
v-model:stackable="componentProps.stackable"
v-model:hideDescription="componentProps.hideDescription"
:is="modalComponentType"
ref="modalComponent"
v-model:partNumber="modalProps.partNumber"
v-model:hsCode="modalProps.hsCode"
v-model:tariffRate="modalProps.tariffRate"
v-model:tariffUnlocked="modalProps.tariffUnlocked"
v-model:description="modalProps.description"
v-model:price="modalProps.price"
v-model:overSeaShare="modalProps.overSeaShare"
v-model:includeFcaFee="modalProps.includeFcaFee"
v-model:length="modalProps.length"
v-model:width="modalProps.width"
v-model:height="modalProps.height"
v-model:weight="modalProps.weight"
v-model:weightUnit="modalProps.weightUnit"
v-model:dimensionUnit="modalProps.dimensionUnit"
v-model:unitCount="modalProps.unitCount"
v-model:mixable="modalProps.mixable"
v-model:stackable="modalProps.stackable"
v-model:hideDescription="modalProps.hideDescription"
:type="modalType"
:premiseIds="editIds"
:fromMassEdit="true"
:countryId=null
:responsive="false"
@close="closeEditModalAction('cancel')"
@accept="closeEditModalAction('accept')"
>
</component>
<div class="modal-content-footer" >
<basic-button v-if="!modalCloseOnly" :show-icon="false" @click="closeEditModalAction('accept')">OK</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="closeEditModalAction('cancel')"> {{ modalCloseOnly ? "Close" : "Cancel" }}
<div class="modal-content-footer" @keydown="handleKeyDown($event)">
<basic-button v-if="!modalCloseOnly" :show-icon="false" @click="closeEditModalAction('accept')">OK
</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="closeEditModalAction('cancel')">
{{ modalCloseOnly ? "Close" : "Cancel" }}
</basic-button>
</div>
</div>
@ -111,21 +164,31 @@ import Modal from "@/components/UI/Modal.vue";
import PriceEdit from "@/components/layout/edit/PriceEdit.vue";
import MaterialEdit from "@/components/layout/edit/MaterialEdit.vue";
import PackagingEdit from "@/components/layout/edit/PackagingEdit.vue";
import DestinationListView from "@/components/layout/edit/DestinationListView.vue";
import logger from "@/logger.js";
import {useNotificationStore} from "@/store/notification.js";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
import SortButton from "@/components/UI/SortButton.vue";
import DestinationMassEdit from "@/components/layout/edit/destination/mass/DestinationMassEdit.vue";
import DestMassCreate from "@/components/layout/edit/destination/mass/DestMassCreate.vue";
import ModalDialog from "@/components/UI/ModalDialog.vue";
import destinationEdit from "@/components/layout/edit/destination/DestinationEdit.vue";
import logger from "@/logger.js";
const COMPONENT_TYPES = {
price: PriceEdit,
material: MaterialEdit,
packaging: PackagingEdit,
destinations: DestinationListView,
destinations: DestMassCreate,
routes: DestinationMassEdit,
amount: DestinationMassEdit
}
export default {
name: "MassEdit",
components: {
ModalDialog,
SortButton,
Modal,
MassEditDialog,
ListEdit,
@ -135,19 +198,51 @@ export default {
BulkEditRow,
BasicButton
},
data() {
return {
ids: [],
isCtrlPressed: false,
isShiftPressed: false,
overallCheck: false,
overallIndeterminate: false,
bulkQuery: null,
modalTitle: null,
modalType: null,
modalProps: null,
editIds: null,
processingMessage: "Please wait. Calculating ...",
showCalculationModal: false,
isInitialLoad: true,
modalDialogShow: false,
modalStash: null,
denyText: 'No'
}
},
computed: {
...mapStores(usePremiseEditStore, useNotificationStore),
...mapStores(usePremiseEditStore, useNotificationStore, useDestinationEditStore),
disableButtons() {
return this.premiseEditStore.selectedLoading;
},
premises() {
return this.premiseEditStore.getPremisses;
},
hasSelection() {
if (this.premiseEditStore.isLoading || this.premiseEditStore.selectedLoading) {
return false;
}
return this.premiseEditStore.getSelectedPremissesIds?.length > 0;
return !this.addAll && !this.applyFilter && this.premiseEditStore.someChecked;
},
applyFilter() {
return this.isCtrlPressed && this.isShiftPressed;
},
addAll() {
return this.isCtrlPressed && !this.isShiftPressed;
},
showMultiselectAction() {
return this.selectCount > 0;
},
selectCount() {
return this.selectedPremisses?.length ?? 0;
},
selectedPremisses() {
return this.premiseEditStore.getSelectedPremisses;
return this.premiseEditStore.getSelectedPremiseIds?.length ?? 0;
},
showEmpty() {
return this.premiseEditStore.showEmpty;
@ -158,29 +253,18 @@ export default {
showData() {
return this.premiseEditStore.showData;
},
overallCheck() {
return this.premiseEditStore.isLoading ? false : this.premiseEditStore.getPremisses?.every(p => p.selected === true) ?? false;
},
showMultiselectAction() {
return this.selectCount > 0;
},
modalCloseOnly() {
return this.modalType === 'material' && !this.componentProps.tariffUnlocked;
//TODO: fix material editing.
return this.modalType === 'material' && !this.modalProps.tariffUnlocked; //TODO: check all selected.
},
showEditModal() {
modalShow() {
return ((this.modalType ?? null) !== null);
},
componentProps() {
return this.componentData?.props ?? null;
},
componentType() {
modalComponentType() {
return this.modalType ? COMPONENT_TYPES[this.modalType] : null;
},
componentData() {
return this.modalType ? this.componentsData[this.modalType] : null;
},
showProcessingModal() {
return this.premiseEditStore.showProcessingModal || this.showCalculationModal;
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal;
},
shownProcessingMessage() {
return this.processingMessage;
@ -188,52 +272,50 @@ export default {
},
watch: {
showProcessingModal(newState, _) {
if(newState) {
if (newState) {
this.notificationStore.setSpinner(this.shownProcessingMessage);
}
else {
} else {
this.notificationStore.clearSpinner();
}
}
},
created() {
async created() {
this.bulkQuery = this.$route.params.ids;
this.ids = new UrlSafeBase64().decodeIds(this.$route.params.ids);
this.premiseEditStore.loadPremissesForced(this.ids);
},
const premisses = await this.premiseEditStore.load(this.ids);
this.destinationEditStore.setupDestinations(premisses);
data() {
return {
ids: [],
bulkQuery: null,
modalType: null,
componentsData: {
price: {props: {price: 0, overSeaShare: 0, includeFcaFee: false}},
material: {props: {partNumber: "", hsCode: null, tariffRate: null, tariffUnlocked: false, description: "", hideDescription: false}},
packaging: {
props: {
length: 0,
width: 0,
height: 0,
weight: 0,
weightUnit: "KG",
dimensionUnit: "MM",
unitCount: 1,
mixable: true,
stackable: true
}
},
destinations: {props: {}},
mounted() {
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
},
editIds: null,
dataSourceId: null,
processingMessage: "Please wait. Calculating ...",
showCalculationModal: false,
}
beforeUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
updateUrl(id) {
handleKeyDown(event) {
if (event.key === 'Control') {
this.isCtrlPressed = true;
} else if (event.key === 'Shift') {
this.isShiftPressed = true;
}
if (event.key === 'Escape') {
this.fillData(this.modalType);
this.modalType = null;
}
},
handleKeyUp(event) {
if (event.key === 'Control') {
this.isCtrlPressed = false;
} else if (event.key === 'Shift') {
this.isShiftPressed = false;
}
},
updateUrl(id) {
const idx = this.ids.findIndex(curId => curId === id);
if (idx > -1) {
@ -247,88 +329,181 @@ export default {
});
}
},
async startCalculation() {
destMgmt() {
this.fillData('destinations');
this.editIds = null;
this.modalTitle = 'Destination Manager'
this.modalType = 'destinations';
},
async calculate() {
this.showCalculationModal = true;
const error = await this.premiseEditStore.startCalculation();
if (error === null) {
this.closeMassEdit()
this.close()
}
this.showCalculationModal = false;
},
closeMassEdit() {
close() {
this.$router.push({name: "calculation-list"});
},
/* checkbox handling */
updateCheckBox(data) {
this.premiseEditStore.setChecked(data.id, data.checked);
this.updateOverallCheckBox();
},
updateCheckBoxes(value) {
this.premiseEditStore.setAll(value);
this.updateOverallCheckBox();
},
multiselectAction(action) {
this.openModal(action, this.selectedPremisses.map(p => p.id));
updateOverallCheckBox() {
this.overallCheck = this.premiseEditStore.allChecked;
if (!this.overallCheck)
this.overallIndeterminate = this.premiseEditStore.someChecked;
},
/* click listeners */
onToolbarAction(action) {
if (action === 'deselect') {
this.updateCheckBoxes(false);
} else
this.openModal(action, this.premiseEditStore.getSelectedPremiseIds);
},
onClickAction(data) {
const massEdit = 0 !== this.selectCount
this.openModal(data.action, massEdit ? this.premiseEditStore.getSelectedPremissesIds : [data.id], data.id, massEdit);
},
openModal(type, ids, dataSource = -1, massEdit = true) {
if (type !== 'destinations')
this.fillData(type, dataSource, massEdit)
else {
this.premiseEditStore.prepareDestinations(dataSource, ids, massEdit, true);
const actions = data.action.split("-");
if (actions.length === 1) {
const massEdit = 0 !== this.selectCount;
this.openModal(data.action, massEdit ? this.premiseEditStore.getSelectedPremiseIds : [data.id], data.id, massEdit);
} else if (actions.length === 2) { /* ctrl or ctrl + shift */
this.premiseEditStore.setBy(actions[0], actions[1], data.id);
this.updateOverallCheckBox();
}
},
/* modal handling */
modalDialogClick(action) {
this.modalDialogShow = false;
if (action === 'dismiss') {
this.modalStash = null;
return;
}
this.dataSourceId = dataSource !== -1 ? dataSource : null;
if (action === 'deny') {
this.openModal(this.modalStash.type, this.modalStash.ids, this.modalStash.dataSource, this.modalStash.massEdit)
this.modalStash = null;
return;
}
this.destMgmt();
},
openModal(type, ids, dataSource = -1, massEdit = true) {
logger.log("open modal", type, ids, dataSource, massEdit, this.modalStash, this.modalDialogShow)
if ((type === 'amount' || type === 'routes') && this.modalStash === null) {
const state = this.destinationEditStore.checkDestinationAssignment(ids);
if (state === 'some' || state === 'none') {
this.denyText = state === 'none' ? null : 'No'
this.modalDialogShow = true;
// stash for later.
this.modalStash = {type: type, ids: ids, dataSource: dataSource, massEdit: massEdit};
}
}
if ((type === 'amount' || type === 'routes')) {
if (dataSource !== -1)
ids = [dataSource];
}
if (!this.modalDialogShow) {
logger.log("open modal (actual)", type, ids, dataSource, massEdit, this.modalStash, this.modalDialogShow)
this.fillData(type, dataSource, massEdit);
this.editIds = ids;
this.modalType = type;
logger.info("open modal", massEdit, this.modalType, this.editIds, this.dataSourceId)
}
},
async closeEditModalAction(action) {
if (this.modalType === "destinations") {
if (action === "accept") {
await this.premiseEditStore.executeDestinationsMassEdit();
} else {
this.premiseEditStore.cancelMassEdit();
}
} else if (action === "accept") {
const props = this.componentsData[this.modalType].props;
switch (this.modalType) {
case "price":
await this.premiseEditStore.batchUpdatePrice(this.editIds, props);
break;
case "material":
await this.premiseEditStore.batchUpdateMaterial(this.editIds, props);
break;
case "packaging":
await this.premiseEditStore.batchUpdatePackaging(this.editIds, props);
break;
let massUpdate = false;
if (this.modalType === 'amount' || this.modalType === 'routes' || this.modalType === "destinations") {
if (action === 'accept') {
if (this.modalType === "destinations") {
const setMatrix = this.$refs.modalComponent?.destMatrix;
if (setMatrix) {
await this.destinationEditStore.massSetDestinations(setMatrix);
}
} else {
massUpdate = true
}
}
// Clear data
this.fillData(this.modalType);
this.modalType = null;
if(massUpdate) {
await this.destinationEditStore.massUpdateDestinations(this.editIds);
}
if (this.modalStash && action === 'accept') {
setTimeout(() => {
this.openModal(this.modalStash.type, this.modalStash.ids, this.modalStash.dataSource, this.modalStash.massEdit);
this.modalStash = null;
}, 300);
} else {
this.modalStash = null;
}
} else if (action === "accept") {
await this.premiseEditStore.batchUpdate(this.modalType, this.editIds, this.modalProps);
// Clear data
this.fillData(this.modalType);
this.modalType = null;
} else if (action === "cancel") {
// Clear data
this.fillData(this.modalType);
this.modalType = null;
}
},
fillData(type, id = -1, hideDescription = false) {
if (id === -1) {
// clear
this.componentsData = {
price: {props: {price: null, overSeaShare: null, includeFcaFee: null}},
material: {
props: {
if (type === 'price')
this.modalProps = {price: null, overSeaShare: null, includeFcaFee: null};
if (type === 'material')
this.modalProps = {
partNumber: "",
hsCode: null,
tariffRate: null,
tariffUnlocked: false,
description: null,
hideDescription: hideDescription
}
},
packaging: {
props: {
};
if (type === 'packaging')
this.modalProps = {
length: null,
width: null,
height: null,
@ -338,22 +513,27 @@ export default {
unitCount: null,
mixable: true,
stackable: true
}
},
destinations: {props: {}},
};
if (type === 'amount' || type === 'routes' || type === 'destinations') {
this.modalTitle = "Edit destinations";
this.modalProps = {};
}
} else {
const premise = this.premiseEditStore.getById(id);
this.modalTitle = "Edit ".concat(type);
if (type === "price") {
this.componentsData.price.props = {
this.modalProps = {
price: premise.material_cost,
overSeaShare: premise.oversea_share,
includeFcaFee: premise.is_fca_enabled
}
} else if (type === "material") {
this.componentsData.material.props = {
this.modalProps = {
partNumber: premise.material.part_number,
hsCode: premise.hs_code,
tariffRate: premise.tariff_rate ?? null,
@ -363,7 +543,7 @@ export default {
}
} else if (type === "packaging") {
this.componentsData.packaging.props = {
this.modalProps = {
length: premise.handling_unit.length,
width: premise.handling_unit.width,
height: premise.handling_unit.height,
@ -374,17 +554,76 @@ export default {
mixable: premise.is_mixable ?? true,
stackable: premise.is_stackable ?? true
}
} else if (type === 'amount' || type === 'routes' || type === 'destinations') {
this.modalTitle = "Edit destinations";
this.modalProps = {type: type};
}
}
},
/* Animation hooks */
onBeforeEnter(el) {
if (this.isInitialLoad) {
el.style.opacity = 0;
el.style.transform = 'translateY(2rem)';
}
}
,
onEnter(el, done) {
if (this.isInitialLoad) {
const index = parseInt(el.dataset.index) || 0;
const delay = index * 50; // 50ms Verzögerung pro Element
setTimeout(() => {
el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
el.style.opacity = 1;
el.style.transform = 'translateY(0)';
// Cleanup nach Animation
setTimeout(() => {
el.style.transition = '';
el.style.opacity = '';
el.style.transform = '';
done();
// Nach dem letzten Element isInitialLoad deaktivieren
if (index === this.premises.length - 1) {
this.isInitialLoad = false;
}
}, 400);
}, delay);
} else {
done();
}
}
}
}
</script>
<style scoped>
.sub-header {
flex-shrink: 0; /* Prevent header from shrinking */
margin-bottom: 1.6rem;
}
/* Global style für copy-mode cursor */
.edit-calculation-container.has-selection :deep(.copyable-cell:hover) {
cursor: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPgogIDxyZWN0IHg9Ijg0IiB5PSIzMiIgd2lkdGg9IjEzNiIgaGVpZ2h0PSIxMzYiIGZpbGw9IndoaXRlIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iOCIgcng9IjQiLz4KICA8cmVjdCB4PSIzNiIgeT0iODQiIHdpZHRoPSIxMzYiIGhlaWdodD0iMTM2IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjgiIHJ4PSI0Ii8+Cjwvc3ZnPg==") 12 12, pointer;
.edit-calculation-container.has-selection :deep(.edit-calculation-list-header-cell--copyable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDExMi41MWgtMi4yNGMtMS4yNCwwLTIuMjQtMS0yLjI0LTIuMjR2LTIuMjQiLz48bGluZSBjbGFzcz0iYyIgeDE9IjcwLjU3IiB5MT0iNzYuNjciIHgyPSI2My44NSIgeTI9Ijc2LjY3Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI3MC41NyIgeTE9IjExMi41MSIgeDI9IjY2LjA5IiB5Mj0iMTEyLjUxIi8+PGxpbmUgY2xhc3M9ImMiIHgxPSI4Ni4yNSIgeTE9Ijk5LjA3IiB4Mj0iODYuMjUiIHkyPSI5Mi4zNSIvPjxsaW5lIGNsYXNzPSJjIiB4MT0iNTAuNDEiIHkxPSI5Ni44MyIgeDI9IjUwLjQxIiB5Mj0iOTIuMzUiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTgxLjc3LDExMi41MWgyLjI0YzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0yLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS43Nyw3Ni42N2gyLjI0YzEuMjQsMCwyLjI0LDEsMi4yNCwyLjI0djIuMjQiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU0Ljg5LDc2LjY3aC0yLjI0Yy0xLjI0LDAtMi4yNCwxLTIuMjQsMi4yNHYyLjI0Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04Ni4yNSw5OS4wN2gxMS4yYzEuMjQsMCwyLjI0LTEsMi4yNC0yLjI0di0zMS4zNmMwLTEuMjQtMS0yLjI0LTIuMjQtMi4yNGgtMzEuMzZjLTEuMjQsMC0yLjI0LDEtMi4yNCwyLjI0djExLjIiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJkIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjwvc3ZnPg==") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.edit-calculation-container.add-all :deep(.bulk-edit-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uY3tmaWxsOm5vbmU7fS5jLC5ke3N0cm9rZTojMDEwMTAxO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6NXB4O30uZHtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjxnIGlkPSJhIj48bGluZSBjbGFzcz0iYyIgeDE9IjczLjAzIiB5MT0iNzUuNTUiIHgyPSI2Ni4zOCIgeTI9Ijc1LjU1Ii8+PGxpbmUgY2xhc3M9ImMiIHgxPSI2Ni4zOCIgeTE9IjExMi4xNiIgeDI9IjczLjAzIiB5Mj0iMTEyLjE2Ii8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSw3NS41NWg0Ljk5Yy45MiwwLDEuNjYuNzUsMS42NiwxLjY2djQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9Ijg4LjAxIiB5MT0iOTcuMTgiIHgyPSI4OC4wMSIgeTI9IjkwLjUzIi8+PHBhdGggY2xhc3M9ImMiIGQ9Im04MS4zNSwxMTIuMTZoNC45OWMuOTIsMCwxLjY2LS43NSwxLjY2LTEuNjZ2LTQuOTkiLz48bGluZSBjbGFzcz0iYyIgeDE9IjUxLjQiIHkxPSI5MC41MyIgeDI9IjUxLjQiIHkyPSI5Ny4xOCIvPjxwYXRoIGNsYXNzPSJjIiBkPSJtNTguMDUsMTEyLjE2aC00Ljk5Yy0uOTIsMC0xLjY2LS43NS0xLjY2LTEuNjZ2LTQuOTkiLz48cGF0aCBjbGFzcz0iYyIgZD0ibTU4LjA1LDc1LjU1aC00Ljk5Yy0uOTIsMC0xLjY2Ljc1LTEuNjYsMS42NnY0Ljk5Ii8+PC9nPjxnIGlkPSJiIj48cGF0aCBjbGFzcz0iZCIgZD0ibTQ0LjA4LDQ0LjA3bDMyLjk0LTkuMmMxLjY5LS41MiwyLjY0LTIuMzEsMi4xMi00LS4zLS45OC0xLjA1LTEuNzUtMi4wMS0yLjA5TDYuNzMsMi42N2MtMS42Ny0uNTctMy40OS4zMy00LjA2LDItLjIzLjY2LS4yMywxLjM4LDAsMi4wNWwyNi4xMSw3MC40Yy41OCwxLjY3LDIuNCwyLjU2LDQuMDcsMS45OC45Ny0uMzMsMS43MS0xLjExLDIuMDEtMi4wOWw5LjIyLTMyLjk0WiIvPjwvZz48L3N2Zz4=") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
/* Global style für filter-mode cursor */
.edit-calculation-container.apply-filter :deep(.bulk-edit-row__cell--filterable:hover) {
cursor: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDEyOC41MSAxMzQuMDUiPjxkZWZzPjxzdHlsZT4uZHtzdHJva2U6IzAwMDt9LmQsLmV7ZmlsbDpub25lO30uZCwuZSwuZntzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLXdpZHRoOjVweDt9LmUsLmZ7c3Ryb2tlOiMwMTAxMDE7fS5me2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9ImEiPjxsaW5lIGNsYXNzPSJlIiB4MT0iNzMuMDMiIHkxPSI3NS41NSIgeDI9IjY2LjM4IiB5Mj0iNzUuNTUiLz48bGluZSBjbGFzcz0iZSIgeDE9IjY2LjM4IiB5MT0iMTEyLjE2IiB4Mj0iNzMuMDMiIHkyPSIxMTIuMTYiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDc1LjU1aDQuOTljLjkyLDAsMS42Ni43NSwxLjY2LDEuNjZ2NC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iODguMDEiIHkxPSI5Ny4xOCIgeDI9Ijg4LjAxIiB5Mj0iOTAuNTMiLz48cGF0aCBjbGFzcz0iZSIgZD0ibTgxLjM1LDExMi4xNmg0Ljk5Yy45MiwwLDEuNjYtLjc1LDEuNjYtMS42NnYtNC45OSIvPjxsaW5lIGNsYXNzPSJlIiB4MT0iNTEuNCIgeTE9IjkwLjUzIiB4Mj0iNTEuNCIgeTI9Ijk3LjE4Ii8+PHBhdGggY2xhc3M9ImUiIGQ9Im01OC4wNSwxMTIuMTZoLTQuOTljLS45MiwwLTEuNjYtLjc1LTEuNjYtMS42NnYtNC45OSIvPjxwYXRoIGNsYXNzPSJlIiBkPSJtNTguMDUsNzUuNTVoLTQuOTljLS45MiwwLTEuNjYuNzUtMS42NiwxLjY2djQuOTkiLz48L2c+PGcgaWQ9ImIiPjxwYXRoIGNsYXNzPSJmIiBkPSJtNDQuMDgsNDQuMDdsMzIuOTQtOS4yYzEuNjktLjUyLDIuNjQtMi4zMSwyLjEyLTQtLjMtLjk4LTEuMDUtMS43NS0yLjAxLTIuMDlMNi43MywyLjY3Yy0xLjY3LS41Ny0zLjQ5LjMzLTQuMDYsMi0uMjMuNjYtLjIzLDEuMzgsMCwyLjA1bDI2LjExLDcwLjRjLjU4LDEuNjcsMi40LDIuNTYsNC4wNywxLjk4Ljk3LS4zMywxLjcxLTEuMTEsMi4wMS0yLjA5bDkuMjItMzIuOTRaIi8+PC9nPjxnIGlkPSJjIj48bGluZSBjbGFzcz0iZCIgeDE9Ijk5LjM4IiB5MT0iOTQuMTkiIHgyPSIxMjYuMDEiIHkyPSI5NC4xOSIvPjxsaW5lIGNsYXNzPSJkIiB4MT0iMTEyLjY5IiB5MT0iODAuODciIHgyPSIxMTIuNjkiIHkyPSIxMDcuNTEiLz48L2c+PC9zdmc+") 12 12, pointer;
background-color: #f8fafc;
border-radius: 0.8rem;
}
@ -398,8 +637,6 @@ export default {
.modal-content-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
margin-top: 1.6rem;
min-width: 50rem;
}
@ -409,25 +646,19 @@ export default {
gap: 1.6rem;
}
/* Container Animation */
/* Sort Animation für Rows */
.sort-list-move {
transition: transform 0.4s ease;
}
.list-edit-enter-from {
/* Verhindere Animation während des Entfernens */
.sort-list-leave-active {
position: absolute;
opacity: 0;
transform: translateY(-20px);
max-height: 0;
transition: opacity 0.2s ease;
}
.list-edit-leave-to {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
}
.list-edit-enter-active,
.list-edit-leave-active {
transition: all 0.4s ease;
overflow: hidden;
}
/* Enter-Animation wird via JavaScript gesteuert für staggered effect */
.spinner-container {
@ -450,13 +681,15 @@ export default {
overflow: hidden;
margin-top: 3rem;
margin-bottom: 3rem;
}
.edit-calculation-list-body {
position: relative;
}
.edit-calculation-list-header {
display: grid;
grid-template-columns: 6rem 1fr 1fr 1.5fr 1.5fr 1.5fr 10rem;
grid-template-columns: 6rem 0.8fr 0.7fr 1fr 1fr 1.2fr 2fr 10rem;
gap: 1.6rem;
padding: 2.4rem;
background-color: #ffffff;
@ -468,6 +701,24 @@ export default {
letter-spacing: 0.08rem;
}
.edit-calculation-list-header-cell {
display: flex;
align-items: center;
gap: 0.8rem;
}
.edit-calculation-list-header-cell--copyable {
}
.edit-calculation-list-header-cell--clickable {
cursor: pointer;
user-select: none;
}
.edit-calculation-list-header-cell--selected {
color: #002F54;
}
.edit-calculation-container {
display: flex;
flex-direction: column;

View file

@ -174,11 +174,10 @@ export default {
},
close() {
if (this.bulkEditQuery) {
//TODO: deselect and save
// this.premiseEditStore.deselectPremise();
//TODO: deselect element and save
this.$router.push({name: 'bulk', params: {ids: this.bulkEditQuery}});
} else {
//TODO: deselect and save
//TODO: deselect element and save
this.$router.push({name: 'home'});
}
},
@ -215,6 +214,13 @@ export default {
flex-direction: column;
}
.edit-calculation-spinner-container
{
display: flex;
padding-top: 10rem;
justify-content: center;
}
.header-container {
display: flex;
justify-content: space-between;

View file

@ -29,6 +29,8 @@ import {mapStores} from "pinia";
import {useActiveUserStore} from "@/store/activeuser.js";
import Apps from "@/components/layout/config/Apps.vue";
import Users from "@/components/layout/config/Users.vue";
import CalculationDumpList from "@/components/layout/dev/CalculationDumpList.vue";
import CalculationDumps from "@/components/layout/config/CalculationDumps.vue";
export default {
name: "Config",
@ -56,6 +58,11 @@ export default {
component: markRaw(ErrorLog),
props: {isSelected: false},
},
calculationDump: {
title: 'Calculation dump',
component: markRaw(CalculationDumps),
props: {isSelected: false},
},
materialsTab: {
title: 'Materials',
component: markRaw(Materials),
@ -89,6 +96,7 @@ export default {
}
if (this.activeUserStore.isService) {
tabs.push(this.calculationDump);
tabs.push(this.appsTab);
}

View file

@ -109,6 +109,7 @@ const router = createRouter({
},
{
path: '/config',
name: 'config',
component: Config,
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
@ -121,7 +122,7 @@ const router = createRouter({
},
},
{
path: '/dev/dump/:id',
path: '/dumps/dump/:id',
component: CalculationDump,
name: 'dev-calculation-dump'

View file

@ -26,7 +26,7 @@ export const useActiveUserStore = defineStore('activeUser', {
allowReporting(state) {
if (state.user === null)
return false;
return state.user.groups?.includes("super") || state.user.groups?.includes("freight") || state.user.groups?.includes("packaging") || state.user.groups?.includes("material") || state.user.groups?.includes("basic") || state.user.groups?.includes("calculation");
return state.user.groups?.includes("super") || state.user.groups?.includes("basic") || state.user.groups?.includes("calculation");
},
isSuper(state) {
if (state.user === null)

View file

@ -0,0 +1,224 @@
import {defineStore} from 'pinia'
import performRequest from "@/backend.js";
import {config} from '@/config'
export const useDestinationEditStore = defineStore('destinationEdit', {
state: () => ({
destinations: null,
loading: false,
initialized: false,
handlingCostMatrix: null,
quantityMatrix: null,
routeMatrix: null
}),
getters: {
checkDestinationAssignment(state) {
return (ids) => {
let some = false;
let all = true;
ids.forEach(id => {
const dest = state.destinations?.get(id);
if ((dest ?? null) === null || dest.length === 0)
all = false;
else
some = true;
});
if (all)
return "all";
else if (some)
return "some";
else
return "none";
}
},
getByPremiseId(state) {
return (id) => {
return state.destinations?.get(id);
}
},
getByPremiseIds(state) {
return (ids) => {
return new Map(
[...state.destinations].filter(([premiseId, destinations]) => ids.includes(premiseId))
);
}
},
showProcessingModal(state) {
return state.loading;
},
getHandlingCostMatrix(state) {
return state.handlingCostMatrix;
},
getQuantityMatrix(state) {
return state.quantityMatrix;
},
getRouteMatrix(state) {
return state.routeMatrix;
}
},
actions: {
setHandlingCostMatrix(handlingCostMatrix) {
this.handlingCostMatrix = handlingCostMatrix;
},
setQuantityMatrix(quantityMatrix) {
this.quantityMatrix = quantityMatrix;
},
setRouteMatrix(routeMatrix) {
this.routeMatrix = routeMatrix;
},
setupDestinations(premisses) {
this.loading = true;
const temp = new Map();
premisses.forEach(p => temp.set(p.id, p.destinations));
this.destinations = temp;
this.initialized = true;
this.loading = false;
},
async massSetDestinations(updateMatrix) {
this.loading = true;
const toBeAdded = {};
const toBeDeletedMap = new Map();
updateMatrix.forEach(row => {
toBeAdded[row.id] = row.destinations.filter(d => d.selected).map(d => d.id);
toBeDeletedMap.set(row.id, row.destinations.filter(d => !d.selected).map(d => d.id));
});
const url = `${config.backendUrl}/calculation/destination`;
const {
data: data,
headers: headers
} = await performRequest(this, 'POST', url, {'destination_node_ids': toBeAdded});
this.destinations.forEach((destinations, premiseId) => {
const toBeDeleted = toBeDeletedMap.get(premiseId);
const filtered = destinations !== null ? destinations.filter(d => !toBeDeleted?.includes(d.destination_node.id)) : [];
this.destinations.set(premiseId, [...filtered, ...data[premiseId]]);
});
this.loading = false;
},
async massUpdateDestinations(premiseIds) {
this.loading = true;
await new Promise(resolve => setTimeout(() => {
this.updateQuantity();
resolve();
}, 10));
await new Promise(resolve => setTimeout(() => {
this.updateHandlingCosts();
resolve();
}, 10));
await new Promise(resolve => setTimeout(() => {
this.updateRoutes();
resolve();
}, 10));
const destinationMap = new Map();
premiseIds.forEach(premiseId => {
this.destinations.get(premiseId)?.forEach(toUpdate => {
destinationMap.set(toUpdate.id,{
annual_amount: toUpdate.annual_amount,
repackaging_costs: toUpdate.repackaging_costs,
handling_costs: toUpdate.handling_costs,
disposal_costs: toUpdate.disposal_costs,
is_d2d: toUpdate.is_d2d,
rate_d2d: toUpdate.rate_d2d,
lead_time_d2d: toUpdate.lead_time_d2d,
route_selected_id: toUpdate.routes.find(r => r.is_selected)?.id ?? null,
})
} )
});
await performRequest(this, 'PUT', `${config.backendUrl}/calculation/destination/all`, {destinations: Object.fromEntries(destinationMap)}, false);
this.loading = false;
},
updateRoutes() {
this.routeMatrix.forEach(row => {
row.ids.forEach(premiseId => {
row.destinations.forEach(destinationUpdateInfo => {
const destOfCurPremisses = this.destinations.get(premiseId);
if ((destOfCurPremisses ?? null) !== null) {
const destOfCurPremise = destOfCurPremisses.find(d => destinationUpdateInfo.destinationNodeId === d.destination_node.id);
if(destOfCurPremisses && destinationUpdateInfo.ids.includes(destOfCurPremise?.id)) {
/* set d2d stuff */
destOfCurPremise.is_d2d = destinationUpdateInfo.isD2d;
destOfCurPremise.rate_d2d = destinationUpdateInfo.rateD2d;
destOfCurPremise.lead_time_d2d = destinationUpdateInfo.leadTimeD2d;
/* set selected route */
const selectedRoute = destinationUpdateInfo.routes?.find(r => r.routeCompareString === destinationUpdateInfo.selectedRoute);
destOfCurPremise.routes.forEach(r => r.is_selected = false);
if(selectedRoute) {
const routeOfCurPremise = destOfCurPremise.routes.find(r => selectedRoute.ids.includes(r.id));
if(routeOfCurPremise) {
routeOfCurPremise.is_selected = true;
}
}
}
}
});
});
});
},
updateHandlingCosts() {
this.handlingCostMatrix.forEach(row => {
const destinations = this.destinations.get(row.id);
if ((destinations ?? null) !== null) {
const destination = destinations.find(dest => dest.id === row.destinationId);
if ((destination ?? null) !== null) {
destination.disposal_costs = row.disposal_costs;
destination.repackaging_costs = row.repackaging_costs;
destination.handling_costs = row.handling_costs;
}
}
});
},
updateQuantity() {
this.quantityMatrix.forEach(row => {
const destinations = this.destinations.get(row.id);
if ((destinations ?? null) !== null) {
row.destinations
.filter(newDest => newDest.id !== null)
.forEach(newDest => {
const found = destinations.find(dest => dest.id === newDest.id);
if (found)
found.annual_amount = newDest.annual_amount;
});
}
});
}
}
});

View file

@ -95,8 +95,8 @@ export const useNotificationStore = defineStore('notification', {
this.stopAutoSubmitTimer()
this.sendCache = [];
} else {
console.error("Error transmitting errors: " + url, params);
console.error(response, await response?.text());
logger.error("Error transmitting errors: " + url, params);
logger.error(response, await response?.text());
this.startAutoSubmitTimer();
}
},
@ -128,7 +128,7 @@ export const useNotificationStore = defineStore('notification', {
const pinia = this.$pinia || getActivePinia()
if (pinia && pinia._s) {
pinia._s.forEach((store, storeId) => {
if (storeId !== 'error' && storeId !== 'errorLog' && store.$state) {
if (storeId !== 'notification' && storeId !== 'errorLog' && store.$state) {
storeState[storeId] = {
...toRaw(store.$state)
}
@ -137,7 +137,7 @@ export const useNotificationStore = defineStore('notification', {
}
}
} catch (err) {
console.warn('Failed to capture store state:', err);
logger.warn('Failed to capture store state:', err);
return {};
}

View file

@ -1,7 +1,5 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {toRaw} from "vue";
import {useNotificationStore} from "@/store/notification.js";
import logger from "@/logger.js"
import performRequest from '@/backend.js'
@ -9,6 +7,9 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
state() {
return {
premisses: null,
selectedIds: [],
sortedBy: 'id',
order: new Map([['id', 'desc'], ['material', 'desc'], ['supplier', 'desc']]),
/**
* set to true while the store is loading the premises.
@ -20,56 +21,11 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
*/
selectedLoading: false,
destinations: null,
processDestinationMassEdit: false,
selectedDestination: null,
throwsException: true,
}
},
getters: {
getCountryIdByPremiseIds(state) {
return function (ids) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
const premiss = state.premisses?.filter(p => ids.some(id => id === p.id));
const premiseCountryMap = new Map();
premiss?.forEach(premise => {
premiseCountryMap.set(premise.id, premise.supplier?.country?.id);
});
return premiseCountryMap;
}
},
/**
* Returns the ids of all premises.
* @param state
* @returns {*}
*/
getPremiseIds(state) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses?.map(p => p.id);
},
/**
* Returns the premises.
* @param state
@ -113,7 +69,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
* @param state
* @returns {T[]}
*/
getSelectedPremisses(state) {
getSelectedPremiseIds(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
@ -121,24 +77,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
return null;
}
return state.premisses.filter(p => p.selected);
},
/**
* Returns all premise ids that are selected.
* @param state
* @returns {T[]}
*/
getSelectedPremissesIds(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected).map(p => p.id);
return state.selectedIds;
},
/**
@ -156,7 +95,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
/**
* Returns true if the premises are loaded and not empty. The frontend can show a the loaded premisses.
* Returns true if the premises are loaded and not empty. The frontend can show the loaded premisses.
* @param state
* @returns {boolean}
*/
@ -178,76 +117,108 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
/**
* Getters for single edit view
* ============================
* Getters for controlling getters
* ======================================
*/
/**
* Returns true if only one premise is selected.
* @param state
* @returns {boolean|null}
*/
isSingleSelect(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
allChecked(state) {
if (state.premisses.length > state.selectedIds.length)
return false;
return null;
for (const premise of state.premisses) {
if (!state.selectedIds.includes(premise.id))
return false;
}
return state.premisses.filter(p => p.selected).length === 1;
return state.premisses.length !== 0;
},
/**
* Returns the id of the single selected premise.
* @param state
* @returns {*}
*/
singleSelectId(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
someChecked(state) {
for (const premise of state.premisses) {
if (state.selectedIds.includes(premise.id))
return true;
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses.find(p => p.selected)?.id;
return false;
},
/**
* Returns the single selected premise.
* @param state
* @returns {*}
*/
singleSelectedPremise(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
isChecked(state) {
return (id) => {
return state.selectedIds.includes(id);
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses?.find(p => p.selected);
},
activeSort(state) {
return state.sortedBy;
},
directionSort(state) {
return (sort) => {
return state.order.get(sort);
}
}
},
actions: {
sort(type) {
this.loading = true;
const direction = (type !== this.sortedBy) ? this.order.get(type) : (this.order.get(type) === 'asc' ? 'desc' : 'asc');
const temp = this.premisses.slice();
temp.sort((a, b) => {
if (type === 'material')
return direction === 'asc' ?
a.material.part_number.localeCompare(b.material.part_number) :
b.material.part_number.localeCompare(a.material.part_number);
else if (type === 'supplier')
return direction === 'asc' ?
a.supplier.name.localeCompare(b.supplier.name) :
b.supplier.name.localeCompare(a.supplier.name);
else return a.id - b.id;
});
this.premisses = temp;
this.sortedBy = type;
this.order.set(type, direction);
this.loading = false;
},
async startCalculation() {
const body = this.premisses.map(p => p.id);
const url = `${config.backendUrl}/calculation/start/`;
let error = null;
await performRequest(this, 'PUT', url, body, false, ['Premiss validation error', 'Internal Server Error']).catch(e => {
logger.log("startCalculation exception", e.errorObj);
error = e.errorObj;
})
return error;
},
/**
* Save
*/
async batchUpdate(type, ids, data) {
switch (type) {
case 'price':
this.batchUpdatePrice(ids, data);
break;
case 'material':
this.batchUpdateMaterial(ids, data);
break;
case 'packaging':
this.batchUpdatePackaging(ids, data);
break;
}
},
async batchUpdatePrice(ids, priceData) {
const updatedPremises = this.premisses.map(p => {
if (ids.includes(p.id)) {
return {
@ -279,13 +250,10 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
});
this.premisses = updatedPremises;
return await this.saveMaterial(ids, materialData);
},
async batchUpdatePackaging(ids, packagingData) {
const updatedPremises = this.premisses.map(p => {
if (ids.includes(p.id)) {
return {
@ -308,354 +276,13 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
});
this.premisses = updatedPremises;
logger.info("packaging data:", toRaw(packagingData), "update result", toRaw(updatedPremises));
return await this.savePackaging(ids, packagingData);
},
async startCalculation() {
const body = this.premisses.map(p => p.id);
const url = `${config.backendUrl}/calculation/start/`;
let error = null;
await performRequest(this, 'PUT', url, body, false, ['Premiss validation error', 'Internal Server Error']).catch(e => {
logger.log("startCalculation exception", e.errorObj);
error = e.errorObj;
})
return error;
},
/**
* DESTINATION stuff
* =================
*/
prepareDestinations(dataSourcePremiseId, editedPremiseIds, massEdit = false, fromMassEditView = false) {
if (this.premisses === null) return;
if (!editedPremiseIds || !dataSourcePremiseId || editedPremiseIds.length === 0) return;
this.destinations = {
premise_ids: editedPremiseIds,
massEdit: massEdit,
fromMassEditView: fromMassEditView,
destinations: this.premisses.find(p => String(p.id) === String(dataSourcePremiseId))?.destinations.map(d => this.copyAllFromPremises(d, !massEdit)) ?? [],
};
this.selectedDestination = null;
},
async executeDestinationsMassEdit() {
if (!this.destinations.massEdit) {
this.destinations.premise_ids.forEach(premiseId => {
const toPremise = this.getById(premiseId);
this.destinations.destinations.forEach(fromDest => {
const toDest = toPremise.destinations.find(to => fromDest.id.substring(1) === String(to.id));
if ((toDest ?? null) === null) {
throw new Error("Destination not found in premise: " + premiseId + " -> " + d.id);
}
this.copyAllToPremise(fromDest, toDest);
const body = {
annual_amount: toDest.annual_amount,
repackaging_costs: toDest.repackaging_costs,
handling_costs: toDest.handling_costs,
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
performRequest(this, 'PUT', url, body, false);
});
});
} else {
this.processDestinationMassEdit = true;
const destinations = [];
this.destinations.destinations.forEach(d => {
const dest = {
destination_node_id: d.destination_node.id,
annual_amount: d.annual_amount,
disposal_costs: d.userDefinedHandlingCosts ? d.disposal_costs : null,
repackaging_costs: d.userDefinedHandlingCosts ? d.repackaging_costs : null,
handling_costs: d.userDefinedHandlingCosts ? d.handling_costs : null,
}
destinations.push(dest);
})
const body = {destinations: destinations, premise_id: this.destinations.premise_ids};
const url = `${config.backendUrl}/calculation/destination/`;
const {data: data, headers: headers} = await performRequest(this, 'PUT', url, body).catch(e => {
this.destinations = null;
this.processDestinationMassEdit = false;
});
if (data) {
for (const id of Object.keys(data)) {
this.premisses.find(p => String(p.id) === id).destinations = data[id];
}
}
this.destinations = null;
this.processDestinationMassEdit = false;
}
},
cancelMassEdit() {
this.destinations = null;
},
copyAllFromPremises(from, fullCopy = true) {
const d = {};
d.id = `e${from.id}`;
d.destination_node = structuredClone(toRaw(from.destination_node));
d.routes = fullCopy ? structuredClone(toRaw(from.routes)) : null;
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
d.handling_costs = from.handling_costs;
d.disposal_costs = from.disposal_costs;
d.repackaging_costs = from.repackaging_costs;
d.userDefinedHandlingCosts = from.handling_costs !== null || from.disposal_costs !== null || from.repackaging_costs !== null;
return d;
},
copyAllToPremise(from, to, fullCopy = true) {
const d = to ?? {};
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
d.lead_time_d2d = from.is_d2d ? from.lead_time_d2d : null;
if (from.userDefinedHandlingCosts) {
d.disposal_costs = from.disposal_costs;
d.repackaging_costs = from.repackaging_costs;
d.handling_costs = from.handling_costs;
} else {
d.disposal_costs = null;
d.repackaging_costs = null;
d.handling_costs = null;
}
if (fullCopy && (from.routes ?? null) !== null) {
to.routes.forEach(route => route.is_selected = from.routes.find(r => r.id === route.id)?.is_selected ?? false);
}
return d;
},
/**
* Selects all destinations for the given "ids" for editing.
* This creates a copy of the destination with id "id".
* They are written back as soon as the user closes the dialog.
*/
selectDestination(id) {
if (this.premisses === null) return;
logger.info("selectDestination:", id)
const dest = this.destinations.destinations.find(d => d.id === id);
if ((dest ?? null) == null) {
const error = {
code: 'Frontend error.',
message: `Destination not found: ${id}. Please contact support.`,
trace: null
}
throw new Error("Internal frontend error: Destination not found: " + id);
}
this.selectedDestination = structuredClone(toRaw(dest));
},
async deselectDestinations(save = false) {
if (this.premisses === null) return;
if (save) {
const idx = this.destinations.destinations.findIndex(d => d.id === this.selectedDestination.id);
this.destinations.destinations.splice(idx, 1, this.selectedDestination);
if (!this.destinations.fromMassEditView) {
//TODO write trough backend if no massEdit
const toDest = this.singleSelectedPremise.destinations.find(to => this.selectedDestination.id.substring(1) === String(to.id));
this.copyAllToPremise(this.selectedDestination, toDest);
const body = {
annual_amount: toDest.annual_amount,
repackaging_costs: toDest.repackaging_costs,
handling_costs: toDest.handling_costs,
disposal_costs: toDest.disposal_costs,
is_d2d: toDest.is_d2d,
rate_d2d: toDest.rate_d2d,
lead_time_d2d: toDest.lead_time_d2d,
route_selected_id: toDest.routes.find(r => r.is_selected)?.id ?? null,
};
logger.info(body)
const url = `${config.backendUrl}/calculation/destination/${toDest.id}`;
await performRequest(this, 'PUT', url, body, false);
}
}
this.selectedDestination = null;
},
async deleteDestination(id) {
/*
* 1. delete from destinations copy
*/
const idx = this.destinations.destinations.findIndex(d => d.id === id);
if (idx === -1) {
logger.info("Destination not found in mass edit: , id)");
return;
}
this.destinations.destinations.splice(idx, 1);
/*
* 2. delete from backend if not mass edit
*/
if (!this.destinations.massEdit && id.startsWith('e')) { /* 'v'-ids cannot be deleted because they only exist in the frontend */
if (this.premisses === null) return;
const origId = id.substring(1);
const url = `${config.backendUrl}/calculation/destination/${origId}`;
await performRequest(this, 'DELETE', url, null, false).catch(async e => {
logger.error("Unable to delete destination: " + origId + "");
logger.error(e);
await this.loadPremissesIfNeeded(this.premisses.map(p => p.id));
});
for (const p of this.premisses) {
const toBeDeleted = p.destinations.findIndex(d => String(d.id) === String(origId))
logger.info(toBeDeleted)
if (toBeDeleted !== -1) {
p.destinations.splice(toBeDeleted, 1)
break;
}
}
}
},
async addDestination(node) {
if (this.destinations.massEdit) {
const existing = this.destinations.destinations.find(d => d.destination_node.id === node.id);
logger.info(existing)
if ((existing ?? null) !== null) {
logger.info("Destination already exists", node.id);
return [existing.id];
}
const destination = {
id: `v${node.id}`,
destination_node: structuredClone(toRaw(node)),
massEdit: true,
annual_amount: 0,
is_d2d: false,
rate_d2d: null,
lead_time_d2d: null,
disposal_costs: null,
repackaging_costs: null,
handling_costs: null,
userDefinedHandlingCosts: false,
};
this.destinations.destinations.push(destination);
return [destination.id];
} else {
const id = node.id;
this.processDestinationMassEdit = true;
const toBeUpdated = this.destinations.fromMassEditView ? this.destinations.premise_ids : this.premisses?.filter(p => p.selected).map(p => p.id);
if (toBeUpdated === null || toBeUpdated.length === 0) return;
const body = {destination_node_id: id, premise_id: toBeUpdated};
const url = `${config.backendUrl}/calculation/destination/`;
const {data: destinations} = await performRequest(this, 'POST', url, body).catch(e => {
this.loading = false;
this.selectedLoading = false;
this.processDestinationMassEdit = false;
throw e;
});
const mappedIds = []
for (const id of Object.keys(destinations)) {
const premise = this.premisses.find(p => String(p.id) === id)
premise.destinations.push(destinations[id]);
const mappedDestination = this.copyAllFromPremises(destinations[id], true);
mappedIds.push(mappedDestination.id);
this.destinations.destinations.push(mappedDestination);
}
this.processDestinationMassEdit = false;
return mappedIds;
}
},
/**
* Replace the premisses with the loaded ones by id.
* This is used to update the premisses after a "Set" change.
* @param premisses
* @param loadedData
* @returns {*}
*/
replacePremissesById(premisses, loadedData) {
const replacementMap = new Map(loadedData.map(obj => [obj.id, obj]));
const replaced = premisses.map(obj => replacementMap.get(obj.id) || obj);
logger.info("Replaced", replaced);
return replaced;
},
/**
* Save
*/
async savePrice(ids = null, priceData = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.selectedIds.map(id => this.premisses.find(p => String(p.id) === String(id))))) : null;
if (!toBeUpdated?.length) return;
@ -675,7 +302,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
async savePackaging(ids = null, packagingData = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => this.selectedIds.includes(p.id)))) : null;
if (!toBeUpdated?.length) return;
@ -708,7 +335,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
},
async saveMaterial(ids = null, materialData = null) {
let success = true;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => p.selected))) : null;
const toBeUpdated = this.premisses ? (ids ? (ids.map(id => this.premisses.find(p => String(p.id) === String(id)))) : (this.premisses.filter(p => this.selectedIds.includes(p.id)))) : null;
if (!toBeUpdated?.length) return;
@ -733,80 +360,56 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
* PREMISE stuff
* =================
*/
deselectPremise() {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = false);
this.destinations = null;
this.selectedDestination = null;
this.selectedLoading = false;
},
setAll(value) {
this.selectedLoading = true;
const updatedPremises = this.premisses.map(p => ({
...p,
selected: value
}));
this.premisses = updatedPremises;
const temp = [];
if (value)
this.premisses.forEach(p => temp.push(p.id));
this.selectedIds = temp;
this.selectedLoading = false;
},
setSelectTo(ids, value) {
setChecked(premiseId, checked) {
this.selectedLoading = true;
const idsSet = new Set(ids);
const updatedPremises = this.premisses.map(p => ({
...p,
selected: idsSet.has(p.id) ? value : p.selected
}));
this.premisses = updatedPremises;
this.selectedLoading = false;
},
async selectSinglePremise(id, ids) {
this.selectedLoading = true;
await this.loadPremissesIfNeeded(ids);
this.premisses.forEach(p => p.selected = String(id) === String(p.id));
this.prepareDestinations(id, [id]);
this.selectedLoading = false;
},
async loadAndSelectSinglePremise(id) {
this.loading = true;
this.selectedLoading = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', `${[id]}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
});
this.premisses = data;
this.premisses.forEach(p => p.selected = true);
this.prepareDestinations(id, [id]);
this.selectedLoading = false;
this.loading = false;
},
async loadPremissesIfNeeded(ids, exact = false) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id) && (!exact || ids.length === this.premisses.length)) : true;
if (reload) {
await this.loadPremissesForced(ids);
if (checked) {
if (!this.selectedIds.includes(premiseId)) {
this.selectedIds.push(premiseId);
}
} else {
const idx = this.selectedIds.indexOf(premiseId);
if (idx !== -1)
this.selectedIds.splice(idx, 1);
}
this.selectedLoading = false;
},
async loadPremissesForced(ids) {
setBy(type, action, ofId) {
this.selectedLoading = true;
const premise = this.premisses.find(p => p.id === ofId);
const temp = [];
if (action === 'append')
temp.push(...this.selectedIds);
this.premisses.forEach(p => {
if (type === 'supplier' && p.supplier.id === premise.supplier.id) {
temp.push(p.id);
} else if (type === 'material' && p.material.id === premise.material.id) {
temp.push(p.id);
}
});
this.selectedIds = temp;
this.selectedLoading = false;
},
async load(ids) {
this.loading = true;
this.premises = [];
@ -820,10 +423,11 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
});
this.premisses = data;
this.premisses.forEach(p => p.selected = false);
this.selectedIds = [];
this.loading = false;
return this.premisses;
},
removePremise(id) {
const idx = this.premisses.findIndex(p => p.id === id);

View file

@ -90,9 +90,13 @@ export const usePremiseSingleEditStore = defineStore('premiseSingleEdit', {
if (this.premise === null) return;
this.routing = true;
const body = {destination_node_id: node.id, premise_id: [this.premise.id]};
const destinationNodeIds = {};
destinationNodeIds[this.premise.id] = [node.id, ...this.premise.destinations.map(d => d.destination_node.id)];
const body = {destination_node_ids: destinationNodeIds};
const url = `${config.backendUrl}/calculation/destination/`;
logger.info("addDestination", body, url);
const {data: destinations} = await performRequest(this, 'POST', url, body).catch(e => {
this.routing = false;
@ -101,9 +105,10 @@ export const usePremiseSingleEditStore = defineStore('premiseSingleEdit', {
const ids = []
for (const destId of Object.keys(destinations)) {
this.premise.destinations.push(destinations[destId]);
ids.push(destinations[destId].id);
if (destinations[this.premise.id]?.length !== 0)
for (const destId of Object.keys(destinations[this.premise.id])) {
this.premise.destinations.push(destinations[this.premise.id][destId]);
ids.push(destinations[this.premise.id][destId].id);
}
this.routing = false;

View file

@ -14,12 +14,12 @@ public class LccApplication {
Runtime runtime = Runtime.getRuntime();
long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
logger.info("LCC Start - Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
logger.info("LCC Launcher (start) - Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
SpringApplication.run(LccApplication.class, args);
usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
logger.info("LCC End - Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
logger.info("LCC Launcher (finished) - Memory: {} used, {} total, {} free, {} max ", usedMemory, runtime.totalMemory() / 1024 / 1024, runtime.freeMemory() / 1024 / 1024, runtime.maxMemory() / 1024 / 1024);
}

View file

@ -21,7 +21,6 @@ public class ShutdownListener {
log.error("Application shutdown. Context: {}, Thread: {}", event.getApplicationContext(), Thread.currentThread());
log.error("Thread stack dump:");
Thread.dumpStack();
}

View file

@ -133,7 +133,7 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handlePremiseValidationException(PremiseValidationError exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getName(),
"Premiss validation error",
"Validation error",
exception.getMessage(),
Arrays.asList(exception.getStackTrace())
);

View file

@ -9,6 +9,7 @@ import de.avatic.lcc.dto.calculation.create.CreatePremiseDTO;
import de.avatic.lcc.dto.calculation.create.PremiseSearchResultDTO;
import de.avatic.lcc.dto.calculation.edit.PremiseDetailDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationCreateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationMassUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationSetDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.dto.calculation.edit.masterData.MaterialUpdateDTO;
@ -29,8 +30,6 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@ -130,7 +129,8 @@ public class PremiseController {
@GetMapping({"/edit", "/edit/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<List<PremiseDetailDTO>> getPremises(@RequestParam List<Integer> premissIds) {
return ResponseEntity.ok(premisesServices.getPremises(premissIds));
var premisses = premisesServices.getPremises(premissIds);
return ResponseEntity.ok(premisses);
}
@PutMapping({"/start", "/start/"})
@ -176,14 +176,14 @@ public class PremiseController {
@PostMapping({"/destination", "/destination/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Map<Integer, DestinationDTO>> createDestination(@RequestBody @Valid DestinationCreateDTO destinationCreateDTO) {
return ResponseEntity.ok(destinationService.createDestination(destinationCreateDTO));
public ResponseEntity<Map<Integer, List<DestinationDTO>>> createDestination(@RequestBody @Valid DestinationCreateDTO destinationCreateDTO) {
return ResponseEntity.ok(destinationService.massSetDestinations(destinationCreateDTO));
}
@PutMapping({"/destination", "/destination/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Map<Integer, List<DestinationDTO>>> setDestination(@RequestBody DestinationSetDTO destinationSetDTO) {
return ResponseEntity.ok(destinationService.setDestination(destinationSetDTO));
return ResponseEntity.ok(destinationService.massSetDestinationProperties(destinationSetDTO));
}
@GetMapping({"/destination/{id}", "/destination/{id}/"})
@ -200,6 +200,13 @@ public class PremiseController {
return ResponseEntity.ok().build();
}
@PutMapping({"/destination/all", "/destination/all/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Void> updateAllDestination(@RequestBody @Valid DestinationMassUpdateDTO destinationUpdateDTO) {
destinationUpdateDTO.getDestinations().forEach(destinationService::updateDestination);
return ResponseEntity.ok().build();
}
@DeleteMapping({"/destination/{id}", "/destination/{id}/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<Void> deleteDestination(@PathVariable Integer id) {

View file

@ -0,0 +1,40 @@
package de.avatic.lcc.controller.configuration;
import de.avatic.lcc.dto.error.CalculationJobDumpDTO;
import de.avatic.lcc.repositories.error.DumpRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/dumps")
public class CalculationDumpController {
private final DumpRepository dumpRepository;
public CalculationDumpController(DumpRepository dumpRepository) {
this.dumpRepository = dumpRepository;
}
@GetMapping({"/dump/{id}", "/dump/{id}/"})
public ResponseEntity<CalculationJobDumpDTO> getDump(@PathVariable Integer id) {
return ResponseEntity.ok(dumpRepository.getDump(id));
}
@GetMapping({"/dump/", "/dump"})
public ResponseEntity<List<CalculationJobDumpDTO>> listDumps(
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) {
var dump = dumpRepository.listDumps(new SearchQueryPagination(page, limit));
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(dump.getTotalElements()))
.header("X-Page-Count", String.valueOf(dump.getTotalPages()))
.header("X-Current-Page", String.valueOf(page))
.body(dump.toList());
}
}

View file

@ -1,7 +1,6 @@
package de.avatic.lcc.controller.dev;
import de.avatic.lcc.config.filter.DevUserEmulationFilter;
import de.avatic.lcc.dto.error.CalculationJobDumpDTO;
import de.avatic.lcc.dto.users.UserDTO;
import de.avatic.lcc.repositories.error.DumpRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
@ -21,36 +20,15 @@ import java.util.List;
@RequestMapping({"/api/dev", "/api/dev/"})
public class DevController {
private final DumpRepository dumpRepository;
private final UserRepository userRepository;
private final UserTransformer userTransformer;
public DevController(DumpRepository dumpRepository, UserRepository userRepository, UserTransformer userTransformer) {
this.dumpRepository = dumpRepository;
public DevController(UserRepository userRepository, UserTransformer userTransformer) {
this.userRepository = userRepository;
this.userTransformer = userTransformer;
}
@GetMapping({"/dump/{id}", "/dump/{id}/"})
public ResponseEntity<CalculationJobDumpDTO> getDump(@PathVariable Integer id) {
return ResponseEntity.ok(dumpRepository.getDump(id));
}
@GetMapping({"/dump/", "/dump"})
public ResponseEntity<List<CalculationJobDumpDTO>> listDumps(
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) {
var dump = dumpRepository.listDumps(new SearchQueryPagination(page, limit));
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(dump.getTotalElements()))
.header("X-Page-Count", String.valueOf(dump.getTotalPages()))
.header("X-Current-Page", String.valueOf(page))
.body(dump.toList());
}
@GetMapping({"/user"})
public ResponseEntity<List<UserDTO>> listUser(@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) {

View file

@ -8,6 +8,7 @@ import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -34,6 +35,7 @@ public class ReportingController {
* @param reportingService Service used for generating reports.
* @param excelReportingService Service used for generating Excel files for reports.
*/
public ReportingController(ReportingService reportingService, ExcelReportingService excelReportingService) {
this.reportingService = reportingService;
this.excelReportingService = excelReportingService;
@ -46,6 +48,7 @@ public class ReportingController {
* @return A list of suppliers grouped by categories.
*/
@GetMapping({"/search", "/search/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<List<List<NodeDTO>>> findSupplierForReporting(@RequestParam(value = "material") Integer materialId) {
return ResponseEntity.ok(reportingService.findSupplierForReporting(materialId));
}
@ -58,6 +61,7 @@ public class ReportingController {
* @return The generated report details.
*/
@GetMapping({"/view", "/view/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<List<ReportDTO>> getReport(@RequestParam(value = "material") Integer materialId, @RequestParam(value = "sources", required = false) List<Integer> nodeIds, @RequestParam(value = "userSources", required = false) List<Integer> userNodeIds) {
return ResponseEntity.ok(reportingService.getReport(materialId, nodeIds, userNodeIds));
}
@ -70,6 +74,7 @@ public class ReportingController {
* @return The Excel file as an attachment in the response.
*/
@GetMapping({"/download", "/download/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION', 'BASIC')")
public ResponseEntity<InputStreamResource> downloadReport(@RequestParam(value = "material") Integer materialId, @RequestParam(value = "sources", required = false) List<Integer> nodeIds, @RequestParam(value = "userSources", required = false) List<Integer> userNodeIds) {
HttpHeaders headers = new HttpHeaders();

View file

@ -6,32 +6,20 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
public class DestinationCreateDTO {
@NotEmpty (message = "At least one premise must be selected")
@NotNull (message = "At least one premise must be selected")
@JsonProperty("premise_id")
List<@Min(value = 1, message = "Invalid premise id") Integer> premiseId;
@NotEmpty(message = "At least one premise must be selected")
@NotNull(message = "At least one premise must be selected")
@JsonProperty("destination_node_ids")
Map<Integer, List<@Min(value = 1, message = "Missing destination ids") Integer>> destinationNodeIds;
@Min(value = 1, message = "Invalid destination node id")
@NotNull (message = "Destination node id must be provided")
@JsonProperty("destination_node_id")
Integer destinationNodeId;
public List<Integer> getPremiseId() {
return premiseId;
public Map<Integer, List<Integer>> getDestinationNodeIds() {
return destinationNodeIds;
}
public void setPremiseId(List<Integer> premiseId) {
this.premiseId = premiseId;
}
public Integer getDestinationNodeId() {
return destinationNodeId;
}
public void setDestinationNodeId(Integer destinationNodeId) {
this.destinationNodeId = destinationNodeId;
public void setDestinationNodeIds(Map<Integer, List<Integer>> destinationNodeIds) {
this.destinationNodeIds = destinationNodeIds;
}
}

View file

@ -0,0 +1,19 @@
package de.avatic.lcc.dto.calculation.edit.destination;
import jakarta.validation.Valid;
import java.util.Map;
public class DestinationMassUpdateDTO {
Map<Integer, @Valid DestinationUpdateDTO> destinations;
public Map<Integer, DestinationUpdateDTO> getDestinations() {
return destinations;
}
public void setDestinations(Map<Integer, DestinationUpdateDTO> destinations) {
this.destinations = destinations;
}
}

View file

@ -1,11 +1,10 @@
package de.avatic.lcc.dto.calculation.edit.destination;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
public class DestinationSetDTO {
@ -15,6 +14,7 @@ public class DestinationSetDTO {
@JsonProperty("destinations")
List<DestinationSetListItemDTO> destinations;
public List<Integer> getPremiseId() {
return premiseId;
}

View file

@ -26,6 +26,23 @@ public class PackagingDimension {
private Boolean isDeprecated;
public static PackagingDimension getEmpty(PackagingType type) {
var dimension = new PackagingDimension();
dimension.setType(type);
dimension.setLength(null);
dimension.setWidth(null);
dimension.setHeight(null);
dimension.setDimensionUnit(DimensionUnit.MM);
dimension.setWeight(null);
dimension.setWeightUnit(WeightUnit.KG);
dimension.setContentUnitCount(null);
return dimension;
}
public Integer getId() {
return id;
}

View file

@ -227,7 +227,7 @@ public class NodeRepository {
return ps;
}, chainKeyHolder);
Integer chainId = chainKeyHolder.getKey().intValue();
Integer chainId = Objects.requireNonNull(chainKeyHolder.getKey()).intValue();
// Insert entries for this chain
for (Map.Entry<Integer, Integer> entry : predecessorChain.entrySet()) {

View file

@ -108,8 +108,8 @@ public class PackagingDimensionRepository {
}, keyHolder);
return Optional.ofNullable(!Objects.requireNonNull(keyHolder.getKeys()).isEmpty() ? ((Integer) keyHolder.getKeys().values().iterator().next())
: null);
Number key = (Number) Objects.requireNonNull(keyHolder.getKeys()).values().iterator().next();
return Optional.ofNullable(key != null ? key.intValue() : null);
}
public Optional<Integer> setDeprecatedById(Integer id) {

View file

@ -167,8 +167,8 @@ public class PackagingRepository {
}, keyHolder);
return Optional.ofNullable(!Objects.requireNonNull(keyHolder.getKeys()).isEmpty() ? ((Integer) keyHolder.getKeys().values().iterator().next())
: null);
Number key = (Number) Objects.requireNonNull(keyHolder.getKeys()).values().iterator().next();
return Optional.ofNullable(key != null ? key.intValue() : null);
}

View file

@ -17,6 +17,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DestinationRepository {
@ -50,6 +51,21 @@ public class DestinationRepository {
return jdbcTemplate.query(query, new DestinationMapper(), id);
}
@Transactional
public List<Destination> getByPremiseIdAndUserId(Integer premiseId, Integer userId) {
String premiseCheckQuery = "SELECT COUNT(*) FROM premise WHERE id = ? AND user_id = ?";
Integer count = jdbcTemplate.queryForObject(premiseCheckQuery, Integer.class, premiseId, userId);
if (count == null || count == 0) {
return Collections.emptyList();
}
String query = "SELECT * FROM premise_destination WHERE premise_id = ?";
return jdbcTemplate.query(query, new DestinationMapper(), premiseId);
}
@Transactional
public void update(Integer id, Integer annualAmount, BigDecimal repackingCost, BigDecimal disposalCost, BigDecimal handlingCost, Boolean isD2d, BigDecimal d2dRate, BigDecimal d2dLeadTime) {
if (id == null) {
@ -157,20 +173,73 @@ public class DestinationRepository {
}, ids.toArray());
}
// @Transactional
// public List<Destination> getByPremiseIdsAndNodeId(List<Integer> premiseId, Integer nodeId, Integer userId) {
// String placeholder = String.join(",", Collections.nCopies(premiseId.size(), "?"));
// String query = "SELECT * FROM premise_destination JOIN premise ON premise_destination.premise_id = premise.id WHERE premise_destination.premise_id IN (" + placeholder + ") AND premise_destination.destination_node_id = ? AND premise.user_id = ?";
//
// // Create array with all parameters
// Object[] params = new Object[premiseId.size() + 2];
// for (int i = 0; i < premiseId.size(); i++) {
// params[i] = premiseId.get(i);
// }
// params[premiseId.size()] = nodeId;
// params[premiseId.size() + 1] = userId;
//
// return jdbcTemplate.query(query, new DestinationMapper(), params);
// }
@Transactional
public List<Destination> getByPremiseIdsAndNodeId(List<Integer> premiseId, Integer nodeId, Integer userId) {
String placeholder = String.join(",", Collections.nCopies(premiseId.size(), "?"));
String query = "SELECT * FROM premise_destination JOIN premise ON premise_destination.premise_id = premise.id WHERE premise_destination.premise_id IN (" + placeholder + ") AND premise_destination.destination_node_id = ? AND premise.user_id = ?";
public Map<Integer, List<Destination>> getByPremiseIdsAndNodeIds(Map<Integer, List<Integer>> premiseToNodes, Integer userId) {
if (premiseToNodes.isEmpty()) {
return new HashMap<>();
}
// Flatten all premise IDs and node IDs for the query
List<Integer> allPremiseIds = new ArrayList<>(premiseToNodes.keySet());
Set<Integer> allNodeIds = premiseToNodes.values().stream()
.flatMap(List::stream)
.collect(Collectors.toSet());
String premisePlaceholder = String.join(",", Collections.nCopies(allPremiseIds.size(), "?"));
String nodePlaceholder = String.join(",", Collections.nCopies(allNodeIds.size(), "?"));
String query = "SELECT * FROM premise_destination " +
"JOIN premise ON premise_destination.premise_id = premise.id " +
"WHERE premise_destination.premise_id IN (" + premisePlaceholder + ") " +
"AND premise_destination.destination_node_id IN (" + nodePlaceholder + ") " +
"AND premise.user_id = ?";
// Create array with all parameters
Object[] params = new Object[premiseId.size() + 2];
for (int i = 0; i < premiseId.size(); i++) {
params[i] = premiseId.get(i);
}
params[premiseId.size()] = nodeId;
params[premiseId.size() + 1] = userId;
Object[] params = new Object[allPremiseIds.size() + allNodeIds.size() + 1];
int index = 0;
return jdbcTemplate.query(query, new DestinationMapper(), params);
for (Integer premiseId : allPremiseIds) {
params[index++] = premiseId;
}
for (Integer nodeId : allNodeIds) {
params[index++] = nodeId;
}
params[index] = userId;
List<Destination> allDestinations = jdbcTemplate.query(query, new DestinationMapper(), params);
// Group destinations by premise ID and filter by the requested node IDs
Map<Integer, List<Destination>> result = new HashMap<>();
for (Map.Entry<Integer, List<Integer>> entry : premiseToNodes.entrySet()) {
Integer premiseId = entry.getKey();
Set<Integer> requestedNodeIds = new HashSet<>(entry.getValue());
List<Destination> filteredDestinations = allDestinations.stream()
.filter(d -> d.getPremiseId().equals(premiseId) &&
requestedNodeIds.contains(d.getDestinationNodeId()))
.collect(Collectors.toList());
result.put(premiseId, filteredDestinations);
}
return result;
}
@Transactional
@ -278,7 +347,10 @@ public class DestinationRepository {
Destination entity = new Destination();
entity.setId(rs.getInt("id"));
entity.setAnnualAmount(rs.getInt("annual_amount"));
var amount = rs.getInt("annual_amount");
entity.setAnnualAmount(rs.wasNull() ? null : amount);
entity.setPremiseId(rs.getInt("premise_id"));
entity.setDestinationNodeId(rs.getInt("destination_node_id"));
entity.setRateD2d(rs.getBigDecimal("rate_d2d"));

View file

@ -124,7 +124,7 @@ public class PremiseRepository {
@Transactional(readOnly = true)
public List<Premise> getPremisesById(List<Integer> premiseIds) {
if(premiseIds.isEmpty())
if (premiseIds.isEmpty())
return Collections.emptyList();
String placeholders = String.join(",", Collections.nCopies(premiseIds.size(), "?"));
@ -163,7 +163,7 @@ public class PremiseRepository {
}
@Transactional
public void updatePackaging(List<Integer> premiseIds, PackagingDimension hu, PackagingDimension shu, Boolean stackable, Boolean mixable) {
public void resetPackaging(List<Integer> premiseIds, PackagingDimension hu, PackagingDimension shu, Boolean stackable, Boolean mixable) {
if (premiseIds == null || premiseIds.isEmpty() || hu == null) {
return;
@ -179,7 +179,7 @@ public class PremiseRepository {
params.addValue("weight", hu.getWeight());
params.addValue("dimensionUnit", hu.getDimensionUnit().name());
params.addValue("weightUnit", hu.getWeightUnit().name());
params.addValue("unitCount", hu.getContentUnitCount() * shu.getContentUnitCount());
params.addValue("unitCount", (hu.getContentUnitCount() == null || shu.getContentUnitCount() == null) ? null : hu.getContentUnitCount() * shu.getContentUnitCount());
params.addValue("stackable", isStackable);
params.addValue("mixable", isMixable);
params.addValue("premiseIds", premiseIds);
@ -203,7 +203,7 @@ public class PremiseRepository {
}
@Transactional
public void updatePackaging(List<Integer> premiseIds, PackagingDimension hu, Boolean stackable, Boolean mixable) {
public void resetPackaging(List<Integer> premiseIds, PackagingDimension hu, Boolean stackable, Boolean mixable) {
if (premiseIds == null || premiseIds.isEmpty()) {
@ -335,7 +335,7 @@ public class PremiseRepository {
ps.setBigDecimal(i + 1, (BigDecimal) param);
} else if (param instanceof Integer) {
ps.setInt(i + 1, (Integer) param);
} else if(param instanceof Boolean) {
} else if (param instanceof Boolean) {
ps.setBoolean(i + 1, (Boolean) param);
}
}
@ -346,6 +346,18 @@ public class PremiseRepository {
throw new DatabaseException("Premise update failed for " + premiseIds.size() + " premises. Affected rows: " + affectedRows);
}
@Transactional
public void resetPrice(List<Integer> premiseIds) {
if (premiseIds == null || premiseIds.isEmpty()) {
return;
}
String placeholders = String.join(",", Collections.nCopies(premiseIds.size(), "?"));
String query = "UPDATE premise SET material_cost = null, is_fca_enabled = false, oversea_share = null WHERE id IN (" + placeholders + ")";
jdbcTemplate.update(query, premiseIds.toArray());
}
@Transactional
public void updatePrice(List<Integer> premiseIds, BigDecimal price, Boolean includeFcaFee, BigDecimal overseaShare) {
// Build dynamic SET clause based on non-null parameters

View file

@ -3,14 +3,12 @@ package de.avatic.lcc.service.access;
import de.avatic.lcc.dto.calculation.DestinationDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationCreateDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationSetDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationSetListItemDTO;
import de.avatic.lcc.dto.calculation.edit.destination.DestinationUpdateDTO;
import de.avatic.lcc.model.db.nodes.Node;
import de.avatic.lcc.model.db.premises.Premise;
import de.avatic.lcc.model.db.premises.route.*;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.premise.*;
import de.avatic.lcc.repositories.properties.PropertyRepository;
import de.avatic.lcc.repositories.users.UserNodeRepository;
import de.avatic.lcc.service.calculation.RoutingService;
import de.avatic.lcc.service.transformer.premise.DestinationTransformer;
@ -51,38 +49,19 @@ public class DestinationService {
this.authorizationService = authorizationService;
}
@Transactional
public Map<Integer, List<DestinationDTO>> setDestination(DestinationSetDTO dto) {
var admin = authorizationService.isSuper();
Integer userId = authorizationService.getUserId();
if (!admin)
destinationRepository.checkOwner(dto.getPremiseId(), userId);
private Map<Integer, List<Destination>> processDestinations(List<Premise> premisesToProcess, Map<Integer, List<Integer>> destinationNodeIds, Integer annualAmount, Number repackingCost, Number disposalCost, Number handlingCost, Map<RouteIds, List<RouteInformation>> routes) {
deleteAllDestinationsByPremiseId(dto.getPremiseId(), false);
var destMap = new HashMap<Integer, List<Destination>>();
var premisses = premiseRepository.getPremisesById(dto.getPremiseId());
Map<RouteIds, List<RouteInformation>> routes = findRoutes(premisses, dto.getDestinations().stream().map(DestinationSetListItemDTO::getDestinationNodeId).toList());
Map<Integer, List<Destination>> destinations = dto.getDestinations().stream()
.flatMap(destination -> createDestination(premisses, destination.getDestinationNodeId(), destination.getAnnualAmount(), destination.getRepackingCost(), destination.getDisposalCost(), destination.getHandlingCost(), routes).stream())
.collect(Collectors.groupingBy(Destination::getPremiseId));
return destinations.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream().map(destinationTransformer::toDestinationDTO).toList()));
}
private List<Destination> createDestination(List<Premise> premisesToProcess, Integer destinationNodeId, Integer annualAmount, Number repackingCost, Number disposalCost, Number handlingCost, Map<RouteIds, List<RouteInformation>> routes) {
Node destinationNode = nodeRepository.getById(destinationNodeId).orElseThrow();
for (var premise : premisesToProcess) {
var destinations = new ArrayList<Destination>();
for (var premise : premisesToProcess) {
for (var destinationNodeId : destinationNodeIds.get(premise.getId())) {
Node destinationNode = nodeRepository.getById(destinationNodeId).orElseThrow();
var destination = new Destination();
destination.setDestinationNodeId(destinationNodeId);
destination.setPremiseId(premise.getId());
@ -100,39 +79,94 @@ public class DestinationService {
Node source = premise.getSupplierNodeId() == null ? userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow() : nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
if (routes != null)
//noinspection SpringTransactionalMethodCallsInspection
saveRoute(routes.get(new RouteIds(source.getId(), destinationNodeId, premise.getSupplierNodeId() == null)), destination.getId());
destinations.add(destination);
}
return destinations;
destMap.put(premise.getId(), destinations);
}
return destMap;
}
@Transactional
public Map<Integer, DestinationDTO> createDestination(DestinationCreateDTO dto) {
public Map<Integer, List<DestinationDTO>> massSetDestinationProperties(DestinationSetDTO dto) {
var admin = authorizationService.isSuper();
Integer userId = authorizationService.getUserId();
if (!admin)
destinationRepository.checkOwner(dto.getPremiseId(), userId);
deleteAllDestinationsByPremiseId(dto.getPremiseId(), false);
var premisses = premiseRepository.getPremisesById(dto.getPremiseId());
// TODO no routing in set ... only props.
// Map<RouteIds, List<RouteInformation>> routes = findRoutes(premisses, dto.getDestinations().stream().map(DestinationSetListItemDTO::getDestinationNodeId).toList());
// Map<Integer, List<Destination>> destinations = dto.getDestinations().stream()
// .flatMap(destination -> processDestinations(premisses, destination.getDestinationNodeId(), destination.getAnnualAmount(), destination.getRepackingCost(), destination.getDisposalCost(), destination.getHandlingCost(), null).stream())
// .collect(Collectors.groupingBy(Destination::getPremiseId));
// return destinations.entrySet().stream()
// .collect(Collectors.toMap(
// Map.Entry::getKey,
// entry -> entry.getValue().stream().map(destinationTransformer::toDestinationDTO).toList()));
return null;
}
private List<Integer> getDestinationToRemove(List<Destination> oldDestinations, List<Integer> newIds) {
return oldDestinations.stream().filter(dest -> !newIds.contains(dest.getDestinationNodeId())).map(Destination::getId).collect(Collectors.toList());
}
private List<Integer> getNodeIdsToAdd(List<Destination> oldDestinations, List<Integer> newIds) {
var oldIds = oldDestinations.stream().map(Destination::getDestinationNodeId).toList();
return newIds.stream().filter(id -> !oldIds.contains(id)).collect(Collectors.toList());
}
@Transactional
public Map<Integer, List<DestinationDTO>> massSetDestinations(DestinationCreateDTO dto) {
Integer userId = authorizationService.getUserId();
var existingDestinations = destinationRepository.getByPremiseIdsAndNodeId(dto.getPremiseId(), dto.getDestinationNodeId(), userId);
Map<Integer, List<Integer>> destinationsToProcess = new HashMap<>();
var premisesIdsToProcess = new ArrayList<Integer>();
for (var premiseId : dto.getPremiseId()) {
if (existingDestinations.stream().map(Destination::getPremiseId).noneMatch(id -> id.equals(premiseId))) {
premisesIdsToProcess.add(premiseId);
}
var requestedDestMap = dto.getDestinationNodeIds();
var existingDestMap = dto.getDestinationNodeIds().keySet().stream().collect(Collectors.toMap(id -> id, id -> destinationRepository.getByPremiseIdAndUserId(id, userId)));
for (Integer premiseId : requestedDestMap.keySet()) {
var requestedDestinations = requestedDestMap.get(premiseId);
var existingDestinations = existingDestMap.getOrDefault(premiseId, Collections.emptyList());
/* remove deselected */
var toRemove = getDestinationToRemove(existingDestinations, requestedDestinations);
deleteDestinationsById(toRemove, false);
/* find new selected */
var toAdd = getNodeIdsToAdd(existingDestinations, requestedDestinations);
destinationsToProcess.put(premiseId, toAdd);
}
if (premisesIdsToProcess.isEmpty())
if (destinationsToProcess.isEmpty())
return new HashMap<>();
var premisses = premiseRepository.getPremisesById(premisesIdsToProcess);
Map<RouteIds, List<RouteInformation>> routes = findRoutes(premisses, Collections.singletonList(dto.getDestinationNodeId()));
var premisses = premiseRepository.getPremisesById(new ArrayList<>(destinationsToProcess.keySet()));
Map<RouteIds, List<RouteInformation>> routes = findRoutes(premisses, destinationsToProcess);
var destinations = createDestination(premisses, dto.getDestinationNodeId(), null, null, null, null, routes);
return destinations.stream().collect(Collectors.toMap(Destination::getPremiseId, destinationTransformer::toDestinationDTO));
var destinations = processDestinations(premisses, destinationsToProcess, null, null, null, null, routes);
return destinations.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream()
.map(destinationTransformer::toDestinationDTO)
.toList()
));
}
public DestinationDTO getDestination(Integer id) {
@ -170,14 +204,16 @@ public class DestinationService {
}
private Map<RouteIds, List<RouteInformation>> findRoutes(List<Premise> premisses, List<Integer> destinationIds) {
private Map<RouteIds, List<RouteInformation>> findRoutes(List<Premise> premisses, Map<Integer, List<Integer>> routingRequest) {
Map<RouteIds, List<RouteInformation>> routes = new HashMap<>();
Map<Integer, Node> nodes = new HashMap<>();
Map<Integer, Node> userNodes = new HashMap<>();
for (var premise : premisses) {
for (var destinationId : destinationIds) {
for (var destinationId : routingRequest.get(premise.getId())) {
boolean isUserSupplierNode = (premise.getSupplierNodeId() == null);
var ids = new RouteIds(isUserSupplierNode ? premise.getUserSupplierNodeId() : premise.getSupplierNodeId(), destinationId, isUserSupplierNode);
if (routes.containsKey(ids)) continue;
@ -194,6 +230,8 @@ public class DestinationService {
userNodes.put(premise.getUserSupplierNodeId(), userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow());
}
//TODO in parallel
routes.put(ids, routingService.findRoutes(nodes.get(destinationId), isUserSupplierNode ? userNodes.get(premise.getUserSupplierNodeId()) : nodes.get(premise.getSupplierNodeId()), isUserSupplierNode));
}
}
@ -255,6 +293,11 @@ public class DestinationService {
destinations.forEach(destination -> deleteDestinationById(destination.getId(), deleteRoutesOnly));
}
@Transactional
public void deleteDestinationsById(List<Integer> ids, boolean deleteRoutesOnly) {
ids.forEach(id -> deleteDestinationById(id, deleteRoutesOnly));
}
@Transactional
public void deleteDestinationById(Integer id, boolean deleteRoutesOnly) {
var admin = authorizationService.isSuper();

View file

@ -96,7 +96,9 @@ public class PremisesService {
if (!admin)
premiseRepository.checkOwner(premiseIds, userId);
return premiseRepository.getPremisesById(premiseIds).stream().filter(p -> p.getState().equals(PremiseState.DRAFT)).map(premiseTransformer::toPremiseDetailDTO).toList();
var premisses = premiseRepository.getPremisesById(premiseIds).stream().filter(p -> p.getState().equals(PremiseState.DRAFT)).toList();
return premisses.stream().map(premiseTransformer::toPremiseDetailDTO).toList();
}
@ -140,7 +142,6 @@ public class PremisesService {
calculationIds.add(calculationJobRepository.insert(job));
}
});
premiseRepository.setStatus(premises, PremiseState.COMPLETED);
@ -199,7 +200,7 @@ public class PremisesService {
var dimensions = packagingDTO.getDimensions() == null ? null : dimensionTransformer.toDimensionEntity(packagingDTO.getDimensions());
premiseRepository.updatePackaging(packagingDTO.getPremiseIds(), dimensions, packagingDTO.getStackable(), packagingDTO.getMixable());
premiseRepository.resetPackaging(packagingDTO.getPremiseIds(), dimensions, packagingDTO.getStackable(), packagingDTO.getMixable());
}
@ -305,7 +306,7 @@ public class PremisesService {
premiseRepository.updateMaterial(Collections.singletonList(newId), old.getHsCode(), old.getTariffRate(), old.getTariffUnlocked());
premiseRepository.updatePrice(Collections.singletonList(newId), old.getMaterialCost(), old.getFcaEnabled(), old.getOverseaShare());
premiseRepository.updatePackaging(Collections.singletonList(newId), dimensionTransformer.toDimensionEntity(old), old.getHuStackable(), old.getHuMixable());
premiseRepository.resetPackaging(Collections.singletonList(newId), dimensionTransformer.toDimensionEntity(old), old.getHuStackable(), old.getHuMixable());
premiseRepository.setPackagingId(newId, old.getPackagingId());
destinationService.duplicate(old.getId(), newId);

View file

@ -46,6 +46,7 @@ public class TaxationResolverService {
private Map<TaxationResolverRequest, TaxationResolverApiResponse> doRequests(List<TaxationResolverRequest> requests) {
/* country id -> union type */
Map<Integer, CustomUnionType> union = requests.stream()
.map(TaxationResolverRequest::countryId)
.distinct()
@ -57,8 +58,10 @@ public class TaxationResolverService {
.orElseThrow()
));
var byCustomUnion = requests.stream().collect(Collectors.groupingBy(r -> (union.get(r.countryId()))));
/* split by incomplete hs codes and complete hs codes */
var filteredRequests = byCustomUnion.getOrDefault(CustomUnionType.NONE, Collections.emptyList()).stream().collect(Collectors.partitioningBy(r -> r.material().getHsCode() != null && r.material().getHsCode().length() < 10));
var joined = Stream.concat(
@ -293,7 +296,7 @@ public class TaxationResolverService {
}
}
if (selectedDuty != null && (maxDuty - minDuty < 0.02)) {
if (selectedDuty != null && (maxDuty - minDuty <= 0.02)) {
return new TaxationResolverResponse(selectedDuty, selectedMeasure, selectedHsCode, request.material(), request.countryId());
}
@ -302,7 +305,7 @@ public class TaxationResolverService {
// just continue
}
return new TaxationResolverResponse(null, null, null, request.material(), request.countryId());
return new TaxationResolverResponse(null, null, request.material().getHsCode(), request.material(), request.countryId());
}

View file

@ -7,6 +7,8 @@ import de.avatic.lcc.model.db.error.SysErrorType;
import de.avatic.lcc.repositories.bulk.BulkOperationRepository;
import de.avatic.lcc.repositories.error.SysErrorRepository;
import de.avatic.lcc.service.transformer.error.SysErrorTransformer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@ -19,28 +21,32 @@ import java.util.concurrent.TimeUnit;
@Service
public class BulkOperationExecutionService {
private static final Logger logger = LoggerFactory.getLogger(BulkOperationExecutionService.class);
private final BulkOperationRepository bulkOperationRepository;
private final BulkExportService bulkExportService;
private final BulkImportService bulkImportService;
private final SysErrorRepository sysErrorRepository;
private final SysErrorTransformer sysErrorTransformer;
private final Executor bulkProcessingExecutor;
public BulkOperationExecutionService(BulkOperationRepository bulkOperationRepository, BulkExportService bulkExportService, BulkImportService bulkImportService, SysErrorRepository sysErrorRepository, SysErrorTransformer sysErrorTransformer, @Qualifier("bulkProcessingExecutor") Executor bulkProcessingExecutor) {
public BulkOperationExecutionService(BulkOperationRepository bulkOperationRepository, BulkExportService bulkExportService, BulkImportService bulkImportService, SysErrorRepository sysErrorRepository, SysErrorTransformer sysErrorTransformer) {
this.bulkOperationRepository = bulkOperationRepository;
this.bulkExportService = bulkExportService;
this.bulkImportService = bulkImportService;
this.sysErrorRepository = sysErrorRepository;
this.sysErrorTransformer = sysErrorTransformer;
this.bulkProcessingExecutor = bulkProcessingExecutor;
}
@Async("bulkProcessingExecutor")
public CompletableFuture<Void> launchExecution(Integer id) {
logger.info("Starting bulk operation execution for ID: {}", id);
try {
execution(id);
logger.info("Bulk operation execution completed successfully for ID: {}", id);
return CompletableFuture.completedFuture(null);
} catch (Throwable e) {
logger.error("Error during bulk operation execution for ID: {}", id, e);
bulkOperationRepository.updateState(id, BulkOperationState.EXCEPTION);
var error = new SysError();
@ -59,13 +65,14 @@ public class BulkOperationExecutionService {
}
public void execution(Integer id) {
logger.debug("Executing bulk operation for ID: {}", id);
var operation = bulkOperationRepository.getOperationById(id);
if (operation.isPresent()) {
var op = operation.get();
if (op.getProcessState() == BulkOperationState.SCHEDULED) {
logger.info("Processing bulk operation ID: {}, type: {}, file type: {}", id, op.getProcessingType(), op.getFileType());
bulkOperationRepository.updateState(id, BulkOperationState.PROCESSING);
try {
if (op.getProcessingType() == BulkProcessingType.EXPORT) {
@ -78,6 +85,9 @@ public class BulkOperationExecutionService {
}
} catch (Throwable e) {
logger.error("Error during bulk operation execution for ID: {}", id, e);
op.setProcessState(BulkOperationState.EXCEPTION);
var error = new SysError();

View file

@ -2,10 +2,12 @@ package de.avatic.lcc.service.calculation;
import de.avatic.lcc.dto.calculation.edit.PremiseDetailDTO;
import de.avatic.lcc.model.db.packaging.PackagingDimension;
import de.avatic.lcc.model.db.packaging.PackagingType;
import de.avatic.lcc.model.db.premises.Premise;
import de.avatic.lcc.model.db.premises.PremiseState;
import de.avatic.lcc.model.db.properties.PackagingProperty;
import de.avatic.lcc.model.db.properties.PackagingPropertyMappingId;
import de.avatic.lcc.model.db.properties.SystemPropertyMappingId;
import de.avatic.lcc.repositories.MaterialRepository;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.packaging.PackagingDimensionRepository;
@ -13,8 +15,9 @@ import de.avatic.lcc.repositories.packaging.PackagingPropertiesRepository;
import de.avatic.lcc.repositories.packaging.PackagingRepository;
import de.avatic.lcc.repositories.premise.PremiseRepository;
import de.avatic.lcc.repositories.users.UserNodeRepository;
import de.avatic.lcc.service.api.CustomApiService;
import de.avatic.lcc.service.access.DestinationService;
import de.avatic.lcc.service.access.PropertyService;
import de.avatic.lcc.service.api.CustomApiService;
import de.avatic.lcc.service.api.TaxationResolverService;
import de.avatic.lcc.service.transformer.generic.DimensionTransformer;
import de.avatic.lcc.service.transformer.premise.PremiseTransformer;
@ -26,7 +29,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@Service
@ -43,8 +48,9 @@ public class PremiseCreationService {
private final PackagingPropertiesRepository packagingPropertiesRepository;
private final AuthorizationService authorizationService;
private final TaxationResolverService taxationResolverService;
private final PropertyService propertyService;
public PremiseCreationService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DestinationService destinationService, UserNodeRepository userNodeRepository, NodeRepository nodeRepository, MaterialRepository materialRepository, DimensionTransformer dimensionTransformer, PackagingRepository packagingRepository, PackagingDimensionRepository packagingDimensionRepository, PackagingPropertiesRepository packagingPropertiesRepository, CustomApiService customApiService, AuthorizationService authorizationService, TaxationResolverService taxationResolverService) {
public PremiseCreationService(PremiseRepository premiseRepository, PremiseTransformer premiseTransformer, DestinationService destinationService, UserNodeRepository userNodeRepository, NodeRepository nodeRepository, MaterialRepository materialRepository, DimensionTransformer dimensionTransformer, PackagingRepository packagingRepository, PackagingDimensionRepository packagingDimensionRepository, PackagingPropertiesRepository packagingPropertiesRepository, CustomApiService customApiService, AuthorizationService authorizationService, TaxationResolverService taxationResolverService, PropertyService propertyService) {
this.premiseRepository = premiseRepository;
this.premiseTransformer = premiseTransformer;
this.destinationService = destinationService;
@ -58,6 +64,7 @@ public class PremiseCreationService {
this.authorizationService = authorizationService;
this.taxationResolverService = taxationResolverService;
this.propertyService = propertyService;
}
@Transactional
@ -65,6 +72,8 @@ public class PremiseCreationService {
var userId = authorizationService.getUserId();
userNodeRepository.checkOwner(userSupplierIds, userId);
Optional<Double> defaultTariff = propertyService.getProperty(SystemPropertyMappingId.TARIFF_RATE);
/* Build all resulting premises */
List<TemporaryPremise> premises = Stream.concat(
supplierIds.stream().flatMap(id -> materialIds.stream().map(materialId -> new TemporaryPremise(materialId, id, null, false))),
@ -81,13 +90,15 @@ public class PremiseCreationService {
if (p.getPremise() == null) { // create new
p.setId(premiseRepository.insert(p.getMaterialId(), p.getSupplierId(), p.getUserSupplierId(), p.getGeoLat(), p.getGeoLng(), p.getCountryId(), userId));
fillPremise(p, tariffs, userId);
fillPremise(p, tariffs, defaultTariff, userId);
} else if (p.getPremise().getState().equals(PremiseState.DRAFT)) { // recycle
p.setId(p.getPremise().getId());
if (createEmpty) {
// reset to defaults.
fillPremise(p, tariffs, userId);
fillPremise(p, tariffs, defaultTariff, userId);
// remove destinations
destinationService.deleteAllDestinationsByPremiseId(Collections.singletonList(p.getId()), false);
}
} else if (p.getPremise().getState().equals(PremiseState.COMPLETED)) {
@ -102,38 +113,44 @@ public class PremiseCreationService {
}
private void copyPremise(TemporaryPremise p, Integer userId) {
var old = p.getPremise();
premiseRepository.updateMaterial(Collections.singletonList(p.getId()), old.getHsCode(), old.getTariffRate(), old.getTariffUnlocked());
premiseRepository.updatePrice(Collections.singletonList(p.getId()), old.getMaterialCost(), old.getFcaEnabled(), old.getOverseaShare());
premiseRepository.updatePackaging(Collections.singletonList(p.getId()), dimensionTransformer.toDimensionEntity(old), old.getHuStackable(), old.getHuMixable());
premiseRepository.resetPackaging(Collections.singletonList(p.getId()), dimensionTransformer.toDimensionEntity(old), old.getHuStackable(), old.getHuMixable());
premiseRepository.setPackagingId(p.getId(), old.getPackagingId());
}
private void fillPremise(TemporaryPremise p, List<TaxationResolverService.TaxationResolverResponse> tariffs, Integer userId) {
private void fillPremise(TemporaryPremise p, List<TaxationResolverService.TaxationResolverResponse> tariffs, Optional<Double> optTariff, Integer userId) {
if (!p.isUserSupplier()) {
var packaging = packagingRepository.getByMaterialIdAndSupplierId(p.getMaterialId(), p.getSupplierId());
Optional<PackagingDimension> hu = packaging.flatMap( o -> packagingDimensionRepository.getById(o.getHuId()));
Optional<PackagingDimension> shu = packaging.flatMap(o ->packagingDimensionRepository.getById(o.getShuId()));
Optional<PackagingDimension> hu = packaging.flatMap(o -> packagingDimensionRepository.getById(o.getHuId()));
Optional<PackagingDimension> shu = packaging.flatMap(o -> packagingDimensionRepository.getById(o.getShuId()));
if (hu.isPresent() && shu.isPresent()) {
boolean stackable = packagingPropertiesRepository.getByPackagingIdAndType(packaging.get().getId(), PackagingPropertyMappingId.STACKABLE.name()).map(PackagingProperty::getValue).map(Boolean::valueOf).orElse(false);
boolean mixable = packagingPropertiesRepository.getByPackagingIdAndType(packaging.get().getId(), PackagingPropertyMappingId.MIXABLE.name()).map(PackagingProperty::getValue).map(Boolean::valueOf).orElse(false);
premiseRepository.updatePackaging(Collections.singletonList(p.getId()), hu.get(), shu.get(), stackable, mixable); //TODO clarify if the hu unit count in packaging data is total unit count or shu count (shu*hu or hu)
premiseRepository.resetPackaging(Collections.singletonList(p.getId()), hu.get(), shu.get(), stackable, mixable); //TODO clarify if the hu unit count in packaging data is total unit count or shu count (shu*hu or hu)
premiseRepository.setPackagingId(p.getId(), packaging.get().getId());
} else {
premiseRepository.resetPackaging(Collections.singletonList(p.getId()), PackagingDimension.getEmpty(PackagingType.HU), PackagingDimension.getEmpty(PackagingType.SHU), true, true);
premiseRepository.setPackagingId(p.getId(), null);
}
premiseRepository.resetPrice(Collections.singletonList(p.getId()));
}
var defaultTariff = optTariff.orElse(null);
tariffs.stream()
.filter(r -> r.material().getId().equals(p.getMaterialId()) && r.countryId().equals(p.getCountryId()))
.findFirst()
.ifPresent(value -> premiseRepository.updateMaterial(Collections.singletonList(
p.getId()),
value.actualHsCode(),
value.tariffRate() == null ? null : BigDecimal.valueOf(value.tariffRate()),
value.tariffRate() == null ? (defaultTariff == null ? null : BigDecimal.valueOf(defaultTariff)) : BigDecimal.valueOf(value.tariffRate()),
value.tariffRate() == null));
}
@ -167,10 +184,10 @@ public class PremiseCreationService {
userNodeRepository.getById(temporaryPremise.getUserSupplierId()).orElseThrow(() -> new NotFoundException(NotFoundException.NotFoundType.USER_NODE, "id", String.valueOf(temporaryPremise.getUserSupplierId()))) :
nodeRepository.getById(temporaryPremise.getSupplierId()).orElseThrow(() -> new NotFoundException(NotFoundException.NotFoundType.NODE, "id", String.valueOf(temporaryPremise.getSupplierId())));
if(temporaryPremise.isUserSupplier()) {
if (temporaryPremise.isUserSupplier()) {
var id = userNodeRepository.getOwnerById(temporaryPremise.getUserSupplierId());
if(id.isPresent() && !id.get().equals(userId)) {
if (id.isPresent() && !id.get().equals(userId)) {
throw new ForbiddenException("Unable to access this node id " + temporaryPremise.getUserSupplierId());
}
}

View file

@ -334,7 +334,7 @@ public class RoutingService {
}
finalSection.setRate(matrixRate);
finalSection.setApproxDistance(distanceService.getDistance(container.getSourceNode(), toNode, false));
finalSection.setApproxDistance(distanceService.getDistance(container.getSourceNode(), toNode, true));
rates.add(finalSection);
}
@ -373,7 +373,7 @@ public class RoutingService {
}
private boolean skipCain(List<Node> chain, TemporaryContainer container) {
if (containerRateRepository.hasMainRun(chain.getFirst().getId()))
if (containerRateRepository.hasMainRun(chain.getLast().getId()))
return true;
Set<Integer> countryIds = new HashSet<>();
@ -699,7 +699,7 @@ public class RoutingService {
if (matrixRate.isPresent()) {
matrixRateObj.setRate(matrixRate.get());
matrixRateObj.setApproxDistance(distanceService.getDistance(startNode, endNode, false));
matrixRateObj.setApproxDistance(distanceService.getDistance(startNode, endNode, true));
container.getRates().add(matrixRateObj);
return matrixRateObj;
} else {

View file

@ -67,7 +67,7 @@ public class RouteSectionCostCalculationService {
result.setUnmixedPrice(premise.getHuMixable());
// Get nodes and distance
Node fromNode = nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow();
Node fromNode = premise.getSupplierNodeId() != null ? nodeRepository.getById(premise.getSupplierNodeId()).orElseThrow() : userNodeRepository.getById(premise.getUserSupplierNodeId()).orElseThrow();
Node toNode = nodeRepository.getById(destination.getDestinationNodeId()).orElseThrow();
double distance = distanceService.getDistance(fromNode, toNode, false);

View file

@ -167,12 +167,16 @@ public class MaterialFastExcelMapper {
try {
// Extract and validate data
String partNumber = getCellValue(row, MaterialHeader.PART_NUMBER.ordinal(), rowNumber);
String description = getCellValue(row, MaterialHeader.DESCRIPTION.ordinal(), rowNumber);
String hsCode = getCellValue(row, MaterialHeader.HS_CODE.ordinal(), rowNumber);
String description = getCellValueAllowEmpty(row, MaterialHeader.DESCRIPTION.ordinal(), rowNumber);
String hsCode = getCellValueAllowEmpty(row, MaterialHeader.HS_CODE.ordinal(), rowNumber);
String operation = getCellValue(row, MaterialHeader.OPERATION.ordinal(), rowNumber);
if(description == null || description.isEmpty())
description = partNumber;
// Validate lengths
validateLength(partNumber, 0, 12, "Part Number", rowNumber);
if (hsCode != null)
validateLength(hsCode, 0, 11, "HS Code", rowNumber);
validateLength(description, 1, 500, "Description", rowNumber);
@ -212,6 +216,13 @@ public class MaterialFastExcelMapper {
}
}
/**
* Gets a cell value as string with proper error handling
*/
private String getCellValueAllowEmpty(Row row, int columnIndex, int rowNumber) {
return row.getCellAsString(columnIndex).orElse(null);
}
/**
* Gets a cell value as string with proper error handling
*/
@ -248,6 +259,6 @@ public class MaterialFastExcelMapper {
* Validates HS Code (placeholder for API validation)
*/
private boolean validateHsCode(String hsCode) {
return hsCode.length() >= 10 && hsCode.length() <= 12 && hsCode.matches("[0-9]+");
return hsCode == null || (hsCode.length() >= 8 && hsCode.length() <= 12 && hsCode.matches("[0-9]+"));
}
}

View file

@ -136,29 +136,28 @@ public class PreCalculationCheckService {
private void periodCheck(ValidityPeriod period, PropertySet set) {
if(set == null)
if (set == null)
throw new PremiseValidationError("There are no system properties for the given date. Please contact your administrator.");
if(period == null)
if (period == null)
throw new PremiseValidationError("There are no rates for the given date. Please contact your administrator.");
if(ValidityPeriodState.VALID != period.getState() && ValidityPeriodState.EXPIRED != period.getState())
if (ValidityPeriodState.VALID != period.getState() && ValidityPeriodState.EXPIRED != period.getState())
throw new PremiseValidationError("There are no valid rates for the given date. Please contact your administrator.");
if(ValidityPeriodState.VALID != set.getState() && ValidityPeriodState.EXPIRED != period.getState())
if (ValidityPeriodState.VALID != set.getState() && ValidityPeriodState.EXPIRED != period.getState())
throw new PremiseValidationError("There are no valid system properties for the given date. Please contact your administrator.");
//TODO: sicherstellen, dass die valid days für den zeitpunkt galten zu dem die valid period galt (wenn rückwirkend gerechnet wird)
var validDays = propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.VALID_DAYS, set.getId());
var renewals = period.getRenewals();
if(validDays.isEmpty())
if (validDays.isEmpty())
throw new PremiseValidationError("There are no valid days property. Please contact your administrator");
var validDaysInt = Integer.parseInt(validDays.get().getCurrentValue());
if(!period.getStartDate().plusDays((((long) validDaysInt * renewals)+validDaysInt)).isAfter(LocalDateTime.now()))
if (!period.getStartDate().plusDays((((long) validDaysInt * renewals) + validDaysInt)).isAfter(LocalDateTime.now()))
throw new PremiseValidationError("There are no valid rates for the given date. Please contact your administrator.");
}
@ -325,14 +324,14 @@ public class PreCalculationCheckService {
private void materialCheck(Premise premise) {
if(premise.getHsCode() == null || premise.getHsCode().length() < 10)
throw new PremiseValidationError("Invalid HS code.");
var isDeclarable = eUTaxationResolverService.validate(premise.getHsCode());
if (!isDeclarable)
throw new PremiseValidationError("Invalid HS code.");
// if (premise.getTariffUnlocked()) {
// if (premise.getHsCode() == null || premise.getHsCode().length() < 10)
// throw new PremiseValidationError("Invalid HS code (10 digits expected).");
//
// var isDeclarable = eUTaxationResolverService.validate(premise.getHsCode());
//
// if (!isDeclarable) throw new PremiseValidationError("Invalid HS code (not declarable).");
// }
if (premise.getTariffRate() == null) {
throw new PremiseValidationError("Tariff rate not entered.");

View file

@ -67,10 +67,10 @@ public class SysErrorTransformer {
while (matcher.find()) {
SysErrorTraceItem item = new SysErrorTraceItem();
item.setFile(matcher.group(2));
item.setLine(Integer.parseInt(matcher.group(3)));
item.setMethod(matcher.group(1));
item.setFullPath("at " + matcher.group(1));
item.setFile(matcher.group(2) == null ? "na" : matcher.group(2));
item.setLine(Integer.parseInt(matcher.group(3) == null ? "0" : matcher.group(3)));
item.setMethod(matcher.group(1) == null ? "na" : matcher.group(1));
item.setFullPath("at " + (matcher.group(1) == null ? "na" : matcher.group(1)));
items.add(item);
@ -84,7 +84,7 @@ public class SysErrorTransformer {
public SysErrorTraceItem toSysErrorTraceItem(StackTraceElement traceElement) {
SysErrorTraceItem item = new SysErrorTraceItem();
item.setFile(traceElement.getFileName());
item.setFile(traceElement.getFileName() == null ? "na" : traceElement.getFileName());
item.setLine(traceElement.getLineNumber());
item.setMethod(traceElement.getMethodName());
item.setFullPath("at " + traceElement.getClassName() + "." + traceElement.getMethodName());

View file

@ -17,9 +17,7 @@ import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
@ -62,8 +60,11 @@ public class CalculationIntegrationTests {
var premise3 = premisesBeforeUpdate.stream().filter(p -> p.getHuUnitCount() == 3).findFirst().orElseThrow();
var createDto = new DestinationCreateDTO();
createDto.setPremiseId(Collections.singletonList(premise1.getId()));
createDto.setDestinationNodeId(nodeId);
var map = new HashMap<Integer, List<Integer>>();
map.put(premise1.getId(), List.of(nodeId));
createDto.setDestinationNodeIds(map);
var response = mockMvc.perform(post("/api/calculation/destination")
.content(objectMapper.writeValueAsString(createDto))

View file

@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@ -61,8 +62,11 @@ public class DestinationIntegrationTest {
var premise3 = premisesBeforeUpdate.stream().filter(p -> p.getHuUnitCount() == 3).findFirst().orElseThrow();
var dto = new DestinationCreateDTO();
dto.setPremiseId(Arrays.asList(premise1.getId(), premise3.getId()));
dto.setDestinationNodeId(nodeId);
var map = new HashMap<Integer, List<Integer>>();
map.put(premise1.getId(), List.of(nodeId));
map.put(premise3.getId(), List.of(nodeId));
dto.setDestinationNodeIds(map);
mockMvc.perform(post("/api/calculation/destination")
.content(objectMapper.writeValueAsString(dto))