mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-09-20 06:35:56 +08:00
Merge pull request #7840 from aignatov-bio/ai-sci-10954-import-items-to-box
Add import/export for box [SCI-10954]
This commit is contained in:
commit
8d5adad6b5
|
@ -2,10 +2,10 @@
|
|||
|
||||
class StorageLocationsController < ApplicationController
|
||||
before_action :check_storage_locations_enabled, except: :unassign_rows
|
||||
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows)
|
||||
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows export_container import_container)
|
||||
before_action :check_read_permissions, except: %i(index create tree actions_toolbar)
|
||||
before_action :check_create_permissions, only: :create
|
||||
before_action :check_manage_permissions, only: %i(update destroy duplicate move unassign_rows)
|
||||
before_action :check_manage_permissions, only: %i(update destroy duplicate move unassign_rows import_container)
|
||||
before_action :set_breadcrumbs_items, only: %i(index show)
|
||||
|
||||
def index
|
||||
|
@ -98,6 +98,25 @@ class StorageLocationsController < ApplicationController
|
|||
render json: { status: :ok }
|
||||
end
|
||||
|
||||
def export_container
|
||||
xlsx = StorageLocations::ExportService.new(@storage_location, current_user).to_xlsx
|
||||
|
||||
send_data(
|
||||
xlsx,
|
||||
filename: "#{@storage_location.name.gsub(/\s/, '_')}_export_#{Date.current}.xlsx",
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
end
|
||||
|
||||
def import_container
|
||||
result = StorageLocations::ImportService.new(@storage_location, params[:file], current_user).import_items
|
||||
if result[:status] == :ok
|
||||
render json: result
|
||||
else
|
||||
render json: result, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def actions_toolbar
|
||||
render json: {
|
||||
actions:
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/* global GLOBAL_CONSTANTS I18n */
|
||||
|
||||
export default {
|
||||
name: 'DragAndDropUpload',
|
||||
|
@ -69,7 +70,9 @@ export default {
|
|||
// check if it's a correct file type
|
||||
const fileExtension = file.name.split('.')[1];
|
||||
if (!this.supportedFormats.includes(fileExtension)) {
|
||||
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError');
|
||||
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError', {
|
||||
extensions: this.supportedFormats.join(', ')
|
||||
});
|
||||
this.$emit('file:error', error);
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
:scrollMode="paginationMode"
|
||||
@assign="assignRow"
|
||||
@move="moveRow"
|
||||
@import="openImportModal = true"
|
||||
@unassign="unassignRows"
|
||||
@tableReloaded="handleTableReload"
|
||||
@selectionChanged="selectedItems = $event"
|
||||
|
@ -35,6 +36,12 @@
|
|||
:cellId="cellIdToUnassign"
|
||||
@close="openAssignModal = false; this.reloadingTable = true"
|
||||
></AssignModal>
|
||||
<ImportModal
|
||||
v-if="openImportModal"
|
||||
:containerId="containerId"
|
||||
@close="openImportModal = false"
|
||||
@reloadTable="reloadingTable = true"
|
||||
></ImportModal>
|
||||
<ConfirmationModal
|
||||
:title="i18n.t('storage_locations.show.unassign_modal.title')"
|
||||
:description="storageLocationUnassignDescription"
|
||||
|
@ -53,6 +60,7 @@ import axios from '../../packs/custom_axios.js';
|
|||
import DataTable from '../shared/datatable/table.vue';
|
||||
import Grid from './grid.vue';
|
||||
import AssignModal from './modals/assign.vue';
|
||||
import ImportModal from './modals/import.vue';
|
||||
import ConfirmationModal from '../shared/confirmation_modal.vue';
|
||||
import RemindersRender from './renderers/reminders.vue';
|
||||
|
||||
|
@ -63,7 +71,8 @@ export default {
|
|||
Grid,
|
||||
AssignModal,
|
||||
ConfirmationModal,
|
||||
RemindersRender
|
||||
RemindersRender,
|
||||
ImportModal
|
||||
},
|
||||
props: {
|
||||
canManage: {
|
||||
|
@ -92,6 +101,7 @@ export default {
|
|||
return {
|
||||
reloadingTable: false,
|
||||
openEditModal: false,
|
||||
openImportModal: false,
|
||||
editModalMode: null,
|
||||
editStorageLocation: null,
|
||||
objectToMove: null,
|
||||
|
@ -117,7 +127,7 @@ export default {
|
|||
field: 'position_formatted',
|
||||
headerName: this.i18n.t('storage_locations.show.table.position'),
|
||||
sortable: true,
|
||||
notSelectable: true,
|
||||
notSelectable: true
|
||||
},
|
||||
{
|
||||
field: 'reminders',
|
||||
|
@ -157,6 +167,14 @@ export default {
|
|||
});
|
||||
}
|
||||
|
||||
left.push({
|
||||
name: 'import',
|
||||
icon: 'sn-icon sn-icon-import',
|
||||
label: this.i18n.t('storage_locations.show.import_modal.import_button'),
|
||||
type: 'emit',
|
||||
buttonStyle: 'btn btn-light'
|
||||
});
|
||||
|
||||
return {
|
||||
left,
|
||||
right: []
|
||||
|
|
118
app/javascript/vue/storage_locations/modals/import.vue
Normal file
118
app/javascript/vue/storage_locations/modals/import.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<div ref="modal" class="modal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<i class="sn-icon sn-icon-close"></i>
|
||||
</button>
|
||||
<h4 class="modal-title truncate" id="edit-project-modal-label">
|
||||
{{ i18n.t('storage_locations.show.import_modal.title') }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="modal-body flex flex-col grow">
|
||||
<p>
|
||||
{{ i18n.t('storage_locations.show.import_modal.description') }}
|
||||
</p>
|
||||
<h3 class="my-0 text-sn-dark-grey mb-3">
|
||||
{{ i18n.t('storage_locations.show.import_modal.export') }}
|
||||
</h3>
|
||||
<div class="flex gap-4 mb-6">
|
||||
<a
|
||||
:href="exportUrl"
|
||||
target="_blank"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<i class="sn-icon sn-icon-export"></i>
|
||||
{{ i18n.t('storage_locations.show.import_modal.export_button') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 class="my-0 text-sn-dark-grey mb-3">
|
||||
{{ i18n.t('storage_locations.show.import_modal.import') }}
|
||||
</h3>
|
||||
<DragAndDropUpload
|
||||
class="h-60"
|
||||
@file:dropped="uploadFile"
|
||||
@file:error="handleError"
|
||||
@file:error:clear="this.error = null"
|
||||
:supportingText="`${i18n.t('storage_locations.show.import_modal.drag_n_drop')}`"
|
||||
:supportedFormats="['xlsx']"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
|
||||
<i class="sn-icon sn-icon-alert-warning"></i>
|
||||
<div class="my-auto">{{ error }}</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" @click="close" aria-label="Close">
|
||||
{{ i18n.t('general.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DragAndDropUpload from '../../shared/drag_and_drop_upload.vue';
|
||||
import modalMixin from '../../shared/modal_mixin';
|
||||
import axios from '../../../packs/custom_axios';
|
||||
import {
|
||||
export_container_storage_location_path,
|
||||
import_container_storage_location_path
|
||||
} from '../../../routes.js';
|
||||
|
||||
export default {
|
||||
name: 'ImportContainer',
|
||||
emits: ['uploadFile', 'close'],
|
||||
components: {
|
||||
DragAndDropUpload
|
||||
},
|
||||
mixins: [modalMixin],
|
||||
props: {
|
||||
containerId: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
error: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
exportUrl() {
|
||||
return export_container_storage_location_path({
|
||||
id: this.containerId
|
||||
});
|
||||
},
|
||||
importUrl() {
|
||||
return import_container_storage_location_path({
|
||||
id: this.containerId
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleError(error) {
|
||||
this.error = error;
|
||||
},
|
||||
uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
|
||||
// required payload
|
||||
formData.append('file', file);
|
||||
|
||||
axios.post(this.importUrl, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
.then(() => {
|
||||
this.$emit('reloadTable');
|
||||
this.close();
|
||||
}).catch((error) => {
|
||||
this.handleError(error.response.data.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
43
app/services/storage_locations/export_service.rb
Normal file
43
app/services/storage_locations/export_service.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'caxlsx'
|
||||
|
||||
module StorageLocations
|
||||
class ExportService
|
||||
include Canaid::Helpers::PermissionsHelper
|
||||
def initialize(storage_location, user)
|
||||
@storage_location = storage_location
|
||||
@user = user
|
||||
end
|
||||
|
||||
def to_xlsx
|
||||
package = Axlsx::Package.new
|
||||
workbook = package.workbook
|
||||
|
||||
workbook.add_worksheet(name: 'Box Export') do |sheet|
|
||||
sheet.add_row ['Box position', 'Item ID', 'Item name']
|
||||
|
||||
@storage_location.storage_location_repository_rows.each do |storage_location_item|
|
||||
row = storage_location_item.repository_row
|
||||
row_name = row.name if can_read_repository?(@user, row.repository)
|
||||
sheet.add_row [format_position(storage_location_item), storage_location_item.repository_row_id, row_name]
|
||||
end
|
||||
end
|
||||
|
||||
package.to_stream.read
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_position(item)
|
||||
position = item.metadata['position']
|
||||
|
||||
return unless position
|
||||
|
||||
column_letter = ('A'..'Z').to_a[position[0] - 1]
|
||||
row_number = position[1]
|
||||
|
||||
"#{column_letter}#{row_number}"
|
||||
end
|
||||
end
|
||||
end
|
102
app/services/storage_locations/import_service.rb
Normal file
102
app/services/storage_locations/import_service.rb
Normal file
|
@ -0,0 +1,102 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'caxlsx'
|
||||
|
||||
module StorageLocations
|
||||
class ImportService
|
||||
def initialize(storage_location, file, user)
|
||||
@storage_location = storage_location
|
||||
@file = file
|
||||
@user = user
|
||||
end
|
||||
|
||||
def import_items
|
||||
sheet = SpreadsheetParser.open_spreadsheet(@file)
|
||||
incoming_items = SpreadsheetParser.spreadsheet_enumerator(sheet).reject { |r| r.all?(&:blank?) }
|
||||
|
||||
# Check if the file has proper headers
|
||||
header = SpreadsheetParser.parse_row(incoming_items[0], sheet)
|
||||
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_structure') } unless header[0] == 'Box position' && header[1] == 'Item ID'
|
||||
|
||||
# Remove first row
|
||||
incoming_items.shift
|
||||
|
||||
incoming_items.map! { |r| SpreadsheetParser.parse_row(r, sheet) }
|
||||
|
||||
# Check duplicate positions in the file
|
||||
if @storage_location.with_grid? && incoming_items.pluck(0).uniq.length != incoming_items.length
|
||||
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_position') }
|
||||
end
|
||||
|
||||
existing_items = @storage_location.storage_location_repository_rows.map do |item|
|
||||
[convert_position_number_to_letter(item), item.repository_row_id, item.id]
|
||||
end
|
||||
|
||||
items_to_unassign = []
|
||||
|
||||
existing_items.each do |existing_item|
|
||||
if incoming_items.any? { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
|
||||
incoming_items.reject! { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
|
||||
else
|
||||
items_to_unassign << existing_item[2]
|
||||
end
|
||||
end
|
||||
|
||||
error_message = ''
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@storage_location.storage_location_repository_rows.where(id: items_to_unassign).discard_all
|
||||
|
||||
incoming_items.each do |row|
|
||||
if @storage_location.with_grid?
|
||||
position = convert_position_letter_to_number(row[0])
|
||||
|
||||
unless position[0].to_i <= @storage_location.grid_size[0].to_i && position[1].to_i <= @storage_location.grid_size[1].to_i
|
||||
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_position')
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
end
|
||||
|
||||
repository_row = RepositoryRow.find_by(id: row[1])
|
||||
|
||||
unless repository_row
|
||||
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_item', row_id: row[1].to_i)
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@storage_location.storage_location_repository_rows.create!(
|
||||
repository_row: repository_row,
|
||||
metadata: { position: position },
|
||||
created_by: @user
|
||||
)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
return { status: :error, message: error_message }
|
||||
end
|
||||
|
||||
{ status: :ok }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def convert_position_letter_to_number(position)
|
||||
return unless position
|
||||
|
||||
column_letter = position[0]
|
||||
row_number = position[1]
|
||||
|
||||
[column_letter.ord - 64, row_number]
|
||||
end
|
||||
|
||||
def convert_position_number_to_letter(item)
|
||||
position = item.metadata['position']
|
||||
|
||||
return unless position
|
||||
|
||||
column_letter = ('A'..'Z').to_a[position[0] - 1]
|
||||
row_number = position[1]
|
||||
|
||||
"#{column_letter}#{row_number}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2341,7 +2341,7 @@ en:
|
|||
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
|
||||
dragAndDropUpload:
|
||||
notSingleFileError: 'Single file import only. Please import one file at a time.'
|
||||
wrongFileTypeError: 'The file has invalid extension (.csv, .xlsx, .txt or .tsv.)'
|
||||
wrongFileTypeError: 'The file has invalid extension (%{extensions}).'
|
||||
emptyFileError: 'You have uploaded empty file. There is not much to import.'
|
||||
fileTooLargeError: 'File too large. Max file size limit is'
|
||||
importText:
|
||||
|
@ -2698,6 +2698,18 @@ en:
|
|||
column: 'Column'
|
||||
inventory: 'Inventory'
|
||||
item: 'Item'
|
||||
import_modal:
|
||||
import_button: 'Import items'
|
||||
title: "Import items to a box"
|
||||
description: "Import items to a box allows for assigning items to a box and updating positions within a grid box. First, export the current box data to download a file listing the items already in the box. Then, edit the exported file to add or update the items you want to place in the box. When importing the file, ensure it includes the ‘Position’ and ‘Item ID’ columns for a successful import."
|
||||
export: "Export"
|
||||
export_button: "Export current box"
|
||||
import: "Import"
|
||||
drag_n_drop: ".xlsx file"
|
||||
errors:
|
||||
invalid_structure: "The imported file content doesn't meet criteria."
|
||||
invalid_position: "Positions in the file must match with the box."
|
||||
invalid_item: "Item ID %{row_id} doesn't exist."
|
||||
index:
|
||||
head_title: "Locations"
|
||||
new_location: "New location"
|
||||
|
|
|
@ -828,6 +828,8 @@ Rails.application.routes.draw do
|
|||
post :unassign_rows
|
||||
get :available_positions
|
||||
get :shareable_teams
|
||||
get :export_container
|
||||
post :import_container
|
||||
end
|
||||
resources :storage_location_repository_rows, only: %i(index create destroy update) do
|
||||
collection do
|
||||
|
|
Loading…
Reference in a new issue