FRONTEND/BACKEND: Refactor and extend error-handling capabilities; update ErrorStore with new actions like clearErrors and expanded error fields (traceCombined); rework PremiseEditStore for enhanced destination and premise state management; introduce mass destination editing functionality; modify UI components (CalculationListItem.vue) for improved styling and base64-encoded route handling; backend adjustments include adding support for externalMappingId in NodeTransformer and RouteNodeRepository.

This commit is contained in:
Jan 2025-08-26 09:54:37 +02:00
parent 6eaf3d4abc
commit 1690d869d6
40 changed files with 2321 additions and 446 deletions

View file

@ -1,4 +1,5 @@
<template>
<error-notification></error-notification>
<the-header></the-header>
<router-view v-slot="slotProps">
<transition name="route" mode="out-in">
@ -11,9 +12,11 @@
import TheHeader from "@/components/layout/TheHeader.vue";
import ErrorNotification from "@/components/UI/ErrorNotifcation.vue";
export default {
components: {TheHeader}
components: {ErrorNotification, TheHeader},
}
</script>
@ -75,4 +78,8 @@ body {
transform: translateX(0);
}
</style>

View file

@ -22,7 +22,7 @@ export default{
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'grey', 'exception'].includes(value)
validator: (value) => ['primary', 'secondary', 'grey', 'exception', 'skeleton'].includes(value)
}
},
computed: {
@ -89,6 +89,18 @@ export default{
border-color: transparent;
}
.batch--skeleton {
border: 0.1rem solid #002F54;
background-color: transparent;
color: #002F54;
}
.batch--skeleton-grey {
border: 0.1rem solid #6b7280;
background-color: transparent;
color: #6b7280;
}
.batch-icon {
flex-shrink: 0;
}

View file

@ -3,7 +3,7 @@
v-if="!link"
:class="buttonClasses"
class="btn"
@click="$emit('click', $event)"
@click="handleClick"
>
<component
v-if="showIcon"
@ -15,7 +15,7 @@
<span class="btn-text"><slot></slot></span>
</button>
<router-link v-else :to="to" :class="buttonClasses"
class="btn">
class="btn" :disabled="disabled">
<component
v-if="showIcon"
:is="iconComponent"
@ -78,6 +78,12 @@ export default defineComponent({
const iconName = this.icon.charAt(0).toUpperCase() + this.icon.slice(1)
return `Ph${iconName}`
}
},
methods: {
handleClick(event) {
if(this.disabled) return;
this.$emit('click', event);
}
}
})
</script>

View file

