mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-09-20 06:35:56 +08:00
Add assign/unassign modal and move modal [SCI-10870]
This commit is contained in:
parent
66d620b5da
commit
2da397cc76
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -95,3 +95,7 @@ public/marvin4js-license.cxl
|
||||||
|
|
||||||
/app/assets/builds/*
|
/app/assets/builds/*
|
||||||
!/app/assets/builds/.keep
|
!/app/assets/builds/.keep
|
||||||
|
|
||||||
|
# Ignore automatically generated js-routes files.
|
||||||
|
/app/javascript/routes.js
|
||||||
|
/app/javascript/routes.d.ts
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -94,6 +94,7 @@ gem 'graphviz'
|
||||||
|
|
||||||
gem 'cssbundling-rails'
|
gem 'cssbundling-rails'
|
||||||
gem 'jsbundling-rails'
|
gem 'jsbundling-rails'
|
||||||
|
gem 'js-routes'
|
||||||
|
|
||||||
gem 'tailwindcss-rails', '~> 2.4'
|
gem 'tailwindcss-rails', '~> 2.4'
|
||||||
|
|
||||||
|
|
|
@ -386,6 +386,8 @@ GEM
|
||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
|
js-routes (2.2.8)
|
||||||
|
railties (>= 4)
|
||||||
jsbundling-rails (1.1.1)
|
jsbundling-rails (1.1.1)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
json (2.6.3)
|
json (2.6.3)
|
||||||
|
@ -826,6 +828,7 @@ DEPENDENCIES
|
||||||
image_processing
|
image_processing
|
||||||
img2zpl!
|
img2zpl!
|
||||||
jbuilder
|
jbuilder
|
||||||
|
js-routes
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
json-jwt
|
json-jwt
|
||||||
json_matchers
|
json_matchers
|
||||||
|
|
2
Rakefile
2
Rakefile
|
@ -5,3 +5,5 @@ require File.expand_path('../config/application', __FILE__)
|
||||||
|
|
||||||
Rails.application.load_tasks
|
Rails.application.load_tasks
|
||||||
Doorkeeper::Rake.load_tasks
|
Doorkeeper::Rake.load_tasks
|
||||||
|
# Update js-routes file before javascript build
|
||||||
|
task 'javascript:build' => 'js:routes:typescript'
|
||||||
|
|
|
@ -10,14 +10,14 @@ class RepositoriesController < ApplicationController
|
||||||
include MyModulesHelper
|
include MyModulesHelper
|
||||||
|
|
||||||
before_action :load_repository, except: %i(index create create_modal sidebar archive restore actions_toolbar
|
before_action :load_repository, except: %i(index create create_modal sidebar archive restore actions_toolbar
|
||||||
export_modal export_repositories)
|
export_modal export_repositories list)
|
||||||
before_action :load_repositories, only: :index
|
before_action :load_repositories, only: %i(index list)
|
||||||
before_action :load_repositories_for_archiving, only: :archive
|
before_action :load_repositories_for_archiving, only: :archive
|
||||||
before_action :load_repositories_for_restoring, only: :restore
|
before_action :load_repositories_for_restoring, only: :restore
|
||||||
before_action :check_view_all_permissions, only: %i(index sidebar)
|
before_action :check_view_all_permissions, only: %i(index sidebar list)
|
||||||
before_action :check_view_permissions, except: %i(index create_modal create update destroy parse_sheet
|
before_action :check_view_permissions, except: %i(index create_modal create update destroy parse_sheet
|
||||||
import_records sidebar archive restore actions_toolbar
|
import_records sidebar archive restore actions_toolbar
|
||||||
export_modal export_repositories)
|
export_modal export_repositories list)
|
||||||
before_action :check_manage_permissions, only: %i(rename_modal update)
|
before_action :check_manage_permissions, only: %i(rename_modal update)
|
||||||
before_action :check_delete_permissions, only: %i(destroy destroy_modal)
|
before_action :check_delete_permissions, only: %i(destroy destroy_modal)
|
||||||
before_action :check_archive_permissions, only: %i(archive restore)
|
before_action :check_archive_permissions, only: %i(archive restore)
|
||||||
|
@ -44,6 +44,16 @@ class RepositoriesController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list
|
||||||
|
results = @repositories
|
||||||
|
results = results.name_like(params[:query]) if params[:query].present?
|
||||||
|
render json: { data: results.map { |r| [r.id, r.name] } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def rows_list
|
||||||
|
render json: { data: @repository.repository_rows.map { |r| [r.id, r.name] } }
|
||||||
|
end
|
||||||
|
|
||||||
def sidebar
|
def sidebar
|
||||||
render json: {
|
render json: {
|
||||||
html: render_to_string(partial: 'repositories/sidebar', locals: {
|
html: render_to_string(partial: 'repositories/sidebar', locals: {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class StorageLocationRepositoryRowsController < ApplicationController
|
class StorageLocationRepositoryRowsController < ApplicationController
|
||||||
before_action :load_storage_location_repository_row, only: %i(update destroy)
|
before_action :load_storage_location_repository_row, only: %i(update destroy move)
|
||||||
before_action :load_storage_location
|
before_action :load_storage_location
|
||||||
before_action :load_repository_row, only: %i(create update destroy)
|
before_action :load_repository_row, only: %i(create update destroy move)
|
||||||
before_action :check_read_permissions, except: %i(create actions_toolbar)
|
before_action :check_read_permissions, except: %i(create actions_toolbar)
|
||||||
before_action :check_manage_permissions, only: %i(create update destroy)
|
before_action :check_manage_permissions, only: %i(create update destroy)
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@ class StorageLocationRepositoryRowsController < ApplicationController
|
||||||
).call
|
).call
|
||||||
render json: storage_location_repository_row,
|
render json: storage_location_repository_row,
|
||||||
each_serializer: Lists::StorageLocationRepositoryRowSerializer,
|
each_serializer: Lists::StorageLocationRepositoryRowSerializer,
|
||||||
include: %i(repository_row),
|
|
||||||
meta: (pagination_dict(storage_location_repository_row) unless @storage_location.with_grid?)
|
meta: (pagination_dict(storage_location_repository_row) unless @storage_location.with_grid?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -22,8 +21,7 @@ class StorageLocationRepositoryRowsController < ApplicationController
|
||||||
|
|
||||||
if @storage_location_repository_row.save
|
if @storage_location_repository_row.save
|
||||||
render json: @storage_location_repository_row,
|
render json: @storage_location_repository_row,
|
||||||
serializer: Lists::StorageLocationRepositoryRowSerializer,
|
serializer: Lists::StorageLocationRepositoryRowSerializer
|
||||||
include: :repository_row
|
|
||||||
else
|
else
|
||||||
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
|
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
@ -39,13 +37,30 @@ class StorageLocationRepositoryRowsController < ApplicationController
|
||||||
|
|
||||||
if @storage_location_repository_row.save
|
if @storage_location_repository_row.save
|
||||||
render json: @storage_location_repository_row,
|
render json: @storage_location_repository_row,
|
||||||
serializer: Lists::StorageLocationRepositoryRowSerializer,
|
serializer: Lists::StorageLocationRepositoryRowSerializer
|
||||||
include: :repository_row
|
|
||||||
else
|
else
|
||||||
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
|
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def move
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@storage_location_repository_row.discard
|
||||||
|
@storage_location_repository_row = StorageLocationRepositoryRow.create!(
|
||||||
|
repository_row: @repository_row,
|
||||||
|
storage_location: @storage_location,
|
||||||
|
metadata: storage_location_repository_row_params[:metadata] || {},
|
||||||
|
created_by: current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: @storage_location_repository_row,
|
||||||
|
serializer: Lists::StorageLocationRepositoryRowSerializer
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if @storage_location_repository_row.discard
|
if @storage_location_repository_row.discard
|
||||||
render json: {}
|
render json: {}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class StorageLocationsController < ApplicationController
|
class StorageLocationsController < ApplicationController
|
||||||
before_action :load_storage_location, only: %i(update destroy duplicate move show)
|
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows)
|
||||||
before_action :check_read_permissions, except: %i(index create tree actions_toolbar)
|
before_action :check_read_permissions, except: %i(index create tree actions_toolbar)
|
||||||
before_action :check_create_permissions, only: :create
|
before_action :check_create_permissions, only: :create
|
||||||
before_action :check_manage_permissions, only: %i(update destroy duplicate move)
|
before_action :check_manage_permissions, only: %i(update destroy duplicate move)
|
||||||
|
@ -81,10 +81,20 @@ class StorageLocationsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def tree
|
def tree
|
||||||
records = current_team.storage_locations.where(parent: nil, container: false)
|
records = current_team.storage_locations.where(parent: nil, container: [false, params[:container] == 'true'])
|
||||||
render json: storage_locations_recursive_builder(records)
|
render json: storage_locations_recursive_builder(records)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def available_positions
|
||||||
|
render json: { positions: @storage_location.available_positions }
|
||||||
|
end
|
||||||
|
|
||||||
|
def unassign_rows
|
||||||
|
@storage_location.storage_location_repository_rows.where(id: params[:ids]).discard_all
|
||||||
|
|
||||||
|
render json: { status: :ok }
|
||||||
|
end
|
||||||
|
|
||||||
def actions_toolbar
|
def actions_toolbar
|
||||||
render json: {
|
render json: {
|
||||||
actions:
|
actions:
|
||||||
|
@ -172,7 +182,9 @@ class StorageLocationsController < ApplicationController
|
||||||
storage_locations.map do |storage_location|
|
storage_locations.map do |storage_location|
|
||||||
{
|
{
|
||||||
storage_location: storage_location,
|
storage_location: storage_location,
|
||||||
children: storage_locations_recursive_builder(storage_location.storage_locations.where(container: false))
|
children: storage_locations_recursive_builder(
|
||||||
|
storage_location.storage_locations.where(container: [false, params[:container] == 'true'])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,13 +3,13 @@
|
||||||
<div v-if="withGrid">
|
<div v-if="withGrid">
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<div class="h-11">
|
<div class="h-11">
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary" @click="assignRow">
|
||||||
<i class="sn-icon sn-icon-new-task"></i>
|
<i class="sn-icon sn-icon-new-task"></i>
|
||||||
{{ i18n.t('storage_locations.show.toolbar.assign') }}
|
{{ i18n.t('storage_locations.show.toolbar.assign') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Grid :gridSize="gridSize" :assignedItems="assignedItems" />
|
<Grid :gridSize="gridSize" :assignedItems="assignedItems" @assign="assignRowToPosition"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full bg-white p-4">
|
<div class="h-full bg-white p-4">
|
||||||
<DataTable :columnDefs="columnDefs"
|
<DataTable :columnDefs="columnDefs"
|
||||||
|
@ -20,9 +20,31 @@
|
||||||
:toolbarActions="toolbarActions"
|
:toolbarActions="toolbarActions"
|
||||||
:actionsUrl="actionsUrl"
|
:actionsUrl="actionsUrl"
|
||||||
:scrollMode="paginationMode"
|
:scrollMode="paginationMode"
|
||||||
|
@assign="assignRow"
|
||||||
|
@move="moveRow"
|
||||||
|
@unassign="unassignRows"
|
||||||
@tableReloaded="handleTableReload"
|
@tableReloaded="handleTableReload"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<AssignModal
|
||||||
|
v-if="openAssignModal"
|
||||||
|
:assignMode="assignMode"
|
||||||
|
:selectedContainer="assignToContainer"
|
||||||
|
:selectedPosition="assignToPosition"
|
||||||
|
:selectedRow="rowIdToMove"
|
||||||
|
:cellId="cellIdToUnassign"
|
||||||
|
:withGrid="withGrid"
|
||||||
|
@close="openAssignModal = false; this.reloadingTable = true"
|
||||||
|
></AssignModal>
|
||||||
|
<ConfirmationModal
|
||||||
|
:title="i18n.t('storage_locations.show.unassign_modal.title')"
|
||||||
|
:description="storageLocationUnassignDescription"
|
||||||
|
confirmClass="btn btn-danger"
|
||||||
|
:confirmText="i18n.t('storage_locations.show.unassign_modal.button')"
|
||||||
|
ref="unassignStorageLocationModal"
|
||||||
|
></ConfirmationModal>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -32,12 +54,16 @@
|
||||||
import axios from '../../packs/custom_axios.js';
|
import axios from '../../packs/custom_axios.js';
|
||||||
import DataTable from '../shared/datatable/table.vue';
|
import DataTable from '../shared/datatable/table.vue';
|
||||||
import Grid from './grid.vue';
|
import Grid from './grid.vue';
|
||||||
|
import AssignModal from './modals/assign.vue';
|
||||||
|
import ConfirmationModal from '../shared/confirmation_modal.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'StorageLocationsContainer',
|
name: 'StorageLocationsContainer',
|
||||||
components: {
|
components: {
|
||||||
DataTable,
|
DataTable,
|
||||||
Grid
|
Grid,
|
||||||
|
AssignModal,
|
||||||
|
ConfirmationModal
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
|
@ -52,6 +78,10 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
containerId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
gridSize: Array
|
gridSize: Array
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -62,7 +92,14 @@ export default {
|
||||||
editStorageLocation: null,
|
editStorageLocation: null,
|
||||||
objectToMove: null,
|
objectToMove: null,
|
||||||
moveToUrl: null,
|
moveToUrl: null,
|
||||||
assignedItems: []
|
assignedItems: [],
|
||||||
|
openAssignModal: false,
|
||||||
|
assignToPosition: null,
|
||||||
|
assignToContainer: null,
|
||||||
|
rowIdToMove: null,
|
||||||
|
cellIdToUnassign: null,
|
||||||
|
assignMode: 'assign',
|
||||||
|
storageLocationUnassignDescription: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -123,6 +160,44 @@ export default {
|
||||||
handleTableReload(items) {
|
handleTableReload(items) {
|
||||||
this.reloadingTable = false;
|
this.reloadingTable = false;
|
||||||
this.assignedItems = items;
|
this.assignedItems = items;
|
||||||
|
},
|
||||||
|
assignRow() {
|
||||||
|
this.openAssignModal = true;
|
||||||
|
this.rowIdToMove = null;
|
||||||
|
this.assignToContainer = this.containerId;
|
||||||
|
this.assignToPosition = null;
|
||||||
|
this.cellIdToUnassign = null;
|
||||||
|
this.assignMode = 'assign';
|
||||||
|
},
|
||||||
|
assignRowToPosition(position) {
|
||||||
|
this.openAssignModal = true;
|
||||||
|
this.rowIdToMove = null;
|
||||||
|
this.assignToContainer = this.containerId;
|
||||||
|
this.assignToPosition = position;
|
||||||
|
this.cellIdToUnassign = null;
|
||||||
|
this.assignMode = 'assign';
|
||||||
|
},
|
||||||
|
moveRow(_event, data) {
|
||||||
|
this.openAssignModal = true;
|
||||||
|
this.rowIdToMove = data[0].row_id;
|
||||||
|
this.assignToContainer = null;
|
||||||
|
this.assignToPosition = null;
|
||||||
|
this.cellIdToUnassign = data[0].id;
|
||||||
|
this.assignMode = 'move';
|
||||||
|
},
|
||||||
|
async unassignRows(event, rows) {
|
||||||
|
this.storageLocationUnassignDescription = this.i18n.t(
|
||||||
|
'storage_locations.show.unassign_modal.description',
|
||||||
|
{ items: rows.length }
|
||||||
|
);
|
||||||
|
const ok = await this.$refs.unassignStorageLocationModal.show();
|
||||||
|
if (ok) {
|
||||||
|
axios.post(event.path).then(() => {
|
||||||
|
this.reloadingTable = true;
|
||||||
|
}).catch((error) => {
|
||||||
|
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,9 +25,10 @@
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full w-full rounded-full items-center flex justify-center"
|
class="h-full w-full rounded-full items-center flex justify-center"
|
||||||
|
@click="assignRow(cell.row, cell.column)"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-sn-background-green': cellIsOccupied(cell.row, cell.column),
|
'bg-sn-background-green': cellIsOccupied(cell.row, cell.column),
|
||||||
'bg-white': !cellIsOccupied(cell.row, cell.column)
|
'bg-white cursor-pointer': !cellIsOccupied(cell.row, cell.column)
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ rowsList[cell.row] }}{{ columnsList[cell.column] }}
|
{{ rowsList[cell.row] }}{{ columnsList[cell.column] }}
|
||||||
|
@ -81,6 +82,12 @@ export default {
|
||||||
cellIsOccupied(row, column) {
|
cellIsOccupied(row, column) {
|
||||||
return this.assignedItems.some((item) => item.position[0] === row + 1 && item.position[1] === column + 1);
|
return this.assignedItems.some((item) => item.position[0] === row + 1 && item.position[1] === column + 1);
|
||||||
},
|
},
|
||||||
|
assignRow(row, column) {
|
||||||
|
if (this.cellIsOccupied(row, column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$emit('assign', [row + 1, column + 1]);
|
||||||
|
},
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft;
|
this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft;
|
||||||
this.$refs.rowContainer.scrollTop = this.$refs.cellsContainer.scrollTop;
|
this.$refs.rowContainer.scrollTop = this.$refs.cellsContainer.scrollTop;
|
||||||
|
|
99
app/javascript/vue/storage_locations/modals/assign.vue
Normal file
99
app/javascript/vue/storage_locations/modals/assign.vue
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<template>
|
||||||
|
<div ref="modal" class="modal" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<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 !block">
|
||||||
|
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_title`) }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-4">
|
||||||
|
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_description`) }}
|
||||||
|
</p>
|
||||||
|
<RowSelector v-if="!selectedRow" @change="this.rowId = $event" class="mb-4"></RowSelector>
|
||||||
|
<ContainerSelector v-if="!selectedContainer" @change="this.containerId = $event"></ContainerSelector>
|
||||||
|
<PositionSelector
|
||||||
|
v-if="containerId && !selectedPosition && withGrid"
|
||||||
|
:key="containerId"
|
||||||
|
:selectedContainerId="containerId"
|
||||||
|
@change="this.position = $event"></PositionSelector>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_action`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* global HelperModule */
|
||||||
|
|
||||||
|
import axios from '../../../packs/custom_axios.js';
|
||||||
|
import modalMixin from '../../shared/modal_mixin';
|
||||||
|
import RowSelector from './assign/row_selector.vue';
|
||||||
|
import ContainerSelector from './assign/container_selector.vue';
|
||||||
|
import PositionSelector from './assign/position_selector.vue';
|
||||||
|
import {
|
||||||
|
storage_location_storage_location_repository_rows_path,
|
||||||
|
move_storage_location_storage_location_repository_row_path,
|
||||||
|
|
||||||
|
} from '../../../routes.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NewProjectModal',
|
||||||
|
props: {
|
||||||
|
selectedRow: Number,
|
||||||
|
selectedContainer: Number,
|
||||||
|
cellId: Number,
|
||||||
|
selectedPosition: Array,
|
||||||
|
withGrid: Boolean,
|
||||||
|
assignMode: String
|
||||||
|
},
|
||||||
|
mixins: [modalMixin],
|
||||||
|
computed: {
|
||||||
|
createUrl() {
|
||||||
|
return storage_location_storage_location_repository_rows_path({
|
||||||
|
storage_location_id: this.containerId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
moveUrl() {
|
||||||
|
return move_storage_location_storage_location_repository_row_path(this.containerId, this.cellId);
|
||||||
|
},
|
||||||
|
actionUrl() {
|
||||||
|
return this.assignMode === 'assign' ? this.createUrl : this.moveUrl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rowId: this.selectedRow,
|
||||||
|
containerId: this.selectedContainer,
|
||||||
|
position: this.selectedPosition
|
||||||
|
};
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
RowSelector,
|
||||||
|
ContainerSelector,
|
||||||
|
PositionSelector
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submit() {
|
||||||
|
axios.post(this.actionUrl, {
|
||||||
|
repository_row_id: this.rowId,
|
||||||
|
metadata: { position: this.position?.map((pos) => parseInt(pos, 10)) }
|
||||||
|
}).then(() => {
|
||||||
|
this.$emit('close');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="sci-input-container-v2 left-icon">
|
||||||
|
<input type="text"
|
||||||
|
v-model="query"
|
||||||
|
class="sci-input-field"
|
||||||
|
ref="input"
|
||||||
|
autofocus="true"
|
||||||
|
:placeholder=" i18n.t('storage_locations.index.move_modal.placeholder.find_storage_locations')" />
|
||||||
|
<i class="sn-icon sn-icon-search"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-80 overflow-y-auto">
|
||||||
|
<div class="p-2 flex items-center gap-2 cursor-pointer text-sn-blue hover:bg-sn-super-light-grey"
|
||||||
|
@click="selectStorageLocation(null)"
|
||||||
|
:class="{'!bg-sn-super-light-blue': selectedStorageLocationId == null}">
|
||||||
|
<i class="sn-icon sn-icon-projects"></i>
|
||||||
|
{{ i18n.t('storage_locations.index.move_modal.search_header') }}
|
||||||
|
</div>
|
||||||
|
<MoveTree :storageLocationTrees="filteredStorageLocationTree" :value="selectedStorageLocationId" @selectStorageLocation="selectStorageLocation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import MoveTreeMixin from '../move_tree_mixin';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ContainerSelector',
|
||||||
|
mixins: [MoveTreeMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
container: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedStorageLocationId() {
|
||||||
|
this.$emit('change', this.selectedStorageLocationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,77 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="">
|
||||||
|
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.row`) }}</div>
|
||||||
|
<SelectDropdown
|
||||||
|
:options="availableRows"
|
||||||
|
:value="selectedRow"
|
||||||
|
@change="selectedRow = $event"
|
||||||
|
></SelectDropdown>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.column`) }}</div>
|
||||||
|
<SelectDropdown
|
||||||
|
:disabled="!selectedRow"
|
||||||
|
:options="availableColumns"
|
||||||
|
:value="selectedColumn"
|
||||||
|
@change="selectedColumn= $event"
|
||||||
|
></SelectDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SelectDropdown from '../../../shared/select_dropdown.vue';
|
||||||
|
import axios from '../../../../packs/custom_axios.js';
|
||||||
|
import {
|
||||||
|
available_positions_storage_location_path,
|
||||||
|
} from '../../../../routes.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PositionSelector',
|
||||||
|
components: {
|
||||||
|
SelectDropdown
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
selectedContainerId: Number
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
axios.get(this.positionsUrl)
|
||||||
|
.then((response) => {
|
||||||
|
this.availablePositions = response.data.positions;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
[[this.selectedRow]] = this.availableRows;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
[[this.selectedColumn]] = this.availableColumns;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedRow() {
|
||||||
|
[[this.selectedColumn]] = this.availableColumns;
|
||||||
|
},
|
||||||
|
selectedColumn() {
|
||||||
|
this.$emit('change', [this.selectedRow, this.selectedColumn]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
positionsUrl() {
|
||||||
|
return available_positions_storage_location_path(this.selectedContainerId);
|
||||||
|
},
|
||||||
|
availableRows() {
|
||||||
|
return Object.keys(this.availablePositions).map((row) => [row, row]);
|
||||||
|
},
|
||||||
|
availableColumns() {
|
||||||
|
return (this.availablePositions[this.selectedRow] || []).map((col) => [col, col]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
availablePositions: {},
|
||||||
|
selectedRow: null,
|
||||||
|
selectedColumn: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.inventory`) }}</div>
|
||||||
|
<SelectDropdown
|
||||||
|
:optionsUrl="repositoriesUrl"
|
||||||
|
placeholder="Select inventory"
|
||||||
|
:searchable="true"
|
||||||
|
@change="selectedRepository = $event"
|
||||||
|
></SelectDropdown>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.item`) }}</div>
|
||||||
|
<SelectDropdown
|
||||||
|
:disabled="!selectedRepository"
|
||||||
|
:optionsUrl="rowsUrl"
|
||||||
|
:urlParams="{ repository_id: selectedRepository }"
|
||||||
|
placeholder="Select item"
|
||||||
|
:searchable="true"
|
||||||
|
@change="selectedRow= $event"
|
||||||
|
></SelectDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SelectDropdown from '../../../shared/select_dropdown.vue';
|
||||||
|
import {
|
||||||
|
list_team_repositories_path,
|
||||||
|
rows_list_team_repositories_path
|
||||||
|
} from '../../../../routes.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RowSelector',
|
||||||
|
components: {
|
||||||
|
SelectDropdown
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.teamId = document.body.dataset.currentTeamId;
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedRepository() {
|
||||||
|
this.selectedRow = null;
|
||||||
|
},
|
||||||
|
selectedRow() {
|
||||||
|
this.$emit('change', this.selectedRow);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
repositoriesUrl() {
|
||||||
|
return list_team_repositories_path(this.teamId);
|
||||||
|
},
|
||||||
|
rowsUrl() {
|
||||||
|
if (!this.selectedRepository) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows_list_team_repositories_path(this.teamId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedRepository: null,
|
||||||
|
selectedRow: null,
|
||||||
|
teamId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -51,16 +51,15 @@
|
||||||
|
|
||||||
import axios from '../../../packs/custom_axios.js';
|
import axios from '../../../packs/custom_axios.js';
|
||||||
import modalMixin from '../../shared/modal_mixin';
|
import modalMixin from '../../shared/modal_mixin';
|
||||||
import MoveTree from './move_tree.vue';
|
import MoveTreeMixin from './move_tree_mixin';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NewProjectModal',
|
name: 'NewProjectModal',
|
||||||
props: {
|
props: {
|
||||||
selectedObject: Array,
|
selectedObject: Array,
|
||||||
storageLocationTreeUrl: String,
|
|
||||||
moveToUrl: String
|
moveToUrl: String
|
||||||
},
|
},
|
||||||
mixins: [modalMixin],
|
mixins: [modalMixin, MoveTreeMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedStorageLocationId: null,
|
selectedStorageLocationId: null,
|
||||||
|
@ -68,37 +67,7 @@ export default {
|
||||||
query: ''
|
query: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
|
||||||
MoveTree
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
axios.get(this.storageLocationTreeUrl).then((response) => {
|
|
||||||
this.storageLocationTree = response.data;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
filteredStorageLocationTree() {
|
|
||||||
if (this.query === '') {
|
|
||||||
return this.storageLocationTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.storageLocationTree.map((storageLocation) => (
|
|
||||||
{
|
|
||||||
storage_location: storageLocation.storage_location,
|
|
||||||
children: storageLocation.children.filter((child) => (
|
|
||||||
child.storage_location.name.toLowerCase().includes(this.query.toLowerCase())
|
|
||||||
))
|
|
||||||
}
|
|
||||||
)).filter((storageLocation) => (
|
|
||||||
storageLocation.storage_location.name.toLowerCase().includes(this.query.toLowerCase())
|
|
||||||
|| storageLocation.children.length > 0
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
selectStorageLocation(storageLocationId) {
|
|
||||||
this.selectedStorageLocationId = storageLocationId;
|
|
||||||
},
|
|
||||||
submit() {
|
submit() {
|
||||||
axios.post(this.moveToUrl, {
|
axios.post(this.moveToUrl, {
|
||||||
destination_storage_location_id: this.selectedStorageLocationId || 'root_storage_location'
|
destination_storage_location_id: this.selectedStorageLocationId || 'root_storage_location'
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
class="cursor-pointer flex items-center pl-1 flex-1 gap-2
|
class="cursor-pointer flex items-center pl-1 flex-1 gap-2
|
||||||
text-sn-blue hover:bg-sn-super-light-grey"
|
text-sn-blue hover:bg-sn-super-light-grey"
|
||||||
:class="{'!bg-sn-super-light-blue': storageLocationTree.storage_location.id == value}">
|
:class="{'!bg-sn-super-light-blue': storageLocationTree.storage_location.id == value}">
|
||||||
<i class="sn-icon sn-icon-folder"></i>
|
<i v-if="storageLocationTree.storage_location.container" class="sn-icon sn-icon-item"></i>
|
||||||
<div class="flex-1 truncate p-2 pl-0" :title="storageLocationTree.storage_location.name">
|
<div class="flex-1 truncate p-2 pl-0" :title="storageLocationTree.storage_location.name">
|
||||||
{{ storageLocationTree.storage_location.name }}
|
{{ storageLocationTree.storage_location.name }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import axios from '../../../packs/custom_axios.js';
|
||||||
|
import MoveTree from './move_tree.vue';
|
||||||
|
import {
|
||||||
|
tree_storage_locations_path
|
||||||
|
} from '../../../routes.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mounted() {
|
||||||
|
axios.get(this.storageLocationTreeUrl).then((response) => {
|
||||||
|
this.storageLocationTree = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedStorageLocationId: null,
|
||||||
|
storageLocationTree: [],
|
||||||
|
query: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
storageLocationTreeUrl() {
|
||||||
|
return tree_storage_locations_path({ format: 'json', container: this.container });
|
||||||
|
},
|
||||||
|
filteredStorageLocationTree() {
|
||||||
|
if (this.query === '') {
|
||||||
|
return this.storageLocationTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.storageLocationTree.map((storageLocation) => (
|
||||||
|
{
|
||||||
|
storage_location: storageLocation.storage_location,
|
||||||
|
children: storageLocation.children.filter((child) => (
|
||||||
|
child.storage_location.name.toLowerCase().includes(this.query.toLowerCase())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)).filter((storageLocation) => (
|
||||||
|
storageLocation.storage_location.name.toLowerCase().includes(this.query.toLowerCase())
|
||||||
|
|| storageLocation.children.length > 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MoveTree
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectStorageLocation(storageLocationId) {
|
||||||
|
this.selectedStorageLocationId = storageLocationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -24,7 +24,7 @@
|
||||||
:editStorageLocation="editStorageLocation"
|
:editStorageLocation="editStorageLocation"
|
||||||
/>
|
/>
|
||||||
<MoveModal v-if="objectToMove" :moveToUrl="moveToUrl"
|
<MoveModal v-if="objectToMove" :moveToUrl="moveToUrl"
|
||||||
:selectedObject="objectToMove" :storageLocationTreeUrl="storageLocationTreeUrl"
|
:selectedObject="objectToMove"
|
||||||
@close="objectToMove = null" @move="updateTable()" />
|
@close="objectToMove = null" @move="updateTable()" />
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
:title="storageLocationDeleteTitle"
|
:title="storageLocationDeleteTitle"
|
||||||
|
@ -68,9 +68,6 @@ export default {
|
||||||
},
|
},
|
||||||
directUploadUrl: {
|
directUploadUrl: {
|
||||||
type: String
|
type: String
|
||||||
},
|
|
||||||
storageLocationTreeUrl: {
|
|
||||||
type: String
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
@ -47,6 +47,24 @@ class StorageLocation < ApplicationRecord
|
||||||
metadata['dimensions'] if with_grid?
|
metadata['dimensions'] if with_grid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def available_positions
|
||||||
|
return unless with_grid?
|
||||||
|
|
||||||
|
occupied_positions = storage_location_repository_rows.pluck(:metadata).map { |metadata| metadata['position'] }
|
||||||
|
|
||||||
|
rows = {}
|
||||||
|
|
||||||
|
grid_size[0].times do |row|
|
||||||
|
rows_cells = []
|
||||||
|
grid_size[1].times.filter_map do |col|
|
||||||
|
rows_cells.push(col + 1) if occupied_positions.exclude?([row + 1, col + 1])
|
||||||
|
end
|
||||||
|
rows[row + 1] = rows_cells unless rows_cells.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def recursive_duplicate(old_parent_id = nil, new_parent_id = nil)
|
def recursive_duplicate(old_parent_id = nil, new_parent_id = nil)
|
||||||
|
|
|
@ -28,12 +28,10 @@ module Toolbars
|
||||||
|
|
||||||
def unassign_action
|
def unassign_action
|
||||||
{
|
{
|
||||||
name: 'edit',
|
name: 'unassign',
|
||||||
label: I18n.t('storage_locations.show.toolbar.unassign'),
|
label: I18n.t('storage_locations.show.toolbar.unassign'),
|
||||||
icon: 'sn-icon sn-icon-close',
|
icon: 'sn-icon sn-icon-close',
|
||||||
path: unassign_storage_location_storage_location_repository_rows_path(
|
path: unassign_rows_storage_location_path(@storage_location, ids: @assigned_rows.pluck(:id)),
|
||||||
@storage_location, ids: @assigned_rows.pluck(:id)
|
|
||||||
),
|
|
||||||
type: :emit
|
type: :emit
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,7 +15,6 @@
|
||||||
data-source="<%= storage_locations_path(format: :json, parent_id: params[:parent_id]) %>"
|
data-source="<%= storage_locations_path(format: :json, parent_id: params[:parent_id]) %>"
|
||||||
direct-upload-url="<%= rails_direct_uploads_url %>"
|
direct-upload-url="<%= rails_direct_uploads_url %>"
|
||||||
create-url="<%= storage_locations_path(parent_id: params[:parent_id]) if can_create_storage_locations?(current_team) %>"
|
create-url="<%= storage_locations_path(parent_id: params[:parent_id]) if can_create_storage_locations?(current_team) %>"
|
||||||
storage-location-tree-url="<%= tree_storage_locations_path %>"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
data-source="<%= storage_location_storage_location_repository_rows_path(@storage_location) %>"
|
data-source="<%= storage_location_storage_location_repository_rows_path(@storage_location) %>"
|
||||||
:with-grid="<%= @storage_location.with_grid? %>"
|
:with-grid="<%= @storage_location.with_grid? %>"
|
||||||
:grid-size="<%= @storage_location.grid_size.to_json %>"
|
:grid-size="<%= @storage_location.grid_size.to_json %>"
|
||||||
|
:container-id="<%= @storage_location.id %>"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -105,4 +105,8 @@ Rails.application.configure do
|
||||||
config.x.new_team_on_signup = false
|
config.x.new_team_on_signup = false
|
||||||
end
|
end
|
||||||
config.hosts << "dev.scinote.test"
|
config.hosts << "dev.scinote.test"
|
||||||
|
|
||||||
|
# Automatically update js-routes file
|
||||||
|
# when routes.rb is changed
|
||||||
|
config.middleware.use(JsRoutes::Middleware)
|
||||||
end
|
end
|
||||||
|
|
7
config/initializers/js_routes.rb
Normal file
7
config/initializers/js_routes.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
JsRoutes.setup do |c|
|
||||||
|
# Setup your JS module system:
|
||||||
|
# ESM, CJS, AMD, UMD or nil
|
||||||
|
# c.module_type = "ESM"
|
||||||
|
end
|
|
@ -2684,6 +2684,21 @@ en:
|
||||||
assign: 'Assign item'
|
assign: 'Assign item'
|
||||||
unassign: 'Unassign'
|
unassign: 'Unassign'
|
||||||
move: 'Move'
|
move: 'Move'
|
||||||
|
unassign_modal:
|
||||||
|
title: 'Unassign location'
|
||||||
|
description: 'Are you sure you want to remove %{items} item(s) from their current storage location?'
|
||||||
|
button: 'Unassign'
|
||||||
|
assign_modal:
|
||||||
|
assign_title: 'Assign position'
|
||||||
|
move_title: 'Move item'
|
||||||
|
assign_description: 'Select an item to assign it to a location.'
|
||||||
|
move_description: 'Select a new location for your item.'
|
||||||
|
assign_action: 'Assign'
|
||||||
|
move_action: 'Move'
|
||||||
|
row: 'Row'
|
||||||
|
column: 'Column'
|
||||||
|
inventory: 'Inventory'
|
||||||
|
item: 'Item'
|
||||||
index:
|
index:
|
||||||
head_title: "Locations"
|
head_title: "Locations"
|
||||||
new_location: "New location"
|
new_location: "New location"
|
||||||
|
|
|
@ -194,6 +194,8 @@ Rails.application.routes.draw do
|
||||||
get 'create_modal', to: 'repositories#create_modal',
|
get 'create_modal', to: 'repositories#create_modal',
|
||||||
defaults: { format: 'json' }
|
defaults: { format: 'json' }
|
||||||
get 'actions_toolbar'
|
get 'actions_toolbar'
|
||||||
|
get :list
|
||||||
|
get :rows_list
|
||||||
end
|
end
|
||||||
member do
|
member do
|
||||||
get :export_empty_repository
|
get :export_empty_repository
|
||||||
|
@ -815,11 +817,12 @@ Rails.application.routes.draw do
|
||||||
member do
|
member do
|
||||||
post :move
|
post :move
|
||||||
post :duplicate
|
post :duplicate
|
||||||
|
post :unassign_rows
|
||||||
|
get :available_positions
|
||||||
end
|
end
|
||||||
resources :storage_location_repository_rows, only: %i(index create destroy update) do
|
resources :storage_location_repository_rows, only: %i(index create destroy update) do
|
||||||
collection do
|
collection do
|
||||||
get :actions_toolbar
|
get :actions_toolbar
|
||||||
post :unassign
|
|
||||||
end
|
end
|
||||||
member do
|
member do
|
||||||
post :move
|
post :move
|
||||||
|
|
Loading…
Reference in a new issue