Intermediate commit

This commit is contained in:
Jan 2025-12-02 23:06:28 +01:00
parent 4da6fed8cd
commit 27b56bc92d
34 changed files with 2603 additions and 609 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,4 +1,4 @@
<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="SvgjsSvg1016" data-name="Ebene 1 Kopie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 297.43 345.13">
<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 {
@ -9,10 +9,8 @@
fill: #002f54;
}
</style>
</defs>
<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><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>
</svg></svg></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -29,6 +29,14 @@ export default {
padding: 0;
}
html.modal-open {
background: #f8fafc;
}
.page-header {
font-weight: normal;
margin-bottom: 3rem;

View file

@ -1,16 +1,16 @@
<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>
@ -18,7 +18,7 @@
<script>
export default {
emits:["checkbox-changed"],
emits: ["checkbox-changed", "enter"],
props: {
checked: {
type: Boolean,
@ -40,45 +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: {
focus() {
this.$refs.checkboxInput?.focus();
},
setFilter(event) {
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) {
@ -88,8 +92,8 @@ export default{
}
}
</script>
<style>
<style>
.checkbox-container {
display: flex;
gap: 2rem;

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

@ -4,8 +4,8 @@
class="sort-button"
:class="{ 'active': active }"
>
<PhArrowCircleUp weight="fill"
:size="24"
<PhCaretUp weight="fill"
:size="16"
class="sort-icon"
:class="{ 'rotate': direction === 'asc' }"
/>
@ -13,11 +13,12 @@
</template>
<script>
import {PhArrowCircleUp, PhFunnelSimple} from '@phosphor-icons/vue';
import {PhArrowCircleUp, PhCaretUp, PhFunnelSimple} from '@phosphor-icons/vue';
export default {
name: "SortButton",
components: {
PhCaretUp,
PhArrowCircleUp,
PhFunnelSimple
},

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

@ -6,7 +6,7 @@
<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"
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">
@ -33,7 +33,7 @@
</div>
<div class="bulk-edit-row__cell-container">
<div class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable"
<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">
@ -59,7 +59,7 @@
</div>
<div class="bulk-edit-row__cell-container">
<div class="bulk-edit-row__cell bulk-edit-row__cell--status bulk-edit-row__cell--clickable"
<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">
@ -111,7 +111,7 @@
@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 premise.destinations.slice(0, 3)"
v-for="(destination, index) in destinations.slice(0, 3)"
:key="index">
<div>
<ph-stack size="16"/>
@ -121,7 +121,7 @@
<basic-badge size="compact" variant="secondary">{{ toDestination(destination) }}</basic-badge>
</div>
</div>
<div class="bulk-edit-row__dest-line" v-if="premise.destinations.length > 3">
<div class="bulk-edit-row__dest-line" v-if="destinations.length > 3">
<div></div>
<div> more ...</div>
<div></div>
@ -129,9 +129,9 @@
</div>
<!-- Expanded destinations overlay -->
<div class="bulk-edit-row__destinations-expanded" v-if="premise.destinations.length > 3">
<div class="bulk-edit-row__destinations-expanded" v-if="destinations.length > 3">
<div class="bulk-edit-row__dest-line"
v-for="(destination, index) in premise.destinations"
v-for="(destination, index) in destinations"
:key="index">
<div>
<ph-stack size="16"/>
@ -159,7 +159,7 @@
@click="action($event,'routes')">
<div class="bulk-edit-row__data">
<div class="bulk-edit-row__route-line"
v-for="(destination, index) in premise.destinations.slice(0, 3)"
v-for="(destination, index) in destinations.slice(0, 3)"
:key="index">
<div>
<component :is="toRouteIcon(destination)" size="16"></component>
@ -169,7 +169,7 @@
<basic-badge size="compact" variant="secondary">{{ toDestination(destination) }}</basic-badge>
</div>
</div>
<div class="bulk-edit-row__route-line" v-if="premise.destinations.length > 3">
<div class="bulk-edit-row__route-line" v-if="destinations.length > 3">
<div></div>
<div> more ...</div>
<div></div>
@ -177,9 +177,9 @@
</div>
<!-- Expanded destinations overlay -->
<div class="bulk-edit-row__destinations-expanded" v-if="premise.destinations.length > 3">
<div class="bulk-edit-row__destinations-expanded" v-if="destinations.length > 3">
<div class="bulk-edit-row__route-line"
v-for="(destination, index) in premise.destinations"
v-for="(destination, index) in destinations"
:key="index">
<div>
<component :is="toRouteIcon(destination)" size="16"></component>
@ -193,9 +193,9 @@
<div class="bulk-edit-row__status">
<transition name="badge-transition" mode="out-in">
<circle-badge v-if="destinationCheck && showRouteCheck" :key="'check-route-' + id" variant="primary"
<circle-badge v-if="routeCheck && showRouteCheck" :key="'check-route-' + id" variant="primary"
icon="check" class="badge--check"></circle-badge>
<circle-badge v-else-if="!destinationCheck" :key="'error-route-' + id" variant="exception" icon="exclamation-mark"></circle-badge>
<circle-badge v-else-if="!routeCheck" :key="'error-route-' + id" variant="exception" icon="exclamation-mark"></circle-badge>
</transition>
</div>
</div>
@ -228,6 +228,7 @@ import {
} from "@phosphor-icons/vue";
import {UrlSafeBase64} from "@/common.js";
import CircleBadge from "@/components/UI/CircleBadge.vue";
import {useDestinationEditStore} from "@/store/destinationEdit.js";
export default {
name: "BulkEditRow",
@ -260,11 +261,6 @@ export default {
},
data() {
return {
// Track previous states to detect error->ok transitions
prevMaterialCheck: null,
prevPriceCheck: null,
prevPackagingCheck: null,
prevDestinationCheck: null,
// Flags to show check badges only on transition
showMaterialCheck: false,
showPriceCheck: false,
@ -273,6 +269,8 @@ export default {
showRouteCheck: false,
// Flag to track if component has been initialized
isInitialized: false,
// Store the initial state to prevent false triggers on mount
initialCheckStates: null,
}
},
computed: {
@ -286,57 +284,110 @@ export default {
return this.premise.handling_unit;
},
packagingCheck() {
return this.hu?.length != null && this.hu?.width != null && this.hu?.height != null && this.hu?.weight != null && this.hu?.content_unit_count;
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.premise?.destinations ?? null) === null) || this.premise?.destinations.length === 0)
if (((this.destinations ?? null) === null) || this.destinations.length === 0)
return false;
return !this.premise?.destinations?.some(d => d.annual_amount == null);
return !this.destinations?.some(d => d.annual_amount == null);
},
routeCheck() {
if (((this.destinations ?? null) === null) || this.destinations.length === 0)
return false;
return this.destinations?.every(d => d.routes?.some((route) => route.is_selected));
},
isSelected() {
return this.premiseEditStore.isChecked(this.id);
},
...mapStores(usePremiseEditStore),
destinations() {
return this.destinationEditStore.getByPremiseId(this.id) ?? [];
},
...mapStores(usePremiseEditStore, useDestinationEditStore),
},
watch: {
materialCheck(newVal, oldVal) {
if (this.isInitialized && oldVal === false && newVal === true) {
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) {
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) {
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) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.destination !== true) {
this.showDestinationCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.destination = true;
}
}
},
routeCheck(newVal, oldVal) {
if (this.isInitialized
&& oldVal === false
&& newVal === true
&& this.initialCheckStates?.route !== true) {
this.showRouteCheck = true;
if (this.initialCheckStates) {
this.initialCheckStates.route = true;
}
}
},
},
mounted() {
// Initialize previous states after first render
// Use nextTick to ensure computed properties are evaluated
// 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.prevMaterialCheck = this.materialCheck;
this.prevPriceCheck = this.priceCheck;
this.prevPackagingCheck = this.packagingCheck;
this.prevDestinationCheck = this.destinationCheck;
this.isInitialized = true;
});
},
methods: {
toDestination(destination, limit = 10) {
toDestination(destination, limit = 15) {
return this.toNode(destination.destination_node, limit);
},
toNode(node, limit = 5) {

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

@ -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,221 @@
<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="updateOverallCheck($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 && selectedCount < totalCount;
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}));
}
},
updateOverallCheck(newValue, id) {
this.destPool.find(d => d.id === id).overallCheck = newValue;
this.destMatrix.forEach(r => r.destinations.find(d => d.id === id).selected = newValue);
},
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