@ -0,0 +1,165 @@
<template>
<teleport to="body">
<transition
name="error-container"
tag="div"
class="error-notification-container">
<div class="error-notification" v-if="error">
<div>
<ph-warning size="24"></ph-warning>
</div>
<div class="error-message">
<div class="error-message-title">
{{ title }}
</div>
<div class="error-message-content">
{{ message }}
</div>
<div class="error-view-trace" v-if="trace" @click="activateTrace">
View trace
</div>
</div>
<div class="icon-error-notification">
<ph-x size="24" @click="close"></ph-x>
</div>
<modal :z-index="9001" :state="showTrace"><trace-view :error="error" @close="deactivateTrace"></trace-view></modal>
</div>
</transition>
</teleport>
</template>
<script>
import {useErrorStore} from "@/store/error.js";
import {mapStores} from "pinia";
import TraceView from "@/components/layout/TraceView.vue";
import Modal from "@/components/UI/Modal.vue";
export default {
name: "ErrorNotification",
components: {Modal, TraceView},
data() {
return {
showTrace: false,
}
},
computed: {
...mapStores(useErrorStore),
title() {
return this.error?.title || 'Error';
},
message() {
return this.error?.message || 'An unexpected error occurred';
},
trace() {
return !((this.error?.trace ?? null) === null) || !((this.error?.traceCombined ?? null) === null);
},
error() {
return this.errorStore.lastError;
}
},
methods: {
close() {
this.errorStore.clearErrors();
},
activateTrace() {
this.showTrace = true;
},
deactivateTrace() {
this.showTrace = false;
},
}
}
</script>
<style scoped>
.error-message {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
}
.error-message-title {
font-size: 1.6rem;
font-weight: 500;
}
.error-message-content {
font-size: 1.4rem;
}
.error-view-trace {
font-size: 1.4rem;
cursor: pointer;
text-decoration: underline;
}
.error-notification-container {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translate(-50%, 0);
z-index: 9000;
pointer-events: none;
}
.error-notification {
min-width: 30rem;
max-width: 100rem;
padding: 1.6rem;
border-radius: 0.8rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.6rem;
background-color: #BC2B72;
color: #ffffff;
font-size: 1.4rem;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.icon-error-notification {
cursor: pointer;
transition: all 0.1s ease-in-out;
background-color: #BC2B72;
color: #ffffff;
}
.icon-error-notification:hover, .icon-error-notification:active {
cursor: pointer;
transform: scale(1.1);
}
/* Transition animations */
.error-container-enter-active {
transition: all 0.3s ease;
}
.error-container-leave-active {
transition: all 0.3s ease;
}
.error-container-enter-from {
opacity: 0;
transform: translate(-50%, 100%);
}
.error-container-leave-to {
opacity: 0;
transform: translate(-50%, 100%);
}
.error-container-move {
transition: transform 0.3s ease;
}
</style>

View file

@ -0,0 +1,131 @@
<template>
<transition
name="list-edit-transition"
tag="div"
class="list-edit-container"
>
<div v-if="show" class="list-edit">
<div class="icon-container"><ph-pencil-simple size="24" /><span class="number-circle">{{selectCount}}</span></div>
<div class="list-edit-button" @click="handleAction('material')">Material</div>
<div class="list-edit-button" @click="handleAction('price')">Price</div>
<div class="list-edit-button" @click="handleAction('packaging')">Packaging</div>
<div class="list-edit-button" @click="handleAction('supplier')">Supplier</div>
<div class="list-edit-button" @click="handleAction('destinations')">Destinations & Routes</div>
</div>
</transition>
</template>
<script>
import IconButton from "@/components/UI/IconButton.vue";
import {PhPencilSimple} from "@phosphor-icons/vue";
export default{
name: "MassEditDialog",
components: {PhPencilSimple, IconButton},
emits: ['action'],
props: {
show: {
type: Boolean,
default: false
},
selectCount: {
type: Number,
default: 1
}
},
methods: {
handleAction(action) {
this.$emit('action', action);
}
}
}
</script>
<style scoped>
.list-edit-container {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translate(-50%, 0);
z-index: 1000;
}
.list-edit-button {
padding: 0.8rem;
text-wrap: nowrap;
}
.list-edit-button:hover {
color: #FFFFFF;
background-color: #002F54;
border-radius: 0.8rem;
cursor: pointer;
}
.list-edit {
display: flex;
justify-content: center;
align-items: center;
gap: 3.6rem;
background-color: #5AF0B4;
border-radius: 0.8rem;
flex: 0 0 auto;
padding: 0.8rem 1.6rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
font-size: 1.4rem;
}
.icon-container {
display: flex;
align-items: center;
gap: 1.2rem;
}
.number-circle {
position: relative;
top: 0.8rem;
left: -1.8rem;
display: inline-block;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
/*background-color: #002F54;
color: white;*/
color: #002F54;
text-align: center;
line-height: 1.6rem;
font-size: 1.2rem;
font-weight: 500;
}
/* Transition animations */
.list-edit-transition-enter-active {
transition: all 0.3s ease;
}
.list-edit-transition-leave-active {
transition: all 0.3s ease;
}
.list-edit-transition-enter-from {
opacity: 0;
transform: translate(-50%, 100%); /* beide Transforms kombinieren */
}
.list-edit-transition-leave-to {
opacity: 0;
transform: translate(-50%, 100%); /* beide Transforms kombinieren */
}
.list-edit-transition-move {
transition: transform 0.3s ease;
}
</style>

View file

@ -8,6 +8,7 @@
@keydown.esc="closeOnEscape && close()"
tabindex="-1"
ref="modalOverlay"
:style="modalAddStyle"
>
<box @click.stop>
<slot></slot>
@ -36,9 +37,19 @@ export default {
type: Boolean,
default: true
},
zIndex: {
type: Number,
validators: (value) => value >= 0,
default: 5000,
},
},
emits: ['close'],
computed: {
modalAddStyle() {
return {
zIndex: this.zIndex,
}
},
isVisible() {
return this.state;
}
@ -90,7 +101,7 @@ export default {
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 5000;
padding: 1rem;
}

View file

@ -1,6 +1,6 @@
<template>
<div>
<modal :state="state">
<modal :state="state" :z-index="zIndex">
<div class="modal-dialog">
<div class="modal-dialog-title sub-header">{{ title }}</div>
<div class="modal-dialog-message">{{ message }}</div>
@ -25,6 +25,12 @@ export default {
components: {Modal, BasicButton},
emits: ['click'],
props: {
zIndex: {
type: Number,
required: false,
validators: (value) => value >= 0,
default: 5000
},
title: {
type: String,
required: true

View file

@ -22,6 +22,7 @@
'slide-in': activeTab === index && hasChanged,
'slide-out': activeTab !== index && hasChanged
}]"
v-show="activeTab === index"
>
<!-- Render component if provided -->
<component
@ -94,6 +95,10 @@ export default {
<style scoped>
.tab-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-headers {
@ -101,9 +106,9 @@ export default {
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
background-color: transparent;
position: relative;
flex-shrink: 0; /* Don't let headers shrink */
}
.tab-header {
padding: 12px 20px;
border: none;
@ -119,13 +124,11 @@ export default {
z-index: 2;
}
.tab-header:hover {
background-color: rgba(107, 134, 156, 0.05);
color: #6b7280;
}
.tab-header.active {
color: #002F54;
border-bottom-color: #5AF0B4;
@ -135,17 +138,15 @@ export default {
.tab-content {
padding: 20px;
background-color: white;
min-height: 200px;
flex: 1; /* Take remaining space */
min-height: 0; /* Allow shrinking */
position: relative;
overflow: hidden;
}
.tab-pane {
width: 100%;
position: absolute;
top: 20px;
left: 20px;
right: 20px;
height: 100%;
opacity: 0;
transform: translateY(20px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
@ -155,10 +156,6 @@ export default {
.tab-pane.active {
opacity: 1;
transform: translateY(0);
position: relative;
top: auto;
left: auto;
right: auto;
pointer-events: all;
}
@ -182,7 +179,6 @@ export default {
}
}
/* Keyframe Animations */
@keyframes slideOutUp {
0% {
opacity: 1;
@ -193,5 +189,4 @@ export default {
transform: translateX(30px);
}
}
</style>

View file

@ -1,16 +1,22 @@
<template>
<div class="trace-view-container" :class="trace ? '' : 'trace-view-container--no-trace'">
<div class="trace-view-header">
<h3 class="sub-header">{{ code }}</h3>
<h3 class="sub-header">{{ title }} <br><span class="trace-view-message" v-if="showMessage">{{ message }}</span>
</h3>
<icon-button icon="x" @click="$emit('close')"></icon-button>
</div>
<div v-if="showMessage" class="trace-view-message">{{ message }}</div>
<div v-if="trace" class="trace-view">
<div class="trace-view-exception">{{ exception }}</div>
<div :class="highlightClasses(traceItem)" v-for="traceItem of trace">at {{ fullClassName(traceItem) }} (<span
class="trace-view-file">{{ fileName(traceItem) }}</span>)
</div>
</div>
<div v-if="traceCombined" class="trace-view">
<div class="trace-view-exception">{{ traceCombinedException }}</div>
<div class="trace-item" v-for="traceItem of traceCombinedItems">
<div v-html="highlightFilenames(traceItem)"/>
</div>
</div>
</div>
</template>
@ -18,10 +24,11 @@
import BasicButton from "@/components/UI/BasicButton.vue";
import IconButton from "@/components/UI/IconButton.vue";
import {PhWarning} from "@phosphor-icons/vue";
export default {
name: "TraceView",
components: {IconButton, BasicButton},
components: {PhWarning, IconButton, BasicButton},
emits: ['close'],
props: {
error: {
@ -30,11 +37,23 @@ export default {
}
},
computed: {
traceCombined() {
return this.error.traceCombined;
},
traceCombinedException() {
return this.error.traceCombined?.split('\n')[0];
},
traceCombinedItems() {
return this.error.traceCombined?.split('\n').slice(1);
},
trace() {
return this.error.trace?.stackTrace;
return this.error.trace;
},
exception() {
return this.error.trace?.errorMessage;
return this.error.code;
},
title() {
return this.error.title;
},
code() {
return this.error.code;
@ -47,15 +66,35 @@ export default {
},
},
created() {
console.log(this.error);
console.log(this.error.trace);
},
methods: {
highlightClasses(traceItem) {
return traceItem.className.includes('de.avatic.lcc') ? 'highlight-trace-item' : 'trace-item';
return traceItem.className?.includes('de.avatic.lcc') ? 'highlight-trace-item' : 'trace-item';
},
fullClassName(traceItem) {
return `${traceItem.className}.${traceItem.methodName}`;
},
fileName(traceItem) {
return `${traceItem.fileName}:${traceItem.lineNumber}`;
},
highlightFilenames(text) {
if (!text) return text;
// Regex to match file paths ending with .vue or .js
// This will match paths like http://localhost:5173/src/pages/CalculationSingleEdit.vue
const filePathRegex = /(https?:\/\/[^\s]+\/)?([^\/\s]+\.(vue|js))/g;
return text.replace(filePathRegex, (match, path, filename) => {
// If there's a path, keep it but only bold the filename
if (path) {
return `${path}<strong class="highlighted-filename">${filename}</strong>`;
}
// If it's just a filename, bold it
return `<strong class="highlighted-filename">${filename}</strong>`;
});
}
}
}
@ -66,12 +105,14 @@ export default {
@import url('https://fonts.googleapis.com/css2?family=Cal+Sans&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
.highlight-trace-item {
background-color: #c3cfdf;
color: #002F54;
background-color: #F2F2F2;
padding-left: 1.6rem;
}
.trace-item:hover {
background-color: rgba(107, 134, 156, 0.05);
.trace-item {
padding-left: 1.6rem;
}
.trace-view {
@ -79,7 +120,7 @@ export default {
font-family: Roboto Mono, monospace;
font-size: 1.2rem;
flex: 1;
padding: 1.6rem;
padding: 0 1.6rem;
}
.trace-view-header {
@ -89,8 +130,7 @@ export default {
}
.trace-view-message {
font-size: 1.4rem;
color: #002F54;
font-size: 1.2rem;
}
.trace-view-file {
@ -99,8 +139,8 @@ export default {
}
.trace-view-exception {
font-family: inherit;
font-weight: 500;
}
.trace-view-container {

View file

@ -0,0 +1,328 @@
<template>
<div class="bulk-edit-row">
<div class="edit-calculation-checkbox-cell">
<checkbox :checked="isSelected" @checkbox-changed="updateSelected"></checkbox>
</div>
<div class="edit-calculation-cell--material" :class="copyModeClass"
@click="action('material')">
<div class="edit-calculation-cell-line">{{ premise.material.part_number }}</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="premise.material.name">
{{ premise.material.name }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="premise.hs_code">
<PhBarcode/>
{{ premise.material.hs_code }}
</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" v-if="premise.tariff_rate">
<PhPercent/>
{{ premise.tariff_rate }}
</div>
</div>
<div class="edit-calculation-cell--price" :class="copyModeClass" v-if="showPrice"
@click="action('price')">
<div class="edit-calculation-cell-line">{{ premise.material_cost }} EUR</div>
<div class="edit-calculation-cell-line edit-calculation-cell-subline" >
<basic-badge icon="plus" variant="primary">FCA FEE</basic-badge>
</div>
</div>
<div class="edit-calculation-empty" :class="copyModeClass" v-else @click="action('price')">
<basic-badge variant="exception" icon="warning">MISSING</basic-badge>
</div>
<div v-if="showHu" class="edit-calculation-cell edit-calculation-cell--packaging" :class="copyModeClass"
@click="action('packaging')">
<div class="edit-calculation-cell-line">
<PhVectorThree/>
{{ premise.handling_unit.length }} x
{{ premise.handling_unit.width }} x
{{ premise.handling_unit.height }} {{ premise.handling_unit.dimension_unit }}
</div>
<div class="edit-calculation-cell-line">
<PhBarbell/>
<span>{{ premise.handling_unit.weight }} {{ premise.handling_unit.weight_unit }}</span>
</div>
<div class="edit-calculation-cell-line">
<PhHash/>
{{ premise.handling_unit.content_unit_count }} pcs.
</div>
<div class="edit-calculation-packaging-badges">
<basic-badge v-if="premise.is_stackable" variant="primary" icon="stack">STACKABLE</basic-badge>
<basic-badge v-if="premise.is_mixable" variant="skeleton" icon="shuffle">MIXABLE</basic-badge>
</div>
</div>
<div class="edit-calculation-empty" :class="copyModeClass" v-else
@click="action('packaging')">
<basic-badge variant="exception" icon="warning">MISSING</basic-badge>
</div>
<div class="edit-calculation-cell--supplier" :class="copyModeClass"
@click="action('supplier')">
<div class="edit-calculation-cell--supplier-container" v-if="premise.supplier">
<!-- <div class="edit-calculation-cell&#45;&#45;supplier-flag">-->
<!-- <flag :iso="premise.supplier.country.iso_code" size="m"></flag>-->
<!-- </div>-->
<div class="calculation-list-supplier-data">
<div class="edit-calculation-cell-line">{{ premise.supplier.name }}</div>
<!-- <div class="edit-calculation-cell-subline"> {{ premise.supplier.address }}</div>-->
</div>
</div>
</div>
<div class="edit-calculation-cell--destination" :class="copyModeClass" v-if="showDestinations"
@click="action('destinations')">
<div class="edit-calculation-cell-line">
<span class="number-circle"> {{ destinationsCount }} </span> Destinations
</div>
<div class="edit-calculation-cell-subline" v-for="name in destinationNames"> {{ name }}</div>
</div>
<div class="edit-calculation-empty" :class="copyModeClass" v-else
@click="action('destinations')">
<basic-badge variant="exception" icon="warning">MISSING</basic-badge>
</div>
<div class="edit-calculation-actions-cell">
<icon-button icon="pencil-simple" help-text="Edit this calculation" help-text-position="left"
@click="editSingle"></icon-button>
<icon-button icon="x" help-text="Remove from mass edit" help-text-position="left" @click="remove"></icon-button>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Flag from "@/components/UI/Flag.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import BasicBadge from "@/components/UI/BasicBadge.vue";
import {
PhBarbell,
PhBarcode,
PhEmpty,
PhFactory,
PhHash, PhMapPin,
PhPercent,
PhVectorThree,
PhVectorTwo
} from "@phosphor-icons/vue";
import {UrlSafeBase64} from "@/common.js";
export default {
name: "BulkEditRow",
emits: ['remove', 'action'],
components: {
PhMapPin,
PhFactory,
PhPercent,
PhBarcode, PhBarbell, PhHash, PhVectorThree, PhVectorTwo, PhEmpty, BasicBadge, Flag, IconButton, Checkbox
},
props: {
id: {
type: Number,
required: true
},
copyMode: {
type: Boolean,
default: false
}
},
computed: {
destinationsCount() {
return this.premise.destinations.length;
},
destinationsText() {
return this.premise.destinations.map(d => d.destination_node.name).join(', ');
},
destinationNames() {
const spliceCnt = ((this.premise.destinations.length === 4) ? 4 : 3);
const names = this.premise.destinations.map(d => d.destination_node.name).slice(0, spliceCnt);
if (this.premise.destinations.length > 4) {
names.push('and more ...');
}
return names;
},
showDestinations() {
return (this.destinationsCount > 0);
},
showHu() {
return (this.hu.width && this.hu.length && this.hu.height && this.hu.weight && this.hu.content_unit_count)
},
showPrice() {
return (this.premise.material_cost)
},
isSelected() {
return this.premise.selected;
},
hu() {
return this.premise.handling_unit;
},
...mapStores(usePremiseEditStore),
premise() {
const data = this.premiseEditStore.getById(this.id);
return data;
},
copyModeClass() {
if (this.copyMode) {
return 'edit-calculation-cell--copy-mode';
}
return 'edit-calculation-cell';
}
},
methods: {
updateSelected(value) {
this.premiseEditStore.setSelectTo([this.id], value);
},
editSingle() {
const bulkQuery = this.$route.params.ids;
const urlStr = new UrlSafeBase64().encodeIds([this.id]);
this.$router.push({name: 'bulk-single-edit', params: {id: urlStr, ids: bulkQuery}});
},
action(action) {
this.$emit('action', {id: this.id, action: action});
},
remove() {
this.premiseEditStore.removePremise(this.id);
}
}
}
</script>
<style scoped>
.bulk-edit-row {
display: grid;
grid-template-columns: 6rem 1fr 1fr 1.5fr 1.5fr 1.5fr 10rem;
gap: 1.6rem;
padding: 0 2.4rem;
border-bottom: 0.16rem solid #f3f4f6;
align-items: center;
transition: background-color 0.2s ease;
font-size: 1.2rem;
font-weight: 500;
height: 14rem;
overflow: hidden;
}
/*.bulk-edit-row:hover {
// background-color: rgba(107, 134, 156, 0.05);
//} */
.bulk-edit-row:last-child {
border-bottom: none;
}
.edit-calculation-checkbox-cell {
display: flex;
align-items: center;
justify-content: center;
}
.edit-calculation-cell {
padding: 0.8rem;
border-radius: 0.8rem;
height: 90%;
}
.edit-calculation-cell:hover {
cursor: pointer;
background-color: rgba(107, 134, 156, 0.05);
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
}
.edit-calculation-cell--copy-mode {
padding: 0.8rem;
border-radius: 0.8rem;
height: 90%;
}
.edit-calculation-cell--copy-mode:hover {
cursor: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPgogIDxyZWN0IHg9Ijg0IiB5PSIzMiIgd2lkdGg9IjEzNiIgaGVpZ2h0PSIxMzYiIGZpbGw9IndoaXRlIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iOCIgcng9IjQiLz4KICA8cmVjdCB4PSIzNiIgeT0iODQiIHdpZHRoPSIxMzYiIGhlaWdodD0iMTM2IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjgiIHJ4PSI0Ii8+Cjwvc3ZnPg==") 12 12, pointer;
/*cursor: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0iI2ZmZmZmZiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij4KICA8cGF0aCBkPSJNMjAwLDMySDE2My43NGE0Ny45Miw0Ny45MiwwLDAsMC03MS40OCwwSDU2QTE2LDE2LDAsMCwwLDQwLDQ4VjIxNmExNiwxNiwwLDAsMCwxNiwxNkgyMDBhMTYsMTYsMCwwLDAsMTYtMTZWNDhBMTYsMTYsMCwwLDAsMjAwLDMyWm0tNzIsMGEzMiwzMiwwLDAsMSwzMiwzMkg5NkEzMiwzMiwwLDAsMSwxMjgsMzJaIj48L3BhdGg+Cjwvc3ZnPg==") 12 12, pointer;
*/
background-color: rgba(107, 134, 156, 0.05);
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
}
.edit-calculation-cell--price {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.edit-calculation-cell--supplier {
display: flex;
gap: 1.2rem;
}
.edit-calculation-cell--supplier-container {
display: flex;
height: fit-content;
gap: 0.8rem;
}
.edit-calculation-cell--supplier-flag {
display: flex;
align-items: center;
}
.edit-calculation-cell--packaging, .edit-calculation-cell--material, .edit-calculation-cell--destination {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.edit-calculation-cell-line {
display: flex;
align-items: center;
gap: 0.8rem;
}
.edit-calculation-cell-subline {
font-size: 1.2rem;
font-weight: 400;
color: #6b7280;
}
.edit-calculation-packaging-badges {
display: flex;
gap: 0.8rem;
}
.edit-calculation-empty {
display: flex;
align-items: flex-start;
justify-content: flex-start;
font-size: 1.2rem;
font-weight: 400;
color: #6b7280;
gap: 0.8rem;
}
.edit-calculation-actions-cell {
display: flex;
align-items: center;
gap: 1.6rem;
}
.number-circle {
display: inline-block;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background-color: #002F54;
color: white;
text-align: center;
line-height: 1.6rem;
font-size: 1.2rem;
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<div class="calculation-list-row">
<div class="calculation-list-checkbox-cell">
<div class="edit-calculation-checkbox-cell">
<checkbox :checked="checked" @checkbox-changed="updateCheckBox"></checkbox>
</div>
<div class="calculation-list-material-cell">
<div class="edit-calculation-material-cell">
<div class="material-name">{{ premise.material.name }}</div>
<div class="material-code">{{ premise.material.part_number }}</div>
</div>
@ -39,6 +39,7 @@ import Checkbox from "@/components/UI/Checkbox.vue";
import {mapStores} from "pinia";
import {usePremiseStore} from "@/store/premise.js";
import Flag from "@/components/UI/Flag.vue";
import {UrlSafeBase64} from "@/common.js";
export default {
name: "CalculationListItem",
@ -94,7 +95,8 @@ export default {
this.premise.checked = checked;
},
editClick() {
this.$router.push({name: 'edit', params: {id: this.id}});
const urlStr = new UrlSafeBase64().encodeIds([this.id]);
this.$router.push({name: 'edit', params: {id: urlStr}});
},
deleteClick() {
@ -126,13 +128,13 @@ export default {
border-bottom: none;
}
.calculation-list-checkbox-cell {
.edit-calculation-checkbox-cell {
display: flex;
align-items: center;
justify-content: center;
}
.calculation-list-material-cell {
.edit-calculation-material-cell {
display: flex;
flex-direction: column;
gap: 0.4rem;
@ -159,6 +161,9 @@ export default {
.calculation-list-supplier-flag {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.calculation-list-supplier-data {
@ -205,12 +210,12 @@ export default {
"actions";
}
.calculation-list-checkbox-cell {
.edit-calculation-checkbox-cell {
grid-area: checkbox;
justify-content: flex-start;
}
.calculation-list-material-cell {
.edit-calculation-material-cell {
grid-area: material;
}

View file

@ -12,25 +12,30 @@
:reset-on-select="true"></autosuggest-searchbar>
</div>
<div class="list-container">
<!-- <div class="list-container">-->
<transition-group name="list-item" class="list-container" tag="div" mode="out-in">
<!-- Header -->
<div class="destination-list-header">
<div class="destination-list-header" key="header">
<div>Destination</div>
<div>Annual quantity</div>
<div>Selected route</div>
<div>Action</div>
</div>
<div v-if="premiseEditStore.selectedPremise.destinations.length !== 0">
<destination-item v-for="destination in premiseEditStore.selectedPremise.destinations" :key="destination.id"
<!-- TODO: straight this up -->
<div v-if="showDestinationsList">
<destination-item v-for="destination in destinations" :key="destination.id"
:id="destination.id" :destination="destination" @delete="deleteDestination"
@edit="editDestination"></destination-item>
@edit="editDestination" class="list-item-wrapper"></destination-item>
</div>
<div v-else class="empty-container">
<div v-else class="empty-container" key="empty">
<span class="space-around">No Destinations found.</span>
</div>
</div>
</transition-group>
<!-- </div>-->
<modal :state="editDestinationModalState" >
<destination-edit @accept="deselectDestination(true)" @discard="deselectDestination(false)"></destination-edit>
</modal>
@ -61,19 +66,19 @@ export default {
Modal,
DestinationItem, CalculationListItem, Checkbox, Spinner, BasicButton, AutosuggestSearchbar, IconButton
},
props: {
destinations: {
type: Object,
required: true
}
},
data() {
return {
editDestinationModalState: false,
}
},
computed: {
...mapStores(usePremiseEditStore, useNodeStore)
...mapStores(usePremiseEditStore, useNodeStore),
destinations() {
return this.premiseEditStore.getDestinationsView;
},
showDestinationsList() {
return this.destinations !== null && this.destinations.length > 0;
}
},
methods: {
async fetchDestinations(query) {
@ -85,17 +90,18 @@ export default {
},
async addDestination(node) {
console.log(node)
const [id] = await this.premiseEditStore.addDestination(node.id);
console.log(id);
// todo add to massEdit copy only
const [id] = await this.premiseEditStore.addDestination(node);
this.editDestination(id);
},
deleteDestination(id) {
// todo delete from to massEdit copy only
this.premiseEditStore.deleteDestination(id);
},
editDestination(id) {
if (id && this.destinations.some(d => d.id === id)) {
this.premiseEditStore.selectDestinations([id]);
// TODO refactor.
if (id && this.premiseEditStore.getDestinationById(id) !== null) {
this.premiseEditStore.selectDestination(id);
this.editDestinationModalState = true;
}
},
@ -164,6 +170,119 @@ export default {
letter-spacing: 0.08rem;
}
/* List Animation Styles
.list-item-wrapper {
transition: all 0.3s ease;
}*/
/* Enter animations */
.list-item-enter-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.list-item-leave-active {
transition: all 0.3s ease-out;
}
.list-item-enter-from {
opacity: 0;
transform: translateX(-20px) scale(0.95);
}
.list-item-leave-to {
opacity: 0;
transform: translateX(20px) scale(0.95);
}
/* Move animation for when items shift position */
.list-item-move {
transition: transform 0.3s ease;
}
/* Optional: Add a subtle hover animation for list items
.list-item-wrapper:hover {
transform: translateY(-2px);
box-shadow: 0 0.6rem 1.2rem -0.2rem rgba(0, 0, 0, 0.15);
}*/
/* Stagger animation for initial load */
.list-item-enter-active:nth-child(1) {
transition-delay: 0ms;
}
.list-item-enter-active:nth-child(2) {
transition-delay: 50ms;
}
.list-item-enter-active:nth-child(3) {
transition-delay: 100ms;
}
.list-item-enter-active:nth-child(4) {
transition-delay: 150ms;
}
.list-item-enter-active:nth-child(5) {
transition-delay: 200ms;
}
.list-item-enter-active:nth-child(6) {
transition-delay: 250ms;
}
.list-item-enter-active:nth-child(7) {
transition-delay: 300ms;
}
.list-item-enter-active:nth-child(8) {
transition-delay: 350ms;
}
/* Empty state animation */
.empty-container {
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.destination-list-header {
display: none;

View file

@ -1,24 +1,16 @@
<template>
<div class="container">
<div class="container" @focusout="focusLost">
<div class="caption-column">Part number</div>
<div class="input-column">
<autosuggest-searchbar v-if="editMode"
@selected="partNumberSelected"
:fetch-suggestions="fetchPartNumbers"
:initial-value="partNumber"
title-resolver="part_number"
placeholder="Find part number"
:activate-watcher="true"></autosuggest-searchbar>
<span v-else>{{ partNumber }}</span>
<modal-dialog :state="modalDialogPartNumberState"
accept-text="Yes"
deny-text="No"
@click="modalDialogClick"
message="You have changed the part number. Your current master data (like packaging dimensions) might be outdated. Do you want to reload master data?"
title="Update master data"></modal-dialog>
<icon-button :icon="editIconPartNumber" @click="toggleEditMode"></icon-button>
<span>{{ partNumber }}</span>
<modal :state="modalSelectMaterial" @close="closeEditModal">
<select-material :part-number="partNumber" @close="modalEditClick"/>
</modal>
<icon-button icon="pencil-simple" @click="activateEditMode"></icon-button>
</div>
@ -34,21 +26,19 @@
<div class="input-column">
<div class="hs-code-container" v-if="!editMode">
<div class="hs-code-container">
<autosuggest-searchbar :fetch-suggestions="fetchHsCode" :initial-value="hsCode"
placeholder="Find hs code"></autosuggest-searchbar>
<icon-button icon="ArrowCounterClockwise"></icon-button>
</div>
<div v-else>{{ hsCode }}</div>
<div class="caption-column">Tariff rate [%]</div>
<div v-if="!editMode" class="input-field-container input-field-tariffrate">
<div class="input-field-container input-field-tariffrate">
<input ref="tariffRateInput" :value="tariffRatePercent" @blur="validateInput('tariffRate',$event)"
class="input-field"
autocomplete="off"/>
</div>
<span v-else>{{ tariffRatePercent }}</span>
</div>
@ -67,10 +57,15 @@ import {PhArrowCounterClockwise} from "@phosphor-icons/vue";
import {useMaterialStore} from "@/store/material.js";
import {mapStores} from "pinia";
import {parseNumberFromString} from "@/common.js";
import Modal from "@/components/UI/Modal.vue";
import SelectMaterial from "@/components/layout/material/SelectMaterial.vue";
export default {
name: "MaterialEdit",
components: {PhArrowCounterClockwise, ModalDialog, AutosuggestSearchbar, InputField, Flag, IconButton},
components: {
SelectMaterial,
Modal, PhArrowCounterClockwise, ModalDialog, AutosuggestSearchbar, InputField, Flag, IconButton
},
emits: ["update:tariffRate", "updateMaterial", "update:partNumber", "update:hsCode"],
props: {
description: {
@ -89,41 +84,32 @@ export default {
type: String,
required: true,
},
id: {
type: Number,
required: true,
}
},
computed: {
...mapStores(useMaterialStore),
editIconPartNumber() {
return this.editMode ? "check" : "pencil-simple";
},
tariffRatePercent() {
return this.tariffRate ? (this.tariffRate * 100).toFixed(2) : '';
}
},
data() {
return {
editMode: false,
modalDialogPartNumberState: false,
selectedMaterial: null,
modalSelectMaterial: false,
}
},
methods: {
partNumberSelected(material) {
this.selectedMaterial = material;
focusLost(event) {
if (!this.$el.contains(event.relatedTarget)) {
this.$emit('save', 'material');
}
},
modalDialogClick(action) {
this.modalDialogPartNumberState = false;
if (action === 'accept') {
this.$emit('updateMaterial', this.selectedMaterial.id, 'updateMasterData');
this.editMode = false;
this.selectedMaterial = null;
} else if(action === 'deny') {
this.$emit('updateMaterial', this.selectedMaterial.id, 'keepMasterData');
this.editMode = false;
this.selectedMaterial = null;
closeEditModal() {
this.modalSelectMaterial = false;
},
modalEditClick(data) {
this.closeEditModal();
if (data.action === 'accept') {
this.selectedMaterial = data.material;
this.$emit('updateMaterial', data.material.id, data.updateMasterData ? 'updateMasterData' : 'keepMasterData');
}
},
updateInputValue(inputRef, formattedValue) {
@ -145,21 +131,8 @@ export default {
const inputRef = `${type}Input`;
this.updateInputValue(inputRef, formattedValue);
},
toggleEditMode() {
if (this.editMode) {
if (this.selectedMaterial != null && this.selectedMaterial !== this.partNumber) {
this.modalDialogPartNumberState = true;
} else {
this.editMode = false;
}
} else {
this.editMode = true;
}
},
async fetchPartNumbers(query) {
const materialQuery = {searchTerm: query};
await this.materialStore.setQuery(materialQuery);
return this.materialStore.materials;
activateEditMode() {
this.modalSelectMaterial = true;
},
async fetchHsCode(query) {
const hsCodeQuery = {searchTerm: query};

View file

@ -1,5 +1,5 @@
<template>
<div class="container">
<div class="container" @focusout="focusLost">
<div class="caption-column">MEK_A [EUR]</div>
<div class="input-column">
<div class="input-field-container">
@ -58,6 +58,11 @@ export default {
}
},
methods: {
focusLost(event) {
if (!this.$el.contains(event.relatedTarget)) {
this.$emit('save', 'price');
}
},
validatePrice(event) {
const value = parseNumberFromString(event.target.value, 2);
const validatedValue = Math.max(0, value);

View file

@ -20,7 +20,7 @@
src="https://www.galerie-braunbehrens.de/wp-content/uploads/2020/06/placeholder-google-maps.jpg" alt="map">
</div>
<div class="footer">
<modal :state="selectSupplierModalState">
<modal :state="selectSupplierModalState" @close="closeEditModal">
<select-node @close="modalDialogClose"></select-node>
</modal>
<icon-button icon="plus" @click="openModal"></icon-button>
@ -80,6 +80,9 @@ export default {
}
},
methods: {
closeEditModal() {
this.selectSupplierModalState = false;
},
modalDialogClose(data) {
this.selectSupplierModalState = false;
if (data.action === 'accept') {

View file

@ -13,16 +13,12 @@
</template>
<script>
import Tab from "@/components/UI/Tab.vue";
import TabContainer from "@/components/UI/TabContainer.vue";
import DestinationEditRoutes from "@/components/layout/edit/destination/DestinationEditRoutes.vue";
import DestinationEditHandlingCost from "@/components/layout/edit/destination/DestinationEditHandlingCost.vue";
import {markRaw} from "vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
export default {
name: "DestinationEdit",
@ -51,32 +47,36 @@ export default {
}
</script>
<style scoped>
.tab-container {
flex: 1;
overflow: auto; /* In case content overflows */
min-height: 0; /* Critical: allows flex child to shrink below content size */
}
.destination-edit-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Critical: allows flex child to shrink below content size */
}
.destination-edit-modal-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
flex: 1 0 max(80vw, 80rem);
flex: 1 0 min(60vw, 120rem);
height: min(70vh, 50rem);
min-height: 0; /* Critical: allows flex child to shrink below content size */
}
.destination-edit-actions {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
flex-shrink: 0; /* Prevent buttons from shrinking */
}
.sub-header {
flex-shrink: 0; /* Prevent header from shrinking */
}
</style>

View file

@ -2,7 +2,7 @@
<div class="destination-edit-handling-cost">
<div class="destination-edit-handling-cost-info">
<ph-warning size="18px"></ph-warning>
Handling and repackaging costs can be calculated automatically.
Handling and repackaging costs are calculated automatically.
If needed, you can overwrite these values here.
</div>
<div>
@ -54,8 +54,7 @@ export default {
computed: {
...mapStores(usePremiseEditStore),
destination() {
const [dest] = this.premiseEditStore.getSelectedDestinations;
return dest;
return this.premiseEditStore.getSelectedDestinationsData;
},
repackaging: {
get() {

View file

@ -1,10 +1,17 @@
<template>
<div class="destination-edit-routes-container">
<div v-if="showMassEditWarning" class="destination-edit-route-warning">
<ph-warning size="18px"></ph-warning>
Routing is deactivated during mass edit. Please select routes for each calculation separately.
</div>
<div class="destination-edit-routes">
<div class="destination-edit-column-caption">Destination</div>
<div class="destination-edit-column-data">{{ destination.destination_node.name }}</div>
<div class="destination-edit-column-caption"><tooltip :text="tooltipAnnualAmount" position="right">Annual quantity</tooltip></div>
<div class="destination-edit-column-caption">
<tooltip :text="tooltipAnnualAmount" position="right">Annual quantity</tooltip>
</div>
<div class="destination-edit-column-data">
<div class="input-field-container">
<input :value="annualAmount" @blur="validateAnnualAmount" class="input-field"
@ -12,26 +19,26 @@
</div>
</div>
<div class="destination-edit-column-caption">Transport mode</div>
<div class="destination-edit-column-data destination-edit-cell-routing">
<div v-if="!showMassEditWarning" class="destination-edit-column-caption">Calculation model</div>
<div v-if="!showMassEditWarning" class="destination-edit-column-data destination-edit-cell-routing">
<radio-option name="model" value="routing" v-model="calculationModel">standard routing</radio-option>
<radio-option name="model" value="d2d" v-model="calculationModel">individual rate</radio-option>
</div>
<!-- Single grid cell for caption that transitions content -->
<div class="destination-edit-column-caption">
<div v-if="!showMassEditWarning" class="destination-edit-column-caption destination-edit-caption-top">
<transition name="fade" mode="out-in">
<div v-if="showRoutes || showRouteWarning" key="routes">Routes</div>
<div v-else key="rate">D2D Rate [EUR]</div>
<div v-if="showRoutes || showRouteWarning" key="routes" class="destination-edit-caption-top-elem">Routes</div>
<div v-else key="rate" class="destination-edit-caption-top-elem">D2D Rate [EUR]</div>
</transition>
</div>
<!-- Single grid cell for data that transitions content -->
<div class="destination-edit-column-data">
<div v-if="!showMassEditWarning" class="destination-edit-column-data destination-edit-routes-cell">
<transition name="fade" mode="out-in">
<div v-if="showRoutes" key="routes" class="destination-edit-cell-routes">
<destination-route v-for="route in destination.routes" :key="route.id" :route="route"
:selected="route.selected" @click="selectRoute(route.id)"></destination-route>
:selected="route.is_selected" @click="selectRoute(route.id)"></destination-route>
</div>
<div v-else-if="showRouteWarning">
<div class="destination-edit-route-warning">
@ -62,7 +69,7 @@ export default {
components: {Tooltip, DestinationRoute, RadioOption},
methods: {
selectRoute(id) {
// Your route selection logic
this.destination.routes.forEach(route => route.is_selected = route.id === id);
},
validateAnnualAmount(event) {
const value = parseNumberFromString(event.target.value, 0);
@ -94,8 +101,11 @@ export default {
tooltipAnnualAmount() {
return `Annual quantity that "${this.destination.destination_node.name}" will source from the supplier`
},
showMassEditWarning() {
return (this.destination.massEdit ?? false);
},
showRoutes() {
return !this.destination.is_d2d && this.destination.routes.length > 0;
return !this.destination.is_d2d && this.destination.routes?.length > 0 | false;
},
showRouteWarning() {
return !this.destination.is_d2d && this.destination.routes.length === 0;
@ -114,8 +124,8 @@ export default {
},
...mapStores(usePremiseEditStore),
destination() {
const [dest] = this.premiseEditStore.getSelectedDestinations;
return dest;
//TODO handle multiselect (here?)
return this.premiseEditStore.getSelectedDestinationsData;
},
annualAmount: {
get() {
@ -147,14 +157,24 @@ export default {
opacity: 0;
}
.destination-edit-routes-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0; /* Important for flexbox shrinking */
}
.destination-edit-route-warning {
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-bottom: 1.6rem;
margin-bottom: 1.6rem;
}
.destination-edit-cell-routing {
@ -166,9 +186,10 @@ export default {
.destination-edit-routes {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(4, auto);
grid-template-rows: auto auto auto 1fr; /* Last row takes remaining space */
gap: 1.6rem;
flex: 1 1 auto;
height: 100%;
min-height: 0; /* Important for grid to shrink */
}
.destination-edit-column-data {
@ -178,6 +199,12 @@ export default {
color: #6b7280;
}
.destination-edit-routes-cell {
/* This cell needs to handle overflow */
min-height: 0;
align-self: stretch; /* Take full height of grid row */
}
.destination-edit-column-caption {
font-size: 1.4rem;
font-weight: 500;
@ -187,6 +214,16 @@ export default {
text-wrap: nowrap;
}
.destination-edit-caption-top {
align-self: start; /* Align to top instead of center */
display: flex;
gap: 1.6rem;
}
.destination-edit-caption-top-elem {
margin-top: 0.8rem;
}
.input-field {
border: none;
outline: none;
@ -220,5 +257,9 @@ export default {
display: flex;
flex-direction: column;
gap: 0.8rem;
overflow-y: auto; /* Enable scrolling */
height: 100%;
min-height: 0;
padding-right: 0.8rem; /* Space for scrollbar */
}
</style>

View file

@ -2,8 +2,8 @@
<div class="destination-item-row">
<div class="destination-item-name"><flag :iso="destinationIsoCode" />{{ destination.destination_node.name }}</div>
<div class="destination-item-annual">{{ destination.annual_amount }}</div>
<div class="destination-item-route" v-if="hasRoute"><destination-route :showBorder="false" :route="selectedRoute"></destination-route></div>
<div class="destination-item-route" v-else-if="isD2d"><div class="d2d-routing-container"><PhShippingContainer /> D2D routing</div></div>
<div class="destination-item-route" v-if="isD2d"><div class="d2d-routing-container"><PhShippingContainer /> D2D routing</div></div>
<div class="destination-item-route" v-else-if="hasRoute"><destination-route :showBorder="false" :route="selectedRoute"></destination-route></div>
<div class="destination-item-route" v-else><div class="d2d-routing-container"><PhEmpty /> No route selected</div></div>
<div class="destination-item-action"><icon-button icon="pencil-simple" @click="$emit('edit', destination.id)"></icon-button><icon-button icon="trash" @click="$emit('delete',destination.id)"></icon-button></div>
</div>
@ -34,7 +34,7 @@ export default {
return this.destination.destination_node.country.iso_code;
},
hasRoute() {
return this.destination.routes.some(route => route.is_selected);
return this.destination.routes?.some(route => route.is_selected) | false;
},
isD2d() {
return this.destination.is_d2d;

View file

@ -1,11 +1,13 @@
<template>
<div class="destination-route-container">
<div class="destination-route-container" @click="$emit('click')">
<div :class="containerClass">
<ph-boat :size="18" v-if="isSea" class="destination-route-icon"></ph-boat>
<ph-train :size="18" v-else-if="isRail" class="destination-route-icon"></ph-train>
<ph-truck-trailer :size="18" v-else-if="isRoad" class="destination-route-icon"></ph-truck-trailer>
<ph-navigation-arrow :size="18" v-else class="destination-route-icon"></ph-navigation-arrow>
<span v-for="element in routeElements" class="destination-route-element"> {{ element }} </span>
<div><span v-for="element in routeElements" class="destination-route-element"> {{ element }} </span></div>
<basic-badge v-if="cheapest" variant="secondary">CHEAPEST</basic-badge>
<basic-badge v-if="fastest" variant="primary">FASTEST</basic-badge>
</div>
</div>
@ -14,11 +16,12 @@
<script>
import {PhBoat, PhNavigationArrow, PhTrain, PhTruckTrailer} from "@phosphor-icons/vue";
import {PhBoat, PhHandCoins, PhLightning, PhNavigationArrow, PhTrain, PhTruckTrailer} from "@phosphor-icons/vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "DestinationRoute",
components: {PhNavigationArrow, PhTrain, PhTruckTrailer, PhBoat},
components: {BasicBadge, PhLightning, PhHandCoins, PhNavigationArrow, PhTrain, PhTruckTrailer, PhBoat},
props: {
route: {
type: Object,
@ -36,18 +39,24 @@ export default {
}
},
computed: {
cheapest() {
return this.route.is_cheapest;
},
fastest() {
return this.route.is_fastest;
},
routeElements() {
const routeElem = this.route.transit_nodes.map(n => n.external_mapping_id);
return routeElem;
},
isSea() {
return this.route.variant === "SEA";
return this.route.type === "SEA";
},
isRoad() {
return this.route.variant === "ROAD";
return this.route.type === "ROAD";
},
isRail() {
return this.route.variant === "RAIL";
return this.route.type === "RAIL";
},
containerClass() {
@ -76,6 +85,8 @@ export default {
padding: 0.6rem 1.2rem;
transition: all 0.1s ease;
flex: 0 1 fit-content;
gap: 0.8rem;
align-items: center;
}
.destination-route-container {
@ -86,17 +97,19 @@ export default {
.destination-route-inner-container--bordered {
border: 0.2rem solid #E3EDFF;
color: #6B869C;
}
.destination-route-inner-container--selected {
background: #EEF4FF;
background: #ffffff;
border: 0.2rem solid #8DB3FE;
}
.destination-route-inner-container--bordered:hover {
background: #EEF4FF;
color: #6B869C;
border: 0.2rem solid #8DB3FE;
transform: scale(1.01);
cursor: pointer;
}

View file

@ -0,0 +1,173 @@
<template>
<div class="select-material-modal-container">
<h3 class="sub-header">Select material</h3>
<div class="select-material-container">
<div class="select-material-caption-column">Part number</div>
<div class="select-material-input-column select-material-input-field-suppliername">
<div class="select-material-input-field-suppliername-searchbar">
<autosuggest-searchbar
@selected="actionPartNumberSelect"
:fetch-suggestions="fetchPartNumbers"
:initial-value="partNumber"
title-resolver="part_number"
placeholder="Find part number"
:activate-watcher="true"></autosuggest-searchbar>
</div>
</div>
<div class="select-material-caption-column" v-if="materialSelected">Description</div>
<div class="select-material-input-column select-material-input-field-address" v-if="materialSelected">
{{ description }}
</div>
<div class="select-material-caption-column" v-if="materialSelected">HS Code</div>
<div class="select-material-input-column" v-if="materialSelected">{{ hsCode }}</div>
<div class="select-material-checkbox" v-if="materialSelected">
<tooltip
text="Your current master data (like packaging dimensions) might be outdated after material change. Tick to reload master data">
<checkbox :checked="updateMasterData" @checkbox-changed="checkboxChanged">update master data</checkbox>
</tooltip>
</div>
<div class="select-material-footer">
<basic-button :show-icon="false" :disabled="disableAcceptButton" @click="action('accept')">OK</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="action('cancel')">Cancel</basic-button>
</div>
</div>
</div>
</template>
<script>
import AutosuggestSearchbar from "@/components/UI/AutoSuggestSearchBar.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import Flag from "@/components/UI/Flag.vue";
import {mapStores} from "pinia";
import {useMaterialStore} from "@/store/material.js";
import Tooltip from "@/components/UI/Tooltip.vue";
export default {
name: "SelectMaterial",
emits: ['close'],
components: {Tooltip, Flag, Checkbox, BasicButton, AutosuggestSearchbar},
props: {
partNumber: {
type: String,
required: true
}
},
computed: {
...mapStores(useMaterialStore),
disableAcceptButton() {
return (this.selectedPartNumber === this.partNumber);
},
materialSelected() {
return this.selectedMaterial != null;
},
description() {
return this.selectedMaterial?.name ?? '';
},
hsCode() {
return this.selectedMaterial?.hs_code ?? '';
}
},
created() {
this.selectedPartNumber = this.partNumber;
},
data() {
return {
selectedMaterial: null,
selectedPartNumber: null,
updateMasterData: true,
}
},
methods: {
checkboxChanged(value) {
this.updateMasterData = value;
},
action(action) {
this.$emit('close', {action: action, material: this.selectedMaterial, updateMasterData: this.updateMasterData});
},
actionPartNumberSelect(material) {
this.selectedMaterial = material;
this.selectedPartNumber = material.part_number;
},
async fetchPartNumbers(query) {
const materialQuery = {searchTerm: query};
await this.materialStore.setQuery(materialQuery);
return this.materialStore.materials;
},
}
}
</script>
<style scoped>
.select-material-input-column {
font-size: 1.4rem;
color: #6b7280;
max-width: 30rem;
}
.select-material-input-field-suppliername {
display: flex;
gap: 0.8rem;
align-items: center;
}
.select-material-input-field-suppliername-searchbar {
flex: 1 1 auto;
min-width: 50rem;
}
.select-material-modal-container {
width: 60rem;
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.select-material-container {
flex: 1 1 auto;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(3, fit-content(0));
align-content: center;
gap: 1.6rem;
}
.select-material-caption-column {
font-size: 1.4rem;
font-weight: 500;
align-self: center;
justify-self: end;
color: #001D33
}
.select-material-footer {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
.select-material-checkbox {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
.select-material-input-field-address {
display: flex;
gap: 0.8rem;
align-items: center;
}
.supplier-address {
font-size: 1.4rem;
color: #6b7280;
max-width: 30rem;
}
</style>

View file

@ -1,9 +1,9 @@
import router from './router.js';
//import store from './store/index.js';
import { setupErrorBuffer } from './store/error.js'
import {createApp} from 'vue'
import {createPinia} from 'pinia';
import App from './App.vue'
import { setupErrorBuffer } from './store/error.js'
import {
PhStar,
@ -26,15 +26,15 @@ import {
PhArchive,
PhFloppyDisk,
PhArrowCounterClockwise,
PhCheck, PhBug
PhCheck, PhBug, PhShuffle, PhStack
} from "@phosphor-icons/vue";
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.config.globalProperties.backendUrl = 'http://localhost:8080/api/';
app.component('PhPlus', PhPlus);
app.component('PhDownload', PhDownload);
app.component('PhUpload', PhUpload);
@ -56,11 +56,12 @@ app.component('PhCloudArrowUp', PhCloudArrowUp);
app.component('PhSealCheck', PhSealCheck);
app.component('PhCalculator', PhCalculator);
app.component('PhStar', PhStar);
app.component('PhBug', PhBug)
app.component('PhBug', PhBug);
app.component('PhShuffle', PhShuffle);
app.component('PhStack', PhStack );
app.use(router);
app.use(pinia);
//app.component('base-button', () => import('./components/UI/BasicButton.vue'));
//app.component('base-badge', () => import('./components/UI/BasicBadge.vue'));

View file

@ -1,14 +1,435 @@
<template>
<div class="edit-calculation-container">
<div class="header-container">
<h2 class="page-header">Mass edit calculation</h2>
<div class="header-controls">
<basic-button :show-icon="false" :disabled="premiseEditStore.selectedLoading"
variant="secondary">Close
</basic-button>
<basic-button :show-icon="true"
:disabled="premiseEditStore.selectedLoading"
icon="Calculator" variant="primary">Calculate & close
</basic-button>
</div>
</div>
<transition name="list-edit-container" tag="div">
<transition-group name="list-edit" mode="out-in" class="edit-calculation-list-container" tag="div">
<div class="edit-calculation-list-header" key="header">
<div>
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"></checkbox>
</div>
<div>Material</div>
<div>Price</div>
<div>Packaging</div>
<div>Supplier</div>
<div>Destinations & routes</div>
<div>Actions</div>
</div>
<div v-if="showLoading" class="spinner-container" key="spinner">
<spinner class="space-around"></spinner>
</div>
<div v-else-if="showEmpty" class="empty-container" key="empty">
<span class="space-around">No Calculations found.</span>
</div>
<bulk-edit-row v-else class="edit-calculation-list-item" v-for="id in this.premiseEditStore.getPremiseIds"
:key="id" :id="id" @action="onClickAction" :copy-mode="selectCount !== 0">
</bulk-edit-row>
</transition-group>
</transition>
<modal :z-index="3000" :state="showProcessingModal" @close="closeEditModal">
<div class="edit-calculation-spinner-container space-around">
<spinner></spinner>
<span>{{ processingMessage }}</span>
</div>
</modal>
<mass-edit-dialog v-if="showData" :show="showMultiselectAction" @action="multiselectAction"
:select-count="selectCount"></mass-edit-dialog>
<modal :z-index="2000" :state="showEditModal" @close="closeEditModal">
<div class="modal-content-container">
<component
:is="componentType"
v-bind="componentProps"
>
</component>
<div class="modal-content-footer">
<basic-button :show-icon="false" @click="closeEditModalAction('accept')">OK</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="closeEditModalAction('cancel')">Cancel
</basic-button>
</div>
</div>
</modal>
</div>
</template>
<script>
import {UrlSafeBase64} from "@/common.js";
import {mapStores} from "pinia";
import {usePremiseEditStore} from "@/store/premiseEdit.js";
import BasicButton from "@/components/UI/BasicButton.vue";
import BulkEditRow from "@/components/layout/bulkedit/BulkEditRow.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import CalculationListItem from "@/components/layout/calculation/CalculationListItem.vue";
import Spinner from "@/components/UI/Spinner.vue";
import ListEdit from "@/components/UI/ListEdit.vue";
import MassEditDialog from "@/components/UI/MassEditDialog.vue";
import Modal from "@/components/UI/Modal.vue";
import PriceEdit from "@/components/layout/edit/PriceEdit.vue";
import MaterialEdit from "@/components/layout/edit/MaterialEdit.vue";
import PackagingEdit from "@/components/layout/edit/PackagingEdit.vue";
import SupplierView from "@/components/layout/edit/SupplierView.vue";
import DestinationListView from "@/components/layout/edit/DestinationListView.vue";
const COMPONENT_TYPES = {
price: PriceEdit,
material: MaterialEdit,
packaging: PackagingEdit,
supplier: SupplierView,
destinations: DestinationListView,
}
export default {
name: "MassEdit"
name: "MassEdit",
components: {Modal, MassEditDialog, ListEdit, Spinner, CalculationListItem, Checkbox, BulkEditRow, BasicButton},
computed: {
...mapStores(usePremiseEditStore),
selectCount() {
return this.selectedPremisses?.length ?? 0;
},
selectedPremisses() {
return this.premiseEditStore.getSelectedPremisses;
},
showEmpty() {
return this.premiseEditStore.showEmpty;
},
showLoading() {
return this.premiseEditStore.isLoading;
},
showData() {
return this.premiseEditStore.showData;
},
overallCheck() {
return this.premiseEditStore.isLoading ? false : this.premiseEditStore.getPremisses?.every(p => p.selected === true) ?? false;
},
showMultiselectAction() {
return this.premiseEditStore.getPremisses?.some(p => p.selected === true) ?? false;
},
showEditModal() {
return ((this.modalType ?? null) !== null);
},
componentProps() {
return this.componentData?.props ?? null;
},
componentType() {
return this.modalType ? COMPONENT_TYPES[this.modalType] : null;
},
componentData() {
return this.modalType ? this.componentsData[this.modalType] : null;
},
showProcessingModal() {
return this.premiseEditStore.showProcessingModal;
}
},
created() {
this.bulkQuery = this.$route.params.ids;
this.ids = new UrlSafeBase64().decodeIds(this.$route.params.ids);
this.premiseEditStore.loadPremissesForced(this.ids);
},
data() {
return {
ids: [],
bulkQuery: null,
modalType: null,
componentsData: {
price: {props: {price: 0, overSeaShare: 0, includeFcaFee: true}},
material: {props: {partNumber: "", hsCode: "", tariffRate: 0.00, description: ""}},
packaging: {
props: {
length: 0,
width: 0,
height: 0,
weight: 0,
weightUnit: "KG",
dimensionUnit: "MM",
unitCount: 1,
mixable: true,
stackable: false
}
},
supplier: {
props: {
supplierName: "",
supplierAddress: "",
supplierCoordinates: {latitude: 1, longitude: 2},
isoCode: "DE"
}
},
destinations: {props: {}},
},
editIds: null,
processingMessage: "Please wait. Calculating routes ...",
}
},
methods: {
updateCheckBoxes(value) {
this.premiseEditStore.setSelectTo(this.ids, value);
},
multiselectAction(action) {
this.openModal(action, this.selectedPremisses.map(p => p.id));
},
onClickAction(data) {
if (0 !== this.premiseEditStore.selectCount) {
this.openModal(data.action, this.premiseEditStore.getSelectedPremissesIds, data.id);
} else {
this.openModal(data.action, [data.id], data.id);
}
},
openModal(type, ids, dataSource = -1) {
if (type !== 'destinations')
this.fillData(type, dataSource)
else {
this.premiseEditStore.prepareMassEdit(dataSource, ids);
}
this.editIds = ids;
this.modalType = type;
},
closeEditModalAction(action) {
if (this.modalType === "destinations") {
if(action === "accept") {
this.premiseEditStore.finishMassEdit();
}
} else {
if (action === "accept") {
}
}
// clear data.
this.fillData(this.modalType);
this.closeEditModal();
},
triggerProcessingModal(text) {
if(text) {
this.processingMessage = text;
this.processingModal = true;
}
else
this.processingModal = false
},
closeEditModal() {
this.modalType = null;
},
fillData(type, id = -1) {
if (id === -1) {
// clear
this.componentsData = {
price: {props: {price: 0, overSeaShare: 0.0, includeFcaFee: true}},
material: {props: {partNumber: "", hsCode: "", tariffRate: 0.00, description: ""}},
packaging: {
props: {
length: 0,
width: 0,
height: 0,
weight: 0,
weightUnit: "KG",
dimensionUnit: "MM",
unitCount: 1,
mixable: true,
stackable: false
}
},
supplier: {
props: {
supplierName: "",
supplierAddress: "",
supplierCoordinates: {latitude: 1, longitude: 2},
isoCode: "DE"
}
},
destinations: {props: {}},
};
} else {
const premise = this.premiseEditStore.getById(id);
if (type === "price") {
this.componentsData.price.props = {
price: premise.material_cost,
overSeaShare: premise.oversea_share,
includeFcaFee: premise.is_fca_enabled
}
} else if (type === "material") {
this.componentsData.material.props = {
partNumber: premise.material.part_number,
hsCode: premise.material.hs_code ?? "",
tariffRate: premise.tariff_rate ?? 0.00,
description: premise.material.name ?? ""
}
} else if (type === "packaging") {
this.componentsData.packaging.props = {
length: premise.handling_unit.length ?? 0,
width: premise.handling_unit.width ?? 0,
height: premise.handling_unit.height ?? 0,
weight: premise.handling_unit.weight ?? 0,
weightUnit: premise.handling_unit.weightUnit ?? "KG",
dimensionUnit: premise.handling_unit.dimensionUnit ?? "MM",
unitCount: premise.handling_unit.content_unit_count ?? 1,
mixable: premise.is_mixable ?? true,
stackable: premise.is_stackable ?? true
}
} else if (type === "supplier") {
this.componentsData.supplier.props = {
supplierName: premise.supplier.name ?? "",
supplierAddress: premise.supplier.address ?? "",
supplierCoordinates: {
latitude: premise.supplier.location.latitude,
longitude: premise.supplier.location.longitude
},
isoCode: premise.supplier.country.iso_code
}
}
}
}
}
}
</script>
<template>
<h2>Edit Calculations</h2>
</template>
<style scoped>
.space-around {
margin: 3rem;
}
.edit-calculation-spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3.6rem;
flex: 1 1 auto;
font-size: 1.6rem;
}
.modal-content-container {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.modal-content-footer {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
/* Container Animation */
.list-edit-enter-from {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
}
.list-edit-leave-to {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
}
.list-edit-enter-active,
.list-edit-leave-active {
transition: all 0.4s ease;
overflow: hidden;
}
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
}
.empty-container {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.6rem;
}
.edit-calculation-list-container {
background: white;
border-radius: 1.2rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-top: 3rem;
margin-bottom: 3rem;
}
.edit-calculation-list-header {
display: grid;
grid-template-columns: 6rem 1fr 1fr 1.5fr 1.5fr 1.5fr 10rem;
gap: 1.6rem;
padding: 2.4rem;
background-color: #ffffff;
border-bottom: 0.1rem solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
}
.edit-calculation-spinner-container {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 30rem
}
.edit-calculation-spinner {
font-size: 1.6rem;
width: 24rem;
height: 12rem;
display: flex;
justify-content: center;
align-items: center;
}
.edit-calculation-container {
display: flex;
flex-direction: column;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.6rem;
}
.header-controls {
display: flex;
gap: 1.6rem;
}
</style>

View file

@ -1,30 +1,24 @@
<template>
<div class="edit-calculation-container">
<div class="header-container">
<h2 class="page-header">Edit Calculation</h2>
<h2 class="page-header">Edit calculation</h2>
<div class="header-controls">
<basic-button @click="showCustomToast" :show-icon="false" :disabled="premiseEditStore.selectedLoading" variant="secondary">Close
<basic-button @click="close" :show-icon="false" :disabled="premiseEditStore.selectedLoading" variant="secondary"> {{ fromMassEdit ? 'Back' : 'Close' }}
</basic-button>
<basic-button :show-icon="true" :disabled="premiseEditStore.selectedLoading || premiseEditStore.singleSelectEmpty"
<basic-button v-if="!fromMassEdit" :show-icon="true" :disabled="premiseEditStore.selectedLoading || !premiseEditStore.isSingleSelect"
icon="Calculator" variant="primary">Calculate & close
</basic-button>
</div>
</div>
<Toast ref="toast" />
<modal :state="traceModal"><trace-view :error="premiseEditStore.error" @close="traceModal = false;"></trace-view></modal>
<notification-bar v-if="premiseEditStore.error != null" variant="exception" icon="x"
@icon-clicked="premiseEditStore.error = null">
<div class="errorCode">{{ premiseEditStore.error.code }}</div>
{{ premiseEditStore.error.message }} <span class="trace-link" @click="trace">View Trace</span>
</notification-bar>
<div v-if="premiseEditStore.selectedLoading" class="edit-calculation-spinner-container">
<box class="edit-calculation-spinner">
<spinner></spinner>
</box>
</div>
<div v-else-if="premiseEditStore.singleSelectEmpty" class="edit-calculation-spinner-container">
<div v-else-if="!premiseEditStore.isSingleSelect" class="edit-calculation-spinner-container">
<box class="edit-calculation-spinner">No calculation found.</box>
</div>
<div v-else>
@ -66,7 +60,7 @@
</div>
<h3 class="sub-header">Destinations & routes</h3>
<destination-list-view :destinations="premise.destinations"></destination-list-view>
<destination-list-view></destination-list-view>
</div>
</div>
@ -89,6 +83,7 @@ import Modal from "@/components/UI/Modal.vue";
import TraceView from "@/components/layout/TraceView.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Toast from "@/components/UI/Toast.vue";
import {UrlSafeBase64} from "@/common.js";
export default {
name: "SingleEdit",
@ -109,16 +104,31 @@ export default {
},
data() {
return {
traceModal: false
traceModal: false,
bulkEditQuery: null,
id: null,
}
},
computed: {
...mapStores(usePremiseEditStore),
premise() {
return this.premiseEditStore.selectedPremise;
return this.premiseEditStore.singleSelectedPremise;
},
fromMassEdit() {
return this.bulkEditQuery !== null;
}
},
methods: {
close() {
if(this.bulkEditQuery) {
//TODO: deselect and save
this.$router.push({name: 'bulk', params: {ids: this.bulkEditQuery}});
}
else {
//TODO: deselect and save
this.$router.push({name: 'home'});
}
},
save(type) {
console.log(type);
},
@ -143,7 +153,14 @@ export default {
}
},
created() {
this.premiseEditStore.selectPremise(parseInt(this.$route.params.id));
this.id = new UrlSafeBase64().decodeIds(this.$route.params.id);
if(this.$route.params.ids) {
this.bulkEditQuery = this.$route.params.ids;
this.premiseEditStore.selectPremises(this.id, new UrlSafeBase64().decodeIds(this.$route.params.ids));
} else {
this.premiseEditStore.loadAndSelectSinglePremise(this.id)
}
},
}

View file

@ -3,13 +3,6 @@
<h2 class="page-header">My calculations</h2>
<notification-bar v-if="premiseStore.error != null" variant="exception" icon="x"
@icon-clicked="premiseStore.error = null">
<div class="errorCode">{{ premiseStore.error.code }}</div>
{{ premiseStore.error.message }}
</notification-bar>
<div class="calculation-list-container">
@ -44,7 +37,6 @@
<list-edit :show="showListEdit" @action="handleMultiselectAction"></list-edit>
</div>
@ -64,6 +56,7 @@ import {usePremiseStore} from "@/store/premise.js";
import {mapStores} from "pinia";
import Spinner from "@/components/UI/Spinner.vue";
import ListEdit from "@/components/UI/ListEdit.vue";
import {UrlSafeBase64} from "@/common.js";
export default {
name: "Calculation",
@ -88,7 +81,20 @@ export default {
},
methods: {
handleMultiselectAction(action) {
this.premiseStore.selected
if (action === "delete") {
} else if (action === "edit") {
const ids = this.premiseStore.premises.filter(p => p.checked === true).map(p => p.id);
if (ids.length === 1) {
this.$router.push({name: "edit", params: {id: ids[0]}});
} else {
this.$router.push({name: "bulk", params: {ids: new UrlSafeBase64().encodeIds(ids)}});
}
} else if (action === "archive") {
}
},
updateCheckBoxes(checked) {
this.overallCheck = checked;

View file

@ -11,7 +11,8 @@ const router = createRouter({
routes: [
{
path: '/',
redirect: '/calculations'
redirect: '/calculations',
},
{
path: '/assistant',
@ -21,6 +22,7 @@ const router = createRouter({
{
path: '/calculations',
component: Calculations,
name: 'home',
},
{
path: '/edit/:id',
@ -28,8 +30,14 @@ const router = createRouter({
name: 'edit',
},
{
path: '/bulk',
path: '/bulk/:ids',
component: CalculationMassEdit,
name: 'bulk',
},
{
path: '/bulk/:ids/edit/:id',
component: CalculationSingleEdit,
name: 'bulk-single-edit',
},
{
path: '/reports',

View file

@ -1,5 +1,6 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
export const useAssistantStore = defineStore('assistant', {
@ -31,16 +32,24 @@ export const useAssistantStore = defineStore('assistant', {
console.log(`Creation body: ${jsonBody}`);
const response = await fetch(url, {
const params = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: jsonBody
}).catch(e => {
body: jsonBody};
const request = { url: url, params: params};
const response = await fetch(url, params).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
console.error(this.error);
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
@ -52,13 +61,22 @@ export const useAssistantStore = defineStore('assistant', {
}
console.error(this.error);
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.error = {code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace };
this.loading = false;
console.error(data);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
return;
}
@ -111,6 +129,7 @@ export const useAssistantStore = defineStore('assistant', {
const headers = new Headers();
headers.append('search', encodeURIComponent(query));
const request = { url: url, params: {method: 'GET'}};
const response = await fetch(url, {
method: 'GET',
@ -118,6 +137,11 @@ export const useAssistantStore = defineStore('assistant', {
}).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
@ -128,13 +152,22 @@ export const useAssistantStore = defineStore('assistant', {
trace: null
}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.error = {code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace };
this.loading = false;
console.log(data);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
return;
}

View file

@ -15,15 +15,21 @@ export const useErrorStore = defineStore('error', {
lastError: (state) => state.errors.length > 0 ? state.errors[state.errors.length - 1].error : null,
},
actions: {
clearErrors() {
this.errors = [];
console.log("Cleared errors");
},
async addError(errorDto, options = {}) {
const {request = null, store = null, global = false} = options;
const state = this.captureStoreState(store, global);
const error = {
error: {
code: errorDto.code ?? 'Unknown error',
code: errorDto.code ?? 'Unknown error code',
title: errorDto.title ?? 'Unknown error',
message: errorDto.message ?? 'Unknown message',
trace: errorDto.trace ?? null,
traceCombined: errorDto.traceCombined ?? null,
},
request: request ? JSON.stringify(request) : null,
state: state ? JSON.stringify(state) : null,
@ -119,27 +125,29 @@ export const useErrorStore = defineStore('error', {
export function setupErrorBuffer() {
const errorStore = useErrorStore()
// Unhandled Promise Rejections
window.addEventListener('unhandledrejection', (event) => {
const error = {
code: "Frontend error",
message: event.reason?.message || 'Unhandled Promise Rejection',
trace: event.reason?.stack,
};
errorStore.addError(error, {global: true}).then(r => {} );
})
// JavaScript Errors
window.addEventListener('error', (event) => {
const error = {
code: "Frontend error",
message: event.reason?.message || 'Unhandled Promise Rejection',
trace: event.reason?.stack,
};
errorStore.addError(error, {global: true}).then(r => {} );
})
// // Unhandled Promise Rejections
// window.addEventListener('unhandledrejection', (event) => {
//
// const error = {
// code: "Unhandled rejection",
// title: "Frontend error",
// message: event.reason?.message || 'Unhandled Promise Rejection',
// traceCombined: event.reason?.stack,
// };
//
// errorStore.addError(error, {global: true}).then(r => {} );
// })
//
// // JavaScript Errors
// window.addEventListener('error', (event) => {
// const error = {
// code: "General error",
// title: "Frontend error",
// message: event.reason?.message || 'Unhandled Promise Rejection',
// traceCombined: event.reason?.stack,
// };
// errorStore.addError(error, {global: true}).then(r => {} );
// })
window.addEventListener('beforeunload', () => {
errorStore.submitOnBeforeUnload();

View file

@ -63,7 +63,7 @@ export const useMaterialStore = defineStore('material', {
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.error = {code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace };
this.loading = false;
console.error(this.error);

View file

@ -67,7 +67,7 @@ export const useNodeStore = defineStore('node', {
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
this.error = {code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace };
this.loading = false;
console.error(this.error);

View file

@ -43,41 +43,39 @@ export const usePremiseStore = defineStore('premise', {
if (this.query.deleted)
params.append('deleted', this.query.deleted);
const that = this;
const url = `${config.backendUrl}/calculation/view/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
const response = await fetch(url).catch(e => {
that.error = { code: 'Network error.', message: "Please check your internet connection.", trace: null }
that.loading = false;
this.error = { title: 'Network error.', message: "Please check your internet connection.", trace: null }
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
const data = await response.json().catch(e => {
that.error = { code: 'Malformed response', message: "Malformed server response. Please contact support.", trace: null }
that.loading = false;
this.error = { title: 'Malformed response', message: "Malformed server response. Please contact support.", trace: null }
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (!response.ok) {
that.error = { code: data.error.title, message: data.error.message, trace: data.error.details }
that.loading = false;
this.error = { code: data.error.code, title: data.error.title, message: data.error.message, trace: data.error.trace }
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(that.error, { store: that, request: request});
void errorStore.addError(this.error, { store: this, request: request});
console.log(data);
return;

View file

@ -7,36 +7,327 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
state() {
return {
premisses: null,
singleSelectId: null,
/**
* set to true while the store is loading the premises.
*/
loading: false,
/**
* set to true while the store sets the selected/deselected field in the premises.
*/
selectedLoading: false,
empty: false,
error: null,
selectedDestinations: [],
massEditDestinations: null,
processMassEdit: false,
selectedDestination: null,
throwsException: true,
}
},
getters: {
selectedPremise: (state) => state.premisses.find(p => p.id === state.singleSelectId),
singleSelectEmpty: (state) => state.selectedLoading === false && state.singleSelectId === null,
getDestinationById: (state) => (id) => state.premisses.find(p => p.id === state.singleSelectId)?.destinations.find(d => d.id === id),
getSelectedDestinations: (state) => state.selectedDestinations,
getSelectedDestinationsById: (state) => (id) => state.selectedDestinations?.find(d => d.id === id),
/**
* Returns the ids of all premises.
* @param state
* @returns {*}
*/
getPremiseIds(state) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses?.map(p => p.id);
},
/**
* Returns the premises.
* @param state
* @returns {null|*[]|*}
*/
getPremisses(state) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses;
},
/**
* Returns the premise with the given id.
* @param state
* @returns {function(*): *}
*/
getById(state) {
return function (id) {
if (state.loading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if((id ?? null) === null) {
return null;
}
return state.premisses.find(p => p.id === id);
};
},
/**
* Returns all premises that are selected.
* @param state
* @returns {T[]}
*/
getSelectedPremisses(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected);
},
/**
* Returns all premise ids that are selected.
* @param state
* @returns {T[]}
*/
getSelectedPremissesIds(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected).map(p => p.id);
},
/**
* Getters for controlling frontend views
* ======================================
*/
/**
* Returns true if no premises are loaded. The frontend can show a message that no premises are found.
* @param state
* @returns {*|boolean}
*/
showEmpty(state) {
return (!state.loading && (((state.premisses ?? null) === null) || state.premisses.length === 0))
},
/**
* Returns true if the premises are loaded and not empty. The frontend can show a the loaded premisses.
* @param state
* @returns {boolean}
*/
showData(state) {
return (!state.loading && !((state.premisses ?? null) === null) && state.premisses.length !== 0)
},
/**
* Returns true if the premises are loaded and not empty. The frontend can show a the loaded premisses.
* @param state
* @returns {boolean}
*/
isLoading(state) {
return (state.loading);
},
showProcessingModal(state) {
return state.processMassEdit;
},
/**
* Getters for single edit view
* ============================
*/
/**
* Returns true if only one premise is selected.
* @param state
* @returns {boolean|null}
*/
isSingleSelect(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
return state.premisses.filter(p => p.selected).length === 1;
},
/**
* Returns the id of the single selected premise.
* @param state
* @returns {*}
*/
singleSelectId(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses.find(p => p.selected)?.id;
},
/**
* Returns the single selected premise.
* @param state
* @returns {*}
*/
singleSelectedPremise(state) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
if (!state.isSingleSelect) {
return null;
// throw new Error("Single selected premise accessed, but not in single select mode");
}
return state.premisses?.find(p => p.selected);
},
/**
* Getters for destination editing
* ===============================
*/
getDestinationsView(state) {
if(state.massEditDestinations !== null) {
return state.massEditDestinations.destinations;
}
return state.singleSelectedPremise?.destinations ?? [];
},
getDestinationById(state) {
return function (id) {
if (state.loading || state.selectedLoading) {
if (state.throwsException)
throw new Error("Premises are accessed while still loading.");
return null;
}
for (const p of state.premisses) {
const d = p.destinations.find(d => d.id === id);
if (d !== null) return d;
}
}
},
getSelectedDestinationsData: (state) => state.selectedDestination,
/**
* Mass editing stuff
* ===============================
*/
isMassEdit(state) {
return state.massEditDestinations !== null;
}
},
actions: {
/**
* Selects all destinations for the given ids for editing.
* This creates a copy of the destinations. They are written back as soon as the user closes the dialog.
* DESTINATION stuff
* =================
*/
selectDestinations(ids) {
prepareMassEdit(dataSourcePremiseId, editedPremiseIds) {
if (this.premisses === null) return;
if (!editedPremiseIds || !dataSourcePremiseId || editedPremiseIds.length === 0) return;
this.massEditDestinations = {
premise_ids: editedPremiseIds,
destinations: this.getById(dataSourcePremiseId)?.destinations.map(d => this.copyAllButRouting(d)) ?? [],
toBeAdded: null,
toBeRemoved: null,
};
this.selectedDestination = null;
},
async finishMassEdit() {
this.processMassEdit = true;
this.processMassEdit = false;
},
copyAllButRouting(from, to = null) {
const fromIsOrig = (to === null);
const d = to ?? {};
if(fromIsOrig) {
d.id = `e${from.id}`;
d.destination_node = structuredClone(toRaw(from.destination_node));
d.massEdit = true;
}
d.annual_amount = from.annual_amount;
d.is_d2d = from.is_d2d;
d.rate_d2d = from.is_d2d ? from.rate_d2d : null;
if (fromIsOrig || 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;
}
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;
const destination = !this.isMassEdit ? this.getDestinationById(id) : this.massEditDestinations.destinations.find(d => d.id === id);
const selected = [];
ids.forEach(id => {
const destination = this.getDestinationById(id);
if ((destination ?? null) == null) {
this.error = {
const error = {
code: 'Frontend error.',
message: `Destination not found: ${id}. Please contact support.`,
trace: null
@ -44,53 +335,50 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
throw new Error("Internal frontend error: Destination not found: " + id);
}
const selectedDestination = structuredClone(toRaw(destination));
selectedDestination.userDefinedHandlingCosts = selectedDestination.handling_costs !== null || selectedDestination.disposal_costs !== null || selectedDestination.repackaging_costs !== null;
selected.push(selectedDestination);
});
this.selectedDestinations = selected;
const data = structuredClone(toRaw(destination));
data.userDefinedHandlingCosts = data.handling_costs !== null || data.disposal_costs !== null || data.repackaging_costs !== null;
this.selectedDestination = data;
},
deselectDestinations(save = false) {
if (this.premisses === null) return;
if (save) {
const orig = this.isMassEdit ?
this.massEditDestinations.destinations.find(d => d.id === this.selectedDestination.id) :
this.getDestinationById(this.selectedDestination.id)
this.selectedDestinations.forEach(destination => {
const orig = this.getDestinationById(destination.id);
this.copyAllButRouting(this.selectedDestination, orig);
orig.annual_amount = destination.annual_amount;
orig.is_d2d = destination.is_d2d;
orig.rate_d2d = destination.is_d2d ? destination.rate_d2d : null;
if(destination.userDefinedHandlingCosts) {
orig.disposal_costs = destination.disposal_costs;
orig.repackaging_costs = destination.repackaging_costs;
orig.handling_costs = destination.handling_costs;
} else {
orig.disposal_costs = null;
orig.repackaging_costs = null;
orig.handling_costs = null;
}
destination.routes.forEach(route => {
if (!this.isMassEdit) {
this.selectedDestination.routes.forEach(route => {
const origRoute = orig.routes.find(r => r.id === route.id);
origRoute.is_selected = route.is_selected;
});
})
}
}
this.selectedDestinations = [];
this.selectedDestination = null;
},
async deleteDestination(id) {
if(this.isMassEdit) {
const idx = this.massEditDestinations.destinations.findIndex(d => d.id === id);
if(idx === -1) {
console.info("Destination not found in mass edit: , id)");
return;
}
this.massEditDestinations.destinations.splice(idx, 1);
} else {
if (this.premisses === null) return;
const url = `${config.backendUrl}/calculation/destination/${id}`;
await this.performRequest('DELETE', url, null, false).catch(async e => {
console.error("Unable to delete destination: " + id + "");
console.error(e);
await this.reloadPremisses(this.premisses.map(p => p.id));
throw e;
await this.loadPremissesIfNeeded(this.premisses.map(p => p.id));
});
for (const p of this.premisses) {
@ -100,9 +388,40 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
break;
}
}
}
},
async addDestination(id) {
async addDestination(node) {
if(this.isMassEdit) {
const existing = this.massEditDestinations.destinations.find(d => d.destination_node.id === node.id);
console.log(existing)
if((existing ?? null) !== null) {
console.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,
disposal_costs: null,
repackaging_costs: null,
handling_costs: null,
userDefinedHandlingCosts: false,
};
this.massEditDestinations.destinations.push(destination);
return [destination.id];
} else {
const id = node.id;
const toBeUpdated = this.premisses?.filter(p => p.selected).map(p => p.id);
if (toBeUpdated === null) return;
@ -111,10 +430,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const url = `${config.backendUrl}/calculation/destination/`;
this.loading = true;
this.selectedLoading = true;
const destinations = await this.performRequest('POST', url, body).catch(e => {
this.loading = false;
this.selectedLoading = false;
@ -122,15 +437,19 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
});
for (const id of Object.keys(destinations)) {
this.premisses.find(p => p.id === parseInt(id)).destinations.push(destinations[id]);
this.premisses.find(p => String(p.id) === id).destinations.push(destinations[id]);
}
this.loading = false;
this.selectedLoading = false;
return destinations.map(d => d.id);
return Object.values(destinations).map(d => d.id);
}
},
/**
* Set methods
* (these are more extensive changes. The edited premises are replaced by the one returned by the backend)
*/
async setSupplier(id, updateMasterData) {
console.log("setSupplier");
const body = {supplier_node_id: id, update_master_data: updateMasterData};
@ -156,67 +475,100 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const data = await this.performRequest('PUT', url, body).catch(e => {
this.loading = false;
throw e;
});
if (data) {
data.forEach(p => p.selected = true);
this.replacePremissesById(this.premisses, data);
this.premisses = this.replacePremissesById(this.premisses, data);
}
this.loading = false;
this.selectedLoading = false;
}
},
/**
* Replace the premisses with the loaded ones by id.
* This is used to update the premisses after a "Set" change.
* @param premisses
* @param loadedData
* @returns {*}
*/
replacePremissesById(premisses, loadedData) {
const replacementMap = new Map(loadedData.map(obj => [obj.id, obj]));
return premisses.map(obj => replacementMap.get(obj.id) || obj);
const replaced = premisses.map(obj => replacementMap.get(obj.id) || obj);
console.log("Replaced", replaced);
return replaced;
},
async selectPremise(id) {
/**
* PREMISE stuff
* =================
*/
setSelectTo(ids, value) {
this.selectedLoading = true;
const toSelect = Array.isArray(id) ? id : [id];
const reload = this.premisses ? !toSelect.every((id) => this.premisses.find(d => d.id === id)) : true;
if (reload) {
try {
await this.reloadPremisses(toSelect);
} catch (e) {
this.premisses.forEach(p => p.selected = ids.includes(p.id) ? value : p.selected);
this.selectedLoading = false;
throw e;
}
}
this.premisses.forEach(p => p.selected = toSelect.includes(p.id));
this.singleSelectId = Array.isArray(id) ? null : id;
},
async selectPremises(id, ids) {
this.selectedLoading = true;
await this.loadPremissesIfNeeded(ids);
const toSelect = String(id);
this.premisses.forEach(p => p.selected = toSelect.includes(String(p.id)));
this.selectedLoading = false;
},
async loadAndSelectSinglePremise(id) {
}
,
async reloadPremisses(ids) {
this.loading = true;
this.selectedLoading = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', `${id}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.premisses = await this.performRequest('GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
});
this.premisses.forEach(p => p.selected = true);
this.selectedLoading = false;
this.loading = false;
},
async loadPremissesIfNeeded(ids) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id)) : true;
if (reload) {
await this.loadPremissesForced(ids);
}
},
async loadPremissesForced(ids) {
this.loading = true;
this.empty = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', ids.join(', '));
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
const data = await this.performRequest('GET', url, null).catch(e => {
this.premisses = await this.performRequest('GET', url, null).catch(e => {
this.loading = false;
throw e;
})
});
this.premisses = data;
this.empty = data.length === 0;
this.premisses.forEach(p => p.selected = false);
this.loading = false;
}
},
removePremise(id) {
const idx = this.premisses.findIndex(p => p.id === id);
this.premisses.splice(idx, 1);
},
async performRequest(method, url, body, expectResponse = true) {
const params = {
@ -235,47 +587,57 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const response = await fetch(url, params
).catch(e => {
this.error = {
const error = {
code: 'Network error.',
message: "Please check your internet connection.",
trace: null
}
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw e;
});
let data = null;
if (expectResponse) {
data = await response.json().catch(e => {
this.error = {
const error = {
code: 'Malformed response',
message: "Malformed server response. Please contact support.",
trace: null
}
console.error(this.error);
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
void errorStore.addError(error, {store: this, request: request});
throw e;
});
if (!response.ok) {
this.error = {code: data.error.title, message: data.error.message, trace: data.error.details}
const error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
console.error(this.error);
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, {store: this, request: request});
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
} else {
if (!response.ok) {
this.error = {
const error = {
code: "Return code error " + response.status,
message: "Server returned wrong response code",
trace: null
}
console.error(this.error);
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
}

View file

@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
@ -27,13 +28,10 @@ public class GlobalExceptionHandler {
HttpMessageNotReadableException exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Malformed Request",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
@ -45,13 +43,10 @@ public class GlobalExceptionHandler {
MethodArgumentNotValidException exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Constraint Violation",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
@ -79,13 +74,10 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handleMethodArgumentNotValid(BadRequestException exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
exception.getTitle(),
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
@ -98,13 +90,10 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handleConstraintViolation(MethodArgumentTypeMismatchException exception) { //
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Wrong Datatype",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
@ -115,13 +104,10 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handleForbiddenException(ForbiddenException exception) { //
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Forbidden Error",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.FORBIDDEN);
@ -132,13 +118,10 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDTO> handleConstraintViolation(ConstraintViolationException exception) { //
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Constraint Violation",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.BAD_REQUEST);
@ -147,13 +130,10 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponseDTO> handleGenericException(Exception exception) {
ErrorDTO error = new ErrorDTO(
exception.getClass().getSimpleName(),
exception.getClass().getName(),
"Internal Server Error",
exception.getMessage(),
new HashMap<>() {{
put("errorMessage", exception.getMessage());
put("stackTrace", exception.getStackTrace());
}}
Arrays.asList(exception.getStackTrace())
);
return new ResponseEntity<>(new ErrorResponseDTO(error), HttpStatus.INTERNAL_SERVER_ERROR);

View file

@ -75,6 +75,7 @@ public class PremiseController {
@GetMapping({"/search", "/search/"})
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestHeader String search) {
try {
// Decode the header value
String decodedValue = URLDecoder.decode(search, StandardCharsets.UTF_8);

View file

@ -31,6 +31,8 @@ public class DestinationDTO {
@JsonProperty("rate_d2d")
private BigDecimal rateD2d;
private List<RouteDTO> routes;
public Boolean getD2d() {
return d2d;
}
@ -47,7 +49,6 @@ public class DestinationDTO {
this.annualAmount = annualAmount;
}
private List<RouteDTO> routes;
public Integer getId() {
return id;

View file

@ -1,6 +1,8 @@
package de.avatic.lcc.dto.error;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -8,16 +10,16 @@ public class ErrorDTO {
private String code;
private String title;
private String message;
private Map<String, Object> details;
private List<StackTraceElement> trace;
public ErrorDTO() {
}
public ErrorDTO(String code, String title, String message, Map<String, Object> details) {
public ErrorDTO(String code, String title, String message, List<StackTraceElement> trace) {
this.code = code;
this.title = title;
this.message = message;
this.details = details;
this.trace = trace;
}
public String getCode() {
@ -44,12 +46,13 @@ public class ErrorDTO {
this.title = title;
}
public Map<String, Object> getDetails() {
return details;
public List<StackTraceElement> getTrace() {
return trace;
}
public void setDetails(Map<String, Object> details) {
this.details = details;
public void setTrace(List<StackTraceElement> trace) {
this.trace = trace;
}
@Override
@ -57,7 +60,7 @@ public class ErrorDTO {
return "ErrorDTO{" +
"code='" + code + '\'' +
", message='" + message + '\'' +
", details=" + details +
", trace=" + trace +
'}';
}
@ -68,12 +71,12 @@ public class ErrorDTO {
ErrorDTO errorDTO = (ErrorDTO) o;
return Objects.equals(code, errorDTO.code) &&
Objects.equals(message, errorDTO.message) &&
Objects.equals(details, errorDTO.details);
Objects.equals(trace, errorDTO.trace);
}
@Override
public int hashCode() {
return Objects.hash(code, message, details);
return Objects.hash(code, message, trace);
}
}

View file

@ -122,6 +122,8 @@ public class RouteNodeRepository {
entity.setOutdated(rs.getBoolean("is_outdated"));
entity.setExternalMappingId(rs.getString("external_mapping_id"));
return entity;
}
}

View file

@ -352,6 +352,7 @@ public class RoutingService {
// if the chain is routable -> add the final rate and save the route.
if (routable) {
routeObj.addSection(finalSection);
routeObj.setQuality(ChainQuality.SUPERIOR);
container.addRoute(routeObj);
}

View file

@ -59,6 +59,7 @@ public class NodeTransformer {
dto.setDeprecated(entity.getOutdated());
dto.setLocation(locationTransformer.toLocationDTO(entity));
dto.setUserNode(false);
dto.setExternalMappingId(entity.getExternalMappingId());
return dto;
}

View file

@ -448,7 +448,7 @@ CREATE TABLE IF NOT EXISTS premise_route_section
CONSTRAINT fk_premise_route_section_premise_route_id FOREIGN KEY (premise_route_id) REFERENCES premise_route (id),
FOREIGN KEY (from_route_node_id) REFERENCES premise_route_node (id),
FOREIGN KEY (to_route_node_id) REFERENCES premise_route_node (id),
CONSTRAINT chk_main_run CHECK ((transport_type != 'RAIL' AND transport_type != 'SEA') OR is_main_run IS TRUE),
CONSTRAINT chk_main_run CHECK (transport_type = 'ROAD' OR transport_type = 'POST_RUN' OR is_main_run IS TRUE),
INDEX idx_premise_route_id (premise_route_id),
INDEX idx_from_route_node_id (from_route_node_id),
INDEX idx_to_route_node_id (to_route_node_id)