diff --git a/app/api/v01/custom_attributes.rb b/app/api/v01/custom_attributes.rb index 3a879e510..d7dfa39e3 100644 --- a/app/api/v01/custom_attributes.rb +++ b/app/api/v01/custom_attributes.rb @@ -60,7 +60,7 @@ def custom_attribute_params params do requires :id, type: Integer - requires :name, type: String + requires :name, type: String, regexp: /\A[^:]*\z/ requires :object_type, type: String requires :object_class, type: String optional :default_value, types: [Array[String], String, Integer, Float, Boolean] diff --git a/app/api/v01/destinations.rb b/app/api/v01/destinations.rb index 0141ab220..59f825897 100644 --- a/app/api/v01/destinations.rb +++ b/app/api/v01/destinations.rb @@ -311,6 +311,8 @@ def with_quantities(visit) end end + error! V01::Status.code_response(:code_304), 304 if parsed_ids.empty? && parsed_refs.empty? + destinations_query = current_customer.destinations if parsed_ids.any? && parsed_refs.any? destinations_query = destinations_query.where('destinations.id IN (?) OR destinations.ref IN (?)', parsed_ids, parsed_refs) @@ -322,6 +324,8 @@ def with_quantities(visit) matching_count = destinations_query.count + error! V01::Status.code_response(:code_304), 304 if matching_count.zero? + if current_customer.destinations_count == matching_count current_customer.delete_all_destinations else diff --git a/app/assets/javascripts/plannings.js b/app/assets/javascripts/plannings.js index 9b45cdcac..de0c58e9e 100644 --- a/app/assets/javascripts/plannings.js +++ b/app/assets/javascripts/plannings.js @@ -1849,9 +1849,12 @@ export const plannings_edit = function(params) { var routeId = $(this).closest("[data-route-id]").attr("data-route-id"); routesLayer.focus({routeId: routeId, stopIndex: stopIndex}); } else { - var storeId = $(this).closest("[data-store-id]").attr("data-store-id"); + var li = $(this).closest("[data-store-id]"); + var storeId = li.attr("data-store-id"); if (storeId) { - routesLayer.focus({storeId: storeId}); + var routeId = li.attr("data-origin-route-id"); + var depotType = li.attr("data-type"); + routesLayer.focus({ storeId: storeId, routeId: routeId, depotType: depotType }); } } $(this).blur(); diff --git a/app/assets/javascripts/routes_layers.js b/app/assets/javascripts/routes_layers.js index a3c872e2a..fe759bfb2 100644 --- a/app/assets/javascripts/routes_layers.js +++ b/app/assets/javascripts/routes_layers.js @@ -52,7 +52,7 @@ const popupModule = (function() { return false; }; - const _buildContentForPopup = function(marker, map) { + const _buildContentForPopup = function(marker, map, focusOptions) { var route = marker.properties.route_id && _context.options.routes.filter(function(route) { return route.route_id == marker.properties.route_id; @@ -70,8 +70,16 @@ const popupModule = (function() { if (_ajaxCanBeProceeded()) { var url = _context.options.appBaseUrl; - if (marker.properties.store_id) + if (marker.properties.store_id) { url += 'stores/' + marker.properties.store_id + '.json'; + var params = []; + if (_context.planningId) params.push('planning_id=' + _context.planningId); + if (focusOptions && focusOptions.routeId && focusOptions.depotType) { + params.push('route_id=' + encodeURIComponent(focusOptions.routeId)); + params.push('depot_type=' + encodeURIComponent(focusOptions.depotType)); + } + if (params.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + params.join('&'); + } else if (marker.properties.visit_id) url += 'visits/' + marker.properties.visit_id + '.json'; else if (marker.properties.route_id) @@ -136,14 +144,14 @@ const popupModule = (function() { }); }; - const createPopupForLayer = function(layer, map) { + const createPopupForLayer = function(layer, map, focusOptions) { if (_previousMarker) _previousMarker.closePopup(); if (_previousPopup instanceof L.Popup) _previousPopup.closePopup(); - _buildContentForPopup(layer, map); + _buildContentForPopup(layer, map, focusOptions); }; const initializeModule = function(options, that) { @@ -639,7 +647,10 @@ export const RoutesLayer = L.FeatureGroup.extend({ this.map.setView(this.markerStores[options.storeId].getLatLng(), this.map.getZoom(), { reset: true }); - popupModule.createPopupForLayer(this.markerStores[options.storeId], this.map); + popupModule.createPopupForLayer(this.markerStores[options.storeId], this.map, { + routeId: options.routeId, + depotType: options.depotType + }); } else if (options.routeId) { this._setViewForRoute(options.routeId); } diff --git a/app/assets/stylesheets/scaffolds.css.scss b/app/assets/stylesheets/scaffolds.css.scss index dc4a7cfab..bab5366f5 100644 --- a/app/assets/stylesheets/scaffolds.css.scss +++ b/app/assets/stylesheets/scaffolds.css.scss @@ -701,7 +701,7 @@ input.form-control.number-of-days { } .stop-status-planned, .stop-status-intransit { - background: #357EC7; + background: $primary-color; } .stop-status-started, .stop-status-exception { background: #FBB117; @@ -712,6 +712,9 @@ input.form-control.number-of-days { .stop-status-rejected, .stop-status-undelivered { background: red; } + .stop-status-atstore { + background: var(--info); + } .stop-status-none { background: none; } diff --git a/app/controllers/api_web/v01/stores_controller.rb b/app/controllers/api_web/v01/stores_controller.rb index cd4f9714b..f5f817ecc 100644 --- a/app/controllers/api_web/v01/stores_controller.rb +++ b/app/controllers/api_web/v01/stores_controller.rb @@ -42,6 +42,20 @@ def index def show respond_to do |format| @show_isoline = false + if request.format.json? && params[:planning_id].present? && params[:route_id].present? && params[:depot_type].in?(%w[start stop]) + # Sidebar marker click: show only the depot data for the current route + @planning = current_user.customer.plannings.find_by(id: params[:planning_id]) + if @planning + routes = @planning.routes.includes(vehicle_usage: [:store_start, :store_stop, {vehicle_usage_set: [:store_start, :store_stop]}]) + .select(&:vehicle_usage_id) + route = routes.find { |r| r.id.to_s == params[:route_id] } + if params[:depot_type] == 'start' && route && route.vehicle_usage.default_store_start&.id == @store.id + @store_start_route = route + elsif params[:depot_type] == 'stop' && route && route.vehicle_usage.default_store_stop&.id == @store.id + @store_stop_route = route + end + end + end format.json end end diff --git a/app/controllers/plannings_controller.rb b/app/controllers/plannings_controller.rb index 6cfe04cfa..9c50009b3 100644 --- a/app/controllers/plannings_controller.rb +++ b/app/controllers/plannings_controller.rb @@ -815,8 +815,8 @@ def export_columns ) + (@customer || @planning.customer).custom_attributes.for_visit.map{ |ca| "custom_attributes_visit[#{ca.name}]".to_sym - } + (@customer || @planning.customer).custom_attributes.for_stop_visit.map{ |ca| - "custom_attributes_stop_visit[#{ca.name}]".to_sym + } + (@customer || @planning.customer).custom_attributes.for_export_stops_unique_by_name.map{ |ca| + "custom_attributes_stop[#{ca.name}]".to_sym } end diff --git a/app/controllers/routes_controller.rb b/app/controllers/routes_controller.rb index a251b8a70..3d77ea228 100644 --- a/app/controllers/routes_controller.rb +++ b/app/controllers/routes_controller.rb @@ -203,12 +203,56 @@ def route_params end def route_driver_params - params.require(:route).permit( + permitted = params.require(:route).permit( :status, start_route_data_attributes: [:status], stop_route_data_attributes: [:status], custom_attributes: RecursiveParamsHelper.permit_recursive(params['route']['custom_attributes']) ) + + merge_route_custom_attributes!(permitted) if permitted[:custom_attributes].present? + + permitted + end + + # Merges incoming custom_attributes using composite keys (related_field:name). + # Converts nested or flat incoming params to flat hash with composite keys. + def merge_route_custom_attributes!(permitted) + merged = (@route.custom_attributes || {}).dup + nested_keys = %w[start_route_data stop_route_data] + incoming = permitted[:custom_attributes] + + # Convert nested structure (from form) to composite keys + if incoming.keys.any? { |k| nested_keys.include?(k.to_s) } + incoming.each do |key, value| + key_s = key.to_s + if nested_keys.include?(key_s) && value.is_a?(Hash) + value.each { |name, val| merged["#{key_s}:#{name}"] = val } + elsif !nested_keys.include?(key_s) + merged[key_s] = value + end + end + else + # Flat: infer related_field from route_data_attributes context when keys are not already composite + related_field = if permitted[:start_route_data_attributes].present? && permitted[:stop_route_data_attributes].blank? + 'start_route_data' + elsif permitted[:stop_route_data_attributes].present? && permitted[:start_route_data_attributes].blank? + 'stop_route_data' + end + + incoming.each do |k, v| + key_s = k.to_s + # Keys from form may already be composite (e.g. "start_route_data:Kilométrage"), do not double-prefix + storage_key = if related_field && !key_s.include?(':') + "#{related_field}:#{key_s}" + else + key_s + end + merged[storage_key] = v + end + end + + permitted[:custom_attributes] = merged end def export_params @@ -280,8 +324,8 @@ def export_columns }) + (@customer || @planning.customer).custom_attributes.for_visit.map{ |ca| "custom_attributes_visit[#{ca.name}]".to_sym - } + (@customer || @planning.customer).custom_attributes.for_stop_visit.map{ |ca| - "custom_attributes_stop_visit[#{ca.name}]".to_sym + } + (@customer || @planning.customer).custom_attributes.for_export_stops_unique_by_name.map{ |ca| + "custom_attributes_stop[#{ca.name}]".to_sym } end end diff --git a/app/controllers/stores_controller.rb b/app/controllers/stores_controller.rb index f449b1972..13d2a825b 100644 --- a/app/controllers/stores_controller.rb +++ b/app/controllers/stores_controller.rb @@ -42,6 +42,20 @@ def index def show @show_isoline = true + if request.format.json? && params[:planning_id].present? && params[:route_id].present? && params[:depot_type].in?(%w[start stop]) + # Sidebar marker click: show only the depot data for the current route + @planning = current_user.customer.plannings.find_by(id: params[:planning_id]) + if @planning + routes = @planning.routes.includes(vehicle_usage: [:store_start, :store_stop, {vehicle_usage_set: [:store_start, :store_stop]}]) + .select(&:vehicle_usage_id) + route = routes.find { |r| r.id.to_s == params[:route_id] } + if params[:depot_type] == 'start' && route && route.vehicle_usage.default_store_start&.id == @store.id + @store_start_route = route + elsif params[:depot_type] == 'stop' && route && route.vehicle_usage.default_store_stop&.id == @store.id + @store_stop_route = route + end + end + end end def new diff --git a/app/helpers/custom_attributes_helper.rb b/app/helpers/custom_attributes_helper.rb index 0271be06d..8c098e1ad 100644 --- a/app/helpers/custom_attributes_helper.rb +++ b/app/helpers/custom_attributes_helper.rb @@ -13,13 +13,16 @@ def custom_attribute_default_value_form_field(object_type, typed_default_value) end def custom_attribute_form_field(form, object, custom_attribute, prefix, related_field: nil) - field_name = "#{prefix}[custom_attributes][#{custom_attribute.name}]" + # Use composite key (related_field:name) for Route to distinguish start vs stop + storage_key = CustomAttribute.storage_key_for(custom_attribute.name, related_field: related_field) + field_name = "#{prefix}[custom_attributes][#{storage_key}]" typed_hash = if related_field.present? object.custom_attributes_typed_hash(related_field: related_field) else object.custom_attributes_typed_hash end - current_value = object.custom_attributes.key?(custom_attribute.name) ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value + has_value = object.custom_attributes.key?(storage_key) + current_value = has_value ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value placeholder = custom_attribute.typed_default_value || t('web.form.empty_entry') case custom_attribute.object_type_before_type_cast when 0 @@ -42,13 +45,26 @@ def custom_attribute_form_field(form, object, custom_attribute, prefix, related_ end end - def custom_attribute_template(custom_attribute, object) - current_value = object.custom_attributes.key?(custom_attribute.name) ? object.custom_attributes_typed_hash[custom_attribute.name] : custom_attribute.typed_default_value + # Builds display hash for custom attribute in popups. + # For Route with related_field (e.g. start_route_data, stop_route_data), uses composite storage key. + def custom_attribute_template(custom_attribute, object, related_field: nil) + storage_key = if related_field.present? && object.is_a?(Route) + CustomAttribute.storage_key_for(custom_attribute.name, related_field: related_field) + else + custom_attribute.name + end + typed_hash = if related_field.present? && object.respond_to?(:custom_attributes_typed_hash) + object.custom_attributes_typed_hash(related_field: related_field) + else + object.custom_attributes_typed_hash + end + has_value = object.custom_attributes.key?(storage_key) + current_value = has_value ? typed_hash[custom_attribute.name] : custom_attribute.typed_default_value case custom_attribute.object_type_before_type_cast when 0 { html: "
  • #{custom_attribute.name} :
  • " } when 4 - { html: "
  • #{custom_attribute.name} : #{object.custom_attributes.key?(custom_attribute.name) ? current_value : nil}
  • " } + { html: "
  • #{custom_attribute.name} : #{has_value ? current_value : nil}
  • " } else { html: "
  • #{custom_attribute.name} : #{current_value}
  • " } end diff --git a/app/models/concerns/typed_attribute.rb b/app/models/concerns/typed_attribute.rb index 2536d8842..4b6e2faa7 100644 --- a/app/models/concerns/typed_attribute.rb +++ b/app/models/concerns/typed_attribute.rb @@ -3,6 +3,11 @@ module TypedAttribute class_methods do def typed_attr(current_attribute) + define_method("#{current_attribute}_has_key?") do |name, related_field: nil| + storage_key = CustomAttribute.storage_key_for(name, related_field: related_field) + send(current_attribute).key?(storage_key) + end + define_method("#{current_attribute}_typed_hash") do |related_field: nil| customer = if self.respond_to?(:customer) @@ -24,9 +29,11 @@ def typed_attr(current_attribute) reference_attributes = reference_attributes.where(related_field: nil) end - current_attributes = send(current_attribute) + current_attributes = send(current_attribute) || {} rhash = Hash[reference_attributes.map{ |r_a| - [r_a.name, typed_value(r_a.object_type, current_attributes[r_a.name] || r_a.default_value)] + storage_key = CustomAttribute.storage_key_for(r_a.name, related_field: related_field) + raw_value = current_attributes[storage_key] + [r_a.name, typed_value(r_a.object_type, raw_value || r_a.default_value)] }] rhash end diff --git a/app/models/custom_attribute.rb b/app/models/custom_attribute.rb index 0ef077a95..95a3d90db 100644 --- a/app/models/custom_attribute.rb +++ b/app/models/custom_attribute.rb @@ -36,12 +36,17 @@ class CustomAttribute < ApplicationRecord scope :for_visit, -> { where(object_class: :visit) } scope :for_stop_visit, -> { where(object_class: :stop_visit) } scope :for_stop_store, -> { where(object_class: :stop_store) } + scope :for_export_stops_unique_by_name, -> { + export_stops = where(object_class: [:stop_visit, :stop_store, :route]) + export_stops.where(id: export_stops.unscope(:order).group(:name).select("MIN(id) as id")) + } scope :for_route, -> { where(object_class: :route) } scope :for_related_field, ->(field) { where(related_field: field) } scope :without_related_field, -> { where(related_field: nil) } auto_strip_attributes :name validates :name, presence: true + validates :name, format: { without: /:/, message: :cannot_contain_colon } def typed_default_value case object_type @@ -107,6 +112,11 @@ def self.parse_object_class_value(value) end end + # Composite key for custom_attributes storage: "related_field:name" when related_field present, else "name" + def self.storage_key_for(name, related_field: nil) + related_field.present? ? "#{related_field}:#{name}" : name.to_s + end + def valid_related_fields return [] unless object_class self.class.related_fields_for(object_class).map(&:to_s) diff --git a/app/models/customer.rb b/app/models/customer.rb index ded301f03..65f29950e 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -439,7 +439,6 @@ def delete_destinations(ids) end def delete_all_destinations - stops_relations.delete_all destinations.delete_all self.reload reindex_routes @@ -543,16 +542,15 @@ def destinations_inside_time_distance(position, distance, time, vehicle_usage = private def reindex_routes - Route.includes_stops.scoping do - plannings.reload.each { |p| - p.routes.each do |route| - # reindex remaining stops (like rests) - route.force_reindex - route.outdated = true if !route.geojson_points.try(&:empty?) || !route.geojson_tracks.try(&:empty?) - end - p.save! - } - end + plannings_to_reindex = plannings.includes(routes: [:route_geojson, :stops]) + plannings_to_reindex.each { |p| + p.routes.each do |route| + # reindex remaining stops (like rests) + route.force_reindex + route.outdated = true if !route.geojson_points.try(&:empty?) || !route.geojson_tracks.try(&:empty?) + end + p.save! + } end def devices_update_vehicles diff --git a/app/templates/stops/_show.mustache b/app/templates/stops/_show.mustache index c74db2157..a996c9739 100644 --- a/app/templates/stops/_show.mustache +++ b/app/templates/stops/_show.mustache @@ -261,11 +261,42 @@
    {{#i18n}}plannings.edit.popup.store{{/i18n}} +{{#store_start}} +
    +{{#i18n}}plannings.edit.popup.store_start{{/i18n}}{{#name}} - {{name}}{{/name}} + +{{/store_start}} +{{#store_stop}} +
    +{{#i18n}}plannings.edit.popup.store_stop{{/i18n}}{{#name}} - {{name}}{{/name}} + +{{/store_stop}} {{#error}}