diff --git a/app/assets/javascripts/ajax.js b/app/assets/javascripts/ajax.js
index 18d52f70b..3419dacb4 100644
--- a/app/assets/javascripts/ajax.js
+++ b/app/assets/javascripts/ajax.js
@@ -68,10 +68,11 @@ export const mustache_i18n = function() {
export const applyStopPopupManagePlanning = function(view, managePlanning) {
var mp = managePlanning || {};
view.manage_organize = mp.manage_organize !== false;
- view.planning_move_stops_visible = Boolean(mp.manage_route_stops);
- view.planning_move_stops_usable = Boolean(mp.manage_route_stops) && !Boolean(mp.disable_route_stops);
view.send_stop_to_route_visible = Boolean(mp.manage_stop_move);
view.send_stop_to_route_usable = Boolean(mp.manage_stop_move) && !Boolean(mp.disable_stop_move);
+ view.planning_move_stops_visible = Boolean(mp.manage_route_stops) && view.send_stop_to_route_visible;
+ view.planning_move_stops_usable =
+ Boolean(mp.manage_route_stops) && !Boolean(mp.disable_route_stops) && view.send_stop_to_route_usable;
view.move_stop_allowed = view.send_stop_to_route_usable;
view.stop_active_allowed = Boolean(mp.manage_stop_active) && !Boolean(mp.disable_stop_active);
};
diff --git a/app/assets/javascripts/modals/move_stops_modal.js b/app/assets/javascripts/modals/move_stops_modal.js
index 8aca25595..ea5d93a17 100644
--- a/app/assets/javascripts/modals/move_stops_modal.js
+++ b/app/assets/javascripts/modals/move_stops_modal.js
@@ -36,6 +36,7 @@ export class MoveStopsModal {
this.mustacheI18n = null;
this.modalSelector = '#planning-move-stops-modal';
this.isInitialized = false;
+ this.planningMoveStopsUsable = true;
}
/**
@@ -59,6 +60,7 @@ export class MoveStopsModal {
this.refreshSidebarRoute = options.refreshSidebarRoute;
this.updatePlanningDataHeader = options.updatePlanningDataHeader || function() {};
this.mustacheI18n = options.mustacheI18n;
+ this.planningMoveStopsUsable = options.planningMoveStopsUsable !== false;
if (!this.isInitialized) {
this.setupEventHandlers();
@@ -73,6 +75,10 @@ export class MoveStopsModal {
// Load modal content when opened via data-toggle="modal"
$(this.modalSelector).off('show.bs.modal.moveStops').on('show.bs.modal.moveStops', (ev) => {
try {
+ if (!this.planningMoveStopsUsable) {
+ ev.preventDefault();
+ return;
+ }
const trigger = ev.relatedTarget;
const routeId = trigger && trigger.getAttribute && trigger.getAttribute('data-route-id');
if (!routeId) return;
@@ -99,12 +105,16 @@ export class MoveStopsModal {
// Move stops button click
$("#move-stops-modal").off('click').on('click', () => {
+ if (!this.planningMoveStopsUsable) {
+ return;
+ }
this.handleMoveStops();
});
// Listen when server injected content is ready, then initialize behaviors
$(document).off('move-stops:content-updated').on('move-stops:content-updated', () => {
try {
+ $('#move-stops-modal').prop('disabled', !this.planningMoveStopsUsable);
// Initialize UI widgets
$('#move-stops-toggle').toggleSelect();
$('[type="checkbox"][data-toggle="disable-multiple-actions"]').toggleMultipleActions();
@@ -385,6 +395,9 @@ export class MoveStopsModal {
* @param {string} routeId - Route ID to show modal for
*/
showModal(routeId) {
+ if (!this.planningMoveStopsUsable) {
+ return;
+ }
// Show loading spinner immediately
$(`${this.modalSelector} .modal-body`).html('
').unbind();
$(this.modalSelector).modal('show');
@@ -403,6 +416,9 @@ export class MoveStopsModal {
* @param {Array} stopIds
*/
showModalForStops(stopIds) {
+ if (!this.planningMoveStopsUsable) {
+ return;
+ }
// Show loading spinner immediately
$(`${this.modalSelector} .modal-body`).html('
').unbind();
$(this.modalSelector).modal('show');
diff --git a/app/assets/javascripts/plannings.js b/app/assets/javascripts/plannings.js
index b546595e8..d81fe23a7 100644
--- a/app/assets/javascripts/plannings.js
+++ b/app/assets/javascripts/plannings.js
@@ -2854,7 +2854,7 @@ export const plannings_edit = function(params) {
return;
}
var mpToolbar = params.manage_planning;
- var planningMoveStopsUsable = mpToolbar && mpToolbar.manage_route_stops && !mpToolbar.disable_route_stops;
+ var planningMoveStopsUsable = mpToolbar && mpToolbar.planning_move_stops_usable;
var stopSortableDisabled = !planningMoveStopsUsable;
$sortable_route.sortable({
distance: 8,
@@ -2945,6 +2945,7 @@ export const plannings_edit = function(params) {
quantities: params.quantities,
routesLayer: routesLayer,
refreshSidebarRoute: refreshSidebarRoute,
+ planningMoveStopsUsable: !!(params.manage_planning && params.manage_planning.planning_move_stops_usable),
updatePlanningDataHeader: function() {
updateDataHeader(planning_id);
},
diff --git a/app/assets/javascripts/utils/lasso.js b/app/assets/javascripts/utils/lasso.js
index 5d29a069a..5418ebbdb 100644
--- a/app/assets/javascripts/utils/lasso.js
+++ b/app/assets/javascripts/utils/lasso.js
@@ -18,7 +18,7 @@
'use strict';
-import { ajaxError, beforeSendWaiting, completeAjaxMap } from '../ajax.js';
+import { ajaxError } from '../ajax.js';
import { moveStopsModal } from '../modals/move_stops_modal.js';
/**
@@ -36,6 +36,7 @@ export class LassoModule {
this.dataExtractor = null;
this.waitingRoute = null;
this.refreshRoute = null;
+ this.managePlanning = {};
}
/**
@@ -59,6 +60,7 @@ export class LassoModule {
this.map = mapInstance;
this.planningId = planningIdParam;
this.routesLayer = routesLayerInstance;
+ this.managePlanning = (routesLayerInstance && routesLayerInstance.options && routesLayerInstance.options.popupOptions) || {};
// Store the functions passed from plannings.js
this.waitingRoute = routeWaitingFunc;
@@ -83,11 +85,7 @@ export class LassoModule {
});
this.map.on('lasso.finished', (event) => {
- if (typeof LassoSelection === 'function') {
- LassoSelection(event, this.map, document.querySelector('.sidebar'));
- } else {
- this.onLassoFinished(event);
- }
+ this.onLassoFinished(event);
});
return this.addLassoControl();
@@ -235,8 +233,7 @@ export class LassoModule {
}
});
- // Use MoveStops modal instead of custom modal
- this.showMoveStopsModalForLassoSelection();
+ this.showLassoSelectionModal(processedLayers);
} else {
// Reset lasso tool when selection is empty
this.disableLasso();
@@ -279,11 +276,58 @@ export class LassoModule {
return processedLayers;
}
+ showLassoSelectionModal(processedLayers) {
+ const mp = this.managePlanning || {};
+ if (!mp.planning_move_stops_visible) {
+ this.clearLassoSelection();
+ this.disableLasso();
+ return;
+ }
+
+ const stopIds = processedLayers
+ .map((layer) => layer && layer.properties && layer.properties.stop_id)
+ .filter(Boolean);
+ if (!stopIds.length) {
+ return;
+ }
+
+ const self = this;
+ $.ajax({
+ url: `/plannings/${this.planningId}/selection_details`,
+ data: { stop_ids: stopIds.join(',') },
+ method: 'GET',
+ success(html) {
+ $('#lasso-info-modal').remove();
+ $('body').append(html);
+ $('#lasso-info-modal').modal('show');
+
+ $('#move-stops-btn').off('click.lassoMove').on('click.lassoMove', () => {
+ if (!mp.planning_move_stops_usable || !moveStopsModal.planningMoveStopsUsable) {
+ return;
+ }
+ $('#lasso-info-modal').modal('hide');
+ self.showMoveStopsModalForLassoSelection();
+ });
+
+ $('#lasso-info-modal').off('hidden.bs.modal.lasso').on('hidden.bs.modal.lasso', function() {
+ self.clearLassoSelection();
+ self.disableLasso();
+ $(this).remove();
+ });
+ },
+ error: ajaxError
+ });
+ }
+
/**
* Show MoveStops modal for lasso selection
* This method uses the existing MoveStops modal and simulates stop selection
*/
showMoveStopsModalForLassoSelection() {
+ if (!(this.managePlanning && this.managePlanning.planning_move_stops_usable)) {
+ return;
+ }
+
// Extract stops from selected layers (including processed clusters)
const selectedStops = [];
const processedLayers = this.processSelectedLayers(this.selectedLayers);
@@ -398,6 +442,7 @@ export class LassoModule {
this.dataExtractor = null;
this.waitingRoute = null;
this.refreshRoute = null;
+ this.managePlanning = {};
this.selectedLayers = [];
this.isLassoActive = false;
}
diff --git a/app/controllers/concerns/planning_toolbar_planning_flags.rb b/app/controllers/concerns/planning_toolbar_planning_flags.rb
index 229c47cf0..6b71646a8 100644
--- a/app/controllers/concerns/planning_toolbar_planning_flags.rb
+++ b/app/controllers/concerns/planning_toolbar_planning_flags.rb
@@ -72,12 +72,17 @@ def apply_planning_toolbar_operation_flags!
apply_route_toolbar_operation_flags!
apply_stop_toolbar_operation_flags!
- @manage_planning[:planning_move_stops_visible] = @manage_planning[:manage_route_stops]
- @manage_planning[:planning_move_stops_usable] = @manage_planning[:manage_route_stops] && !@manage_planning[:disable_route_stops]
-
@manage_planning[:send_stop_to_route_visible] = @manage_planning[:manage_stop_move]
@manage_planning[:send_stop_to_route_usable] = @manage_planning[:manage_stop_move] && !@manage_planning[:disable_stop_move]
+ # Bulk move requires both route stops management and per-stop move permission
+ @manage_planning[:planning_move_stops_visible] =
+ @manage_planning[:manage_route_stops] && @manage_planning[:send_stop_to_route_visible]
+ @manage_planning[:planning_move_stops_usable] =
+ @manage_planning[:manage_route_stops] &&
+ !@manage_planning[:disable_route_stops] &&
+ @manage_planning[:send_stop_to_route_usable]
+
@manage_planning[:manage_activate_stops] =
planning_op_visible.call('activate_stops') && @manage_planning[:manage_stop_active]
@manage_planning[:disable_activate_stops] =
diff --git a/app/controllers/plannings_controller.rb b/app/controllers/plannings_controller.rb
index a1f1d0fb3..6312f6de9 100644
--- a/app/controllers/plannings_controller.rb
+++ b/app/controllers/plannings_controller.rb
@@ -388,6 +388,10 @@ def modal
# Render move stops modal content via Rails (.js.erb)
def move_stops_modal
+ bootstrap_manage_planning_flags!
+ deny_unless_operation_usable!(:route, 'stops')
+ deny_unless_operation_usable!(:stop, 'move_stop')
+
route =
if params[:route_id]
route_id = Integer(params[:route_id])
@@ -734,6 +738,7 @@ def move_respond
route_ids << previous_route_id if previous_route_id != route_id
else
deny_unless_operation_usable!(:route, 'stops')
+ deny_unless_operation_usable!(:stop, 'move_stop')
params[:stop_ids].map!(&:to_i)
stops = Stop.joins(:route)
.where(routes: { planning_id: @planning.id })
diff --git a/app/views/customers/import.html.erb b/app/views/customers/import.html.erb
index 478fb32c4..fc15b1c5e 100644
--- a/app/views/customers/import.html.erb
+++ b/app/views/customers/import.html.erb
@@ -7,12 +7,12 @@
<%= bootstrap_form_for @customer, url: upload_dump_customers_path, layout: :horizontal, html: { class: 'form-horizontal', multipart: true } do |f| %>
-
+
+
<%= t('.label') %>
diff --git a/app/views/plannings/_edit.html.haml b/app/views/plannings/_edit.html.haml
index 413e6974f..0d5602407 100644
--- a/app/views/plannings/_edit.html.haml
+++ b/app/views/plannings/_edit.html.haml
@@ -116,8 +116,9 @@
.modal-body.routes
.clearfix
.modal-footer
- %button#move-stops-modal.btn.btn-primary{type: "button"}
- = t('plannings.edit.move_stops')
+ - if @manage_planning[:planning_move_stops_visible]
+ %button#move-stops-modal.btn.btn-primary{type: "button", disabled: !@manage_planning[:planning_move_stops_usable]}
+ = t('plannings.edit.move_stops')
#planning-send-sms-drivers-modal.modal.fade{role: "dialog", tabindex: "-1"}
diff --git a/app/views/routes/operations/_stops.html.haml b/app/views/routes/operations/_stops.html.haml
index 8eea1c828..274a8ece7 100644
--- a/app/views/routes/operations/_stops.html.haml
+++ b/app/views/routes/operations/_stops.html.haml
@@ -1,3 +1,4 @@
+- move_stops_visible = @manage_planning[:planning_move_stops_visible]
- move_stops_usable = @manage_planning[:planning_move_stops_usable]
- if @manage_planning[:manage_route_stops]
.btn-group
@@ -41,11 +42,12 @@
%a.reverse_order{href: "/plannings/#{summary[:planning_id]}/#{route[:route_id]}/reverse_order"}
%i.fa.fa-arrow-right-arrow-left.fa-rotate-90.fa-fw
= t('plannings.edit.reverse_order')
- %li{class: ('disabled' unless move_stops_usable)}
+ - if move_stops_visible
- if move_stops_usable
- %a{"data-route-id": route[:route_id], "data-target": "#planning-move-stops-modal", "data-toggle": "modal", href: "#"}
- %i.fa.fa-truck-ramp-box.fa-fw
- = t('plannings.edit.move_stops')
+ %li
+ %a{"data-route-id": route[:route_id], "data-target": "#planning-move-stops-modal", "data-toggle": "modal", href: "#"}
+ %i.fa.fa-truck-ramp-box.fa-fw
+ = t('plannings.edit.move_stops')
- else
%span.text-muted
%i.fa.fa-truck-ramp-box.fa-fw
diff --git a/app/views/shared/_selection_details.html.haml b/app/views/shared/_selection_details.html.haml
index 73d97480a..8687f007d 100644
--- a/app/views/shared/_selection_details.html.haml
+++ b/app/views/shared/_selection_details.html.haml
@@ -55,6 +55,7 @@
.lasso-actions
%button#clear-lasso-selection.btn.btn-default{"data-dismiss" => "modal", type: "button"}
= t('all.verb.cancel')
- %button#move-stops-btn.btn.btn-primary{disabled: true, type: "button", style: ('display: none;' unless move_stops_visible), data: { move_forbidden: (!move_stops_usable) }}
- %i.fa.fa-arrow-right.fa-fw
- = t('plannings.edit.lasso.move_stops')
+ - if move_stops_visible
+ %button#move-stops-btn.btn.btn-primary{disabled: !move_stops_usable, type: "button", title: (t('plannings.edit.lasso.move_stops_forbidden') unless move_stops_usable)}
+ %i.fa.fa-arrow-right.fa-fw
+ = t('plannings.edit.lasso.move_stops')
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f2289407c..5daf88205 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2166,6 +2166,7 @@ en:
toggle: Enable/disable lasso selection
selection_info: Selection information
move_stops: Move stops
+ move_stops_forbidden: You do not have permission to move stops
stops_moved_success: Stops moved successfully
stops_moved_error: Error moving stops
select_target_route: Transfer stops
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index f469a2210..68ee76ab1 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -2266,6 +2266,7 @@ fr:
toggle: Activer/désactiver la sélection par lasso
selection_info: Informations de sélection
move_stops: Déplacer les arrêts
+ move_stops_forbidden: Vous n’avez pas la permission de déplacer des arrêts
stops_moved_success: Arrêts déplacés avec succès
stops_moved_error: Erreur lors du déplacement des arrêts
select_target_route: Transférer les arrêts
diff --git a/test/controllers/plannings_controller_test.rb b/test/controllers/plannings_controller_test.rb
index 5177c3fe3..4094e475d 100644
--- a/test/controllers/plannings_controller_test.rb
+++ b/test/controllers/plannings_controller_test.rb
@@ -1081,6 +1081,82 @@ def around
sign_in users(:user_one)
end
+ test 'edit sets planning_move_stops_usable false when move_stop is not usable' do
+ u = users(:user_one)
+ ops = Preferences::Catalog.default_operations.deep_dup
+ ops['route']['segment_controls']['stops'] = { 'visible' => true, 'usable' => true }
+ ops['stop']['segment_controls']['move_stop'] = { 'visible' => true, 'usable' => false }
+ role = Role.create!(
+ reseller: resellers(:reseller_one),
+ name: "stops-no-move-stop-#{SecureRandom.hex(4)}",
+ operations: ops,
+ forms: Preferences::Catalog.default_forms
+ )
+ u.update!(role_id: role.id)
+ sign_in u
+
+ get :edit, params: { id: @planning }
+ assert_equal false, assigns(:manage_planning)[:send_stop_to_route_usable]
+ assert_equal false, assigns(:manage_planning)[:planning_move_stops_usable]
+ ensure
+ u.update!(role_id: nil)
+ role&.destroy
+ sign_in users(:user_one)
+ end
+
+ test 'get move_stops_modal is forbidden when move_stop operation is not usable' do
+ u = users(:user_one)
+ ops = Preferences::Catalog.default_operations.deep_dup
+ ops['route']['segment_controls']['stops'] = { 'visible' => true, 'usable' => true }
+ ops['stop']['segment_controls']['move_stop'] = { 'visible' => true, 'usable' => false }
+ role = Role.create!(
+ reseller: resellers(:reseller_one),
+ name: "no-move-stops-modal-move-stop-#{SecureRandom.hex(4)}",
+ operations: ops,
+ forms: Preferences::Catalog.default_forms
+ )
+ u.update!(role_id: role.id)
+ sign_in u
+
+ route = routes(:route_one_one)
+ get :move_stops_modal, params: {
+ planning_id: @planning.id,
+ route_id: route.id,
+ format: :js
+ }
+ assert_response :forbidden
+ ensure
+ u.update!(role_id: nil)
+ role&.destroy
+ sign_in users(:user_one)
+ end
+
+ test 'get move_stops_modal is forbidden when route stops operation is not usable' do
+ u = users(:user_one)
+ ops = Preferences::Catalog.default_operations.deep_dup
+ ops['route']['segment_controls']['stops'] = { 'visible' => true, 'usable' => false }
+ role = Role.create!(
+ reseller: resellers(:reseller_one),
+ name: "no-move-stops-modal-#{SecureRandom.hex(4)}",
+ operations: ops,
+ forms: Preferences::Catalog.default_forms
+ )
+ u.update!(role_id: role.id)
+ sign_in u
+
+ route = routes(:route_one_one)
+ get :move_stops_modal, params: {
+ planning_id: @planning.id,
+ route_id: route.id,
+ format: :js
+ }
+ assert_response :forbidden
+ ensure
+ u.update!(role_id: nil)
+ role&.destroy
+ sign_in users(:user_one)
+ end
+
test 'patch move is forbidden without move_stop operation permission' do
u = users(:user_one)
ops = Preferences::Catalog.default_operations.deep_dup