@ -1,13 +0,0 @@
<script>
export default {
name: "DestinationMassCreate"
}
</script>
<template>
</template>
<style scoped>
</style>

View file

@ -1,14 +1,104 @@
<script>
export default {
name: "DestinationMassEdit"
}
</script>
<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";
export default {
name: "DestinationMassEdit",
components: {BasicButton, TabContainer},
props: {
premiseIds: {
type: Array,
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
currentTab: null,
}
},
computed: {
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},
matchType: 'amount'
},
{
title: 'Handling & Repackaging',
component: markRaw(DestinationMassHandlingCost),
props: {premiseIds: this.premiseIds},
matchType: 'handling'
},
{
title: 'Routes',
component: markRaw(DestinationMassRoute),
props: {premiseIds: this.premiseIds},
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

@ -1,15 +1,446 @@
<script lang="ts">
import {defineComponent} from 'vue'
<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>
</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>
export default defineComponent({
name: "DestinationMassHandlingCost"
})
<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
}
},
data() {
return {
handlingCostMatrix: null,
handlingCostActive: false,
overallDisposalCostValue: null,
overallRepackagingCostValue: null,
overallHandlingCostValue: null,
overallCheck: false,
overallIndeterminate: false,
isCtrlPressed: false,
isShiftPressed: false,
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore),
rows() {
return this.handlingCostMatrix;
},
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;
}
},
created() {
this.buildMatrix();
},
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, 0);
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.handlingCostMatrix.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.handlingCostMatrix
.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.handlingCostMatrix.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)}`;
},
buildMatrix() {
this.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) {
this.handlingCostMatrix.push({
id: premise.id,
material: premise.material.part_number,
supplier: this.toNode(premise.supplier, 15),
supplierId: premise.supplier.id,
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);
}
}
}
}
}
</script>
<template>
</template>
<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: 5rem;
}
.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,266 @@
<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">
<ph-factory size="24"/>
{{ 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="row.handling_costs"
@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="row.repackaging_costs"
@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="row.disposal_costs"
@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";
export default {
name: "DestinationMassHandlingCostRow",
components: {PhMapPin, PhFactory, Checkbox},
emits: ['action', 'update-selected'],
props: {
row: {
type: Object,
required: true
},
disabled: {
type: Boolean,
required: true
}
},
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: 5rem;
}
.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

@ -1,15 +1,414 @@
<script lang="ts">
import {defineComponent} from 'vue'
<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></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>
export default defineComponent({
name: "DestinationMassQuantity"
})
<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
}
},
data() {
return {
destPool: null,
destMatrix: null,
overallCheck: false,
overallIndeterminate: false,
isCtrlPressed: false,
isShiftPressed: false,
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore),
rows() {
return this.destMatrix;
},
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;
},
},
// watch: {
// someChecked(newVal, oldVal) {
// if(newVal === true && oldVal === false) {
// //reset overall inputs
// }
// }
// },
created() {
this.buildMatrix();
},
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.destMatrix.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.destMatrix
.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.destMatrix.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)}`;
},
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());
// destMatrix aufbauen
this.destMatrix = 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
};
});
}
}
}
</script>
<template>
</template>
<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: 5rem;
}
.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: 5rem;
}
.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

