Enable multiple selection in storage locations grid [SCI-11535]

This commit is contained in:
Anton 2025-02-11 11:38:05 +01:00
parent b20b67e196
commit 527e31be82
5 changed files with 187 additions and 54 deletions

View file

@ -19,22 +19,46 @@ class StorageLocationRepositoryRowsController < ApplicationController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@storage_location_repository_row = StorageLocationRepositoryRow.new( storage_location_repository_rows = []
repository_row: @repository_row,
storage_location: @storage_location,
metadata: storage_location_repository_row_params[:metadata] || {},
created_by: current_user
)
@storage_location_repository_row.with_lock do if @storage_location.with_grid?
if @storage_location_repository_row.save params[:positions].each do |position|
log_activity(:storage_location_repository_row_created) if position.dig(2, :occupied)
render json: @storage_location_repository_row, occupied_storage_location_repository_row = @storage_location.storage_location_repository_rows.find_by(id: position.dig(2, :id))
serializer: Lists::StorageLocationRepositoryRowSerializer raise ActiveRecord::RecordInvalid, occupied_row unless discard_storage_location_repository_rows(occupied_storage_location_repository_row)
else end
render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity storage_location_repository_row = StorageLocationRepositoryRow.new(
repository_row: @repository_row,
storage_location: @storage_location,
metadata: { position: position[0..1] },
created_by: current_user
)
storage_location_repository_row.with_lock do
storage_location_repository_row.save!
storage_location_repository_rows << storage_location_repository_row
end
end
else
storage_location_repository_row = StorageLocationRepositoryRow.new(
repository_row: @repository_row,
storage_location: @storage_location,
created_by: current_user
)
storage_location_repository_row.with_lock do
storage_location_repository_row.save!
storage_location_repository_rows << storage_location_repository_row
end end
end end
log_activity(:storage_location_repository_row_created, {
repository_row: @repository_row.id,
position: storage_location_repository_rows.map(&:human_readable_position).join(', ')
})
render json: storage_location_repository_rows, each_serializer: Lists::StorageLocationRepositoryRowSerializer
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end end
end end
@ -43,7 +67,10 @@ class StorageLocationRepositoryRowsController < ApplicationController
@storage_location_repository_row.update(storage_location_repository_row_params) @storage_location_repository_row.update(storage_location_repository_row_params)
if @storage_location_repository_row.save if @storage_location_repository_row.save
log_activity(:storage_location_repository_row_moved) log_activity(:storage_location_repository_row_moved, {
repository_row: @storage_location_repository_row.repository_row_id,
position: @storage_location_repository_row.human_readable_position
})
render json: @storage_location_repository_row, render json: @storage_location_repository_row,
serializer: Lists::StorageLocationRepositoryRowSerializer serializer: Lists::StorageLocationRepositoryRowSerializer
else else
@ -58,17 +85,26 @@ class StorageLocationRepositoryRowsController < ApplicationController
@original_position = @storage_location_repository_row.human_readable_position @original_position = @storage_location_repository_row.human_readable_position
@storage_location_repository_row.discard @storage_location_repository_row.discard
metadata = if @storage_location.with_grid?
{ position: params[:positions][0][0..1] } # For now, we only support moving one row at a time
else
{}
end
@storage_location_repository_row = StorageLocationRepositoryRow.create!( @storage_location_repository_row = StorageLocationRepositoryRow.create!(
repository_row: @repository_row, repository_row: @repository_row,
storage_location: @storage_location, storage_location: @storage_location,
metadata: storage_location_repository_row_params[:metadata] || {}, metadata: metadata,
created_by: current_user created_by: current_user
) )
log_activity( log_activity(
:storage_location_repository_row_moved, :storage_location_repository_row_moved,
{ {
storage_location_original: @original_storage_location.id, storage_location_original: @original_storage_location.id,
position_original: @original_position position_original: @original_position,
repository_row: @storage_location_repository_row.repository_row_id,
position: @storage_location_repository_row.human_readable_position
} }
) )
render json: @storage_location_repository_row, render json: @storage_location_repository_row,
@ -81,8 +117,7 @@ class StorageLocationRepositoryRowsController < ApplicationController
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
if @storage_location_repository_row.discard if discard_storage_location_repository_rows(@storage_location_repository_row)
log_activity(:storage_location_repository_row_deleted)
render json: {} render json: {}
else else
render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity
@ -101,6 +136,18 @@ class StorageLocationRepositoryRowsController < ApplicationController
private private
def discard_storage_location_repository_rows(storage_location_repository_row)
if storage_location_repository_row.discard
log_activity(:storage_location_repository_row_deleted, {
repository_row: storage_location_repository_row.repository_row_id,
position: storage_location_repository_row.human_readable_position
})
return true
end
false
end
def check_storage_locations_enabled def check_storage_locations_enabled
render_403 unless StorageLocation.storage_locations_enabled? render_403 unless StorageLocation.storage_locations_enabled?
end end
@ -142,11 +189,9 @@ class StorageLocationRepositoryRowsController < ApplicationController
.call(activity_type: type_of, .call(activity_type: type_of,
owner: current_user, owner: current_user,
team: @storage_location.team, team: @storage_location.team,
subject: @storage_location_repository_row.storage_location, subject: @storage_location,
message_items: { message_items: {
storage_location: @storage_location_repository_row.storage_location_id, storage_location: @storage_location.id,
repository_row: @storage_location_repository_row.repository_row_id,
position: @storage_location_repository_row.human_readable_position,
user: current_user.id user: current_user.id
}.merge(message_items)) }.merge(message_items))
end end

