BACKEND: fixed near_by routing bug
FRONTEND: added bulk menu
This commit is contained in:
parent
02249d2da4
commit
a2822666a6
8 changed files with 358 additions and 75 deletions
|
|
@ -14,10 +14,71 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
|
|||
params.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const request = {url: url, params: params};
|
||||
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
|
||||
logger.info("Request:", request);
|
||||
|
||||
const response = await fetch(url, params
|
||||
const data = await executeRequest(requestingStore, request);
|
||||
|
||||
logger.info("Response:", data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const performDownload = async (requestingStore, url, expectResponse = true, expectedException = null) => {
|
||||
|
||||
const params = {
|
||||
method: 'GET',
|
||||
};
|
||||
|
||||
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
|
||||
logger.info("Request:", request);
|
||||
|
||||
const processId = await executeRequest(null, request);
|
||||
|
||||
logger.info("Response:", processId);
|
||||
return processId;
|
||||
}
|
||||
|
||||
const performUpload = async (url, file, expectResponse = true, expectedException = null) => {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const params = {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
};
|
||||
|
||||
const request = {url: url, params: params, expectResponse: expectResponse, expectedException: expectedException};
|
||||
logger.info("Request:", request);
|
||||
|
||||
const processId = await executeRequest(null, request);
|
||||
|
||||
logger.info("Response:", processId);
|
||||
return processId;
|
||||
}
|
||||
|
||||
function handleErrorResponse(data, requestingStore, request) {
|
||||
const errorObj = {
|
||||
code: data.error.code,
|
||||
title: data.error.title,
|
||||
message: data.error.message,
|
||||
trace: data.error.trace
|
||||
}
|
||||
|
||||
const error = new Error('Internal backend error');
|
||||
error.errorObj = errorObj;
|
||||
|
||||
if (request.expectedException === null || data.error.title !== request.expectedException) {
|
||||
logger.error(errorObj);
|
||||
const errorStore = useErrorStore();
|
||||
void errorStore.addError(errorObj, {store: requestingStore, request: request});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const executeRequest = async (requestingStore, request) => {
|
||||
const response = await fetch(request.url, request.params
|
||||
).catch(e => {
|
||||
const error = {
|
||||
code: 'Network error.',
|
||||
|
|
@ -33,7 +94,7 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
|
|||
});
|
||||
|
||||
let data = null;
|
||||
if (expectResponse) {
|
||||
if (request.expectResponse) {
|
||||
data = await response.json().catch(e => {
|
||||
const error = {
|
||||
code: 'Malformed response',
|
||||
|
|
@ -48,23 +109,7 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = {
|
||||
code: data.error.code,
|
||||
title: data.error.title,
|
||||
message: data.error.message,
|
||||
trace: data.error.trace
|
||||
}
|
||||
|
||||
const error = new Error('Internal backend error');
|
||||
error.errorObj = errorObj;
|
||||
|
||||
if (expectedException === null || data.error.title !== expectedException) {
|
||||
logger.error(errorObj);
|
||||
const errorStore = useErrorStore();
|
||||
void errorStore.addError(errorObj, {store: requestingStore, request: request});
|
||||
}
|
||||
|
||||
throw error;
|
||||
handleErrorResponse(data, requestingStore, request);
|
||||
}
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
|
|
@ -82,28 +127,12 @@ const performRequest = async (requestingStore, method, url, body, expectResponse
|
|||
|
||||
});
|
||||
|
||||
const errorObj = {
|
||||
code: data.error.code,
|
||||
title: data.error.title,
|
||||
message: data.error.message,
|
||||
trace: data.error.trace
|
||||
}
|
||||
|
||||
const error = new Error('Internal backend error');
|
||||
error.errorObj = errorObj;
|
||||
|
||||
if (expectedException === null || data.error.title !== expectedException) {
|
||||
logger.error(errorObj);
|
||||
const errorStore = useErrorStore();
|
||||
void errorStore.addError(errorObj, {store: requestingStore, request: request});
|
||||
}
|
||||
|
||||
throw error;
|
||||
handleErrorResponse(data, requestingStore, request);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Response:", data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default performRequest;
|
||||
export {performUpload, performDownload};
|
||||
|
|
|
|||
227
src/frontend/src/components/layout/config/BulkOperations.vue
Normal file
227
src/frontend/src/components/layout/config/BulkOperations.vue
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="bulk-operations-container">
|
||||
|
||||
<box variant="border" class="bulk-operations-box-container">
|
||||
<div class="bulk-operations-sub-container">
|
||||
|
||||
<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="empty" v-model="exportType">empty template</radio-option>
|
||||
<radio-option name="export-type" value="full" 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>
|
||||
|
||||
<div class="bulk-operation-action-footer">
|
||||
<basic-button @click="downloadFile" icon="download">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>
|
||||
|
||||
<div class="bulk-operation-action-footer">
|
||||
<basic-button @click="uploadFile" icon="upload" :disabled="!selectedFile">Import</basic-button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</box>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Box from "@/components/UI/Box.vue";
|
||||
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";
|
||||
|
||||
export default {
|
||||
name: "BulkOperations",
|
||||
components: {RadioOption, BasicButton, Box},
|
||||
data() {
|
||||
return {
|
||||
exportType: "empty",
|
||||
exportDataset: "NODE",
|
||||
importDataset: "NODE",
|
||||
importType: "APPEND",
|
||||
selectedFileName: null,
|
||||
selectedFile: null,
|
||||
uploading: false,
|
||||
processId: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async downloadFile() {
|
||||
const url = `${config.backendUrl}/bulk/upload/${this.exportDataset}/${this.exportType}/`
|
||||
this.processId = await performDownload(url);
|
||||
},
|
||||
inputFile(event) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file) {
|
||||
this.selectedFile = file;
|
||||
this.selectedFileName = file.name;
|
||||
} else {
|
||||
this.selectedFile = null;
|
||||
this.selectedFileName = null;
|
||||
}
|
||||
},
|
||||
async uploadFile() {
|
||||
if (!this.selectedFile)
|
||||
return;
|
||||
|
||||
const url = `${config.backendUrl}/bulk/upload/${this.importDataset}/${this.importType}/`
|
||||
this.processId = await performUpload(url, this.selectedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.bulk-operations-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bulk-operations-box-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-operations-sub-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
row-gap: 3.2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-operation-header {
|
||||
font-weight: 500;
|
||||
font-size: 1.4rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bulk-operation-action-footer {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.bulk-operation-caption {
|
||||
font-size: 1.2rem;
|
||||
color: #6B869C;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bulk-operation-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
align-items: flex-start;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
/* File input styling */
|
||||
.file-input-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-select-button {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-button-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: 0.2rem solid transparent;
|
||||
border-radius: 0.8rem;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease-in-out;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 500;
|
||||
background-color: #002F54;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.file-button-label:hover {
|
||||
background-color: #ffffff;
|
||||
color: #002F54;
|
||||
border-color: #002F54;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Icon for file upload */
|
||||
.upload-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Selected file display */
|
||||
.selected-file {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #c3cfdf;
|
||||
border-radius: 0.8rem;
|
||||
font-size: 1.2rem;
|
||||
color: #002F54;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<template>
|
||||
<h3>Bulk Operations</h3>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: "BulkUpload"
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -22,6 +22,7 @@ import Properties from "@/components/layout/config/Properties.vue";
|
|||
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";
|
||||
|
||||
export default {
|
||||
name: "Config",
|
||||
|
|
@ -38,25 +39,9 @@ export default {
|
|||
title: 'Countries',
|
||||
component: markRaw(CountryProperties),
|
||||
},
|
||||
{
|
||||
title: 'Nodes',
|
||||
component: (null),
|
||||
},
|
||||
{
|
||||
title: 'Kilometer rates',
|
||||
component: (null),
|
||||
},
|
||||
{
|
||||
title: 'Container rates',
|
||||
component: (null),
|
||||
},
|
||||
{
|
||||
title: 'Materials & packaging',
|
||||
component: (null),
|
||||
},
|
||||
{
|
||||
title: 'Bulk operations',
|
||||
component: (null),
|
||||
component: markRaw(BulkOperations),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showComparableWarning && hasReport" class="destination-differ-warning">
|
||||
<ph-warning size="18px"></ph-warning>
|
||||
Reports may not be comparable! Calculations differ in destinations and/or their annual quantities.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="report-spinner-container">
|
||||
<div class="report-spinner">
|
||||
<spinner></spinner>
|
||||
|
|
@ -54,6 +59,9 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapStores(useReportsStore),
|
||||
showComparableWarning() {
|
||||
return this.reportsStore.getShowComparableWarning;
|
||||
},
|
||||
hasReport() {
|
||||
return this.reportsStore.reports?.length > 0;
|
||||
},
|
||||
|
|
@ -160,4 +168,17 @@ export default {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.destination-differ-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.4rem;
|
||||
gap: 1.6rem;
|
||||
background-color: #c3cfdf;
|
||||
color: #002F54;
|
||||
border-radius: 0.8rem;
|
||||
padding: 1.6rem;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
|
@ -7,10 +7,14 @@ export const useReportsStore = defineStore('reports', {
|
|||
state() {
|
||||
return {
|
||||
reports: [],
|
||||
showComparableWarning: false,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
getShowComparableWarning(state) {
|
||||
return state.showComparableWarning;
|
||||
},
|
||||
getChartScale(state) {
|
||||
let max = 0;
|
||||
|
||||
|
|
@ -39,10 +43,42 @@ export const useReportsStore = defineStore('reports', {
|
|||
|
||||
const url = `${config.backendUrl}/reports/view/${params.size === 0 ? '' : '?'}${params.toString()}`;
|
||||
|
||||
this.reports = await performRequest(this,'GET', url, null).catch(e => {
|
||||
this.reports = await performRequest(this, 'GET', url, null).catch(e => {
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
|
||||
this.showComparableWarning = false;
|
||||
for (const [idx, report] of this.reports.entries()) {
|
||||
for (const otherReport of this.reports.slice(idx + 1)) {
|
||||
if (report.premises.length !== otherReport.premises.length) {
|
||||
this.showComparableWarning = true;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const premise of report.premises) {
|
||||
|
||||
const otherPremise = otherReport.premises.find(otherPremise => otherPremise.destination.external_mapping_id === premise.destination.external_mapping_id);
|
||||
|
||||
if((otherPremise ?? null) == null) {
|
||||
this.showComparableWarning = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if(otherPremise.annual_quantity !== premise.annual_quantity) {
|
||||
this.showComparableWarning = true;
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if(this.showComparableWarning)
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ public class BulkOperationController {
|
|||
* @param file The file to be uploaded, provided as a multipart file.
|
||||
* @return A ResponseEntity indicating whether the upload was processed successfully.
|
||||
*/
|
||||
@PostMapping("/upload/{type}/{processing_type}")
|
||||
@PostMapping({"/upload/{type}/{processing_type}","/upload/{type}/{processing_type}/"})
|
||||
public ResponseEntity<Integer> uploadFile(@PathVariable("processing_type") BulkProcessingType processingType, @PathVariable BulkFileType type, @RequestParam("file") MultipartFile file) {
|
||||
return ResponseEntity.ok(bulkProcessingService.processFile(type, processingType, file));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ public class RouteRepository {
|
|||
return Optional.empty();
|
||||
}
|
||||
|
||||
/* if(1 < route.size())
|
||||
TODO throw something */
|
||||
if(1 < route.size())
|
||||
throw new DatabaseException("Multiple selected routes for destination with id " + id);
|
||||
|
||||
|
||||
return Optional.of(route.getFirst());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue