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