intermediate commit. optical adjustments to mass edit ... functional adjustments pending

This commit is contained in:
Jan 2025-11-25 17:09:11 +01:00
parent 9ef5fe9fc7
commit 3658372271
21 changed files with 934 additions and 345 deletions

View file

@ -0,0 +1,99 @@
name: Auto-Tag Release
on:
push:
branches:
- main
jobs:
tag-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Alle Tags holen
- name: Determine version bump
id: bump_type
run: |
# Hole die letzten Commits seit dem letzten Tag
LATEST_TAG=$(git tag -l "v*" --sort=-v:refname | head -n 1)
if [ -z "$LATEST_TAG" ]; then
echo "bump=minor" >> $GITHUB_OUTPUT
echo "Kein Tag vorhanden, starte mit minor"
exit 0
fi
# Analysiere Commits seit letztem Tag
COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s")
echo "=== Commits seit ${LATEST_TAG} ==="
echo "$COMMITS"
echo "=================================="
# Prüfe auf Breaking Changes / Major Updates
if echo "$COMMITS" | grep -qiE "^BREAKING CHANGE:|^[^:]+!:|breaking:|major:"; then
echo "bump=major" >> $GITHUB_OUTPUT
echo "✓ Breaking Change gefunden → MAJOR"
# Prüfe auf Features / Minor Updates
elif echo "$COMMITS" | grep -qiE "^feat:|^feature:|minor:"; then
echo "bump=minor" >> $GITHUB_OUTPUT
echo "✓ Feature gefunden → MINOR"
# Prüfe auf Bugfixes
elif echo "$COMMITS" | grep -qiE "^fix:|bugfix:"; then
echo "bump=patch" >> $GITHUB_OUTPUT
echo "✓ Bugfix gefunden → PATCH"
# Prüfe auf Chores/Docs/etc
elif echo "$COMMITS" | grep -qiE "^chore:|^docs:|^style:|^refactor:|^test:|^build:|^ci:"; then
echo "bump=patch" >> $GITHUB_OUTPUT
echo "✓ Chore/Docs gefunden → PATCH"
# Fallback: Kein Pattern erkannt → PATCH
else
echo "bump=patch" >> $GITHUB_OUTPUT
echo "⚠ Kein Pattern erkannt → PATCH (Fallback)"
fi
- name: Calculate new tag
id: get_tag
run: |
LATEST_TAG=$(git tag -l "v*" --sort=-v:refname | head -n 1)
if [ -z "$LATEST_TAG" ]; then
NEW_TAG="v1.0.0"
else
VERSION=${LATEST_TAG#v}
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
case ${{ steps.bump_type.outputs.bump }} in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
fi
echo "new_tag=${NEW_TAG}" >> $GITHUB_OUTPUT
echo "Neues Tag: ${NEW_TAG}"
- name: Create and push tag
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
git tag -a ${{ steps.get_tag.outputs.new_tag }} -m "Release ${{ steps.get_tag.outputs.new_tag }}"
git push origin ${{ steps.get_tag.outputs.new_tag }}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LCC</title> <title>Logistics Cost Calculation Tool</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -44,7 +44,7 @@ export default {
html { html {
font-size: 62.5%; font-size: 62.5%;
font-family: 'Poppins', sans-serif; font-family: 'Arial', sans-serif;
} }
body { body {

View file

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

View file

@ -2,6 +2,7 @@
<div class="checkbox-container"> <div class="checkbox-container">
<label class="checkbox-item" :class="{ disabled: disabled }" @change="setFilter"> <label class="checkbox-item" :class="{ disabled: disabled }" @change="setFilter">
<input <input
@keydown.enter="$emit('enter', $event)"
type="checkbox" type="checkbox"
:checked="isChecked" :checked="isChecked"
:disabled="disabled" :disabled="disabled"
@ -72,6 +73,9 @@ export default{
this.updateIndeterminateState(this.isIndeterminate); this.updateIndeterminateState(this.isIndeterminate);
}, },
methods: { methods: {
focus() {
this.$refs.checkboxInput?.focus();
},
setFilter(event) { setFilter(event) {
if (this.disabled) return; if (this.disabled) return;
this.isChecked = event.target.checked; this.isChecked = event.target.checked;

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<div class="outer-container"> <div class="outer-container">
<div class="container" :class="{ 'responsive': responsive }" @focusout="focusLost"> <div class="container" :class="{ 'responsive': responsive }" @focusout="focusLost">
<!-- Wiederholende Felder als Array definieren und mit v-for rendern -->
<template v-for="field in displayFields" :key="field.name"> <template v-for="field in displayFields" :key="field.name">
<div class="caption-column">{{ field.label }}</div> <div class="caption-column">{{ field.label }}</div>
<div class="input-column"> <div class="input-column">
@ -15,6 +15,7 @@
@blur="field.onBlur" @blur="field.onBlur"
class="input-field" class="input-field"
autocomplete="off" autocomplete="off"
:placeholder="fromMassEdit ? '<keep>' : ''"
/> />
</div> </div>
</div> </div>
@ -92,6 +93,10 @@ export default {
responsive: { responsive: {
type: Boolean, type: Boolean,
default: true, default: true,
},
fromMassEdit: {
type: Boolean,
default: false,
} }
}, },
computed: { computed: {

View file

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

View file

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

View file

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

View file

@ -4,15 +4,15 @@
<h2 class="page-header">Mass edit calculation</h2> <h2 class="page-header">Mass edit calculation</h2>
<div class="header-controls"> <div class="header-controls">
<basic-button :show-icon="false" <basic-button :show-icon="false"
:disabled="premiseEditStore.selectedLoading" :disabled="disableButtons"
variant="secondary" variant="secondary"
@click="closeMassEdit" @click="close"
>Close >Close
</basic-button> </basic-button>
<basic-button :show-icon="true" <basic-button :show-icon="true"
:disabled="premiseEditStore.selectedLoading" :disabled="disableButtons"
icon="Calculator" variant="primary" icon="Calculator" variant="primary"
@click="startCalculation" @click="calculate"
>Calculate & close >Calculate & close
</basic-button> </basic-button>
@ -24,13 +24,15 @@
<div class="edit-calculation-list-header" key="header"> <div class="edit-calculation-list-header" key="header">
<div> <div>
<checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"></checkbox> <checkbox @checkbox-changed="updateCheckBoxes" :checked="overallCheck"
:indeterminate="overallIndeterminate"></checkbox>
</div> </div>
<div>Material</div> <div>Material</div>
<div>Price</div> <div>Price</div>
<div>Packaging</div> <div>Packaging</div>
<div>Supplier</div> <div>Supplier</div>
<div>Destinations & routes</div> <div>Annual Quantity</div>
<div>Routes</div>
<div>Actions</div> <div>Actions</div>
</div> </div>
@ -43,8 +45,8 @@
<span class="space-around">No Calculations found.</span> <span class="space-around">No Calculations found.</span>
</div> </div>
<bulk-edit-row v-else class="edit-calculation-list-item" v-for="premise of this.premiseEditStore.getPremisses" <bulk-edit-row v-else class="edit-calculation-list-item" v-for="premise of this.premises"
:key="premise.id" :id="premise.id" :premise="premise" @action="onClickAction" :key="premise.id" :id="premise.id" :premise="premise" @action="onClickAction" @select="updateCheckBox"
@remove="updateUrl"> @remove="updateUrl">
</bulk-edit-row> </bulk-edit-row>
@ -65,9 +67,11 @@
v-model:tariffRate="componentProps.tariffRate" v-model:tariffRate="componentProps.tariffRate"
v-model:tariffUnlocked="componentProps.tariffUnlocked" v-model:tariffUnlocked="componentProps.tariffUnlocked"
v-model:description="componentProps.description" v-model:description="componentProps.description"
v-model:price="componentProps.price" v-model:price="componentProps.price"
v-model:overSeaShare="componentProps.overSeaShare" v-model:overSeaShare="componentProps.overSeaShare"
v-model:includeFcaFee="componentProps.includeFcaFee" v-model:includeFcaFee="componentProps.includeFcaFee"
v-model:length="componentProps.length" v-model:length="componentProps.length"
v-model:width="componentProps.width" v-model:width="componentProps.width"
v-model:height="componentProps.height" v-model:height="componentProps.height"
@ -77,16 +81,24 @@
v-model:unitCount="componentProps.unitCount" v-model:unitCount="componentProps.unitCount"
v-model:mixable="componentProps.mixable" v-model:mixable="componentProps.mixable"
v-model:stackable="componentProps.stackable" v-model:stackable="componentProps.stackable"
v-model:hideDescription="componentProps.hideDescription" v-model:hideDescription="componentProps.hideDescription"
:fromMassEdit="true"
:countryId=null :countryId=null
:responsive="false" :responsive="false"
@close="closeEditModalAction('cancel')" @close="closeEditModalAction('cancel')"
@accept="closeEditModalAction('accept')"
> >
</component> </component>
<div class="modal-content-footer" >
<basic-button v-if="!modalCloseOnly" :show-icon="false" @click="closeEditModalAction('accept')">OK</basic-button> <div class="modal-content-footer">
<basic-button variant="secondary" :show-icon="false" @click="closeEditModalAction('cancel')"> {{ modalCloseOnly ? "Close" : "Cancel" }} <basic-button v-if="!modalCloseOnly" :show-icon="false" @click="closeEditModalAction('accept')">OK
</basic-button>
<basic-button variant="secondary" :show-icon="false" @click="closeEditModalAction('cancel')">
{{ modalCloseOnly ? "Close" : "Cancel" }}
</basic-button> </basic-button>
</div> </div>
</div> </div>
@ -137,11 +149,20 @@ export default {
}, },
computed: { computed: {
...mapStores(usePremiseEditStore, useNotificationStore), ...mapStores(usePremiseEditStore, useNotificationStore),
disableButtons() {
return this.premiseEditStore.selectedLoading;
},
premises() {
return this.premiseEditStore.getPremisses;
},
hasSelection() { hasSelection() {
if (this.premiseEditStore.isLoading || this.premiseEditStore.selectedLoading) { if (this.premiseEditStore.isLoading || this.premiseEditStore.selectedLoading) {
return false; return false;
} }
return this.premiseEditStore.getSelectedPremissesIds?.length > 0; return this.premiseEditStore.someChecked;
},
showMultiselectAction() {
return this.selectCount > 0;
}, },
selectCount() { selectCount() {
return this.selectedPremisses?.length ?? 0; return this.selectedPremisses?.length ?? 0;
@ -158,14 +179,8 @@ export default {
showData() { showData() {
return this.premiseEditStore.showData; return this.premiseEditStore.showData;
}, },
overallCheck() {
return this.premiseEditStore.isLoading ? false : this.premiseEditStore.getPremisses?.every(p => p.selected === true) ?? false;
},
showMultiselectAction() {
return this.selectCount > 0;
},
modalCloseOnly() { modalCloseOnly() {
return this.modalType === 'material' && !this.componentProps.tariffUnlocked; return this.modalType === 'material' && !this.componentProps.tariffUnlocked; //TODO: check all selected.
}, },
showEditModal() { showEditModal() {
return ((this.modalType ?? null) !== null); return ((this.modalType ?? null) !== null);
@ -188,10 +203,9 @@ export default {
}, },
watch: { watch: {
showProcessingModal(newState, _) { showProcessingModal(newState, _) {
if(newState) { if (newState) {
this.notificationStore.setSpinner(this.shownProcessingMessage); this.notificationStore.setSpinner(this.shownProcessingMessage);
} } else {
else {
this.notificationStore.clearSpinner(); this.notificationStore.clearSpinner();
} }
} }
@ -201,15 +215,25 @@ export default {
this.ids = new UrlSafeBase64().decodeIds(this.$route.params.ids); this.ids = new UrlSafeBase64().decodeIds(this.$route.params.ids);
this.premiseEditStore.loadPremissesForced(this.ids); this.premiseEditStore.loadPremissesForced(this.ids);
}, },
data() { data() {
return { return {
ids: [], ids: [],
overallCheck: false,
overallIndeterminate: false,
bulkQuery: null, bulkQuery: null,
modalType: null, modalType: null,
componentsData: { componentsData: {
price: {props: {price: 0, overSeaShare: 0, includeFcaFee: false}}, price: {props: {price: 0, overSeaShare: 0, includeFcaFee: false}},
material: {props: {partNumber: "", hsCode: null, tariffRate: null, tariffUnlocked: false, description: "", hideDescription: false}}, material: {
props: {
partNumber: "",
hsCode: null,
tariffRate: null,
tariffUnlocked: false,
description: "",
hideDescription: false
}
},
packaging: { packaging: {
props: { props: {
length: 0, length: 0,
@ -247,27 +271,48 @@ export default {
}); });
} }
}, },
async startCalculation() { async calculate() {
this.showCalculationModal = true; this.showCalculationModal = true;
const error = await this.premiseEditStore.startCalculation(); const error = await this.premiseEditStore.startCalculation();
if (error === null) { if (error === null) {
this.closeMassEdit() this.close()
} }
this.showCalculationModal = false; this.showCalculationModal = false;
}, },
closeMassEdit() { close() {
this.$router.push({name: "calculation-list"}); this.$router.push({name: "calculation-list"});
}, },
updateCheckBox(data) {
this.premiseEditStore.setChecked(data.id, data.checked);
this.updateOverallCheckBox();
},
updateCheckBoxes(value) { updateCheckBoxes(value) {
console.log("set all", value)
this.premiseEditStore.setAll(value); this.premiseEditStore.setAll(value);
this.updateOverallCheckBox();
},
updateOverallCheckBox() {
this.overallCheck = this.premiseEditStore.allChecked;
if (!this.overallCheck)
this.overallIndeterminate = this.premiseEditStore.someChecked;
}, },
multiselectAction(action) { multiselectAction(action) {
this.openModal(action, this.selectedPremisses.map(p => p.id)); this.openModal(action, this.selectedPremisses.map(p => p.id));
}, },
onClickAction(data) { onClickAction(data) {
const massEdit = 0 !== this.selectCount if (data.action === 'supplier-select') {
this.openModal(data.action, massEdit ? this.premiseEditStore.getSelectedPremissesIds : [data.id], data.id, massEdit); this.premiseEditStore.setBy('supplier', data.id);
this.updateOverallCheckBox();
} else if (data.action === 'material-select') {
this.premiseEditStore.setBy('material', data.id);
this.updateOverallCheckBox();
} else {
const massEdit = 0 !== this.selectCount
this.openModal(data.action, massEdit ? this.premiseEditStore.getSelectedPremissesIds : [data.id], data.id, massEdit);
}
}, },
openModal(type, ids, dataSource = -1, massEdit = true) { openModal(type, ids, dataSource = -1, massEdit = true) {
@ -383,7 +428,7 @@ export default {
<style scoped> <style scoped>
/* Global style für copy-mode cursor */ /* Global style für copy-mode cursor */
.edit-calculation-container.has-selection :deep(.copyable-cell:hover) { .edit-calculation-container.has-selection :deep(.bulk-edit-row__cell--clickable:hover) {
cursor: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPgogIDxyZWN0IHg9Ijg0IiB5PSIzMiIgd2lkdGg9IjEzNiIgaGVpZ2h0PSIxMzYiIGZpbGw9IndoaXRlIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iOCIgcng9IjQiLz4KICA8cmVjdCB4PSIzNiIgeT0iODQiIHdpZHRoPSIxMzYiIGhlaWdodD0iMTM2IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjgiIHJ4PSI0Ii8+Cjwvc3ZnPg==") 12 12, pointer; cursor: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPgogIDxyZWN0IHg9Ijg0IiB5PSIzMiIgd2lkdGg9IjEzNiIgaGVpZ2h0PSIxMzYiIGZpbGw9IndoaXRlIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iOCIgcng9IjQiLz4KICA8cmVjdCB4PSIzNiIgeT0iODQiIHdpZHRoPSIxMzYiIGhlaWdodD0iMTM2IiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjgiIHJ4PSI0Ii8+Cjwvc3ZnPg==") 12 12, pointer;
background-color: #f8fafc; background-color: #f8fafc;
border-radius: 0.8rem; border-radius: 0.8rem;
@ -456,7 +501,7 @@ export default {
.edit-calculation-list-header { .edit-calculation-list-header {
display: grid; display: grid;
grid-template-columns: 6rem 1fr 1fr 1.5fr 1.5fr 1.5fr 10rem; grid-template-columns: 6rem 0.8fr 0.7fr 1fr 1fr 1.2fr 2fr 10rem;
gap: 1.6rem; gap: 1.6rem;
padding: 2.4rem; padding: 2.4rem;
background-color: #ffffff; background-color: #ffffff;

View file

@ -215,6 +215,13 @@ export default {
flex-direction: column; flex-direction: column;
} }
.edit-calculation-spinner-container
{
display: flex;
padding-top: 10rem;
justify-content: center;
}
.header-container { .header-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -128,7 +128,7 @@ export const useNotificationStore = defineStore('notification', {
const pinia = this.$pinia || getActivePinia() const pinia = this.$pinia || getActivePinia()
if (pinia && pinia._s) { if (pinia && pinia._s) {
pinia._s.forEach((store, storeId) => { pinia._s.forEach((store, storeId) => {
if (storeId !== 'error' && storeId !== 'errorLog' && store.$state) { if (storeId !== 'notification' && storeId !== 'errorLog' && store.$state) {
storeState[storeId] = { storeState[storeId] = {
...toRaw(store.$state) ...toRaw(store.$state)
} }

View file

@ -9,6 +9,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
state() { state() {
return { return {
premisses: null, premisses: null,
selectedIds: [],
/** /**
* set to true while the store is loading the premises. * set to true while the store is loading the premises.
@ -20,14 +21,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
*/ */
selectedLoading: false, selectedLoading: false,
destinations: null,
processDestinationMassEdit: false,
selectedDestination: null,
throwsException: true, throwsException: true,
} }
}, },
getters: { getters: {
@ -54,21 +48,21 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
}, },
/** // /**
* Returns the ids of all premises. // * Returns the ids of all premises.
* @param state // * @param state
* @returns {*} // * @returns {*}
*/ // */
getPremiseIds(state) { // getPremiseIds(state) {
if (state.loading) { // if (state.loading) {
if (state.throwsException) // if (state.throwsException)
throw new Error("Premises are accessed while still loading."); // throw new Error("Premises are accessed while still loading.");
//
return null; // return null;
} // }
//
return state.premisses?.map(p => p.id); // return state.premisses?.map(p => p.id);
}, // },
/** /**
* Returns the premises. * Returns the premises.
@ -242,7 +236,30 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
return state.premisses?.find(p => p.selected); return state.premisses?.find(p => p.selected);
}, },
allChecked(state) {
if (state.premisses.length > state.selectedIds.length)
return false;
for (const premise of state.premisses) {
if (!state.selectedIds.includes(premise.id))
return false;
}
return state.premisses.length !== 0;
},
someChecked(state) {
for (const premise of state.premisses) {
if (state.selectedIds.includes(premise.id))
return true;
}
return false;
},
isChecked(state) {
return (id) => {
return state.selectedIds.includes(id);
}
},
}, },
@ -280,7 +297,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
this.premisses = updatedPremises; this.premisses = updatedPremises;
return await this.saveMaterial(ids, materialData); return await this.saveMaterial(ids, materialData);
}, },
async batchUpdatePackaging(ids, packagingData) { async batchUpdatePackaging(ids, packagingData) {
@ -633,22 +649,6 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
}, },
/**
* Replace the premisses with the loaded ones by id.
* This is used to update the premisses after a "Set" change.
* @param premisses
* @param loadedData
* @returns {*}
*/
replacePremissesById(premisses, loadedData) {
const replacementMap = new Map(loadedData.map(obj => [obj.id, obj]));
const replaced = premisses.map(obj => replacementMap.get(obj.id) || obj);
logger.info("Replaced", replaced);
return replaced;
},
/** /**
* Save * Save
*/ */
@ -733,71 +733,51 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
* PREMISE stuff * PREMISE stuff
* ================= * =================
*/ */
deselectPremise() {
this.selectedLoading = true;
this.premisses.forEach(p => p.selected = false);
this.destinations = null;
this.selectedDestination = null;
this.selectedLoading = false;
},
setAll(value) { setAll(value) {
this.selectedLoading = true; this.selectedLoading = true;
const updatedPremises = this.premisses.map(p => ({ const temp = [];
...p,
selected: value if (value)
})); this.premisses.forEach(p => temp.push(p.id));
this.premisses = updatedPremises;
this.selectedIds = temp;
this.selectedLoading = false; this.selectedLoading = false;
}, },
setSelectTo(ids, value) { setChecked(premiseId, checked) {
this.selectedLoading = true; this.selectedLoading = true;
const idsSet = new Set(ids); if (checked) {
const updatedPremises = this.premisses.map(p => ({ if (!this.selectedIds.includes(premiseId)) {
...p, this.selectedIds.push(premiseId);
selected: idsSet.has(p.id) ? value : p.selected }
})); } else {
this.premisses = updatedPremises; const idx = this.selectedIds.indexOf(premiseId);
if (idx !== -1)
this.selectedIds.splice(idx, 1);
}
this.selectedLoading = false; this.selectedLoading = false;
}, },
async selectSinglePremise(id, ids) { setBy(type, ofId) {
this.selectedLoading = true; this.selectedLoading = true;
const premise = this.premisses.find(p => p.id === ofId);
await this.loadPremissesIfNeeded(ids); const temp = [];
this.premisses.forEach(p => p.selected = String(id) === String(p.id)); this.premisses.forEach(p => {
if(type === 'supplier' && p.supplier.id === premise.supplier.id) {
this.prepareDestinations(id, [id]); temp.push(p.id);
} else if(type === 'material' && p.material.id === premise.material.id) {
this.selectedLoading = false; temp.push(p.id);
}, }
async loadAndSelectSinglePremise(id) {
this.loading = true;
this.selectedLoading = true;
this.premises = [];
const params = new URLSearchParams();
params.append('premissIds', `${[id]}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, 'GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
}); });
this.premisses = data;
this.premisses.forEach(p => p.selected = true); this.selectedIds = temp;
this.prepareDestinations(id, [id]);
this.selectedLoading = false; this.selectedLoading = false;
this.loading = false;
}, },
async loadPremissesIfNeeded(ids, exact = false) { async loadPremissesIfNeeded(ids, exact = false) {
const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id) && (!exact || ids.length === this.premisses.length)) : true; const reload = this.premisses ? !ids.every((id) => this.premisses.find(d => d.id === id) && (!exact || ids.length === this.premisses.length)) : true;

View file

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

View file

@ -278,7 +278,10 @@ public class DestinationRepository {
Destination entity = new Destination(); Destination entity = new Destination();
entity.setId(rs.getInt("id")); entity.setId(rs.getInt("id"));
entity.setAnnualAmount(rs.getInt("annual_amount"));
var amount = rs.getInt("annual_amount");
entity.setAnnualAmount(rs.wasNull() ? null : amount);
entity.setPremiseId(rs.getInt("premise_id")); entity.setPremiseId(rs.getInt("premise_id"));
entity.setDestinationNodeId(rs.getInt("destination_node_id")); entity.setDestinationNodeId(rs.getInt("destination_node_id"));
entity.setRateD2d(rs.getBigDecimal("rate_d2d")); entity.setRateD2d(rs.getBigDecimal("rate_d2d"));

View file

@ -140,7 +140,6 @@ public class PremisesService {
calculationIds.add(calculationJobRepository.insert(job)); calculationIds.add(calculationJobRepository.insert(job));
} }
}); });
premiseRepository.setStatus(premises, PremiseState.COMPLETED); premiseRepository.setStatus(premises, PremiseState.COMPLETED);

View file

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

View file

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