FRONTEND: bulk download working.

Added views for nodes, materials and rates
This commit is contained in:
Jan 2025-09-16 20:44:41 +02:00
parent 3b683018de
commit e5bd56d3a9
55 changed files with 2174 additions and 310 deletions

View file

@ -17,13 +17,13 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
logger.info("Request:", request);
const data = await executeRequest(requestingStore, request);
const resp = await executeRequest(requestingStore, request);
logger.info("Response:", data);
return data;
logger.info("Response:", resp);
return resp;
}
const performDownload = async (url, expectResponse = true, expectedException = null) => {
const performDownload = async (url, toFile, expectResponse = true, expectedException = null) => {
const params = {
method: 'GET',
@ -32,15 +32,15 @@ const performDownload = async (url, expectResponse = true, expectedException = n
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException, type: 'blob'};
logger.info("Request:", request);
const blob = await executeRequest(null, request);
const resp = await executeRequest(null, request);
const downloadUrl = window.URL.createObjectURL(blob);
const downloadUrl = window.URL.createObjectURL(resp.data);
// Create temporary link element and trigger download
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `export.xlsx`; // or get filename from response headers
link.download = toFile;
document.body.appendChild(link);
link.click();
@ -62,10 +62,10 @@ const performUpload = async (url, file, expectResponse = true, expectedException
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
logger.info("Request:", request);
const processId = await executeRequest(null, request);
const resp = await executeRequest(null, request);
logger.info("Response:", processId);
return processId;
logger.info("Response:", resp.data);
return resp.data;
}
function handleErrorResponse(data, requestingStore, request) {
@ -104,6 +104,7 @@ const executeRequest = async (requestingStore, request) => {
throw e;
});
let data = null;
if (request.expectResponse) {
try {
@ -149,7 +150,7 @@ const executeRequest = async (requestingStore, request) => {
}
}
return data;
return {data: data, headers: response.headers};
}
export default performRequest;

View file

@ -1,5 +1,5 @@
<template>
<div class="box" :class="{'box-shadowed': variant === 'shadow', 'box-bordered': variant === 'border', 'stretch-content': stretchContent}">
<div class="box" :class="{'box-padding': !removePadding,'box-shadowed': variant === 'shadow', 'box-bordered': variant === 'border', 'stretch-content': stretchContent}">
<slot></slot>
</div>
</template>
@ -17,6 +17,10 @@ export default {
stretchContent: {
type: Boolean,
default: false
},
removePadding: {
type: Boolean,
default: false
}
}
}
@ -35,7 +39,6 @@ export default {
.box-bordered {
border: 0.1rem solid #E3EDFF;
}
.box {
@ -43,6 +46,10 @@ export default {
background: white;
border-radius: 0.8rem;
position: relative;
}
.box-padding {
padding: 1.5rem;
}

View file

@ -0,0 +1,265 @@
<template>
<div class="table-container">
<!-- Search Bar -->
<div class="search-container">
<input
v-model="searchQuery"
type="text"
placeholder="Search..."
class="search-input"
/>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading">
Loading...
</div>
<!-- Error State -->
<div v-else-if="error" class="error">
Error: {{ error }}
</div>
<!-- Table -->
<div v-else class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key" class="table-header">
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="filteredData.length === 0" class="no-data">
<td :colspan="columns.length" class="no-data-cell">
{{ searchQuery ? 'No results found' : 'No data available' }}
</td>
</tr>
<tr v-else v-for="(item, index) in filteredData" :key="index" class="table-row">
<td v-for="column in columns" :key="column.key" class="table-cell">
{{ getCellValue(item, column) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
name: 'DataTable',
props: {
// Callback function that returns array of objects
dataSource: {
type: Function,
required: true
},
// Array of column configurations
// Each column should have: { key: 'fieldName', label: 'Display Name', formatter: fn (optional) }
columns: {
type: Array,
required: true,
validator(columns) {
return columns.every(col => col.key && col.label);
}
},
// Fields to search in (if not provided, searches all column keys)
searchFields: {
type: Array,
default: () => []
},
// Auto-load data on mount
autoLoad: {
type: Boolean,
default: true
}
},
data() {
return {
data: [],
loading: false,
error: null,
searchQuery: ''
};
},
computed: {
filteredData() {
if (!this.searchQuery.trim()) {
return this.data;
}
const query = this.searchQuery.toLowerCase().trim();
const fieldsToSearch = this.searchFields.length > 0
? this.searchFields
: this.columns.map(col => col.key);
return this.data.filter(item => {
return fieldsToSearch.some(field => {
const value = this.getNestedValue(item, field);
return value && value.toString().toLowerCase().includes(query);
});
});
}
},
mounted() {
if (this.autoLoad) {
this.loadData();
}
},
methods: {
async loadData() {
this.loading = true;
this.error = null;
try {
const result = await this.dataSource();
if (Array.isArray(result)) {
this.data = result;
} else {
throw new Error('Data source must return an array');
}
} catch (err) {
this.error = err.message || 'Failed to load data';
this.data = [];
} finally {
this.loading = false;
}
},
// Get nested object values using dot notation (e.g., 'user.profile.name')
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null;
}, obj);
},
// Get cell value with optional formatting
getCellValue(item, column) {
const rawValue = this.getNestedValue(item, column.key);
if (column.formatter && typeof column.formatter === 'function') {
return column.formatter(rawValue, item);
}
return rawValue !== null && rawValue !== undefined ? rawValue : '';
},
// Public method to refresh data
refresh() {
this.loadData();
},
// Public method to clear search
clearSearch() {
this.searchQuery = '';
}
}
};
</script>
<style scoped>
.table-container {
width: 100%;
}
.search-container {
margin-bottom: 16px;
}
.search-input {
width: 100%;
max-width: 300px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.loading, .error {
padding: 20px;
text-align: center;
font-size: 14px;
}
.loading {
color: #666;
}
.error {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.table-wrapper {
overflow-x: auto;
border: 1px solid #ddd;
border-radius: 4px;
}
.data-table {
width: 100%;
border-collapse: collapse;
background-color: white;
}
.table-header {
background-color: #f8f9fa;
color: #495057;
font-weight: 600;
padding: 12px 16px;
text-align: left;
border-bottom: 2px solid #dee2e6;
position: sticky;
top: 0;
}
.table-row {
transition: background-color 0.2s;
}
.table-row:hover {
background-color: #f8f9fa;
}
.table-row:nth-child(even) {
background-color: #fdfdfd;
}
.table-cell {
padding: 12px 16px;
border-bottom: 1px solid #dee2e6;
vertical-align: top;
}
.no-data-cell {
padding: 40px 16px;
text-align: center;
color: #6c757d;
font-style: italic;
}
/* Responsive design */
@media (max-width: 768px) {
.search-input {
max-width: 100%;
}
.table-header,
.table-cell {
padding: 8px 12px;
font-size: 14px;
}
}
</style>

View file

@ -0,0 +1,201 @@
```vue
<template>
<div class="pagination" v-if="pageCount > 1">
<!-- Previous button -->
<button
class="pagination-btn"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
<PhCaretLeft :size="18" /> Previous
</button>
<!-- First page -->
<button
v-if="showFirstPage"
class="pagination-btn page-number"
:class="{ active: page === 1 }"
@click="goToPage(1)"
>
1
</button>
<!-- First ellipsis -->
<span v-if="showFirstEllipsis" class="ellipsis">...</span>
<!-- Page numbers around current page -->
<button
v-for="pageNum in visiblePages"
:key="pageNum"
class="pagination-btn page-number"
:class="{ active: page === pageNum }"
@click="goToPage(pageNum)"
>
{{ pageNum }}
</button>
<!-- Last ellipsis -->
<span v-if="showLastEllipsis" class="ellipsis">...</span>
<!-- Last page -->
<button
v-if="showLastPage"
class="pagination-btn page-number"
:class="{ active: page === pageCount }"
@click="goToPage(pageCount)"
>
{{ pageCount }}
</button>
<!-- Next button -->
<button
class="pagination-btn"
:disabled="page >= pageCount"
@click="goToPage(page + 1)"
>
Next <PhCaretRight :size="18" />
</button>
<!-- Page info -->
<div class="page-info">
Page {{ page }} of {{ pageCount }} ({{ totalCount }} total items)
</div>
</div>
</template>
<script>
import {PhCaretLeft, PhCaretRight} from "@phosphor-icons/vue";
export default {
name: "Pagination",
components: {PhCaretLeft, PhCaretRight},
props: {
page: {
type: Number,
required: true,
default: 1
},
pageCount: {
type: Number,
required: true,
default: 1
},
totalCount: {
type: Number,
required: true,
default: 0
},
maxVisiblePages: {
type: Number,
default: 5
}
},
emits: ['page-change'],
computed: {
visiblePages() {
const delta = Math.floor(this.maxVisiblePages / 2);
let start = Math.max(2, this.page - delta);
let end = Math.min(this.pageCount - 1, this.page + delta);
// Adjust if we're near the beginning or end
if (this.page <= delta + 1) {
end = Math.min(this.pageCount - 1, this.maxVisiblePages);
}
if (this.page >= this.pageCount - delta) {
start = Math.max(2, this.pageCount - this.maxVisiblePages + 1);
}
const pages = [];
for (let i = start; i <= end; i++) {
if (i !== 1 && i !== this.pageCount) {
pages.push(i);
}
}
return pages;
},
showFirstPage() {
return this.pageCount > 1;
},
showLastPage() {
return this.pageCount > 1 && this.pageCount !== 1;
},
showFirstEllipsis() {
return this.visiblePages.length > 0 && this.visiblePages[0] > 2;
},
showLastEllipsis() {
return this.visiblePages.length > 0 &&
this.visiblePages[this.visiblePages.length - 1] < this.pageCount - 1;
}
},
methods: {
goToPage(pageNumber) {
if (pageNumber >= 1 && pageNumber <= this.pageCount && pageNumber !== this.page) {
this.$emit('page-change', pageNumber);
}
}
}
}
</script>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 20px 0;
flex-wrap: wrap;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
border: 0.2rem solid #E3EDFF;
background: transparent;
color: #002F54;
cursor: pointer;
border-radius: 0.4rem;
font-size: 1.4rem;
font-family: inherit;
transition: all 0.2s ease;
min-width: 40px;
}
.pagination-btn:hover:not(:disabled) {
background: #EEF4FF;
border-color: #8DB3FE;
transform: scale(1.01);
}
.pagination-btn:disabled {
background: rgba(238, 244, 255, 0.2);
border-color: rgba(141, 179, 254, 0.2);
color: rgba(0, 47, 84, 0.2);
cursor: not-allowed;
}
.pagination-btn.active {
background: #EEF4FF;
}
.page-number {
font-weight: 500;
}
.ellipsis {
padding: 8px 4px;
color: #666;
font-size: 14px;
}
.page-info {
margin-left: 1.6rem;
font-size: 1.2rem;
color: #6b7280;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<div class="search-bar-container">
<div class="search-wrapper">
<PhMagnifyingGlass :size="32" weight="bold" class="search-icon"/>
<input
@input="inputChanged"
v-model="value"
type="text"
class="search-input"
:placeholder="placeholder"
autocomplete="off"
>
</div>
</div>
</template>
<script>
import {useDebounceFn} from "@vueuse/core";
export default {
name: "SearchBar",
emits: ['update:modelValue', 'inputChanged'],
props: {
modelValue: {
type: String,
required: false,
default: ''
},
placeholder: {
type: String,
required: false,
default: 'Search term'
}
},
data() {
return {
debouncedSearch: null,
}
},
created() {
this.debouncedSearch = useDebounceFn((query) => {
this.handleSearch(query);
}, 300);
},
methods: {
handleSearch(query) {
this.$emit('inputChanged', query);
}
},
computed: {
value: {
get() {
return this.modelValue;
},
set(value) {
this.debouncedSearch(value);
this.$emit('update:modelValue', value);
}
}
}
}
</script>
<style scoped>
.search-bar-container {
background: white;
display: flex;
align-items: center;
gap: 2.4rem;
}
.search-wrapper {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 1 auto;
}
.search-wrapper:hover {
background: #EEF4FF;
border: 0.2rem solid #8DB3FE;
/*transform: translateY(2px);*/
transform: scale(1.01);
}
.search-icon {
width: 1.8rem;
height: 1.8rem;
margin-right: 1.2rem;
color: #6B869C;
flex-shrink: 0;
}
.search-input {
flex: 1 1 auto;
border: none;
outline: none;
font-size: 1.4rem;
color: #002F54;
background: transparent;
font-weight: 400;
}
.search-input::placeholder {
color: #6B869C;
font-weight: 400;
}
</style>

View file

@ -0,0 +1,265 @@
<template>
<div class="table-container">
<div class="table-search-bar-container">
<search-bar class="search-bar" v-model="filter" @input-changed="reload"></search-bar>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key" class="table-header" :class="getAlignment(column.align)">
{{ column.label }}
</th>
</tr>
</thead>
<transition name="list-container" mode="out-in">
<tbody v-if="data.length === 0">
<tr class="no-data">
<td :colspan="columns.length" class="no-data-cell">
{{ filter ? 'No results found' : 'No data available' }}
</td>
</tr>
</tbody>
<tbody v-else-if="loading">
<tr class="no-data">
<td :colspan="columns.length" class="no-data-cell">
<spinner class="space-around"></spinner>
</td>
</tr>
</tbody>
<tbody v-else>
<tr v-for="(item, index) in data" :key="index" class="table-row">
<td v-for="column in columns" :key="column.key" class="table-cell" :class="getAlignment(column.align)">
<span v-if="column.iconResolver == null">{{ getCellValue(item, column) }}</span>
<component v-else
:is="getCellValue(item, column)"
weight="regular"
size="24"
class="table-icon"
/>
</td>
</tr>
</tbody>
</transition>
</table>
</div>
</div>
<pagination :total-count="totalCount" :page-count="pageCount" :page="page" @page-change="updatePage"></pagination>
</template>
<script>
import Spinner from "@/components/UI/Spinner.vue";
import Checkbox from "@/components/UI/Checkbox.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import SearchBar from "@/components/UI/SearchBar.vue";
import Box from "@/components/UI/Box.vue";
import Pagination from "@/components/UI/Pagination.vue";
export default {
name: "TableView",
components: {Pagination, Box, SearchBar, BasicButton, Checkbox, Spinner},
props: {
dataSource: {
type: Function,
required: true
},
columns: {
type: Array,
required: true,
validator(columns) {
return columns.every(col => col.key && col.label);
}
},
page: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 10
},
pageCount: {
type: Number,
default: 1
},
totalCount: {
type: Number,
default: 0
}
},
mounted() {
this.reload();
},
computed: {},
methods: {
async updatePage(page) {
this.reload(this.filter, page);
},
async reload(filter, page = 1) {
this.loading = true;
const query = {searchTerm: filter, page: page, pageSize: this.pageSize};
this.data = await this.dataSource(query);
this.loading = false;
},
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null;
}, obj);
},
getAlignment(alignment) {
if (alignment == null || alignment === 'left') {
return 'table-cell--left';
} else if (alignment === 'right') {
return 'table-cell--right';
} else if (alignment === 'center') {
return 'table-cell--center';
}
},
getCellValue(item, column) {
const rawValue = this.getNestedValue(item, column.key);
if (column.iconResolver && typeof column.iconResolver === 'function') {
return column.iconResolver(rawValue, item);
}
if (column.iconResolver) {
return 'PhImageBroken';
}
if (column.formatter && typeof column.formatter === 'function') {
return column.formatter(rawValue, item);
}
return rawValue !== null && rawValue !== undefined ? rawValue : '';
},
},
data() {
return {
data: [],
filter: '',
loading: false,
};
},
}
</script>
<style scoped>
/* Container Animation */
.list-container-enter-active, .list-container-leave-active {
transition: all 0.3s ease;
}
.list-container-enter-from, .list-container-leave-to {
opacity: 0;
}
.table-icon {
transition: all 0.1s ease-in-out;
color: #6B869C;
}
.search-bar {
width: 30%;
}
.table-search-bar-container {
display: flex;
justify-content: flex-end;
padding: 1.6rem;
}
.no-data-cell {
text-align: center;
font-size: 1.6rem;
color: #6B869C;
padding: 2.4rem;
}
.table-container {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.6rem;
overflow: visible;
border-radius: 0.8rem;
box-shadow: 0 0.4rem 0.6rem -0.1rem rgba(0, 0, 0, 0.1);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.space-around {
margin: 3rem;
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: transparent;
border: 0 solid #E3EDFF;
}
.table-header {
border-bottom: 1px solid rgba(107, 134, 156, 0.2);
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
text-align: left;
padding: 2.4rem;
}
.table-row {
font-weight: 400;
font-size: 1.4rem;
color: #6B869C;
}
.table-cell {
padding: 2.4rem;
background-color: transparent;
border-bottom: 0.1rem solid #E3EDFF;
text-align: right;
}
.table-cell--left {
text-align: left;
}
.table-cell--right {
text-align: right;
}
.table-cell--center {
text-align: center;
}
.table-row:hover {
background-color: rgba(107, 134, 156, 0.05);
}
</style>

View file

@ -2,69 +2,91 @@
<div>
<div class="bulk-operations-container">
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<box variant="border" class="bulk-operation-box-status-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-header">Bulk operation status</div>
</div>
</box>
<div class="bulk-operation-header">Export</div>
<div class="bulk-operation-caption">type</div>
<div class="bulk-operation-data">
<radio-option name="export-type" value="templates" v-model="exportType">empty template</radio-option>
<radio-option name="export-type" value="download" v-model="exportType">full data export</radio-option>
</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="export-dataset" value="NODE" v-model="exportDataset">nodes</radio-option>
<radio-option name="export-dataset" value="COUNTRY_MATRIX" v-model="exportDataset">kilometer rates</radio-option>
<radio-option name="export-dataset" value="CONTAINER_RATE" v-model="exportDataset">container rates</radio-option>
<radio-option name="export-dataset" value="MATERIAL" v-model="exportDataset">materials</radio-option>
<radio-option name="export-dataset" value="PACKAGING" v-model="exportDataset">packaging</radio-option>
</div>
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-action-footer">
<basic-button @click="downloadFile" icon="download">Export</basic-button>
</div>
<div class="bulk-operation-header">Export</div>
<div class="bulk-operation-caption">type</div>
<div class="bulk-operation-data">
<radio-option name="export-type" value="templates" v-model="exportType">empty template</radio-option>
<radio-option name="export-type" value="download" v-model="exportType">full data export</radio-option>
</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="export-dataset" value="NODE" v-model="exportDataset">nodes</radio-option>
<radio-option name="export-dataset" value="COUNTRY_MATRIX" v-model="exportDataset">kilometer rates
</radio-option>
<radio-option name="export-dataset" value="CONTAINER_RATE" v-model="exportDataset">container rates
</radio-option>
<radio-option name="export-dataset" value="MATERIAL" v-model="exportDataset">materials</radio-option>
<radio-option name="export-dataset" value="PACKAGING" v-model="exportDataset">packaging</radio-option>
</div>
</box>
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-caption">validity period</div>
<div class="bulk-operation-data">
<dropdown :options="periods"
emptyText="No property set available"
class="period-select"
placeholder="Select a property set"
v-model="selectedPeriod"
:disabled="!showValidityPeriod"
></dropdown>
</div>
<div class="bulk-operation-header">Import</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="import-dataset" value="NODE" v-model="importDataset">nodes</radio-option>
<radio-option name="import-dataset" value="COUNTRY_MATRIX" v-model="importDataset">kilometer rates</radio-option>
<radio-option name="import-dataset" value="CONTAINER_RATE" v-model="importDataset">container rates</radio-option>
<radio-option name="import-dataset" value="MATERIAL" v-model="importDataset">materials</radio-option>
<radio-option name="import-dataset" value="PACKAGING" v-model="importDataset">packaging</radio-option>
</div>
<div class="bulk-operation-caption">import mode</div>
<div class="bulk-operation-data">
<radio-option name="import-type" value="APPEND" v-model="importType">append existing data</radio-option>
<radio-option name="import-type" value="FULL" v-model="importType">fully replace data</radio-option>
</div>
<div class="bulk-operation-caption">file</div>
<div class="bulk-operation-data">
<div class="file-input-container">
<label for="upload" class="file-button-label">
Choose file
</label>
<input @change="inputFile" class="file-select-button" type="file" id="upload" name="upload" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
</div>
<div id="selectedFile" class="selected-file" v-if="selectedFileName">{{ selectedFileName }}</div>
</div>
<div class="bulk-operation-action-footer">
<basic-button @click="uploadFile" icon="upload" :disabled="!selectedFile">Import</basic-button>
<div class="bulk-operation-action-footer">
<basic-button @click="downloadFile" icon="download">Schedule Export</basic-button>
</div>
</div>
</box>
<box variant="border" class="bulk-operations-box-container">
<div class="bulk-operations-sub-container">
<div class="bulk-operation-header">Import</div>
<div class="bulk-operation-caption">dataset</div>
<div class="bulk-operation-data">
<radio-option name="import-dataset" value="NODE" v-model="importDataset">nodes</radio-option>
<radio-option name="import-dataset" value="COUNTRY_MATRIX" v-model="importDataset">kilometer rates
</radio-option>
<radio-option name="import-dataset" value="CONTAINER_RATE" v-model="importDataset">container rates
</radio-option>
<radio-option name="import-dataset" value="MATERIAL" v-model="importDataset">materials</radio-option>
<radio-option name="import-dataset" value="PACKAGING" v-model="importDataset">packaging</radio-option>
</div>
<div class="bulk-operation-caption">import mode</div>
<div class="bulk-operation-data">
<radio-option name="import-type" value="APPEND" v-model="importType">append existing data</radio-option>
<radio-option name="import-type" value="FULL" v-model="importType">fully replace data</radio-option>
</div>
<div class="bulk-operation-caption">file</div>
<div class="bulk-operation-data">
<div class="file-input-container">
<label for="upload" class="file-button-label">
Choose file
</label>
<input @change="inputFile" class="file-select-button" type="file" id="upload" name="upload"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/>
</div>
<div id="selectedFile" class="selected-file" v-if="selectedFileName">{{ selectedFileName }}</div>
</div>
</box>
<div class="bulk-operation-action-footer">
<basic-button @click="uploadFile" icon="upload" :disabled="!selectedFile">Import</basic-button>
</div>
</div>
</box>
</div>
</div>
@ -77,10 +99,13 @@ import BasicButton from "@/components/UI/BasicButton.vue";
import RadioOption from "@/components/UI/RadioOption.vue";
import performRequest, {performUpload, performDownload} from "@/backend.js";
import {config} from "@/config.js";
import Dropdown from "@/components/UI/Dropdown.vue";
import {mapStores} from "pinia";
import {useValidityPeriodStore} from "@/store/validityPeriod.js";
export default {
name: "BulkOperations",
components: {RadioOption, BasicButton, Box},
components: {Dropdown, RadioOption, BasicButton, Box},
data() {
return {
exportType: "templates",
@ -93,11 +118,49 @@ export default {
processId: null,
}
},
computed: {
...mapStores(useValidityPeriodStore),
showValidityPeriod() {
return this.exportType === "download" && (this.exportDataset === "COUNTRY_MATRIX" || this.exportDataset === "CONTAINER_RATE");
},
selectedPeriod: {
get() {
return this.validityPeriodStore.getSelectedPeriod
},
async set(value) {
this.validityPeriodStore.setSelectedPeriod(value);
}
},
periods() {
const periods = [];
const ps = this.validityPeriodStore.getPeriods;
const current = this.validityPeriodStore.getCurrentPeriodId;
if ((ps ?? null) === null) {
return null;
}
for (const p of ps) {
const value = (p.state === "DRAFT" || p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`;
const period = {id: p.id, value: value};
if (p.state !== "VALID" || p.id === current)
periods.push(period);
}
return periods;
}
},
created() {
this.validityPeriodStore.loadPeriods();
},
methods: {
async downloadFile() {
const fileName = `lcc_export_${this.exportDataset.toLowerCase()}_${this.exportType.toLowerCase()}.xlsx`;
const url = `${config.backendUrl}/bulk/${this.exportType}/${this.exportDataset}/`
this.processId = await performDownload(url);
this.processId = await performDownload(url, fileName);
},
inputFile(event) {
const file = event.target.files[0];
@ -130,6 +193,10 @@ export default {
gap: 1rem;
}
.bulk-operation-box-status-container {
grid-column: 1 / -1;
}
.bulk-operations-box-container {
flex: 1;
}

View file

@ -0,0 +1,64 @@
<template>
<table-view ref="tableViewRef" :data-source="fetch" :columns="materialColumns" :page="pagination.page" :page-size="pageSize" :page-count="pagination.pageCount" :total-count="pagination.totalCount"></table-view>
</template>
<script>
import TableView from "@/components/UI/TableView.vue";
import {mapStores} from "pinia";
import {useMaterialStore} from "@/store/material.js";
export default {
name: "materials",
components: {TableView},
computed: {
... mapStores(useMaterialStore),
},
created() {
this.materialStore.updateMaterials()
},
methods: {
async fetch(query) {
await this.materialStore.setQueryForList(query);
this.pagination = this.materialStore.pagination;
return this.materialStore.materials;
}
},
data() {
return {
materialColumns: [
{
key: 'part_number',
label: 'Part number',
},
{
key: 'name',
label: 'Description',
},
{
key: 'hs_code',
label: 'HS Code',
},
{
key: 'is_deprecated',
label: 'active',
formatter: (value) => {
return !value ? 'Yes' : 'No';
}
},
],
pagination: {
page: 1,
pageCount: 1,
totalCount: 0
},
pageSize: 10
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,75 @@
<template>
<table-view ref="tableViewRef" :data-source="fetch" :columns="nodeColumns" :page="pagination.page" :page-size="pageSize" :page-count="pagination.pageCount" :total-count="pagination.totalCount"></table-view>
</template>
<script>
import TableView from "@/components/UI/TableView.vue";
import {mapStores} from "pinia";
import {useNodeStore} from "@/store/node.js";
export default {
name: "Nodes",
components: {TableView},
computed: {
... mapStores(useNodeStore),
},
created() {
this.nodeStore.loadNodes()
},
methods: {
async fetch(query) {
await this.nodeStore.setQuery(query);
this.pagination = this.nodeStore.pagination;
return this.nodeStore.nodes;
}
},
data() {
return {
nodeColumns: [
{
key: 'external_mapping_id',
label: 'Mapping ID',
},
{
key: 'name',
label: 'Name',
},
{
key: 'address',
label: 'Address',
},
{
key: 'country.iso_code',
label: 'Country (ISO Code)',
},
{
key: 'types',
label: 'Type',
formatter: (value) => {
return value.map(v => v.toUpperCase()).join(', ');
}
},
{
key: 'is_deprecated',
label: 'active',
formatter: (value) => {
return !value ? 'Yes' : 'No';
}
},
],
pagination: {
page: 1,
pageCount: 1,
totalCount: 0
},
pageSize: 10
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,241 @@
<template>
<div class="container-rate-container">
<div class="container-rate-header">
<div class="container-rate-search-container">
<span class="period-select-caption">Rate type:</span>
<radio-option name="rateType" value="container" v-model="rateType">Container rates</radio-option>
<radio-option name="rateType" value="matrix" v-model="rateType">Kilometer rates</radio-option>
</div>
<div v-if="!loadingPeriods" class="period-select-container">
<span class="period-select-caption">Validity period:</span>
<dropdown :options="periods"
emptyText="No validity period available"
class="period-select"
placeholder="Select a validity period"
v-model="selectedPeriod"
></dropdown>
<tooltip position="left" text="Invalidate the selected validity period">
<icon-button icon="trash" @click="deletePeriod" :disabled="disableDeleteButton"></icon-button>
</tooltip>
<modal-dialog title="Do you really want to invalidate this validity period?"
dismiss-text="No"
accept-text="Yes"
:state="modalDialogDeleteState"
message="If you invalidate this property set, this will also invalidate all calculations done with this property set. This cannot be undone!"
@click="deleteModalClick"
>
</modal-dialog>
</div>
</div>
<table-view ref="tableViewRef" :data-source="fetch" :columns="selectedTypeColumns" :page="pagination.page" :page-size="pageSize" :page-count="pagination.pageCount" :total-count="pagination.totalCount"></table-view>
</div>
</template>
<script>
import Dropdown from "@/components/UI/Dropdown.vue";
import IconButton from "@/components/UI/IconButton.vue";
import Tooltip from "@/components/UI/Tooltip.vue";
import ModalDialog from "@/components/UI/ModalDialog.vue";
import {mapStores} from "pinia";
import {useValidityPeriodStore} from "@/store/validityPeriod.js";
import AutosuggestSearchbar from "@/components/UI/AutoSuggestSearchBar.vue";
import DataTable from "@/components/UI/DataTable.vue";
import TableView from "@/components/UI/TableView.vue";
import RadioOption from "@/components/UI/RadioOption.vue";
import {useMatrixRateStore} from "@/store/matrixRate.js";
import {useContainerRateStore} from "@/store/containerRate.js";
export default {
name: "Rates",
components: {RadioOption, TableView, DataTable, AutosuggestSearchbar, ModalDialog, Tooltip, IconButton, Dropdown},
computed: {
...mapStores(useValidityPeriodStore, useMatrixRateStore, useContainerRateStore),
loadingPeriods() {
// return this.propertiesStore.isLoading;
return false;
},
isValidPeriodActive() {
const state = this.validityPeriodStore.getPeriodState(this.selectedPeriod);
return state === "VALID" || state === "DRAFT";
},
disableDeleteButton() {
const state = this.validityPeriodStore.getPeriodState(this.selectedPeriod);
return state === "VALID" || state === "INVALID" || state === "DRAFT";
},
rateType: {
get() {
return this.rateTypeValue;
},
set(value) {
if (value === "matrix") {
this.selectedTypeColumns = this.matrixColumns;
} else {
this.selectedTypeColumns = this.containerColumns;
}
this.rateTypeValue = value;
this.$refs.tableViewRef.reload('', 1);
}
},
selectedPeriod: {
get() {
return this.validityPeriodStore.getSelectedPeriod
},
async set(value) {
this.validityPeriodStore.setSelectedPeriod(value);
// await this.propertiesStore.loadProperties(value);
// await this.countryStore.selectPeriod(value);
}
},
periods() {
const periods = [];
const vp = this.validityPeriodStore.getPeriods;
// const current = this.propertySetsStore.getCurrentPeriodId; //TODO
const current = 1;
if ((vp ?? null) === null) {
return null;
}
for (const p of vp) {
const value = (p.state === "DRAFT") ? "DRAFT" : (p.state === "VALID") ? "CURRENT" : `${this.buildDate(p.start_date)} - ${this.buildDate(p.end_date)} ${p.state === "INVALID" ? "(INVALID)" : ""}`;
const period = {id: p.id, value: value};
if (p.state !== "VALID" || p.id === current)
periods.push(period);
}
return periods;
}
},
data() {
return {
pageSize: 10,
pagination: { page: 1, pageCount: 10, totalCount: 1 },
rateTypeValue: "container",
selectedTypeColumns: [],
matrixColumns: [
{key: 'source.iso_code', label: 'From Country (ISO code)'},
{key: 'destination.iso_code', label: 'To Country (ISO code)'},
{key: 'rate', align: 'right', label: 'Rate [EUR/km]'},
],
containerColumns: [
{
key: 'type', label: 'Type', align: 'center', iconResolver: (rawValue, item) => {
if (rawValue === "SEA") {
return "PhBoat";
} else if (rawValue === "RAIL") {
return "PhTrain"
} else if (rawValue === "ROAD" || rawValue === "POST_RUN") {
return "PhTruck"
}
}
},
{key: 'source.name', align: 'left', label: 'From node'},
{key: 'destination.name', align: 'left', label: 'To node'},
{key: 'lead_time', align: 'right', label: 'Lead time [days]'},
{key: 'rates.TEU', align: 'right',label: '20 ft. GP rate [EUR]'},
{key: 'rates.FEU', align: 'right',label: '40 ft. GP rate [EUR]'},
{key: 'rates.HC', align: 'right', label: '40 ft. HC rate [EUR]'},
],
modalDialogDeleteState: false,
selectedPeriodId: null
}
},
async created() {
this.selectedTypeColumns = this.containerColumns;
await this.validityPeriodStore.loadPeriods();
await this.matrixRateStore.setQuery();
await this.containerRateStore.setQuery();
},
methods: {
async fetch(query) {
query.periodId = this.selectedPeriod;
if (this.rateType === "container") {
await this.containerRateStore.setQuery(query);
this.pagination = this.containerRateStore.getPagination;
return this.containerRateStore.getRates;
} else if (this.rateType === "matrix") {
await this.matrixRateStore.setQuery(query);
this.pagination = this.matrixRateStore.getPagination;
return this.matrixRateStore.getRates;
}
return [];
},
deletePeriod() {
if (!this.disableDeleteButton) {
this.modalDialogDeleteState = true;
}
},
deleteModalClick() {
}
}
}
</script>
<style scoped>
.table-view {
margin-top: 1.6rem;
}
.container-rate-container {
display: flex;
flex-direction: column;
}
.container-rate-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
gap: 3.2rem;
}
.container-rate-search-container {
display: flex;
justify-content: stretch;
align-items: center;
flex: 1;
margin-bottom: 1rem;
gap: 1.6rem;
font-size: 1.4rem;
}
.container-rate-search-searchbar {
flex: 1;
}
.period-select-container {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 1rem;
gap: 1.6rem;
font-size: 1.4rem;
}
.period-select-caption {
font-weight: 500;
}
</style>

View file

@ -85,7 +85,7 @@ export default {
},
methods: {
async fetchDestinations(query) {
await this.nodeStore.setQuery({searchTerm: query, nodeType: "DESTINATION", includeUserNode: false});
await this.nodeStore.setSearch({searchTerm: query, nodeType: "DESTINATION", includeUserNode: false});
return this.nodeStore.nodes;
},
resolveFlag(node) {

View file

@ -80,7 +80,7 @@ export default {
},
async fetchSupplier(query) {
console.log("Fetching supplier for query: " + query);
await this.nodeStore.setQuery({searchTerm: query, nodeType: 'SOURCE', includeUserNode: true});
await this.nodeStore.setSearch({searchTerm: query, nodeType: 'SOURCE', includeUserNode: true});
return this.nodeStore.nodes;
},
resolveFlag(node) {

View file

@ -13,6 +13,7 @@ import {
PhX,
PhTrain,
PhTruckTrailer,
PhTruck,
PhBoat,
PhPencilSimple,
PhLock,
@ -48,6 +49,7 @@ app.component('PhWarning', PhWarning);
app.component('PhLock', PhLock);
app.component('PhLockOpen', PhLockOpen);
app.component('PhTruckTrailer', PhTruckTrailer);
app.component('PhTruck', PhTruck);
app.component('PhBoat', PhBoat);
app.component('PhTrain', PhTrain);
app.component('PhPencilSimple', PhPencilSimple);

View file

@ -23,6 +23,9 @@ import Box from "@/components/UI/Box.vue";
import CountryProperties from "@/components/layout/config/CountryProperties.vue";
import StagedChanges from "@/components/layout/config/StagedChanges.vue";
import BulkOperations from "@/components/layout/config/BulkOperations.vue";
import Rates from "@/components/layout/config/Rates.vue";
import Nodes from "@/components/layout/config/Nodes.vue";
import Materials from "@/components/layout/config/Materials.vue";
export default {
name: "Config",
@ -39,6 +42,18 @@ export default {
title: 'Countries',
component: markRaw(CountryProperties),
},
{
title: 'Materials',
component: markRaw(Materials),
},
{
title: 'Nodes',
component: markRaw(Nodes),
},
{
title: 'Rates',
component: markRaw(Rates),
},
{
title: 'Bulk operations',
component: markRaw(BulkOperations),

View file

@ -91,15 +91,18 @@ export default {
}
},
methods: {
downloadReport() {
this.reportsStore.downloadReport();
},
createReport() {
this.showModal = true;
},
buildDate(date) {
return `${date[0]}-${date[1].toString().padStart(2, '0')}-${date[2].toString().padStart(2, '0')}`
},
closeModal(data) {
async closeModal(data) {
if (data.action === 'accept') {
this.reportsStore.fetchReports(data.materialId, data.supplierIds);
await this.reportsStore.fetchReports(data.materialId, data.supplierIds);
}
this.showModal = false;
}

View file

View file

@ -0,0 +1,63 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const useContainerRateStore = defineStore('containerRate', {
state() {
return {
rates: [],
loading: false,
query: {},
pagination: {}
}
},
getters: {
isLoading(state) {
return state.loading;
},
getRates(state) {
return state.rates;
},
getPagination(state) {
return state.pagination;
}
},
actions: {
async setQuery(query = {}) {
this.query = query;
await this.updateMatrixRates();
},
async updateMatrixRates() {
this.loading = true;
this.rates = [];
const params = new URLSearchParams();
if (this.query?.searchTerm && this.query.searchTerm !== '')
params.append('filter', this.query.searchTerm);
if(this.query?.periodId)
params.append('valid', this.query.periodId);
if(this.query?.page)
params.append('page', this.query.page);
if(this.query?.pageSize)
params.append('limit', this.query.pageSize);
const url = `${config.backendUrl}/rates/container/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, "GET", url, null);
this.rates = data;
this.pagination = { page: parseInt(headers.get('X-Current-Page')), pageCount: parseInt(headers.get('X-Page-Count')), totalCount: parseInt(headers.get('X-Total-Count'))};
console.log(this.pagination)
this.loading = false;
}
}
});

View file

@ -1,6 +1,7 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const useMaterialStore = defineStore('material', {
state() {
@ -15,6 +16,11 @@ export const useMaterialStore = defineStore('material', {
},
getters: {},
actions: {
async setQueryForList(query) {
this.query = query;
this.query.excludeDeprecated = false;
await this.updateMaterials();
},
async setQuery(query) {
this.query = query;
await this.updateMaterials();
@ -30,48 +36,28 @@ export const useMaterialStore = defineStore('material', {
if (this.query.searchTerm)
params.append('filter', this.query.searchTerm);
if(this.query?.page)
params.append('page', this.query.page);
if(this.query?.pageSize)
params.append('limit', this.query.pageSize);
if(this.query?.excludeDeprecated !== null)
params.append('excludeDeprecated', this.query.excludeDeprecated);
const url = `${config.backendUrl}/materials/${params.size === 0 ? '' : '?'}${params.toString()}`;
const request = { url: url, params: {method: 'GET'}};
const response = await fetch(url).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
const {data: data, headers: headers} = await performRequest(this, "GET", url, null, true);
throw e;
});
const data = await response.json().catch(e => {
this.error = {
code: 'Malformed response',
message: "Malformed server response. Please contact support.",
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.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(this.error, { store: this, request: request});
return;
}
this.pagination = {
page: parseInt(headers.get('X-Current-Page')),
pageCount: parseInt(headers.get('X-Page-Count')),
totalCount: parseInt(headers.get('X-Total-Count'))
};
this.loading = false;
this.empty = data.length === 0;

View file

@ -0,0 +1,60 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const useMatrixRateStore = defineStore('matrixRate', {
state() {
return {
rates: [],
loading: false,
empty: true,
error: null,
query: {},
pagination: {}
}
},
getters: {
isLoading(state) {
return state.loading;
},
getRates(state) {
return state.rates;
},
getPagination(state) {
return state.pagination;
}
},
actions: {
async setQuery(query = {}) {
this.query = query;
await this.updateMatrixRates();
},
async updateMatrixRates() {
this.loading = true;
const params = new URLSearchParams();
if (this.query?.searchTerm && this.query.searchTerm !== '')
params.append('filter', this.query.searchTerm);
if(this.query?.periodId)
params.append('valid', this.query.periodId);
if(this.query?.page)
params.append('page', this.query.page);
if(this.query?.pageSize)
params.append('limit', this.query.pageSize);
const url = `${config.backendUrl}/rates/matrix/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, "GET", url, null);
this.rates = data;
this.pagination = { page: parseInt(headers.get('X-Current-Page')), pageCount: parseInt(headers.get('X-Page-Count')), totalCount: parseInt(headers.get('X-Total-Count'))};
this.loading = false;
}
}
});

View file

@ -1,6 +1,7 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
export const useNodeStore = defineStore('node', {
@ -18,8 +19,14 @@ export const useNodeStore = defineStore('node', {
}
},
actions: {
async setSearch(query) {
this.query = query;
this.query.type = 'search';
await this.loadNodes();
},
async setQuery(query) {
this.query = query;
this.query.type = 'list';
await this.loadNodes();
},
async loadNodes() {
@ -35,47 +42,27 @@ export const useNodeStore = defineStore('node', {
if (this.query.includeUserNode)
params.append('include_user_node', this.query.includeUserNode);
const url = `${config.backendUrl}/nodes/search/${params.size === 0 ? '' : '?'}${params.toString()}`;
if(this.query?.page)
params.append('page', this.query.page);
const request = { url: url, params: {method: 'GET'}};
if(this.query?.pageSize)
params.append('limit', this.query.pageSize);
console.log(url)
const response = await fetch(url).catch(e => {
this.error = {code: 'Network error.', message: "Please check your internet connection.", trace: null}
this.loading = false;
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
const endpoint = this.query.type === 'list' ? '/nodes' : '/nodes/search'
const url = `${config.backendUrl}${endpoint}/${params.size === 0 ? '' : '?'}${params.toString()}`;
throw e;
});
const data = await response.json().catch(e => {
this.error = {
code: 'Malformed response',
message: "Malformed server response. Please contact support.",
trace: null
}
this.loading = false;
const {data: data, headers: headers} = await performRequest(this, "GET", url, null, true);
console.error(this.error);
const errorStore = useErrorStore();
void errorStore.addError(this.error, { store: this, request: request});
throw e;
});
if (this.query.type === 'list')
this.pagination = {
page: parseInt(headers.get('X-Current-Page')),
pageCount: parseInt(headers.get('X-Page-Count')),
totalCount: parseInt(headers.get('X-Total-Count'))
};
if (!response.ok) {
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(this.error, { store: this, request: request});
return;
}
this.loading = false;
this.empty = data.length === 0;

View file

@ -396,10 +396,10 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const body = {destinations: destinations, premise_id: this.destinations.premise_ids};
const url = `${config.backendUrl}/calculation/destination/`;
const data = await performRequest(this,'PUT', url, body).catch(e => {
const { data: data, headers: headers } = await performRequest(this,'PUT', url, body).catch(e => {
this.destinations = null;
this.processDestinationMassEdit = false;
})
});
if (data) {
for (const id of Object.keys(data)) {
@ -605,7 +605,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
const url = `${config.backendUrl}/calculation/destination/`;
const destinations = await performRequest(this,'POST', url, body).catch(e => {
const {data: destinations } = await performRequest(this,'POST', url, body).catch(e => {
this.loading = false;
this.selectedLoading = false;
throw e;
@ -666,7 +666,7 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
body.premise_id = toBeUpdated;
logger.info(url, body)
const data = await performRequest(this,'PUT', url, body).catch(e => {
const {data: data} = await performRequest(this,'PUT', url, body).catch(e => {
this.loading = false;
});
@ -835,10 +835,11 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
params.append('premissIds', `${[id]}`);
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.premisses = await performRequest(this,'GET', url, null).catch(e => {
const { data: data, headers: headers } = await performRequest(this,'GET', url, null).catch(e => {
this.selectedLoading = false;
this.loading = false;
});
this.premisses = data;
this.premisses.forEach(p => p.selected = true);
this.prepareDestinations(id, [id]);
@ -861,9 +862,10 @@ export const usePremiseEditStore = defineStore('premiseEdit', {
params.append('premissIds', ids.join(', '));
const url = `${config.backendUrl}/calculation/edit/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.premisses = await performRequest(this,'GET', url, null).catch(e => {
const { data: data, headers: headers } = await performRequest(this,'GET', url, null).catch(e => {
this.loading = false;
});
this.premisses = data;
this.premisses.forEach(p => p.selected = false);
this.loading = false;

View file

@ -1,6 +1,6 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import performRequest from '@/backend.js'
import performRequest, {performDownload} from '@/backend.js'
export const useReportsStore = defineStore('reports', {
@ -9,6 +9,8 @@ export const useReportsStore = defineStore('reports', {
reports: [],
showComparableWarning: false,
loading: false,
materialId: null,
supplierIds: [],
}
},
getters: {
@ -28,6 +30,19 @@ export const useReportsStore = defineStore('reports', {
}
},
actions: {
async downloadReport() {
if(this.materialId === null && this.supplierIds?.length === 0)
return;
const params = new URLSearchParams();
params.append('material', this.materialId);
params.append('sources', this.supplierIds);
const url = `${config.backendUrl}/reports/download/${params.size === 0 ? '' : '?'}${params.toString()}`;
const fileName = `report_${this.materialId}_${this.supplierIds.join('_')}.xlsx`;
await performDownload(url,fileName);
},
reset() {
this.reports = [];
},
@ -37,15 +52,19 @@ export const useReportsStore = defineStore('reports', {
this.loading = true;
this.reports = [];
this.materialId = materialId;
this.supplierIds = supplierIds;
const params = new URLSearchParams();
params.append('material', materialId);
params.append('sources', supplierIds);
const url = `${config.backendUrl}/reports/view/${params.size === 0 ? '' : '?'}${params.toString()}`;
this.reports = await performRequest(this, 'GET', url, null).catch(e => {
const {data: data} = await performRequest(this, 'GET', url, null).catch(e => {
this.loading = false;
});
this.reports = data;
this.showComparableWarning = false;

View file

@ -0,0 +1,157 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import { useStageStore } from './stage.js'
export const useValidityPeriodStore = defineStore('validityPeriod', {
state() {
return {
periods: null,
selectedPeriod: null,
}
},
getters: {
getPeriods(state) {
return state.periods;
},
getCurrentPeriodId(state) {
if (state.periods === null)
return null;
for (const period of state.periods) {
if (period.state === "DRAFT")
return period.id;
}
for (const period of state.periods) {
if (period.state === "VALID")
return period.id;
}
},
getSelectedPeriod(state) {
return state.selectedPeriod;
},
getPeriodState(state) {
return function(periodId) {
if (state.periods === null)
return null;
return state.periods.find(p => p.id === periodId)?.state;
}
}
},
actions: {
setSelectedPeriod(periodId) {
this.selectedPeriod = periodId;
},
async invalidate() {
const url = `${config.backendUrl}/rates/periods/${this.getSelectedPeriod}/`;
this.periods = await this.performRequest('DELETE', url, null, false);
await this.reload();
},
async loadPeriods() {
this.loading = true;
const url = `${config.backendUrl}/rates/periods`;
this.periods = await this.performRequest('GET', url, null, );
this.selectedPeriod = this.getCurrentPeriodId;
this.loading = false;
},
async performRequest(method, url, body, expectResponse = true) {
const params = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if ((body ?? null) !== null) {
params.body = JSON.stringify(body);
}
const request = {url: url, params: params};
console.log("Request:", request);
const response = await fetch(url, params
).catch(e => {
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 => {
const error = {
code: 'Malformed response',
message: "Malformed server response. Please contact support.",
trace: null
}
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw e;
});
if (!response.ok) {
const error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
} else {
if (!response.ok) {
const data = await response.json().catch(e => {
const error = {
code: "Return code error " + response.status,
message: "Server returned wrong response code",
trace: null
}
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
});
const error = {
code: data.error.code,
title: data.error.title,
message: data.error.message,
trace: data.error.trace
}
console.error(error);
const errorStore = useErrorStore();
void errorStore.addError(error, {store: this, request: request});
throw new Error('Internal backend error');
}
}
console.log("Response:", data);
return data;
}
}
});

View file

@ -4,6 +4,8 @@ import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@ -13,6 +15,7 @@ import java.util.Arrays;
@Configuration
@EnableWebMvc
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsConfig implements WebMvcConfigurer {
@Autowired
@ -25,19 +28,27 @@ public class CorsConfig implements WebMvcConfigurer {
public void addCorsMappings(@NotNull CorsRegistry registry) {
String[] activeProfiles = environment.getActiveProfiles();
System.out.println("Active profiles: " + Arrays.toString(activeProfiles));
System.out.println("Allowed CORS: " + allowedCors);
if (Arrays.asList(activeProfiles).contains("dev")) {
System.out.println("Applying DEV CORS configuration");
// Development CORS configuration
registry.addMapping("/api/**")
.allowedOriginPatterns("http://localhost:*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
.allowCredentials(false);
} else {
// Production CORS configuration
registry.addMapping("/api/**")
.allowedOrigins(allowedCors)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
.allowCredentials(true);
}
}

View file

@ -18,29 +18,35 @@ public class CorsFilter implements Filter {
private String allowedOrigins;
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String origin = request.getHeader("Origin");
// Check if it's a development environment and localhost
if (origin != null && origin.startsWith("http://localhost:")) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Max-Age", "3600");
}
// Handle preflight OPTIONS requests
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(req, res);
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
filterChain.doFilter(servletRequest, servletResponse);
}
// @Override
// public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
// throws IOException, ServletException {
//
// HttpServletRequest request = (HttpServletRequest) req;
// HttpServletResponse response = (HttpServletResponse) res;
//
// String origin = request.getHeader("Origin");
//
// // Check if it's a development environment and localhost
// if (origin != null && origin.startsWith("http://localhost:")) {
// response.setHeader("Access-Control-Allow-Origin", origin);
// response.setHeader("Access-Control-Allow-Credentials", "true");
// response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
// response.setHeader("Access-Control-Allow-Headers", "*");
// response.setHeader("Access-Control-Expose-Headers", "X-Total-Count, X-Page-Count, X-Current-Page");
// response.setHeader("Access-Control-Max-Age", "3600");
// }
//
// // Handle preflight OPTIONS requests
// if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
// response.setStatus(HttpServletResponse.SC_OK);
// return;
// }
//
// chain.doFilter(req, res);
// }
}

View file

@ -6,10 +6,17 @@ import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
public class SecurityConfig {
@Bean
@Profile("!dev & !test") // Only active when NOT in dev profile
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

View file

@ -37,11 +37,12 @@ public class MaterialController {
*/
@GetMapping("/")
public ResponseEntity<List<MaterialDTO>> listMaterials(
@RequestParam(defaultValue = "true") String excludeDeprecated,
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(required = false) Optional<String> filter) {
SearchQueryResult<MaterialDTO> materials = materialService.listMaterial(filter, page, limit);
SearchQueryResult<MaterialDTO> materials = materialService.listMaterial(filter, page, limit,Boolean.parseBoolean(excludeDeprecated));
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(materials.getTotalElements()))

View file

@ -33,7 +33,7 @@ public class NodeController {
this.userNodeService = userNodeService;
}
@GetMapping("/")
@GetMapping({"","/"})
public ResponseEntity<List<NodeDTO>> listNodes(@RequestParam(required = false) String filter, @RequestParam(defaultValue = "1") @Min(1) Integer page, @RequestParam(defaultValue = "20") @Min(1) Integer limit) {
nodeService.listNodes(filter, page, limit);

View file

@ -51,8 +51,9 @@ public class RateController {
* @param validAt the specific date and time to filter container rates, optional
* @return a ResponseEntity containing the list of container rates and additional pagination headers
*/
@GetMapping("/container")
@GetMapping({"/container", "/container/" })
public ResponseEntity<List<ContainerRateDTO>> listContainerRates(
@RequestParam(defaultValue = "") String filter,
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(name= "valid", required = false) Integer validityPeriodId,
@ -61,13 +62,13 @@ public class RateController {
SearchQueryResult<ContainerRateDTO> containerRates = null;
if(validAt != null) {
containerRates = containerRateService.listRates(limit, page, validAt);
containerRates = containerRateService.listRates(filter, limit, page, validAt);
}
else if(validityPeriodId != null) {
containerRates = containerRateService.listRates(limit, page, validityPeriodId);
containerRates = containerRateService.listRates(filter, limit, page, validityPeriodId);
}
else {
containerRates = containerRateService.listRates(limit, page);
containerRates = containerRateService.listRates(filter, limit, page);
}
return ResponseEntity.ok()
@ -83,7 +84,7 @@ public class RateController {
* @param id the unique identifier of the container whose rate information is to be retrieved
* @return a ResponseEntity containing the ContainerRateDTO with the rate information of the specified container
*/
@GetMapping("/container/{id}")
@GetMapping({"/container/{id}", "/container/{id}/"})
public ResponseEntity<ContainerRateDTO> getContainerRate(@PathVariable Integer id) {
return ResponseEntity.ok(containerRateService.getContainerRate(id));
}
@ -98,8 +99,9 @@ public class RateController {
* @return a {@link ResponseEntity} containing a list of {@link MatrixRateDTO} wrapped in the response body,
* including pagination headers.
*/
@GetMapping("/matrix")
@GetMapping({"/matrix","/matrix/"})
public ResponseEntity<List<MatrixRateDTO>> listMatrixRates(
@RequestParam(defaultValue = "") String filter,
@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(required = false) Integer valid,
@ -108,13 +110,13 @@ public class RateController {
SearchQueryResult<MatrixRateDTO> rates = null;
if(validAt != null) {
rates = matrixRateService.listRates(limit, page, validAt);
rates = matrixRateService.listRates(filter, limit, page, validAt);
}
else if(valid != null) {
rates = matrixRateService.listRates(limit, page, valid);
rates = matrixRateService.listRates(filter, limit, page, valid);
}
else {
rates = matrixRateService.listRates(limit, page);
rates = matrixRateService.listRates(filter, limit, page);
}
return ResponseEntity.ok()
@ -130,7 +132,7 @@ public class RateController {
* @param id the unique identifier of the matrix rate to retrieve
* @return a ResponseEntity containing the MatrixRateDTO for the specified ID
*/
@GetMapping("/matrix/{id}")
@GetMapping({"/matrix/{id}", "/matrix/{id}/"})
public ResponseEntity<MatrixRateDTO> getMatrixRate(@PathVariable Integer id) {
return ResponseEntity.ok(matrixRateService.getRate(id));
}
@ -140,7 +142,7 @@ public class RateController {
*
* @return ResponseEntity containing the list of ValidityPeriodDTO objects.
*/
@GetMapping("/periods")
@GetMapping({"/periods", "/periods/"})
public ResponseEntity<List<ValidityPeriodDTO>> listPeriods() {
return ResponseEntity.ok(validityPeriodService.listPeriods());
}
@ -151,7 +153,7 @@ public class RateController {
* @param id The ID of the validity period to invalidate.
* @return ResponseEntity indicating the operation status.
*/
@DeleteMapping("/periods/{id}")
@DeleteMapping({"/periods/{id}", "/periods/{id}/"})
public ResponseEntity<Void> invalidatePeriod(@PathVariable Integer id) {
validityPeriodService.invalidate(id);
return ResponseEntity.ok().build();
@ -163,7 +165,7 @@ public class RateController {
* @return ResponseEntity containing a Boolean value indicating
* whether rate drafts exist (true) or not (false).
*/
@GetMapping("/staged_changes")
@GetMapping( {"/staged_changes", "/staged_changes/"})
public ResponseEntity<Boolean> checkRateDrafts() {
return ResponseEntity.ok(rateApprovalService.hasRateDrafts());
}
@ -173,7 +175,7 @@ public class RateController {
*
* @return ResponseEntity with HTTP 200 status if the operation is successful.
*/
@PutMapping("/staged_changes")
@PutMapping({"/staged_changes", "/staged_changes/"})
public ResponseEntity<Void> approveRateDrafts() {
rateApprovalService.approveRateDrafts();
return ResponseEntity.ok().build();

View file

@ -67,7 +67,7 @@ public class ReportingController {
* @param nodeIds A list of node IDs (sources) to include in the downloaded report.
* @return The Excel file as an attachment in the response.
*/
@GetMapping("/download")
@GetMapping({"/download","/download/"})
public ResponseEntity<InputStreamResource> downloadReport(@RequestParam(value = "material") Integer materialId, @RequestParam(value = "sources") List<Integer> nodeIds) {
HttpHeaders headers = new HttpHeaders();

View file

@ -1,11 +1,11 @@
package de.avatic.lcc.dto.configuration.matrixrates;
import de.avatic.lcc.dto.generic.NodeDTO;
import de.avatic.lcc.dto.generic.CountryDTO;
public class MatrixRateDTO {
private Integer id;
private NodeDTO origin;
private NodeDTO destination;
private CountryDTO source;
private CountryDTO destination;
private Number rate;
public Integer getId() {
@ -16,22 +16,6 @@ public class MatrixRateDTO {
this.id = id;
}
public NodeDTO getOrigin() {
return origin;
}
public void setOrigin(NodeDTO origin) {
this.origin = origin;
}
public NodeDTO getDestination() {
return destination;
}
public void setDestination(NodeDTO destination) {
this.destination = destination;
}
public Number getRate() {
return rate;
}
@ -39,4 +23,20 @@ public class MatrixRateDTO {
public void setRate(Number rate) {
this.rate = rate;
}
public CountryDTO getSource() {
return source;
}
public void setSource(CountryDTO source) {
this.source = source;
}
public CountryDTO getDestination() {
return destination;
}
public void setDestination(CountryDTO destination) {
this.destination = destination;
}
}

View file

@ -11,7 +11,7 @@ public class ContainerRateDTO {
private Integer id;
private NodeDTO origin;
private NodeDTO source;
private NodeDTO destination;
@ -33,12 +33,12 @@ public class ContainerRateDTO {
this.id = id;
}
public NodeDTO getOrigin() {
return origin;
public NodeDTO getSource() {
return source;
}
public void setOrigin(NodeDTO origin) {
this.origin = origin;
public void setSource(NodeDTO source) {
this.source = source;
}
public NodeDTO getDestination() {

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.dto.generic;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
@ -15,6 +16,9 @@ public class MaterialDTO {
@JsonProperty("hs_code")
private String hsCode;
@JsonProperty("is_deprecated")
private Boolean isDeprecated;
public MaterialDTO() {
}
@ -57,6 +61,15 @@ public class MaterialDTO {
this.name = name;
}
@JsonIgnore
public Boolean getDeprecated() {
return isDeprecated;
}
public void setDeprecated(Boolean deprecated) {
isDeprecated = deprecated;
}
@Override
public String toString() {
return "MaterialSummaryDTO{" +

View file

@ -3,7 +3,7 @@ package de.avatic.lcc.model.bulk;
public enum NodeHeader implements HeaderProvider {
MAPPING_ID("Mapping ID"), NAME("Name"), ADDRESS("Address"),
COUNTRY("Country (ISO 3166-1)"), GEO_LATITUDE("Latitude"), GEO_LONGITUDE("Longitude"),
IS_ORIGIN("Origin"), IS_INTERMEDIATE("Intermediate"), IS_DESTINATION("Destination"),
IS_SOURCE("Source"), IS_INTERMEDIATE("Intermediate"), IS_DESTINATION("Destination"),
OUTBOUND_COUNTRIES("Outbound countries (ISO 3166-1)"), PREDECESSOR_NODES("Predecessor Nodes (Mapping ID)"),
IS_PREDECESSOR_MANDATORY("Predecessors mandatory");

View file

@ -45,7 +45,7 @@ public enum DimensionUnit {
}
throw new IllegalArgumentException("Unknown DimensionUnit: " + value +
". Valid values are: t, kg, g (case insensitive)");
". Valid values are: mm, cm, m (case insensitive)");
}

View file

@ -106,6 +106,18 @@ public class MaterialRepository {
return new SearchQueryResult<>(materials, pagination.getPage(), totalCount, pagination.getLimit());
}
@Transactional
public Optional<Material> getByIdIncludeDeprecated(Integer id) {
String query = "SELECT * FROM material WHERE id = ?";
var material = jdbcTemplate.query(query, new MaterialMapper(), id);
if(material.isEmpty())
return Optional.empty();
return Optional.ofNullable(material.getFirst());
}
@Transactional
public Optional<Material> getById(Integer id) {
String query = "SELECT * FROM material WHERE id = ? AND is_deprecated = FALSE";

View file

@ -26,12 +26,62 @@ public class ContainerRateRepository {
this.jdbcTemplate = jdbcTemplate;
}
public SearchQueryResult<ContainerRate> listRatesByPeriodId(SearchQueryPagination pagination, Integer periodId) {
String query = "SELECT * FROM container_rate WHERE validity_period_id = ? ORDER BY id LIMIT ? OFFSET ?";
String countQuery = "SELECT COUNT(*) FROM container_rate WHERE validity_period_id = ?";
Integer totalCount = jdbcTemplate.queryForObject(countQuery, Integer.class, periodId);
/**
* Retrieves a paginated list of {@link ContainerRate} entries filtered by a specific validity period ID.
* Optionally filters results by searching node names and external mapping IDs for both source and destination nodes.
*
* @param filter optional search term to filter by node names or external mapping IDs (case-insensitive partial match).
* Searches across from_node and to_node names and external_mapping_ids. Can be null or empty.
* @param pagination the {@link SearchQueryPagination} object containing limit, offset, and page details
* @param periodId the ID of the validity period to filter the rates by
* @return a {@link SearchQueryResult} containing a list of filtered {@link ContainerRate} entities,
* total count, and pagination details
*/
public SearchQueryResult<ContainerRate> listRatesByPeriodId(String filter, SearchQueryPagination pagination, Integer periodId) {
StringBuilder queryBuilder = new StringBuilder();
StringBuilder countQueryBuilder = new StringBuilder();
List<Object> params = new ArrayList<>();
List<Object> countParams = new ArrayList<>();
return new SearchQueryResult<>(jdbcTemplate.query(query, new ContainerRateMapper(), periodId, pagination.getLimit(), pagination.getOffset()), pagination.getPage(), totalCount, pagination.getLimit());
// Base query with node joins
String baseQuery = """
FROM container_rate cr
JOIN node fn ON cr.from_node_id = fn.id
JOIN node tn ON cr.to_node_id = tn.id
WHERE cr.validity_period_id = ?
""";
queryBuilder.append("SELECT cr.* ").append(baseQuery);
countQueryBuilder.append("SELECT COUNT(*) ").append(baseQuery);
params.add(periodId);
countParams.add(periodId);
// Add filter conditions if filter is provided
if (filter != null && !filter.trim().isEmpty()) {
String filterCondition = """
AND (fn.name LIKE ? OR fn.external_mapping_id LIKE ?
OR tn.name LIKE ? OR tn.external_mapping_id LIKE ?)
""";
queryBuilder.append(filterCondition);
countQueryBuilder.append(filterCondition);
String filterParam = "%" + filter.trim() + "%";
for (int i = 0; i < 4; i++) {
params.add(filterParam);
countParams.add(filterParam);
}
}
queryBuilder.append(" ORDER BY cr.id LIMIT ? OFFSET ?");
params.add(pagination.getLimit());
params.add(pagination.getOffset());
Integer totalCount = jdbcTemplate.queryForObject(countQueryBuilder.toString(), Integer.class, countParams.toArray());
var results = jdbcTemplate.query(queryBuilder.toString(), new ContainerRateMapper(), params.toArray());
return new SearchQueryResult<>(results, pagination.getPage(), totalCount, pagination.getLimit());
}
public Optional<ContainerRate> getById(Integer id) {
@ -48,7 +98,7 @@ public class ContainerRateRepository {
public List<ContainerRate> listAllRatesByPeriodId(Integer periodId) {
String query = "SELECT * FROM container_rate WHERE validity_period_id = ?";
return jdbcTemplate.query(query, new ContainerRateMapper());
return jdbcTemplate.query(query, new ContainerRateMapper(), periodId);
}
@Transactional

View file

@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ -47,17 +48,61 @@ public class MatrixRateRepository {
/**
* Retrieves a paginated list of {@link MatrixRate} entries filtered by a specific validity period ID.
* Optionally filters results by searching country names and ISO codes for both source and destination countries.
*
* @param filter optional search term to filter by country names or ISO codes (case-insensitive partial match).
* Searches across from_country and to_country names and iso_codes. Can be null or empty.
* @param pagination the {@link SearchQueryPagination} object containing limit, offset, and page details
* @param periodId the ID of the validity period to filter the rates by
* @return a {@link SearchQueryResult} containing a list of filtered {@link MatrixRate} entities,
* total count, and pagination details
* total count, and pagination details
*/
@Transactional
public SearchQueryResult<MatrixRate> listRatesByPeriodId(SearchQueryPagination pagination, Integer periodId) {
String query = "SELECT * FROM country_matrix_rate WHERE validity_period_id = ? ORDER BY id LIMIT ? OFFSET ?";
var totalCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM country_matrix_rate WHERE validity_period_id = ?", Integer.class, periodId);
return new SearchQueryResult<>(jdbcTemplate.query(query, new MatrixRateMapper(), periodId, pagination.getLimit(), pagination.getOffset()), pagination.getPage(), totalCount, pagination.getLimit());
public SearchQueryResult<MatrixRate> listRatesByPeriodId(String filter, SearchQueryPagination pagination, Integer periodId) {
StringBuilder queryBuilder = new StringBuilder();
StringBuilder countQueryBuilder = new StringBuilder();
List<Object> params = new ArrayList<>();
List<Object> countParams = new ArrayList<>();
// Base query with country joins
String baseQuery = """
FROM country_matrix_rate cmr
JOIN country fc ON cmr.from_country_id = fc.id
JOIN country tc ON cmr.to_country_id = tc.id
WHERE cmr.validity_period_id = ?
""";
queryBuilder.append("SELECT cmr.* ").append(baseQuery);
countQueryBuilder.append("SELECT COUNT(*) ").append(baseQuery);
params.add(periodId);
countParams.add(periodId);
// Add filter conditions if filter is provided
if (filter != null && !filter.trim().isEmpty()) {
String filterCondition = """
AND (fc.name LIKE ? OR fc.iso_code LIKE ?
OR tc.name LIKE ? OR tc.iso_code LIKE ?)
""";
queryBuilder.append(filterCondition);
countQueryBuilder.append(filterCondition);
String filterParam = "%" + filter.trim() + "%";
for (int i = 0; i < 4; i++) {
params.add(filterParam);
countParams.add(filterParam);
}
}
queryBuilder.append(" ORDER BY cmr.id LIMIT ? OFFSET ?");
params.add(pagination.getLimit());
params.add(pagination.getOffset());
var totalCount = jdbcTemplate.queryForObject(countQueryBuilder.toString(), Integer.class, countParams.toArray());
var results = jdbcTemplate.query(queryBuilder.toString(), new MatrixRateMapper(), params.toArray());
return new SearchQueryResult<>(results, pagination.getPage(), totalCount, pagination.getLimit());
}
/**
@ -75,7 +120,7 @@ public class MatrixRateRepository {
@Transactional
public List<MatrixRate> listAllRatesByPeriodId(Integer periodId) {
String query = "SELECT * FROM country_matrix_rate WHERE validity_period_id = ?";
return jdbcTemplate.query(query, new MatrixRateMapper());
return jdbcTemplate.query(query, new MatrixRateMapper(), periodId);
}
@Transactional

View file

@ -51,9 +51,9 @@ public class ContainerRateService {
* @return a {@link SearchQueryResult} containing container rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<ContainerRateDTO> listRates(int limit, int page, LocalDateTime validAt) {
public SearchQueryResult<ContainerRateDTO> listRates(String filter, int limit, int page, LocalDateTime validAt) {
Optional<Integer> periodId = validityPeriodRepository.getPeriodId(validAt);
return listRates(limit, page, periodId.orElseThrow());
return listRates(filter, limit, page, periodId.orElseThrow());
}
/**
@ -65,11 +65,11 @@ public class ContainerRateService {
* @return a {@link SearchQueryResult} containing container rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<ContainerRateDTO> listRates(int limit, int page, Integer periodId) {
public SearchQueryResult<ContainerRateDTO> listRates(String filter, int limit, int page, Integer periodId) {
if(null == periodId)
return listRates(limit, page);
return listRates(filter,limit, page);
return SearchQueryResult.map(containerRateRepository.listRatesByPeriodId(new SearchQueryPagination(limit, page), periodId), this::toContainerRateDTO);
return SearchQueryResult.map(containerRateRepository.listRatesByPeriodId(filter, new SearchQueryPagination(page,limit), periodId), this::toContainerRateDTO);
}
/**
@ -81,8 +81,8 @@ public class ContainerRateService {
* @return a {@link SearchQueryResult} containing container rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<ContainerRateDTO> listRates(int limit, int page) {
var data = validityPeriodRepository.getValidPeriodId().map(id -> containerRateRepository.listRatesByPeriodId(new SearchQueryPagination(page, limit), id)).orElseThrow(() -> new InternalErrorException("No validity period that is VALID"));
public SearchQueryResult<ContainerRateDTO> listRates(String filter, int limit, int page) {
var data = validityPeriodRepository.getValidPeriodId().map(id -> containerRateRepository.listRatesByPeriodId(filter, new SearchQueryPagination(page, limit), id)).orElseThrow(() -> new InternalErrorException("No validity period that is VALID"));
return SearchQueryResult.map(data, this::toContainerRateDTO);
}
@ -111,7 +111,7 @@ public class ContainerRateService {
dto.setType(TransportType.valueOf(entity.getType().name()));
dto.setValidityPeriod(validityPeriodTransformer.toValidityPeriodDTO(validityPeriodRepository.getById(entity.getValidityPeriodId())));
dto.setLeadTime(entity.getLeadTime());
dto.setOrigin(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getFromNodeId()).orElseThrow()));
dto.setSource(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getFromNodeId()).orElseThrow()));
dto.setDestination(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getToNodeId()).orElseThrow()));
var rates = new HashMap<String, Number>();

View file

@ -40,15 +40,17 @@ public class MaterialService {
}
/**
* Lists materials based on a filter and pagination parameters.
* Lists materials based on the provided criteria, including optional filtering,
* pagination, and exclusion of deprecated entries.
*
* @param filter the search filter to apply
* @param page the page number for pagination (zero-based)
* @param limit the maximum number of items per page
* @return a {@link SearchQueryResult} containing a list of material DTOs and pagination details
* @param filter an optional filter to apply to the material list, such as a search term
* @param page the page number for pagination
* @param limit the maximum number of materials to include in the result
* @param excludeDeprecated a flag indicating whether to exclude deprecated materials
* @return a {@code SearchQueryResult<MaterialDTO>} containing the paginated list of materials
*/
public SearchQueryResult<MaterialDTO> listMaterial(Optional<String> filter, int page, int limit) {
SearchQueryResult<Material> queryResult = materialRepository.listMaterials(filter, true, new SearchQueryPagination(page, limit));
public SearchQueryResult<MaterialDTO> listMaterial(Optional<String> filter, int page, int limit, boolean excludeDeprecated) {
SearchQueryResult<Material> queryResult = materialRepository.listMaterials(filter, excludeDeprecated, new SearchQueryPagination(page, limit));
return SearchQueryResult.map(queryResult, materialTransformer::toMaterialDTO);
}
@ -60,7 +62,7 @@ public class MaterialService {
* @throws MaterialNotFoundException if no material with the given ID is found
*/
public MaterialDetailDTO getMaterial(Integer id) {
return materialDetailTransformer.toMaterialDetailDTO(materialRepository.getById(id).orElseThrow(() -> new NotFoundException(NotFoundException.NotFoundType.MATERIAL,id.toString())));
return materialDetailTransformer.toMaterialDetailDTO(materialRepository.getByIdIncludeDeprecated(id).orElseThrow(() -> new NotFoundException(NotFoundException.NotFoundType.MATERIAL,id.toString())));
}
/**

View file

@ -2,12 +2,13 @@ package de.avatic.lcc.service.access;
import de.avatic.lcc.dto.configuration.matrixrates.MatrixRateDTO;
import de.avatic.lcc.model.rates.MatrixRate;
import de.avatic.lcc.model.rates.ValidityPeriod;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.country.CountryRepository;
import de.avatic.lcc.repositories.pagination.SearchQueryPagination;
import de.avatic.lcc.repositories.pagination.SearchQueryResult;
import de.avatic.lcc.repositories.rates.MatrixRateRepository;
import de.avatic.lcc.repositories.rates.ValidityPeriodRepository;
import de.avatic.lcc.service.transformer.generic.CountryTransformer;
import de.avatic.lcc.service.transformer.generic.NodeTransformer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -25,15 +26,15 @@ public class MatrixRateService {
private final ValidityPeriodRepository validityPeriodRepository;
private final MatrixRateRepository matrixRateRepository;
private final NodeRepository nodeRepository;
private final NodeTransformer nodeTransformer;
private final CountryRepository countryRepository;
private final CountryTransformer countryTransformer;
public MatrixRateService(ValidityPeriodRepository validityPeriodRepository, MatrixRateRepository matrixRateRepository, NodeRepository nodeRepository, NodeTransformer nodeTransformer) {
public MatrixRateService(ValidityPeriodRepository validityPeriodRepository, MatrixRateRepository matrixRateRepository, CountryRepository countryRepository, CountryTransformer countryTransformer) {
this.validityPeriodRepository = validityPeriodRepository;
this.matrixRateRepository = matrixRateRepository;
this.nodeRepository = nodeRepository;
this.nodeTransformer = nodeTransformer;
this.countryRepository = countryRepository;
this.countryTransformer = countryTransformer;
}
/**
@ -45,9 +46,9 @@ public class MatrixRateService {
* @return a {@link SearchQueryResult} containing matrix rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<MatrixRateDTO> listRates(int limit, int page, LocalDateTime validAt) {
public SearchQueryResult<MatrixRateDTO> listRates(String filter, int limit, int page, LocalDateTime validAt) {
Optional<Integer> periodId = validityPeriodRepository.getPeriodId(validAt);
return listRates(limit, page, periodId.orElseThrow());
return listRates(filter, limit, page, periodId.orElseThrow());
}
/**
@ -59,11 +60,11 @@ public class MatrixRateService {
* @return a {@link SearchQueryResult} containing matrix rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<MatrixRateDTO> listRates(int limit, int page, Integer periodId) {
public SearchQueryResult<MatrixRateDTO> listRates(String filter, int limit, int page, Integer periodId) {
if (null == periodId)
return listRates(limit, page);
return listRates(filter,limit, page);
return SearchQueryResult.map(matrixRateRepository.listRatesByPeriodId(new SearchQueryPagination(page, limit), periodId), this::toMatrixRateDTO);
return SearchQueryResult.map(matrixRateRepository.listRatesByPeriodId(filter, new SearchQueryPagination(page, limit), periodId), this::toMatrixRateDTO);
}
/**
@ -74,9 +75,9 @@ public class MatrixRateService {
* @return a {@link SearchQueryResult} containing matrix rate DTOs with pagination information
*/
@Transactional
public SearchQueryResult<MatrixRateDTO> listRates(int limit, int page) {
public SearchQueryResult<MatrixRateDTO> listRates(String filter, int limit, int page) {
Integer id = validityPeriodRepository.getValidPeriodId().orElseThrow(() -> new IllegalStateException("No valid period found that is VALID"));
return SearchQueryResult.map(matrixRateRepository.listRatesByPeriodId(new SearchQueryPagination(page, limit), id), this::toMatrixRateDTO);
return SearchQueryResult.map(matrixRateRepository.listRatesByPeriodId(filter, new SearchQueryPagination(page, limit), id), this::toMatrixRateDTO);
}
/**
@ -100,8 +101,8 @@ public class MatrixRateService {
MatrixRateDTO dto = new MatrixRateDTO();
dto.setId(entity.getId());
dto.setOrigin(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getFromCountry()).orElseThrow()));
dto.setDestination(nodeTransformer.toNodeDTO(nodeRepository.getById(entity.getToCountry()).orElseThrow()));
dto.setSource(countryTransformer.toCountryDTO(countryRepository.getById(entity.getFromCountry()).orElseThrow()));
dto.setDestination(countryTransformer.toCountryDTO(countryRepository.getById(entity.getToCountry()).orElseThrow()));
dto.setRate(entity.getRate());
return dto;

View file

@ -66,30 +66,35 @@ public class BulkExportService {
var hiddenCountrySheet = workbook.createSheet(HiddenTableType.COUNTRY_HIDDEN_TABLE.getSheetName());
hiddenCountryExcelMapper.fillSheet(hiddenCountrySheet, style);
hiddenCountrySheet.protectSheet(sheetPassword);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenCountrySheet), SheetVisibility.VERY_HIDDEN);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenCountrySheet), SheetVisibility.HIDDEN);
} else if (bulkFileType.equals(BulkFileType.CONTAINER_RATE) || bulkFileType.equals(BulkFileType.PACKAGING)) {
var hiddenNodeSheet = workbook.createSheet(HiddenTableType.NODE_HIDDEN_TABLE.getSheetName());
hiddenNodeExcelMapper.fillSheet(hiddenNodeSheet, style, BulkFileType.PACKAGING.equals(bulkFileType));
hiddenNodeSheet.protectSheet(sheetPassword);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenNodeSheet), SheetVisibility.VERY_HIDDEN);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenNodeSheet), SheetVisibility.HIDDEN);
}
// Create headers based on the bulk file type
switch (bulkFileType) {
case CONTAINER_RATE:
containerRateExcelMapper.fillSheet(worksheet, style, periodId);
containerRateExcelMapper.createConstraints(workbook, worksheet);
break;
case COUNTRY_MATRIX:
matrixRateExcelMapper.fillSheet(worksheet, style, periodId);
matrixRateExcelMapper.createConstraints(workbook, worksheet);
break;
case MATERIAL:
materialExcelMapper.fillSheet(worksheet, style);
materialExcelMapper.createConstraints(worksheet);
break;
case PACKAGING:
packagingExcelMapper.fillSheet(worksheet, style);
packagingExcelMapper.createConstraints(workbook, worksheet);
break;
case NODE:
nodeExcelMapper.fillSheet(worksheet, style);
nodeExcelMapper.createConstraints(workbook, worksheet);
break;
}

View file

@ -5,7 +5,10 @@ import de.avatic.lcc.model.bulk.*;
import de.avatic.lcc.service.bulk.helper.HeaderCellStyleProvider;
import de.avatic.lcc.service.bulk.helper.HeaderGenerator;
import de.avatic.lcc.service.excelMapper.*;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.SheetVisibility;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
@ -44,26 +47,24 @@ public class TemplateExportService {
}
public InputStreamSource generateTemplate(BulkFileType bulkFileType) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Workbook workbook = new XSSFWorkbook();
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Workbook workbook = new XSSFWorkbook();) {
Sheet sheet = workbook.createSheet(BulkFileTypes.valueOf(bulkFileType.name()).getSheetName());
CellStyle style = headerCellStyleProvider.createHeaderCellStyle(workbook);
if(bulkFileType.equals(BulkFileType.COUNTRY_MATRIX) || bulkFileType.equals(BulkFileType.NODE))
{
if (bulkFileType.equals(BulkFileType.COUNTRY_MATRIX) || bulkFileType.equals(BulkFileType.NODE)) {
var hiddenCountrySheet = workbook.createSheet(HiddenTableType.COUNTRY_HIDDEN_TABLE.getSheetName());
hiddenCountryExcelMapper.fillSheet(hiddenCountrySheet, style);
hiddenCountrySheet.protectSheet(sheetPassword);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenCountrySheet), SheetVisibility.VERY_HIDDEN);
}
else if(bulkFileType.equals(BulkFileType.CONTAINER_RATE) || bulkFileType.equals(BulkFileType.PACKAGING))
{
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenCountrySheet), SheetVisibility.HIDDEN);
} else if (bulkFileType.equals(BulkFileType.CONTAINER_RATE) || bulkFileType.equals(BulkFileType.PACKAGING)) {
var hiddenNodeSheet = workbook.createSheet(HiddenTableType.NODE_HIDDEN_TABLE.getSheetName());
hiddenNodeExcelMapper.fillSheet(hiddenNodeSheet, style, BulkFileType.PACKAGING.equals(bulkFileType));
hiddenNodeSheet.protectSheet(sheetPassword);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenNodeSheet), SheetVisibility.VERY_HIDDEN);
workbook.setSheetVisibility(workbook.getSheetIndex(hiddenNodeSheet), SheetVisibility.HIDDEN);
}
// Create headers and constraints based on the bulk file type
@ -71,22 +72,27 @@ public class TemplateExportService {
case CONTAINER_RATE:
headerGenerator.generateHeader(sheet, ContainerRateHeader.class, style);
containerRateExcelMapper.createConstraints(workbook, sheet);
headerGenerator.fixWidth(sheet, ContainerRateHeader.class);
break;
case COUNTRY_MATRIX:
headerGenerator.generateHeader(sheet, MatrixRateHeader.class, style);
matrixRateExcelMapper.createConstraints(workbook, sheet);
headerGenerator.fixWidth(sheet, MatrixRateHeader.class);
break;
case MATERIAL:
headerGenerator.generateHeader(sheet, MaterialHeader.class, style);
materialExcelMapper.createConstraints(sheet);
headerGenerator.fixWidth(sheet, MaterialHeader.class);
break;
case PACKAGING:
headerGenerator.generateHeader(sheet, PackagingHeader.class, style);
packagingExcelMapper.createConstraints(workbook, sheet);
headerGenerator.fixWidth(sheet, PackagingHeader.class);
break;
case NODE:
headerGenerator.generateHeader(sheet, NodeHeader.class, style);
nodeExcelMapper.createConstraints(workbook, sheet);
headerGenerator.fixWidth(sheet, NodeHeader.class);
break;
default:
throw new IllegalArgumentException("Unsupported bulk file type: " + bulkFileType);

View file

@ -10,10 +10,13 @@ import org.apache.poi.ss.util.CellRangeAddressList;
import org.springframework.stereotype.Service;
import java.util.EnumSet;
import java.util.function.Function;
@Service
public class ConstraintGenerator {
private static final int MAX_ROWS = 10000;
public void createBooleanConstraint(Sheet sheet, Integer columnIdx) {
createConstraint(sheet, columnIdx, new String[]{"true", "false"});
@ -23,12 +26,25 @@ public class ConstraintGenerator {
createConstraint(sheet, columnIdx, EnumSet.allOf(values).stream().map(Enum::name).toArray(String[]::new));
}
public <T extends Enum<T>> void createEnumConstraint(Sheet sheet, Integer columnIdx, Class<T> values, Function<T, String> resolver) {
createConstraint(sheet, columnIdx, EnumSet.allOf(values).stream().map(Enum::name).toArray(String[]::new));
}
private void createConstraint(Sheet sheet, Integer columnIdx, String[] values) {
var helper = sheet.getDataValidationHelper();
var constraint = helper.createExplicitListConstraint(values);
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, SpreadsheetVersion.EXCEL2007.getMaxRows(), columnIdx, columnIdx));
validation.createErrorBox("Invalid Value", values.length > 8 ? "Please check dropdown for allowed values." : String.format("Allowed values are: %s.", String.join(", ", values)));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, columnIdx, columnIdx));
String errorMessage = values.length > 8 ? "Please check dropdown for allowed values." :
String.format("Allowed values: %s", String.join(", ", values));
if (errorMessage.length() > 200) {
errorMessage = "Please check dropdown for allowed values.";
}
validation.createErrorBox("Invalid Value", errorMessage);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
@ -37,7 +53,7 @@ public class ConstraintGenerator {
var helper = sheet.getDataValidationHelper();
var constraint = helper.createFormulaListConstraint(namedReference);
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, SpreadsheetVersion.EXCEL2007.getMaxRows(), columnIdx, columnIdx));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, columnIdx, columnIdx));
validation.createErrorBox("Invalid Value", "Please check dropdown for allowed values.");
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
@ -47,7 +63,7 @@ public class ConstraintGenerator {
var helper = sheet.getDataValidationHelper();
var constraint = helper.createTextLengthConstraint(DataValidationConstraint.OperatorType.BETWEEN, String.valueOf(min), String.valueOf(max));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, SpreadsheetVersion.EXCEL2007.getMaxRows(), columnIdx, columnIdx));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, columnIdx, columnIdx));
validation.createErrorBox("Invalid Value", String.format("Allowed length is between %d and %d characters.", min, max));
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
@ -57,7 +73,7 @@ public class ConstraintGenerator {
var helper = sheet.getDataValidationHelper();
var constraint = helper.createDecimalConstraint(DataValidationConstraint.OperatorType.BETWEEN, String.valueOf(min), String.valueOf(max));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, SpreadsheetVersion.EXCEL2007.getMaxRows(), columnIdx, columnIdx));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, columnIdx, columnIdx));
validation.createErrorBox("Invalid Value", String.format("Allowed value is between %f and %f.", min.floatValue(), max.floatValue()));
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
@ -67,23 +83,39 @@ public class ConstraintGenerator {
var helper = sheet.getDataValidationHelper();
var constraint = helper.createIntegerConstraint(DataValidationConstraint.OperatorType.BETWEEN, String.valueOf(min), String.valueOf(max));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, SpreadsheetVersion.EXCEL2007.getMaxRows(), columnIdx, columnIdx));
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, columnIdx, columnIdx));
validation.createErrorBox("Invalid Value", String.format("Allowed value is between %f and %f.", min.floatValue(), max.floatValue()));
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
public Name createReference(Workbook workbook, Integer columnIdx, HiddenTableType hiddenTableType ) {
public Name createReference(Workbook workbook, Integer columnIdx, HiddenTableType hiddenTableType) {
// Check if the referenced sheet exists
Sheet hiddenSheet = workbook.getSheet(hiddenTableType.getSheetName());
if (hiddenSheet == null) {
throw new IllegalStateException("Hidden sheet " + hiddenTableType.getSheetName() + " does not exist");
}
Name namedRange = workbook.createName();
namedRange.setNameName(hiddenTableType.getReferenceName());
String reference = hiddenTableType.getSheetName()+ "!$"+toExcelLetter(columnIdx)+"$1:$"+toExcelLetter(columnIdx)+"$" + SpreadsheetVersion.EXCEL2007.getMaxRows();
// Create a more conservative range - only to the last row with data
int lastRow = Math.max(hiddenSheet.getLastRowNum(), 1); // At least row 1
String columnLetter = toExcelLetter(columnIdx);
String reference = hiddenTableType.getSheetName() + "!$" + columnLetter + "$2:$" + columnLetter + "$" + (lastRow + 1);
namedRange.setRefersToFormula(reference);
return namedRange;
}
private String toExcelLetter(int columnIdx) {
int digit = columnIdx % 26;
int quotient = columnIdx / 26;
return (quotient==0 ? "" : toExcelLetter(quotient)) + (char) ('A' + digit);
StringBuilder result = new StringBuilder();
columnIdx++; // Convert from 0-based to 1-based for the algorithm
while (columnIdx > 0) {
columnIdx--; // Adjust for 1-based indexing
result.insert(0, (char) ('A' + columnIdx % 26));
columnIdx /= 26;
}
return result.toString();
}
}

View file

@ -1,17 +1,37 @@
package de.avatic.lcc.service.bulk.helper;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFFont;
import org.springframework.stereotype.Service;
@Service
public class HeaderCellStyleProvider {
public CellStyle createHeaderCellStyle(Workbook workbook) {
XSSFFont headerFont = (XSSFFont) workbook.createFont();
XSSFColor customTextColor = new XSSFColor(new byte[]{(byte)0, (byte)47, (byte)84}, null); // Blue
headerFont.setColor(customTextColor);
headerFont.setFontName("Arial");
headerFont.setFontHeightInPoints((short)10);
headerFont.setBold(true);
XSSFColor customColor = new XSSFColor(new byte[]{(byte)90, (byte)240, (byte)180}, null);
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
headerStyle.setFillForegroundColor(customColor);
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setBorderBottom(BorderStyle.THIN);
headerStyle.setBorderTop(BorderStyle.THIN);

View file

@ -1,6 +1,7 @@
package de.avatic.lcc.service.bulk.helper;
import de.avatic.lcc.model.bulk.HeaderProvider;
import de.avatic.lcc.model.bulk.NodeHeader;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
@ -12,6 +13,8 @@ import java.util.EnumSet;
@Service
public class HeaderGenerator {
private static final int ADD_COLUMN_SIZE = (10*256);
public <H extends Enum<H> & HeaderProvider> boolean validateHeader(Sheet sheet, Class<H> headers) {
Row row = sheet.getRow(0);
for(H header : EnumSet.allOf(headers)){
@ -57,4 +60,19 @@ public class HeaderGenerator {
}
public void fixWidth(Sheet sheet, String[] headers) {
int idx = 0;
for (String header : headers) {
sheet.autoSizeColumn(idx);
sheet.setColumnWidth(idx,sheet.getColumnWidth(idx)+ADD_COLUMN_SIZE);
idx++;
}
}
public <H extends Enum<H> & HeaderProvider> void fixWidth(Sheet sheet, Class<H> headers) {
for (H header : EnumSet.allOf(headers)) {
sheet.autoSizeColumn(header.ordinal());
sheet.setColumnWidth(header.ordinal(),sheet.getColumnWidth(header.ordinal())+ADD_COLUMN_SIZE);
}
}
}

View file

@ -37,6 +37,7 @@ public class ContainerRateExcelMapper {
public void fillSheet(Sheet sheet, CellStyle headerStyle, Integer periodId) {
headerGenerator.generateHeader(sheet, ContainerRateHeader.class, headerStyle);
containerRateRepository.listAllRatesByPeriodId(periodId).forEach(rate -> mapToRow(rate, sheet.createRow(sheet.getLastRowNum()+1)));
headerGenerator.fixWidth(sheet, ContainerRateHeader.class);
}
private void mapToRow(ContainerRate rate, Row row) {

View file

@ -29,6 +29,7 @@ public class MaterialExcelMapper {
public void fillSheet(Sheet sheet, CellStyle headerStyle) {
headerGenerator.generateHeader(sheet, MaterialHeader.class, headerStyle);
materialRepository.listAllMaterials().forEach(material -> mapToRow(material, sheet.createRow(sheet.getLastRowNum()+1)));
headerGenerator.fixWidth(sheet, MaterialHeader.class);
}
private void mapToRow(Material material, Row row) {
@ -39,7 +40,7 @@ public class MaterialExcelMapper {
public void createConstraints(Sheet sheet) {
constraintGenerator.createLengthConstraint(sheet, MaterialHeader.PART_NUMBER.ordinal(), 0, 12);
constraintGenerator.createLengthConstraint(sheet, MaterialHeader.HS_CODE.ordinal(), 0, 8);
constraintGenerator.createLengthConstraint(sheet, MaterialHeader.HS_CODE.ordinal(), 0, 11);
constraintGenerator.createLengthConstraint(sheet, MaterialHeader.DESCRIPTION.ordinal(), 1, 500);
}

View file

@ -35,6 +35,7 @@ public class MatrixRateExcelMapper {
public void fillSheet(Sheet sheet, CellStyle headerStyle, Integer periodId) {
headerGenerator.generateHeader(sheet, MatrixRateHeader.class, headerStyle);
matrixRateRepository.listAllRatesByPeriodId(periodId).forEach(rate -> mapToRow(rate, sheet.createRow(sheet.getLastRowNum()+1)));
headerGenerator.fixWidth(sheet, MatrixRateHeader.class);
}
public List<MatrixRate> extractSheet(Sheet sheet) {

View file

@ -41,6 +41,7 @@ public class NodeExcelMapper {
{
headerGenerator.generateHeader(sheet, NodeHeader.class, headerStyle);
nodeRepository.listAllNodes(false).forEach(node -> mapToRow(node, sheet.createRow(sheet.getLastRowNum()+1)));
headerGenerator.fixWidth(sheet, NodeHeader.class);
}
private void mapToRow(Node node, Row row) {
@ -50,16 +51,16 @@ public class NodeExcelMapper {
row.createCell(NodeHeader.COUNTRY.ordinal()).setCellValue(countryRepository.getById(node.getCountryId()).orElseThrow().getIsoCode().getCode());
row.createCell(NodeHeader.GEO_LATITUDE.ordinal()).setCellValue(node.getGeoLat().doubleValue());
row.createCell(NodeHeader.GEO_LONGITUDE.ordinal()).setCellValue(node.getGeoLng().doubleValue());
row.createCell(NodeHeader.IS_ORIGIN.ordinal()).setCellValue(node.getSource());
row.createCell(NodeHeader.IS_INTERMEDIATE.ordinal()).setCellValue(node.getIntermediate());
row.createCell(NodeHeader.IS_DESTINATION.ordinal()).setCellValue(node.getDestination());
row.createCell(NodeHeader.IS_SOURCE.ordinal()).setCellValue(node.getSource() ? "true":"false");
row.createCell(NodeHeader.IS_INTERMEDIATE.ordinal()).setCellValue(node.getIntermediate()? "true":"false");
row.createCell(NodeHeader.IS_DESTINATION.ordinal()).setCellValue(node.getDestination()? "true":"false");
row.createCell(NodeHeader.OUTBOUND_COUNTRIES.ordinal()).setCellValue(mapOutboundCountriesToCell(node.getOutboundCountries()));
row.createCell(NodeHeader.PREDECESSOR_NODES.ordinal()).setCellValue(mapChains(node.getNodePredecessors()));
row.createCell(NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal()).setCellValue(node.getPredecessorRequired());
row.createCell(NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal()).setCellValue(node.getPredecessorRequired()? "true":"false");
}
private String mapChains(List<Map<Integer, Integer>> chains) {
return chains.stream().map(this::mapChain).collect(Collectors.joining(", "));
return chains.stream().map(this::mapChain).collect(Collectors.joining("; "));
}
private String mapChain(Map<Integer, Integer> chain) {
@ -78,7 +79,7 @@ public class NodeExcelMapper {
constraintGenerator.createFormulaListConstraint(sheet, NodeHeader.COUNTRY.ordinal(), namedRange.getNameName());
constraintGenerator.createDecimalConstraint(sheet, NodeHeader.GEO_LATITUDE.ordinal(), -90.0, 90.0);
constraintGenerator.createDecimalConstraint(sheet, NodeHeader.GEO_LONGITUDE.ordinal(), -180.0, 180.0);
constraintGenerator.createBooleanConstraint(sheet, NodeHeader.IS_ORIGIN.ordinal());
constraintGenerator.createBooleanConstraint(sheet, NodeHeader.IS_SOURCE.ordinal());
constraintGenerator.createBooleanConstraint(sheet, NodeHeader.IS_INTERMEDIATE.ordinal());
constraintGenerator.createBooleanConstraint(sheet, NodeHeader.IS_DESTINATION.ordinal());
constraintGenerator.createBooleanConstraint(sheet, NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal());
@ -104,7 +105,7 @@ public class NodeExcelMapper {
entity.setCountryId(IsoCode.valueOf(row.getCell(NodeHeader.COUNTRY.ordinal()).getStringCellValue()));
entity.setGeoLat(BigDecimal.valueOf(row.getCell(NodeHeader.GEO_LATITUDE.ordinal()).getNumericCellValue()));
entity.setGeoLng(BigDecimal.valueOf(row.getCell(NodeHeader.GEO_LONGITUDE.ordinal()).getNumericCellValue()));
entity.setSource(row.getCell(NodeHeader.IS_ORIGIN.ordinal()).getBooleanCellValue());
entity.setSource(row.getCell(NodeHeader.IS_SOURCE.ordinal()).getBooleanCellValue());
entity.setIntermediate(row.getCell(NodeHeader.IS_INTERMEDIATE.ordinal()).getBooleanCellValue());
entity.setDestination(row.getCell(NodeHeader.IS_DESTINATION.ordinal()).getBooleanCellValue());
entity.setPredecessorRequired(row.getCell(NodeHeader.IS_PREDECESSOR_MANDATORY.ordinal()).getBooleanCellValue());

View file

@ -54,27 +54,28 @@ public class PackagingExcelMapper {
headerGenerator.generateHeader(sheet, headers.toArray(String[]::new), headerStyle);
packagingRepository.listAllPackaging().forEach(p -> mapToRow(p, headers, sheet.createRow(sheet.getLastRowNum() + 1)));
headerGenerator.fixWidth(sheet, headers.toArray(String[]::new));
}
private void mapToRow(Packaging packaging, ArrayList<String> headers, Row row) {
Optional<PackagingDimension> shu = packagingDimensionRepository.getById(packaging.getShuId());
Optional<PackagingDimension> hu = packagingDimensionRepository.getById(packaging.getShuId());
row.createCell(PackagingHeader.PART_NUMBER.ordinal()).setCellValue(materialRepository.getById(packaging.getMaterialId()).orElseThrow().getPartNumber());
row.createCell(PackagingHeader.PART_NUMBER.ordinal()).setCellValue(materialRepository.getByIdIncludeDeprecated(packaging.getMaterialId()).orElseThrow().getPartNumber());
row.createCell(PackagingHeader.SUPPLIER.ordinal()).setCellValue(nodeRepository.getById(packaging.getSupplierId()).orElseThrow().getExternalMappingId());
mapToCell(row.createCell(PackagingHeader.SHU_HEIGHT.ordinal()), shu, PackagingDimension::getHeight);
mapToCell(row.createCell(PackagingHeader.SHU_WIDTH.ordinal()), shu, PackagingDimension::getWidth);
mapToCell(row.createCell(PackagingHeader.SHU_LENGTH.ordinal()), shu, PackagingDimension::getLength);
mapToCell(row.createCell(PackagingHeader.SHU_WEIGHT.ordinal()), shu, PackagingDimension::getWeight);
mapDimensionToCell(row.createCell(PackagingHeader.SHU_HEIGHT.ordinal()), shu, PackagingDimension::getHeight);
mapDimensionToCell(row.createCell(PackagingHeader.SHU_WIDTH.ordinal()), shu, PackagingDimension::getWidth);
mapDimensionToCell(row.createCell(PackagingHeader.SHU_LENGTH.ordinal()), shu, PackagingDimension::getLength);
mapWeightToCell(row.createCell(PackagingHeader.SHU_WEIGHT.ordinal()), shu, PackagingDimension::getWeight);
mapToCell(row.createCell(PackagingHeader.SHU_UNIT_COUNT.ordinal()), shu, PackagingDimension::getContentUnitCount);
mapToCell(row.createCell(PackagingHeader.SHU_DIMENSION_UNIT.ordinal()), shu, PackagingDimension::getDimensionUnit);
mapToCell(row.createCell(PackagingHeader.SHU_WEIGHT_UNIT.ordinal()), shu, PackagingDimension::getWeightUnit);
mapToCell(row.createCell(PackagingHeader.HU_HEIGHT.ordinal()), hu, PackagingDimension::getHeight);
mapToCell(row.createCell(PackagingHeader.HU_WIDTH.ordinal()), hu, PackagingDimension::getWidth);
mapToCell(row.createCell(PackagingHeader.HU_LENGTH.ordinal()), hu, PackagingDimension::getLength);
mapToCell(row.createCell(PackagingHeader.HU_WEIGHT.ordinal()), hu, PackagingDimension::getWeight);
mapDimensionToCell(row.createCell(PackagingHeader.HU_HEIGHT.ordinal()), hu, PackagingDimension::getHeight);
mapDimensionToCell(row.createCell(PackagingHeader.HU_WIDTH.ordinal()), hu, PackagingDimension::getWidth);
mapDimensionToCell(row.createCell(PackagingHeader.HU_LENGTH.ordinal()), hu, PackagingDimension::getLength);
mapWeightToCell(row.createCell(PackagingHeader.HU_WEIGHT.ordinal()), hu, PackagingDimension::getWeight);
mapToCell(row.createCell(PackagingHeader.HU_UNIT_COUNT.ordinal()), hu, PackagingDimension::getContentUnitCount);
mapToCell(row.createCell(PackagingHeader.HU_DIMENSION_UNIT.ordinal()), hu, PackagingDimension::getDimensionUnit);
mapToCell(row.createCell(PackagingHeader.HU_WEIGHT_UNIT.ordinal()), hu, PackagingDimension::getWeightUnit);
@ -84,6 +85,18 @@ public class PackagingExcelMapper {
}
private void mapDimensionToCell(Cell cell, Optional<PackagingDimension> packaging, Function<PackagingDimension, Integer> provider) {
if (packaging.isPresent())
cell.setCellValue(packaging.get().getDimensionUnit().convertFromMM(provider.apply(packaging.get())));
else cell.setBlank();
}
private void mapWeightToCell(Cell cell, Optional<PackagingDimension> packaging, Function<PackagingDimension, Integer> provider) {
if (packaging.isPresent())
cell.setCellValue(packaging.get().getWeightUnit().convertFromG(provider.apply(packaging.get())));
else cell.setBlank();
}
private <T, R> void mapToCell(Cell cell, Optional<T> dimension, Function<T, R> resolver) {
if (dimension.isPresent()) {
if (resolver.apply(dimension.get()) instanceof Integer)
@ -91,16 +104,23 @@ public class PackagingExcelMapper {
if (resolver.apply(dimension.get()) instanceof String)
cell.setCellValue((String) resolver.apply(dimension.get()));
if (resolver.apply(dimension.get()) instanceof DimensionUnit)
cell.setCellValue(((DimensionUnit) resolver.apply(dimension.get())).name());
cell.setCellValue(((DimensionUnit) resolver.apply(dimension.get())).getDisplayedName());
if (resolver.apply(dimension.get()) instanceof WeightUnit)
cell.setCellValue(((WeightUnit) resolver.apply(dimension.get())).getDisplayedName());
} else cell.setBlank();
}
public void createConstraints(Workbook workbook, Sheet sheet) {
var namedRange = constraintGenerator.createReference(workbook, HiddenNodeHeader.MAPPING_ID.ordinal(), HiddenTableType.ORIGIN_HIDDEN_TABLE);
var namedRange = constraintGenerator.createReference(workbook, HiddenNodeHeader.MAPPING_ID.ordinal(), HiddenTableType.NODE_HIDDEN_TABLE);
constraintGenerator.createFormulaListConstraint(sheet, PackagingHeader.SUPPLIER.ordinal(), namedRange.getNameName());
constraintGenerator.createLengthConstraint(sheet, PackagingHeader.PART_NUMBER.ordinal(), 0, 12);
constraintGenerator.createEnumConstraint(sheet, PackagingHeader.SHU_DIMENSION_UNIT.ordinal(), DimensionUnit.class);
constraintGenerator.createEnumConstraint(sheet, PackagingHeader.SHU_WEIGHT_UNIT.ordinal(), WeightUnit.class);
constraintGenerator.createEnumConstraint(sheet, PackagingHeader.HU_DIMENSION_UNIT.ordinal(), DimensionUnit.class);
constraintGenerator.createEnumConstraint(sheet, PackagingHeader.HU_WEIGHT_UNIT.ordinal(), WeightUnit.class);
//TODO: check hu dimensions...

View file

@ -42,7 +42,12 @@ public class ExcelReportingService {
Sheet sheet = workbook.createSheet("report");
CellStyle headerStyle = headerCellStyleProvider.createHeaderCellStyle(workbook);
headerGenerator.generateHeader(sheet, reports.stream().map(ReportDTO::getSupplier).map(NodeDTO::getName).toArray(String[]::new), headerStyle);
ArrayList<String> headers = new ArrayList<>();
headers.add("");
headers.addAll(reports.stream().map(ReportDTO::getSupplier).map(NodeDTO::getName).toList());
headerGenerator.generateHeader(sheet, headers.toArray(String[]::new), headerStyle);
List<ReportFlattener> flatterers = reports.stream().map(ReportFlattener::new).toList();
@ -57,6 +62,7 @@ public class ExcelReportingService {
Cell cell = row.createCell(cellIdx);
if(flattener.hasData(row.getRowNum())) {
if(!headerWritten) {
row.createCell(0).setCellValue(flattener.getHeader(row.getRowNum()));
@ -72,6 +78,8 @@ public class ExcelReportingService {
if(!hasData) break;
}
headerGenerator.fixWidth(sheet, headers.toArray(String[]::new));
// Return the Excel file as an InputStreamSource
workbook.write(outputStream);
return new ByteArrayResource(outputStream.toByteArray());
@ -116,8 +124,8 @@ public class ExcelReportingService {
private final ReportDTO report;
private List<String> data = new ArrayList<>();
private List<String> dataHeader = new ArrayList<>();
private final List<String> data = new ArrayList<>();
private final List<String> dataHeader = new ArrayList<>();
public ReportFlattener(ReportDTO report) {
this.report = report;
@ -129,6 +137,7 @@ public class ExcelReportingService {
addData(SUPPLIER_NAME, report.getSupplier().getName());
addData(SUPPLIER_ADDRESS, report.getSupplier().getAddress());
// TODO: hardcoded (otherwise values wont match
report.getCost().keySet().forEach(costName -> addData(costName, report.getCost().get(costName)));
report.getRisk().keySet().forEach(riskName -> addData(riskName, report.getRisk().get(riskName)));
@ -143,7 +152,8 @@ public class ExcelReportingService {
addData(DESTINATION_HS_CODE, destination.getHsCode());
addData(DESTINATION_TARIFF_RATE, destination.getTariffRate().toString());
addData(DESTINATION_OVERSHARE, destination.getOverseaShare().toString());
addData(DESTINATION_AIR_FREIGHT_SHARE, destination.getAirFreightShare().toString());
if(destination.getAirFreightShare() != null)
addData(DESTINATION_AIR_FREIGHT_SHARE, destination.getAirFreightShare().toString());
addData(DESTINATION_TRANSPORT_TIME, destination.getTransportTime().toString());
addData(DESTINATION_SAFETY_STOCK, destination.getSafetyStock().toString());
@ -172,7 +182,7 @@ public class ExcelReportingService {
private void addData(String header, ReportEntryDTO data) {
this.dataHeader.add(header);
this.data.add(data.getTotal() + " (" + data.getPercentage().doubleValue()*100 + "%)");
this.data.add(data.getTotal().toString()); // + " (" + data.getPercentage().doubleValue()*100 + "%)");
}
public String getCell(int rowIdx) {

View file

@ -16,6 +16,8 @@ public class MaterialTransformer {
dtoEntry.setName(entity.getName());
dtoEntry.setHsCode(entity.getHsCode());
dtoEntry.setDeprecated(entity.getDeprecated());
return dtoEntry;

View file

@ -91,7 +91,7 @@ public class PremiseTransformer {
var dto = new PremiseDetailDTO();
dto.setId(entity.getId());
dto.setMaterial(materialTransformer.toMaterialDTO(materialRepository.getById(entity.getMaterialId()).orElseThrow()));
dto.setMaterial(materialTransformer.toMaterialDTO(materialRepository.getByIdIncludeDeprecated(entity.getMaterialId()).orElseThrow()));
dto.setMixable(entity.getHuMixable());
dto.setStackable(entity.getHuStackable());

View file

@ -78,7 +78,7 @@ public class ReportTransformer {
var weightedTotalCost = getWeightedTotalCosts(sections);
Premise premise = premiseRepository.getPremiseById(job.getPremiseId()).orElseThrow();
reportDTO.setMaterial(materialTransformer.toMaterialDTO(materialRepository.getById(premise.getMaterialId()).orElseThrow()));
reportDTO.setMaterial(materialTransformer.toMaterialDTO(materialRepository.getByIdIncludeDeprecated(premise.getMaterialId()).orElseThrow()));
var period = getPeriod(job);