@ -1,14 +1,192 @@
<script lang="ts">
import {defineComponent} from 'vue'
<template>
<div class="dest-mass-route-container">
<div class="dest-mass-route-table-wrapper">
<div class="dest-mass-route-table-header">
<div class="dest-mass-route-table-header-checkbox">
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate"></checkbox>
</div>
<div class="dest-mass-route-table-header-supplier">Supplier</div>
<div class="dest-mass-route-table-header-dest"
:key="`${dest.id}`"
v-for="dest in destPool">
<div>{{ toNode(dest.destination_node, 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-route-table">
export default defineComponent({
name: "DestinationMassRoute"
})
</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";
export default {
name: "DestinationMassRoute",
components: {IconButton, Checkbox, DestinationMassQuantityRow},
props: {
premiseIds: {
type: Array,
required: true
}
},
computed: {
...mapStores(useDestinationEditStore, usePremiseEditStore)
},
data() {
return {
destPool: null,
destMatrix: null,
}
},
created() {
this.buildMatrix();
},
methods: {
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)}`;
},
buildMatrix() {
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,
});
}
}
}
this.destPool = Array.from(destMap.values());
// destMatrix aufbauen
const premiseMap = new Map();
this.premiseIds.forEach(pId => {
const premise = this.premiseEditStore.getById(pId);
const destinations = this.destinationEditStore.getByPremiseId(pId);
if (!premiseMap.has(premise.supplier.id)) {
premiseMap.set(premise.supplier.id, {
ids: [],
supplierNodeId: premise.supplier.id,
supplier: premise.supplier.name,
destinations: []
});
}
const row = premiseMap.get(premise.supplier.id);
if (row) {
row.ids.push(premise.id);
this.addDestinationsToRow(row.destinations, destinations)
}
});
this.destMatrix = [];
// .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);
// }
// }
//
// console.log("prem", toRaw( premise), destRaw)
//
// return {
// // id: premise.id,
// // 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
// };
// });
},
addDestinationsToRow(rowDestinations, premiseDestinations) {
premiseDestinations.forEach(premD => {
let existingDest = rowDestinations.find(rowD => rowD.destinationNodeId === premD.destination_node.id) ?? null;
/* create destination, if it does not exist for supplier. */
if (!existingDest) {
existingDest = {
ids: [],
destinationNodeId: premD.destination_node.id,
destinationName: premD.destination_node.name,
routes: this.buildRoutes(premD.routes)
}
// premdD.routes.push
rowDestinations.push(existingDest);
}
/* add orig Destination id to destination */
existingDest.ids.push(premD.id);
/* add route ids to routes */
this.addRoutesToDestination(existingDest.routes, premD.routes)
});
},
addRoutesToDestination(rowRoutes, premiseRoutes) {
// premiseRoutes
},
buildRoutes(routes) {
},
}
}
</script>
<template>
</template>
<style scoped>

View file

@ -4,11 +4,11 @@
<div class="header-container">
<h2 class="page-header">Mass edit calculation</h2>
<div class="header-controls">
<basic-button :show-icon="false"
<basic-button :show-icon="true"
:disabled="disableButtons"
variant="secondary"
@click="close"
>Close
icon="MapPin" variant="primary"
@click="destMgmt"
>Destination manager
</basic-button>
<basic-button :show-icon="true"
:disabled="disableButtons"
@ -16,6 +16,12 @@
@click="calculate"
>Calculate & close
</basic-button>
<basic-button :show-icon="false"
:disabled="disableButtons"
variant="secondary"
@click="close"
>Close
</basic-button>
</div>
</div>
@ -24,17 +30,21 @@
<div class="edit-calculation-list-header">
<div>
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate"></checkbox>
:indeterminate="overallIndeterminate" :disabled="!showData"></checkbox>
</div>
<div class="edit-calculation-list-header-cell">Material
<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')" @click="premiseEditStore.sort('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">Supplier
<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')" @click="premiseEditStore.sort('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>
@ -78,10 +88,18 @@
:select-count="selectCount"></mass-edit-dialog>
<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="modalComponentType"
ref="modalComponent"
v-model:partNumber="modalProps.partNumber"
v-model:hsCode="modalProps.hsCode"
v-model:tariffRate="modalProps.tariffRate"
@ -104,6 +122,9 @@
v-model:hideDescription="modalProps.hideDescription"
:type="modalType"
:premiseIds="editIds"
:fromMassEdit="true"
:countryId=null
:responsive="false"
@ -147,18 +168,25 @@ import PackagingEdit from "@/components/layout/edit/PackagingEdit.vue";
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";
const COMPONENT_TYPES = {
price: PriceEdit,
material: MaterialEdit,
packaging: PackagingEdit,
destinations: null,
destinations: DestMassCreate,
routes: DestinationMassEdit,
amount: DestinationMassEdit
}
export default {
name: "MassEdit",
components: {
ModalDialog,
SortButton,
Modal,
MassEditDialog,
@ -169,6 +197,26 @@ 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, useDestinationEditStore),
disableButtons() {
@ -215,7 +263,7 @@ export default {
return this.modalType ? COMPONENT_TYPES[this.modalType] : null;
},
showProcessingModal() {
return this.premiseEditStore.showProcessingModal || this.showCalculationModal;
return this.premiseEditStore.showProcessingModal || this.destinationEditStore.showProcessingModal ;
},
shownProcessingMessage() {
return this.processingMessage;
@ -237,30 +285,11 @@ export default {
this.destinationEditStore.setupDestinations(premisses);
},
data() {
return {
ids: [],
isCtrlPressed: false,
isShiftPressed: false,
overallCheck: false,
overallIndeterminate: false,
bulkQuery: null,
modalType: null,
modalProps: null,
editIds: null,
dataSourceId: null,
processingMessage: "Please wait. Calculating ...",
showCalculationModal: false,
isInitialLoad: true,
}
},
mounted() {
console.log("add listener")
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
},
beforeUnmount() {
console.log("remove listener")
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keyup', this.handleKeyUp);
},
@ -299,6 +328,12 @@ export default {
});
}
},
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();
@ -345,7 +380,7 @@ export default {
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) {
} else if (actions.length === 2) { /* ctrl or ctrl + shift */
this.premiseEditStore.setBy(actions[0], actions[1], data.id);
this.updateOverallCheckBox();
}
@ -353,37 +388,90 @@ export default {
/* modal handling */
openModal(type, ids, dataSource = -1, massEdit = true) {
if (type !== 'amount' && type !== 'route')
this.fillData(type, dataSource, massEdit)
else {
//TODO new destination handling
// 1. all unset -> goto destination create
// 2. some unset -> ask if goto destination create
// 3. all set -> goto amount/route
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) {
console.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) {
console.log("open modal (actual)", type, ids, dataSource, massEdit, this.modalStash, this.modalDialogShow)
this.fillData(type, dataSource, massEdit);
this.editIds = ids;
this.modalType = type;
}
},
async closeEditModalAction(action) {
if (this.modalType === "destinations") {
//TODO new destination handling
} else if (action === "accept") {
await this.premiseEditStore.batchUpdate(this.modalType, this.editIds, this.modalProps);
if (this.modalType === "destinations") {
if (action === 'accept') {
const destMatrix = this.$refs.modalComponent?.destMatrix;
if (destMatrix) {
await this.destinationEditStore.massSetDestinations(destMatrix);
}
}
// Clear data
this.fillData(this.modalType);
this.modalType = null;
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) {
@ -415,9 +503,15 @@ export default {
stackable: true
};
if (type === 'amount' || type === 'routes' || type === 'destinations')
this.modalProps = {};
} else {
const premise = this.premiseEditStore.getById(id);
this.modalTitle = "Edit ".concat(type);
if (type === "price") {
this.modalProps = {
price: premise.material_cost,
@ -447,9 +541,14 @@ 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 */
@ -458,7 +557,8 @@ export default {
el.style.opacity = 0;
el.style.transform = 'translateY(2rem)';
}
},
}
,
onEnter(el, done) {
if (this.isInitialLoad) {
const index = parseInt(el.dataset.index) || 0;
@ -491,8 +591,13 @@ export default {
</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(.bulk-edit-row__cell--clickable:hover) {
.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;
@ -521,8 +626,6 @@ export default {
.modal-content-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
margin-top: 1.6rem;
min-width: 50rem;
}
@ -593,6 +696,18 @@ export default {
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'});
}
},

View file

@ -1,14 +1,53 @@
import {defineStore} from 'pinia'
import {toRaw} from "vue";
import performRequest from "@/backend.js";
import {config} from '@/config'
export const useDestinationEditStore = defineStore('destinationEdit', {
state: () => ({
destinations: null,
loading: false,
}),
getters: {},
actions: {
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;
}
},
actions: {
setupDestinations(premisses) {
this.loading = true;
@ -18,312 +57,39 @@ export const useDestinationEditStore = defineStore('destinationEdit', {
this.loading = false;
},
async massSetDestinations(updateMatrix) {
this.loading = true;
const toBeAdded = {};
const toBeDeletedMap = new Map();
/**
* 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);
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]]);
});
} 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 => this.selectedIds.includes(p.id)).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;
}
},
async massUpdateDestinations(updateMatrix) {
this.loading = true;
this.loading = false;
},
}
});

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'
@ -164,7 +162,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
this.loading = true;
const direction = (type !== this.sortedBy) ? 'desc' : (this.order.get(type) === 'asc' ? 'desc' : 'asc');
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) => {
@ -179,7 +177,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
else return a.id - b.id;
});
console.log("sort", this.sortedBy, direction, type);
this.premisses = temp;
this.sortedBy = type;
this.order.set(type, direction);

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

@ -29,8 +29,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;
@ -176,14 +174,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}/"})

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;
@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

@ -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

@ -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

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

@ -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);
}
@ -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

@ -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))