View file

@ -5,7 +5,8 @@
:gridSize="gridSize" :gridSize="gridSize"
:assignedItems="assignedItems" :assignedItems="assignedItems"
:selectedItems="selectedItems" :selectedItems="selectedItems"
@assign="assignRowToPosition" :selectedEmptyCells="selectedEmptyCells"
@selectEmptyCell="selectEmptyCell"
@select="selectRow" @select="selectRow"
/> />
</div> </div>
@ -32,11 +33,12 @@
v-if="openAssignModal" v-if="openAssignModal"
:assignMode="assignMode" :assignMode="assignMode"
:selectedContainer="assignToContainer" :selectedContainer="assignToContainer"
:selectedPosition="assignToPosition" :selectedPositions="assignToPositions"
:selectedRow="rowIdToMove" :selectedRow="rowIdToMove"
:selectedRowName="rowNameToMove" :selectedRowName="rowNameToMove"
:cellId="cellIdToUnassign" :cellId="cellIdToUnassign"
@close="openAssignModal = false; resetTableSearch(); this.reloadingTable = true" @assign="assignCallback"
@close="openAssignModal = false"
></AssignModal> ></AssignModal>
<ImportModal <ImportModal
v-if="openImportModal" v-if="openImportModal"
@ -112,8 +114,8 @@ export default {
moveToUrl: null, moveToUrl: null,
assignedItems: [], assignedItems: [],
selectedItems: [], selectedItems: [],
selectedEmptyCells: [],
openAssignModal: false, openAssignModal: false,
assignToPosition: null,
assignToContainer: null, assignToContainer: null,
rowIdToMove: null, rowIdToMove: null,
rowNameToMove: null, rowNameToMove: null,
@ -126,6 +128,14 @@ export default {
paginationMode() { paginationMode() {
return this.withGrid ? 'none' : 'pages'; return this.withGrid ? 'none' : 'pages';
}, },
assignToPositions() {
if (this.assignMode === 'assign' && this.withGrid) {
return this.selectedEmptyCells.map((cell) => [cell.row + 1, cell.column + 1, { occupied: false }]).concat(
this.selectedItems.map((item) => [item.position[0], item.position[1], { occupied: true, id: item.id }])
);
}
return [];
},
tableId() { tableId() {
return this.withGrid ? 'StorageLocationsContainerGrid' : 'StorageLocationsContainer'; return this.withGrid ? 'StorageLocationsContainerGrid' : 'StorageLocationsContainer';
}, },
@ -218,32 +228,37 @@ export default {
} }
this.$refs.table.restoreSelection(); this.$refs.table.restoreSelection();
}, },
selectEmptyCell(cell) {
if (this.selectedEmptyCells.find((c) => c.row === cell.row && c.column === cell.column)) {
this.selectedEmptyCells = this.selectedEmptyCells.filter((c) => c.row !== cell.row || c.column !== cell.column);
} else {
this.selectedEmptyCells.push(cell);
}
},
assignRow() { assignRow() {
this.openAssignModal = true; this.openAssignModal = true;
this.rowIdToMove = null; this.rowIdToMove = null;
this.rowNameToMove = null; this.rowNameToMove = null;
this.assignToContainer = this.containerId; this.assignToContainer = this.containerId;
this.assignToPosition = null; this.assignToPositions = [];
this.cellIdToUnassign = null; this.cellIdToUnassign = null;
this.assignMode = 'assign'; this.assignMode = 'assign';
}, },
assignRowToPosition(position) { assignCallback() {
this.openAssignModal = true; this.openAssignModal = false;
this.rowIdToMove = null; this.resetTableSearch();
this.rowNameToMove = null; this.reloadingTable = true;
this.assignToContainer = this.containerId; this.selectedEmptyCells = [];
this.assignToPosition = position;
this.cellIdToUnassign = null;
this.assignMode = 'assign';
}, },
moveRow(_event, data) { moveRow(_event, data) {
this.assignMode = 'move';
this.openAssignModal = true; this.openAssignModal = true;
this.rowIdToMove = data[0].row_id; this.rowIdToMove = data[0].row_id;
this.rowNameToMove = data[0].row_name || this.i18n.t('storage_locations.show.hidden'); this.rowNameToMove = data[0].row_name || this.i18n.t('storage_locations.show.hidden');
this.assignToContainer = null; this.assignToContainer = null;
this.assignToPosition = null; this.assignToPosition = null;
this.cellIdToUnassign = data[0].id; this.cellIdToUnassign = data[0].id;
this.assignMode = 'move';
}, },
async unassignRows(event, rows) { async unassignRows(event, rows) {
this.storageLocationUnassignDescription = this.i18n.t( this.storageLocationUnassignDescription = this.i18n.t(
@ -255,7 +270,6 @@ export default {
axios.post(event.path).then(() => { axios.post(event.path).then(() => {
this.resetTableSearch(); this.resetTableSearch();
this.reloadingTable = true; this.reloadingTable = true;
}).catch((error) => { }).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger'); HelperModule.flashAlertMsg(error.response.data.error, 'danger');
}); });

View file

@ -3,13 +3,13 @@
<div class="z-10 bg-sn-super-light-grey"></div> <div class="z-10 bg-sn-super-light-grey"></div>
<div ref="columnsContainer" class="overflow-x-hidden"> <div ref="columnsContainer" class="overflow-x-hidden">
<div :style="{'width': `${columnsList.length * 54}px`}"> <div :style="{'width': `${columnsList.length * 54}px`}">
<div v-for="column in columnsList" :key="column" class="uppercase float-left flex items-center justify-center w-[54px] "> <div v-for="column in columnsList" :key="column" @click="selectColumn(column)" class=" cursor-pointer uppercase float-left flex items-center justify-center w-[54px] ">
<span>{{ column }}</span> <span>{{ column }}</span>
</div> </div>
</div> </div>
</div> </div>
<div ref="rowContainer" class="overflow-y-hidden max-h-[70vh]"> <div ref="rowContainer" class="overflow-y-hidden max-h-[70vh]">
<div v-for="row in rowsList" :key="row" class="uppercase flex items-center justify-center h-[54px]"> <div v-for="row in rowsList" :key="row" @click="selectRow(row)" class="cursor-pointer uppercase flex items-center justify-center h-[54px]">
<span>{{ row }}</span> <span>{{ row }}</span>
</div> </div>
</div> </div>
@ -25,7 +25,7 @@
> >
<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)" @click="selectPosition(cell)"
:class="{ :class="{
'bg-sn-background-green': cellIsOccupied(cell), 'bg-sn-background-green': cellIsOccupied(cell),
'bg-sn-grey-100': cellIsHidden(cell), 'bg-sn-grey-100': cellIsHidden(cell),
@ -64,6 +64,10 @@ export default {
selectedItems: { selectedItems: {
type: Array, type: Array,
default: () => [] default: () => []
},
selectedEmptyCells: {
type: Array,
default: () => []
} }
}, },
data() { data() {
@ -108,12 +112,13 @@ export default {
return this.cellObject(cell)?.hidden; return this.cellObject(cell)?.hidden;
}, },
cellIsSelected(cell) { cellIsSelected(cell) {
return this.selectedItems.some((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1); return this.selectedItems.some((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1)
|| this.selectedEmptyCells.some((selectedCell) => selectedCell.row === cell.row && selectedCell.column === cell.column);
}, },
cellIsAvailable(cell) { cellIsAvailable(cell) {
return !this.cellIsOccupied(cell) && !this.cellIsHidden(cell); return !this.cellIsOccupied(cell) && !this.cellIsHidden(cell);
}, },
assignRow(cell) { selectPosition(cell) {
if (this.cellIsOccupied(cell)) { if (this.cellIsOccupied(cell)) {
this.$emit('select', this.cellObject(cell)); this.$emit('select', this.cellObject(cell));
return; return;
@ -123,7 +128,23 @@ export default {
return; return;
} }
this.$emit('assign', [cell.row + 1, cell.column + 1]); this.$emit('selectEmptyCell', cell);
},
selectRow(row) {
this.columnsList.forEach((column) => {
const cell = { row: this.rowsList.indexOf(row), column: column - 1 };
if (!this.cellIsSelected(cell) && !this.cellIsOccupied(cell)) {
this.$emit('selectEmptyCell', cell);
}
});
},
selectColumn(column) {
this.rowsList.forEach((row) => {
const cell = { row: this.rowsList.indexOf(row), column: column - 1 };
if (!this.cellIsSelected(cell) && !this.cellIsOccupied(cell)) {
this.$emit('selectEmptyCell', cell);
}
});
}, },
handleScroll() { handleScroll() {
this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft; this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft;

View file

@ -2,12 +2,36 @@
<div ref="modal" class="modal" tabindex="-1" role="dialog"> <div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div class="modal-content"> <div v-if="overrideWarning" class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i> <i class="sn-icon sn-icon-close"></i>
</button> </button>
<h4 v-if="selectedPosition" class="modal-title truncate !block"> <h4 class="modal-title truncate !block">
{{ i18n.t(`storage_locations.show.assign_modal.override.title`) }}
</h4>
</div>
<div class="modal-body">
<p v-html="i18n.t(`storage_locations.show.assign_modal.override.p_1_html`, {count: this.selectedPositions.filter((p) => p[2].occupied).length})"></p>
<p>
{{ i18n.t(`storage_locations.show.assign_modal.override.p_2`) }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="removeOverride = true; submit()">
{{ i18n.t(`storage_locations.show.assign_modal.override.skip`) }}
</button>
<button class="btn btn-danger" @click="confirmOverride = true; submit()">
{{ i18n.t(`storage_locations.show.assign_modal.override.cta`) }}
</button>
</div>
</div>
<div v-else 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 v-if="selectedPositions.length > 0" class="modal-title truncate !block">
{{ i18n.t(`storage_locations.show.assign_modal.selected_position_title`, { position: formattedPosition }) }} {{ i18n.t(`storage_locations.show.assign_modal.selected_position_title`, { position: formattedPosition }) }}
</h4> </h4>
<h4 v-else-if="assignMode === 'assign' && selectedRow && selectedRowName" class="modal-title truncate !block"> <h4 v-else-if="assignMode === 'assign' && selectedRow && selectedRowName" class="modal-title truncate !block">
@ -33,10 +57,10 @@
<RowSelector v-if="!selectedRow" @change="this.rowId = $event" class="mb-4"></RowSelector> <RowSelector v-if="!selectedRow" @change="this.rowId = $event" class="mb-4"></RowSelector>
<ContainerSelector v-if="!selectedContainer" @change="this.containerId = $event"></ContainerSelector> <ContainerSelector v-if="!selectedContainer" @change="this.containerId = $event"></ContainerSelector>
<PositionSelector <PositionSelector
v-if="containerId && containerId > 0 && !selectedPosition" v-if="containerId && containerId > 0 && !(selectedPositions.length > 0)"
:key="containerId" :key="containerId"
:selectedContainerId="containerId" :selectedContainerId="containerId"
@change="this.position = $event"></PositionSelector> @change="this.positions = [$event]"></PositionSelector>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
@ -71,11 +95,17 @@ export default {
selectedRowName: String, selectedRowName: String,
selectedContainer: Number, selectedContainer: Number,
cellId: Number, cellId: Number,
selectedPosition: Array, selectedPositions: {
type: Array,
default: () => []
},
assignMode: String assignMode: String
}, },
mixins: [modalMixin], mixins: [modalMixin],
computed: { computed: {
showOverrideWarning() {
return this.selectedPositions.find((p) => p[2].occupied);
},
validObject() { validObject() {
return this.rowId && this.containerId && this.containerId > 0; return this.rowId && this.containerId && this.containerId > 0;
}, },
@ -85,8 +115,12 @@ export default {
}); });
}, },
formattedPosition() { formattedPosition() {
if (this.selectedPosition) { if (this.selectedPositions.length > 0) {
return String.fromCharCode(96 + parseInt(this.selectedPosition[0], 10)).toUpperCase() + this.selectedPosition[1]; const pos = [];
this.selectedPositions.forEach((p) => {
pos.push(String.fromCharCode(96 + parseInt(p[0], 10)).toUpperCase() + p[1]);
});
return pos.join(', ');
} }
return ''; return '';
}, },
@ -101,8 +135,11 @@ export default {
return { return {
rowId: this.selectedRow, rowId: this.selectedRow,
containerId: this.selectedContainer, containerId: this.selectedContainer,
position: this.selectedPosition, positions: this.selectedPositions,
saving: false saving: false,
overrideWarning: false,
confirmOverride: false,
removeOverride: false
}; };
}, },
components: { components: {
@ -112,16 +149,26 @@ export default {
}, },
methods: { methods: {
submit() { submit() {
if (this.showOverrideWarning && (!this.confirmOverride && !this.removeOverride)) {
this.overrideWarning = true;
return;
}
if (this.saving) { if (this.saving) {
return; return;
} }
this.saving = true; this.saving = true;
if (this.removeOverride) {
this.positions = this.positions.filter((p) => !p[2].occupied);
}
axios.post(this.actionUrl, { axios.post(this.actionUrl, {
repository_row_id: this.rowId, repository_row_id: this.rowId,
metadata: { position: this.position?.map((pos) => parseInt(pos, 10)) } positions: this.positions.map((p) => [parseInt(p[0], 10), parseInt(p[1], 10), p[2]])
}).then(() => { }).then(() => {
this.$emit('assign');
this.$emit('close'); this.$emit('close');
this.saving = false; this.saving = false;
}).catch((error) => { }).catch((error) => {

View file

@ -2818,7 +2818,13 @@ en:
description_single: 'Are you sure you want to remove item from this location?' description_single: 'Are you sure you want to remove item from this location?'
button: 'Unassign' button: 'Unassign'
assign_modal: assign_modal:
selected_position_title: 'Assign to position %{position}' override:
title: 'Override location'
p_1_html: "You are about to override <b>%{count}</b> occupied locations. This action can't be undone."
p_2: 'Are you sure you want to confirm this override?'
skip: 'Skip occupied locations'
cta: 'Override'
selected_position_title: 'Assign to positions %{position}'
selected_row_title: 'Assign new location' selected_row_title: 'Assign new location'
assign_title: 'Assign position' assign_title: 'Assign position'
move_title: 'Move %{name}' move_title: 'Move %{name}'