diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 36efd5e04..2c798ba87 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,8 @@ // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. "dockerComposeFile": [ "../.devcontainer/docker-compose.yml", - "../docker-compose-qr-shortener.yml" + "../.devcontainer/docker-compose-qr-shortener.yml" + // "../.devcontainer/docker-compose-superset.yml" ], // The 'service' property is the name of the service for the container that VS Code should // use. Update this value and .devcontainer/docker-compose.yml to the real service name. @@ -31,7 +32,7 @@ ] } }, - // "initializeCommand": "cp ./.dockerignore-development ./.dockerignore" + "initializeCommand": "bash .devcontainer/setup-network.sh" // Uncomment the next line if you want start specific services in your Docker Compose config. // "runServices": [], // Uncomment the next line if you want to keep your containers running after VS Code shuts down. diff --git a/.devcontainer/docker-compose-qr-shortener.yml b/.devcontainer/docker-compose-qr-shortener.yml new file mode 100644 index 000000000..56d1a4763 --- /dev/null +++ b/.devcontainer/docker-compose-qr-shortener.yml @@ -0,0 +1,19 @@ +services: + url_shortener: + image: ghcr.io/teritorio/qr-shortener:master + volumes: + - ./data:/data + environment: + RACK_ENV: production + URL_BASE: ${URL_SHORTNER_PUBLIC} + STORAGE_PATH: /data + restart: unless-stopped + ports: + - 127.0.0.1:8635:8635 + networks: + - public-network + +networks: + public-network: + name: public-network + external: true diff --git a/.devcontainer/docker-compose-superset.yml b/.devcontainer/docker-compose-superset.yml new file mode 100644 index 000000000..119e0fd7e --- /dev/null +++ b/.devcontainer/docker-compose-superset.yml @@ -0,0 +1,52 @@ +services: + superset: + image: superset:3.1.3 + build: + context: ../docker/superset + depends_on: + - db + - superset_redis + entrypoint: ["/app/pythonpath/entrypoint.sh"] + environment: + SUPERSET_SECRET_KEY: lkfzoiezhfoizehfoizhoihfopizajfoizafjpoizaufepoaz + DATABASE_DIALECT: postgresql+psycopg2 + POSTGRES_DB: ${POSTGRES_DB:-planner} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-planner} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planner} + REDIS_HOST: superset_redis + SUPERSET_DB: superset + SUPERSET_ADMIN_USERNAME: admin + SUPERSET_ADMIN_EMAIL: admin@superset.com + SUPERSET_ADMIN_PASSWORD: admin + MAPBOX_API_KEY: ${SUPERSET_MAPBOX_API_KEY} + volumes: + - ../docker/superset:/app/pythonpath + ports: + - 127.0.0.1:8088:8088 + restart: unless-stopped + networks: + - public-network + + superset_redis: + image: redis:${REDIS_VERSION:-7-alpine} + restart: unless-stopped + networks: + - public-network + + superset_nginx: + image: superset_nginx + build: + context: ../docker/superset_nginx + depends_on: + - web + - superset + restart: unless-stopped + networks: + - public-network + +networks: + public-network: + name: public-network + external: true diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b2ec235d2..146724fda 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -17,6 +17,9 @@ x-web: &web LOG_FORMAT: ${LOG_FORMAT} URL_SHORTENER: ${URL_SHORTENER:-http://url_shortener:8635} AVAILABLE_SOLVERS: ${AVAILABLE_SOLVERS:-} + POSTGRES_USER: ${POSTGRES_USER:-planner} + POSTGRES_DB: ${POSTGRES_DB:-planner} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planner} AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET} AZURE_TENANT_ID: ${AZURE_TENANT_ID} @@ -25,20 +28,36 @@ x-web: &web - ../.devcontainer/production.rb:/srv/app/config/environments/production.rb - ../docker/uploads:/srv/app/public/uploads depends_on: - - db - - redis-cache + db: + condition: service_healthy + redis-cache: + condition: service_started restart: unless-stopped services: db: + # Place a dump file in the docker/dumps directory to load the database from it if no database is found + # Devcontainer might display that the container is not running, but the database might be loading the dump + # Or the web container might be running the migrations. Wait until the database is ready. image: postgis/postgis:15-3.5 + environment: - POSTGRES_HOST_AUTH_METHOD=trust + - POSTGRES_USER=${POSTGRES_USER:-planner} + - POSTGRES_DB=${POSTGRES_DB:-planner} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-planner} volumes: - pg_data:/var/lib/postgresql/data + - ../docker/dumps:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-planner} -d ${POSTGRES_DB:-planner} && psql -U ${POSTGRES_USER:-planner} -d ${POSTGRES_DB:-planner} -c '\\dt' | grep -q public"] + start_interval: 5s + start_period: 60s + interval: 60s + timeout: 10s + retries: 5 restart: unless-stopped networks: - - planner-network - public-network redis-cache: @@ -46,33 +65,33 @@ services: restart: unless-stopped command: redis-server --save "" networks: - - planner-network - public-network web: <<: *web command: bash -c "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 bundle exec rake db:prepare && bundle exec puma -v -p 8080 --pidfile 'server.pid' -t ${PUMA_WORKERS:-0:1}" networks: - - planner-network - public-network + delayed-job: <<: *web command: bash -c "bundle exec rails runner 'Delayed::Job.delete_all' && LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 bundle exec './bin/delayed_job run'" depends_on: - - db - - redis-cache + db: + condition: service_healthy + redis-cache: + condition: service_started + web: + condition: service_started deploy: replicas: ${DELAYED_JOB_REPLICAS:-1} networks: - - planner-network - public-network volumes: pg_data: networks: - planner-network: - external: false - public-network: name: public-network + attachable: true diff --git a/.devcontainer/setup-network.sh b/.devcontainer/setup-network.sh new file mode 100755 index 000000000..8556fa10e --- /dev/null +++ b/.devcontainer/setup-network.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Script to create the shared Docker network public-network +# To be executed once before using Docker Compose projects + +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Change to workspace directory to ensure we're in the right context +cd "$WORKSPACE_DIR" + +NETWORK_NAME="public-network" + +echo "Checking network $NETWORK_NAME..." + +# Clean up stopped containers that might reference old networks +echo "Cleaning up stopped containers..." +docker container prune -f >/dev/null 2>&1 || true + +# Clean up orphaned networks, but preserve public-network +echo "Cleaning up orphaned networks (preserving $NETWORK_NAME)..." +# Get list of networks to prune (excluding public-network) +NETWORKS_TO_PRUNE=$(docker network ls --filter "type=custom" --format "{{.Name}}" | grep -v "^${NETWORK_NAME}$" | grep -v "^bridge$" | grep -v "^host$" | grep -v "^none$" || true) +if [ -n "$NETWORKS_TO_PRUNE" ]; then + # Prune only networks that are not in use + for net in $NETWORKS_TO_PRUNE; do + if ! docker network inspect "$net" --format '{{range .Containers}}{{.Name}}{{end}}' 2>/dev/null | grep -q .; then + docker network rm "$net" 2>/dev/null || true + fi + done +fi + +# Check if network exists, create if not +if docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then + echo "✓ Network $NETWORK_NAME already exists" + NETWORK_ID=$(docker network inspect "$NETWORK_NAME" --format '{{.Id}}') + echo " ID: $NETWORK_ID" + docker network inspect "$NETWORK_NAME" --format ' Driver: {{.Driver}}' + docker network inspect "$NETWORK_NAME" --format ' Scope: {{.Scope}}' + + # Check for containers still using this network (should be none if external) + CONTAINERS=$(docker ps -a --filter "network=$NETWORK_NAME" --format "{{.Names}}" 2>/dev/null || true) + if [ -n "$CONTAINERS" ]; then + echo " Warning: Found containers using this network:" + echo "$CONTAINERS" | sed 's/^/ /' + fi +else + echo "Creating network $NETWORK_NAME..." + docker network create "$NETWORK_NAME" + echo "✓ Network $NETWORK_NAME created successfully" +fi + +# Verify network exists +if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then + echo "ERROR: Failed to create or verify network $NETWORK_NAME" >&2 + exit 1 +fi + +# Verify network is actually usable by running an ephemeral container on it +echo "Verifying network connectivity..." +if docker run --rm --network "$NETWORK_NAME" alpine:3 true 2>/dev/null; then + echo "✓ Network $NETWORK_NAME is accessible" +else + echo "ERROR: Network $NETWORK_NAME exists but cannot be used by containers" >&2 + echo " Try removing and recreating it:" + echo " docker network rm $NETWORK_NAME" + echo " docker network create $NETWORK_NAME" + exit 1 +fi + +echo "" +echo "Network is ready to be used by all Docker Compose projects." + diff --git a/.gitignore b/.gitignore index c96c8ec7f..f1b0e04b7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ config/initializers/dev.rb docker/uploads/ docker/**/__pycache__/ docker/*.rb +docker/dumps/*.pg_dump.gz /.devcontainer/data/* # Ignore application configuration diff --git a/app/api/v01/entities/user.rb b/app/api/v01/entities/user.rb index 29d1da04b..0199cc6fe 100644 --- a/app/api/v01/entities/user.rb +++ b/app/api/v01/entities/user.rb @@ -30,4 +30,5 @@ def self.entity_name expose(:prefered_unit, documentation: { type: String }) expose(:locale, documentation: { type: String, desc: 'Currently used in mailing' }) expose(:time_zone, documentation: { type: String }) + expose(:default_display_polylines, documentation: { type: 'Boolean' }) end diff --git a/app/api/v01/helper/shared_params.rb b/app/api/v01/helper/shared_params.rb index 279bac1c9..bfec6c30d 100644 --- a/app/api/v01/helper/shared_params.rb +++ b/app/api/v01/helper/shared_params.rb @@ -231,6 +231,7 @@ def filter_tag_ids_belong_to_customer(tag_ids, customer) optional :prefered_unit, type: String optional :locale, type: String optional :time_zone, type: String, values: ActiveSupport::TimeZone.all.map(&:name) + optional :default_display_polylines, type: Boolean end params :request_vehicle do |options| diff --git a/app/api/v01/users.rb b/app/api/v01/users.rb index a9e517bf1..3e6370f60 100644 --- a/app/api/v01/users.rb +++ b/app/api/v01/users.rb @@ -25,9 +25,9 @@ def user_params p = ActionController::Parameters.new(params) p = p[:user] if p.key?(:user) if @current_user.admin? - p.permit(:ref, :email, :password, :customer_id, :layer_id, :url_click2call, :time_zone, :locale, :prefered_unit) + p.permit(:ref, :email, :password, :customer_id, :layer_id, :url_click2call, :time_zone, :locale, :prefered_unit, :default_display_polylines) else - p.permit(:layer_id, :url_click2call, :time_zone, :locale, :prefered_unit) + p.permit(:layer_id, :url_click2call, :time_zone, :locale, :prefered_unit, :default_display_polylines) end end end diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 432208139..64452029f 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -58,7 +58,7 @@ //= require_tree ../../templates // Custom components -//= require active_inactive_drag_drop +//= require utils/active_inactive_drag_drop // jQuery Turbolinks documentation informs to load all scripts before turbolinks //= require jquery.turbolinks diff --git a/app/assets/javascripts/plannings.js b/app/assets/javascripts/plannings.js index de0c58e9e..236fdcbf6 100644 --- a/app/assets/javascripts/plannings.js +++ b/app/assets/javascripts/plannings.js @@ -99,7 +99,8 @@ const iCalendarExport = function(planningId) { }); }; -const spreadsheetModalExport = function(columns, planningId, export_settings) { +const spreadsheetModalExport = function(columns, planningId, export_settings, custom_columns) { + custom_columns = custom_columns || {}; $('#planning-spreadsheet-modal').on('show.bs.modal', function() { if ($('[name=spreadsheet-route]').val()) $('[name=spreadsheet-out-of-route]').parent().parent().hide(); @@ -130,24 +131,33 @@ const spreadsheetModalExport = function(columns, planningId, export_settings) { const $export = $('#columns-export').empty(); const $skip = $('#columns-skip').empty(); - // Fonction pour obtenir le nom d'affichage traduit d'une colonne + // Get display name for a column; falls back to destinations.import_file when key is not in plannings.export_file. + // When column has a custom import header, appends it in parentheses. function getDisplayName(columnKey) { - var displayName; var match = columnKey.match(new RegExp('^(.+)\\[(.*)\\]$')); var rematch = columnKey.match(/^([a-z]+(?:_[a-z]+)*)(\d+)$/); + var baseKey, suffix; if (match) { - var export_translation = 'plannings.export_file.' + match[1]; - displayName = I18n.t(export_translation) + '[' + match[2] + ']'; + baseKey = match[1]; + suffix = '[' + match[2] + ']'; + } else if (rematch) { + baseKey = rematch[1]; + suffix = rematch[2]; + } else { + baseKey = columnKey; + suffix = ''; } - else if (rematch) { - var export_translation = 'plannings.export_file.' + rematch[1]; - displayName = I18n.t(export_translation) + rematch[2]; + var exportKey = 'plannings.export_file.' + baseKey; + var importKey = 'destinations.import_file.' + baseKey; + var t = I18n.t(exportKey); + if (!t || t === exportKey || /^\[missing "[a-z0-9_.]+" translation\]$/.test(String(t))) { + t = I18n.t(importKey); } - else { - var export_translation = 'plannings.export_file.' + columnKey; - displayName = I18n.t(export_translation); + var display = t + suffix; + if (custom_columns[columnKey]) { + display += ' (' + custom_columns[columnKey] + ')'; } - return displayName; + return display; } columnsExport.forEach(function(col) { @@ -699,6 +709,7 @@ export const plannings_edit = function(params) { var map = mapInitialize(params); var popupOptions = params.manage_planning; + var withPolylines = params.default_display_polylines !== false; var routesLayer = new RoutesLayer(planning_id, { url_click2call: url_click2call, unit: prefered_unit, @@ -708,6 +719,7 @@ export const plannings_edit = function(params) { appBaseUrl: params.apiWeb ? '/api-web/0.1/' : '/', popupOptions: popupOptions, disableClusters: params.disable_clusters, + withPolylines: withPolylines, planningId: planning_id }).on('clickStop', function(stop) { enlightenStop({index: stop.index, routeId: stop.routeId}); @@ -2841,7 +2853,7 @@ export const plannings_edit = function(params) { }); }); - spreadsheetModalExport(params.spreadsheet_columns, params.planning_id, params.export_settings); + spreadsheetModalExport(params.spreadsheet_columns, params.planning_id, params.export_settings, params.spreadsheet_custom_columns); var devicesObservePlanning = (function() { @@ -3007,7 +3019,7 @@ var plannings_index = function(params) { var requestPending = false; iCalendarExport(); - spreadsheetModalExport(params.spreadsheet_columns, null, params.export_settings); + spreadsheetModalExport(params.spreadsheet_columns, null, params.export_settings, params.spreadsheet_custom_columns); var vehicle_id = $('#vehicle_id').val(), planning_ids; diff --git a/app/assets/javascripts/routes_layers.js b/app/assets/javascripts/routes_layers.js index 0d266ae7c..469da075c 100644 --- a/app/assets/javascripts/routes_layers.js +++ b/app/assets/javascripts/routes_layers.js @@ -636,10 +636,9 @@ export const RoutesLayer = L.FeatureGroup.extend({ }, switchRoutePolylines: function() { - this.options.disableRoutePolylines = !this.options.disableRoutePolylines; + this.options.withPolylines = !this.options.withPolylines; this.hideAllRoutes(); - this.options.withPolylines = !this.options.disableRoutePolylines; this._loadAll(); }, diff --git a/app/assets/javascripts/scaffolds.js b/app/assets/javascripts/scaffolds.js index 8e1cad543..e47ee45ad 100644 --- a/app/assets/javascripts/scaffolds.js +++ b/app/assets/javascripts/scaffolds.js @@ -677,7 +677,8 @@ L.disableRoutePolylinesControl = function(map, routesLayer) { var button = L.DomUtil.create('a', '', container); button.title = I18n.t('plannings.edit.route_polylines'); - var icon = L.DomUtil.create('i', 'route-polyline-icon fa fa-route fa-lg', button); + var initialIcon = routesLayer.options.withPolylines ? 'fa-route' : 'fa-location-dot'; + var icon = L.DomUtil.create('i', 'route-polyline-icon fa ' + initialIcon + ' fa-lg', button); icon.style.marginLeft = '2px'; container.onclick = function() { diff --git a/app/assets/javascripts/active_inactive_drag_drop.js b/app/assets/javascripts/utils/active_inactive_drag_drop.js similarity index 86% rename from app/assets/javascripts/active_inactive_drag_drop.js rename to app/assets/javascripts/utils/active_inactive_drag_drop.js index 48df674f1..efd936273 100644 --- a/app/assets/javascripts/active_inactive_drag_drop.js +++ b/app/assets/javascripts/utils/active_inactive_drag_drop.js @@ -60,12 +60,46 @@ class ActiveInactiveDragDrop { init() { this.makeDraggable(); + this.addToggleButtons(); this.updateItemOrder(); this.updateHiddenInputs(); this.addFormValidation(); this.addEventListeners(); } + // Add "x" button on each item to toggle between active/inactive lists + addToggleButtons() { + const items = this.container.querySelectorAll(this.options.itemSelector); + items.forEach(item => { + if (item.querySelector('.item-toggle-btn')) return; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'item-toggle-btn'; + btn.setAttribute('aria-label', 'Toggle'); + btn.innerHTML = ''; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleItem(item); + }); + item.appendChild(btn); + }); + } + + toggleItem(item) { + const isInActive = this.activeContainer.contains(item); + const targetContainer = isInActive ? this.inactiveContainer : this.activeContainer; + + if (isInActive && this.activeContainer.children.length <= this.options.minActiveItems) { + return; + } + + targetContainer.appendChild(item); + this.updateItemOrder(); + this.updateHiddenInputs(); + } + addEventListeners() { [this.activeContainer, this.inactiveContainer].forEach(container => { container.addEventListener('dragstart', this.handleDragStart.bind(this)); @@ -176,22 +210,28 @@ class ActiveInactiveDragDrop { getActiveItems() { return Array.from(this.activeContainer.querySelectorAll(this.options.itemSelector)) - .map(item => ({ - id: item.dataset.id, - value: item.dataset.value, - text: item.textContent.trim(), - element: item - })); + .map(item => { + const textEl = item.querySelector(this.options.textDisplaySelector); + return { + id: item.dataset.id, + value: item.dataset.value, + text: textEl ? textEl.textContent.trim() : item.textContent.trim(), + element: item + }; + }); } getInactiveItems() { return Array.from(this.inactiveContainer.querySelectorAll(this.options.itemSelector)) - .map(item => ({ - id: item.dataset.id, - value: item.dataset.value, - text: item.textContent.trim(), - element: item - })); + .map(item => { + const textEl = item.querySelector(this.options.textDisplaySelector); + return { + id: item.dataset.id, + value: item.dataset.value, + text: textEl ? textEl.textContent.trim() : item.textContent.trim(), + element: item + }; + }); } makeDraggable() { @@ -330,6 +370,7 @@ class ActiveInactiveDragDrop { // Public methods refresh() { this.makeDraggable(); + this.addToggleButtons(); this.updateItemOrder(); this.updateHiddenInputs(); } @@ -357,8 +398,20 @@ class ActiveInactiveDragDrop { textSpan.className = this.options.textDisplayClass; textSpan.textContent = itemData.text || itemData.value || itemData.id; + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'item-toggle-btn'; + toggleBtn.setAttribute('aria-label', 'Toggle'); + toggleBtn.innerHTML = ''; + toggleBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleItem(item); + }); + item.appendChild(orderSpan); item.appendChild(textSpan); + item.appendChild(toggleBtn); return item; } diff --git a/app/assets/stylesheets/active_inactive_drag_drop.css.scss b/app/assets/stylesheets/active_inactive_drag_drop.css.scss index 8413d270f..88b2801b2 100644 --- a/app/assets/stylesheets/active_inactive_drag_drop.css.scss +++ b/app/assets/stylesheets/active_inactive_drag_drop.css.scss @@ -7,7 +7,7 @@ gap: 20px; margin: 10px 0; align-items: stretch; - max-height: 400px; + max-height: 55vh; .priority-labels { display: flex; @@ -18,7 +18,6 @@ border: 2px dashed transparent; border-radius: 8px; padding: 10px; - transition: all 0.2s ease; } .priority-labels:empty { @@ -80,7 +79,6 @@ padding: 8px 12px; cursor: move; user-select: none; - transition: all 0.2s ease; position: relative; min-width: 80px; justify-content: space-between; @@ -100,6 +98,10 @@ border-color: $success-color; background: $success-background-color; } + + .item-toggle-btn { + margin-left: auto; + } } } @@ -110,7 +112,6 @@ display: flex; flex-direction: column; align-items: stretch; - max-height: 350px; } .item-list { @@ -122,12 +123,10 @@ border: 2px dashed transparent; border-radius: 8px; padding: 10px; - transition: all 0.2s ease; background: #f8f9fa; flex: 1; align-content: flex-start; overflow-y: auto; - max-height: 250px; } .item-list:empty { @@ -167,7 +166,6 @@ border: 1px solid #ddd; border-radius: 6px; cursor: grab; - transition: all 0.2s ease; user-select: none; min-width: 120px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); @@ -175,8 +173,7 @@ } .draggable-item:hover { - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); - transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15); } .draggable-item:active { @@ -195,24 +192,52 @@ border-color: #dee2e6; } -.item-order { +%label-common { + display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; - background: $primary-color; - color: $white-background; border-radius: 50%; font-size: 12px; font-weight: bold; flex-shrink: 0; } +.item-order { + @extend %label-common; + background: $primary-color; + color: $white-background; +} + +.item-toggle-btn { + @extend %label-common; + padding: 0; + border: none; + cursor: pointer; + background: #dee2e6; + color: #495057; + + &:hover { + background: #adb5bd; + color: #212529; + } +} + .draggable-item.inactive .item-order { background: #6c757d; } +.draggable-item.inactive .item-toggle-btn { + background: #6c757d; + color: $white-background; + + &:hover { + background: #495057; + } +} + .item-text { font-weight: 500; color: #333; @@ -257,7 +282,8 @@ padding: 6px 10px; } - .item-order { + .item-order, + .item-toggle-btn { width: 20px; height: 20px; font-size: 11px; diff --git a/app/controllers/plannings_controller.rb b/app/controllers/plannings_controller.rb index 9c50009b3..9f32b3ad4 100644 --- a/app/controllers/plannings_controller.rb +++ b/app/controllers/plannings_controller.rb @@ -750,11 +750,11 @@ def filename def export_columns [ - :ref_planning, - :planning, + :planning_ref, + :planning_name, :planning_date, :route, - :vehicle, + :ref_vehicle, :order, :stop_type, :active, @@ -802,7 +802,7 @@ def export_columns :priority, :revenue, :force_position, - :tags_visit + :tag_visits ] + ( (@customer || @planning.customer).enable_orders ? [:orders] : @@ -822,11 +822,11 @@ def export_columns def export_summary_columns [ - :ref_planning, - :planning, + :planning_ref, + :planning_name, :planning_date, :route, - :vehicle, + :ref_vehicle, :stop_size, :stop_active_size, :time, diff --git a/app/controllers/routes_controller.rb b/app/controllers/routes_controller.rb index 3d77ea228..cfd81a266 100644 --- a/app/controllers/routes_controller.rb +++ b/app/controllers/routes_controller.rb @@ -266,7 +266,7 @@ def filename def export_columns [ :route, - :vehicle, + :ref_vehicle, :order, :stop_type, :active, @@ -313,7 +313,7 @@ def export_columns :priority, :revenue, :force_position, - :tags_visit + :tag_visits ] + (@route.planning.customer.enable_orders ? [:orders] : @route.planning.customer.deliverable_units.flat_map{ |du| diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 8a1f4cc50..8783bc3d0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -77,6 +77,6 @@ def user_password_params end def user_params - params.require(:user).permit :layer_id, :url_click2call, :time_zone, :prefered_unit + params.require(:user).permit :layer_id, :url_click2call, :time_zone, :prefered_unit, :default_display_polylines end end diff --git a/app/helpers/destinations_helper.rb b/app/helpers/destinations_helper.rb index c19e24b33..e8f0e5cc1 100644 --- a/app/helpers/destinations_helper.rb +++ b/app/helpers/destinations_helper.rb @@ -42,7 +42,7 @@ def columns_destination(customer) def columns_visit(customer) visit_columns = %i[ref_visit duration time_window_start_1 time_window_end_1 time_window_start_2 time_window_end_2] - visit_columns += %i[priority revenue tags_visit force_position] + visit_columns += %i[priority revenue tag_visits force_position] unless @customer.enable_orders customer.deliverable_units.each{ |du| visit_columns += ["pickup#{du.id}".to_sym, "delivery#{du.id}".to_sym] diff --git a/app/helpers/routes_helper.rb b/app/helpers/routes_helper.rb index 2b9d30a6c..f8a8f6385 100644 --- a/app/helpers/routes_helper.rb +++ b/app/helpers/routes_helper.rb @@ -72,12 +72,12 @@ def export_column_titles(customer, columns, custom_columns) if custom_columns&.key?(c) custom_columns[c] elsif (m = /^(.+)\[(.*)\]$/.match(c)) - I18n.t("plannings.export_file.#{m[1]}") + "[#{m[2]}]" + I18n.t("plannings.export_file.#{m[1]}", default: :"destinations.import_file.#{m[1]}") + "[#{m[2]}]" elsif (m = /^([a-z]+(?:_[a-z]+)*)(\d+)$/.match(c)) deliverable_unit = customer.deliverable_units.where(id: m[2].to_i).first I18n.t('destinations.import_file.' + m[1]) + (deliverable_unit.label ? "[#{deliverable_unit.label}]" : "#{deliverable_unit.id}") else - I18n.t('plannings.export_file.' + c.to_s) + I18n.t("plannings.export_file.#{c}", default: :"destinations.import_file.#{c}") end } end diff --git a/app/jobs/importer_destinations.rb b/app/jobs/importer_destinations.rb index e9db30bd3..cacc46511 100644 --- a/app/jobs/importer_destinations.rb +++ b/app/jobs/importer_destinations.rb @@ -88,7 +88,7 @@ def columns_visit revenue: {title: I18n.t('destinations.import_file.revenue'), desc: I18n.t('destinations.import_file.revenue_desc'), format: I18n.t('destinations.import_file.format.float')}, priority: {title: I18n.t('destinations.import_file.priority'), desc: I18n.t('destinations.import_file.priority_desc'), format: I18n.t('destinations.import_file.format.integer')}, force_position: {title: I18n.t('destinations.import_file.force_position'), desc: I18n.t('destinations.import_file.force_position_desc'), format: I18n.t('destinations.import_file.force_position_format')}, - tag_visits: {title: I18n.t('destinations.import_file.tags_visit'), desc: I18n.t('destinations.import_file.tags_visit_desc'), format: I18n.t('destinations.import_file.tags_format')}, + tag_visits: {title: I18n.t('destinations.import_file.tag_visits'), desc: I18n.t('destinations.import_file.tag_visits_desc'), format: I18n.t('destinations.import_file.tags_format')}, duration: {title: I18n.t('destinations.import_file.duration'), desc: I18n.t('destinations.import_file.duration_desc'), format: I18n.t('destinations.import_file.format.hour')}, }.merge(Hash[@deliverable_units.flat_map{ |du| [ diff --git a/app/models/import_csv.rb b/app/models/import_csv.rb index 0d6680045..fabcd4f08 100644 --- a/app/models/import_csv.rb +++ b/app/models/import_csv.rb @@ -214,7 +214,7 @@ def error_and_format_row(message, row) row_content = !row.empty? ? (((h = @column_def && @column_def.dup) ? h : {}).each{ |k, _| h[k] = nil }).merge(row) : nil if row_content row_content[:tags] = row_content[:tags].map(&:label).join(',') if row_content[:tags] && row_content[:tags].is_a?(Enumerable) - row_content[:tags_visit] = row_content[:tags_visit].map(&:label).join(',') if row_content[:tags_visit] && row_content[:tags_visit].is_a?(Enumerable) + row_content[:tag_visits] = row_content[:tag_visits].map(&:label).join(',') if row_content[:tag_visits] && row_content[:tag_visits].is_a?(Enumerable) if @content_code == :html error += '
' error += '' + row_content.keys.map{ |a| '
' + (@importer.columns[a] && @importer.columns[a][:title] ? diff --git a/app/views/layouts/_modal_csv.html.haml b/app/views/layouts/_modal_csv.html.haml index eeaeabb8b..9b27e469b 100644 --- a/app/views/layouts/_modal_csv.html.haml +++ b/app/views/layouts/_modal_csv.html.haml @@ -10,8 +10,8 @@ .container-fluid %input{name: "spreadsheet-route", type: "hidden", value: ""}/ .row.row.form-group - %label.col-md-3.control-label= t 'plannings.edit.dialog.spreadsheet.stops' - .col-md-9 + %label.col-md-2.control-label= t 'plannings.edit.dialog.spreadsheet.stops' + .col-md-10 %div %label %input.spreadsheet-stops{checked: "checked", name: "spreadsheet-out-of-route", type: "checkbox", value: "out-of-route"}/ @@ -29,8 +29,8 @@ %input.spreadsheet-stops{checked: "checked", name: "spreadsheet-stops-inactives", type: "checkbox", value: "inactive"}/ %span= t 'plannings.edit.dialog.spreadsheet.stops_inactives' .row.row.form-group - %label.col-md-3.control-label= t 'plannings.edit.dialog.spreadsheet.columns' - .col-md-9 + %label.col-md-2.control-label= t 'plannings.edit.dialog.spreadsheet.columns' + .col-md-10 .active-inactive-zones#spreadsheet-columns-container{"data-drag-drop" => "spreadsheet", "data-drag-drop-options" => '{"minActiveItems":1,"activeContainerSelector":".active-zone .item-list","inactiveContainerSelector":".inactive-zone .item-list","itemSelector":".draggable-item","orderDisplaySelector":".item-order","textDisplaySelector":".item-text"}'} .active-zone.col-md-6 .zone-title diff --git a/app/views/plannings/_edit.html.haml b/app/views/plannings/_edit.html.haml index 90acaaabc..a9cdedb29 100644 --- a/app/views/plannings/_edit.html.haml +++ b/app/views/plannings/_edit.html.haml @@ -257,6 +257,7 @@ controller.js( export_settings: current_user.export_settings, + spreadsheet_custom_columns: @planning.customer.advanced_options&.dig('import', 'destinations', 'spreadsheetColumnsDef') || {}, manage_planning: @manage_planning, with_stops: !large_plannning, prefered_unit: current_user.prefered_unit, @@ -284,6 +285,7 @@ color_codes: COLORS_TABLE, colors_by_route: Hash[@planning.routes.select(&:vehicle_usage).map{ |r| [r.id, r.default_color] }], disable_clusters: !large_plannning, + default_display_polylines: current_user.default_display_polylines, url_click2call: current_user.link_phone_number, spreadsheet_columns: @spreadsheet_columns, available_stop_status: @planning.customer.enable_stop_status && @planning.customer.device.available_stop_status?, diff --git a/app/views/plannings/index.html.erb b/app/views/plannings/index.html.erb index 2f6259ba1..290fd5264 100644 --- a/app/views/plannings/index.html.erb +++ b/app/views/plannings/index.html.erb @@ -115,6 +115,7 @@ <% controller.js( export_settings: current_user.export_settings, + spreadsheet_custom_columns: @customer.advanced_options&.dig('import', 'destinations', 'spreadsheetColumnsDef') || {}, user_api_key: current_user.api_key, customer_id: @customer.id, spreadsheet_columns: @spreadsheet_columns, diff --git a/app/views/routes/_show.csv.ruby b/app/views/routes/_show.csv.ruby index b547dcd3b..56cac3ab8 100644 --- a/app/views/routes/_show.csv.ruby +++ b/app/views/routes/_show.csv.ruby @@ -4,13 +4,13 @@ stop_custom_attributes = route.planning.customer.custom_attributes.for_export_st if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|').include?('store')) row = { - ref_planning: route.planning.ref, - planning: route.planning.name, + planning_ref: route.planning.ref, + planning_name: route.planning.name, planning_date: route.planning.date && I18n.l(route.planning.date, format: :date), route: route.ref || (route.vehicle_usage_id && route.vehicle_usage.vehicle.name.gsub(%r{[\./\\\-*,!:?;]}, ' ')), - vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), + ref_vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), order: 0, - stop_type: I18n.t('plannings.export_file.stop_type_store'), + stop_type: I18n.t('destinations.import_file.stop_type_store'), active: nil, wait_time: nil, time: route.route_data.start_absolute_time, @@ -55,7 +55,7 @@ if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|' time_window_end_2: nil, force_position: nil, priority: nil, - tags_visit: nil + tag_visits: nil }) row.merge!(Hash[route.planning.customer.enable_orders ? @@ -88,18 +88,18 @@ route.stops.each { |stop| type = case stop.type when StopVisit.name - I18n.t('plannings.export_file.stop_type_visit') + I18n.t('destinations.import_file.stop_type_visit') when StopStore.name - I18n.t('plannings.export_file.stop_type_store_reload') + I18n.t('destinations.import_file.stop_type_store_reload') when StopRest.name I18n.t('plannings.export_file.stop_type_rest') end row = { - ref_planning: route.planning.ref, - planning: route.planning.name, + planning_ref: route.planning.ref, + planning_name: route.planning.name, planning_date: route.planning.date && I18n.l(route.planning.date, format: :date), route: route.ref || (route.vehicle_usage_id && route.vehicle_usage.vehicle.name.gsub(%r{[\./\\\-*,!:?;]}, ' ')), - vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), + ref_vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), order: (index+=1 if route.vehicle_usage_id), stop_type: type, active: ((stop.active ? '1' : '0') if route.vehicle_usage_id), @@ -155,7 +155,7 @@ route.stops.each { |stop| force_position: (I18n.t("plannings.export_file.force_position_#{stop.force_position}") if stop.is_a?(StopVisit) && stop.force_position), priority: (stop.priority if stop.priority), revenue: (stop.visit.revenue if stop.is_a?(StopVisit)), - tags_visit: (stop.visit.tags.collect(&:label).join(',') if stop.is_a?(StopVisit)) + tag_visits: (stop.visit.tags.collect(&:label).join(',') if stop.is_a?(StopVisit)) }) row.merge!(Hash[route.planning.customer.enable_orders ? @@ -184,13 +184,13 @@ route.stops.each { |stop| if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|').include?('store')) row = { - ref_planning: route.planning.ref, - planning: route.planning.name, + planning_ref: route.planning.ref, + planning_name: route.planning.name, planning_date: route.planning.date && I18n.l(route.planning.date, format: :date), route: route.ref || (route.vehicle_usage_id && route.vehicle_usage.vehicle.name.gsub(%r{[\./\\\-*,!:?;]}, ' ')), - vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), + ref_vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), order: index+1, - stop_type: I18n.t('plannings.export_file.stop_type_store'), + stop_type: I18n.t('destinations.import_file.stop_type_store'), active: nil, wait_time: nil, time: (route.route_data.end_absolute_time if route.route_data.end), @@ -235,7 +235,7 @@ if route.vehicle_usage_id && (!@params.key?(:stops) || @params[:stops].split('|' time_window_end_2: nil, force_position: nil, priority: nil, - tags_visit: nil + tag_visits: nil }) row.merge!(Hash[route.planning.customer.enable_orders ? diff --git a/app/views/routes/_summary.csv.ruby b/app/views/routes/_summary.csv.ruby index 67e657b23..437e07f28 100644 --- a/app/views/routes/_summary.csv.ruby +++ b/app/views/routes/_summary.csv.ruby @@ -1,9 +1,9 @@ row = { - ref_planning: route.planning.ref, - planning: route.planning.name, + planning_ref: route.planning.ref, + planning_name: route.planning.name, planning_date: route.planning.date && I18n.l(route.planning.date, format: :date), route: route.ref || (route.vehicle_usage_id && route.vehicle_usage.vehicle.name.gsub(%r{[/\\\-*,!:?;.]}, ' ')), - vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), + ref_vehicle: (route.vehicle_usage.vehicle.ref if route.vehicle_usage_id), stop_size: route.stops_size, stop_active_size: route.size_active, time: route.end && route.start && time_over_day(route.end - route.start), diff --git a/app/views/users/_form.html.haml b/app/views/users/_form.html.haml index af7186632..9dd161e57 100644 --- a/app/views/users/_form.html.haml +++ b/app/views/users/_form.html.haml @@ -13,6 +13,7 @@ .row.form-group %span.col-md-offset-2.col-md-8 #map.map-fixed-size + = render partial: 'shared/check_box', locals: { form: f, field: :default_display_polylines, label: t('activerecord.attributes.user.default_display_polylines') } = f.select :prefered_unit, User.unities, {}, {control_class: 'form-control'} = f.select :prefered_currency, currencies_table, {}, {control_class: 'form-control'} = f.text_field :url_click2call do diff --git a/config/database.yml.docker b/config/database.yml.docker index be6b6a48a..2390122b4 100644 --- a/config/database.yml.docker +++ b/config/database.yml.docker @@ -2,13 +2,13 @@ default: &default adapter: postgresql pool: 5 timeout: 5000 - username: <%= ENV['POSTGRES_USERNAME'] || 'postgres' %> - password: <%= ENV['POSTGRES_PASSWORD'] || 'postgres' %> + username: <%= ENV['POSTGRES_USER'] || 'planner' %> + password: <%= ENV['POSTGRES_PASSWORD'] || 'planner' %> host: <%= ENV['POSTGRES_HOST'] || 'db' %> development: <<: *default - database: <%= ENV['POSTGRES_DATABASE'] || 'postgres' %> + database: <%= ENV['POSTGRES_DB'] || 'planner' %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run @@ -16,8 +16,8 @@ development: # production. test: <<: *default - database: <%= ENV['POSTGRES_DATABASE'] || 'test' %> + database: <%= ENV['POSTGRES_DB'] || 'planner_test' %> production: <<: *default - database: <%= ENV['POSTGRES_DATABASE'] || 'postgres' %> + database: <%= ENV['POSTGRES_DB'] || 'planner' %> diff --git a/config/locales/de.yml b/config/locales/de.yml index d7d0c0fdb..19ef72a80 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1242,8 +1242,8 @@ de: without_visit_desc: 'besagt, dass es keine zugehörigen Besuche gibt' ref_visit: Referenzbesuch ref_visit_desc: Referenz-ID dieses Besuchs. - tags_visit: Tags des Besuchs - tags_visit_desc: tags dieses Besuchs (um diese beispielsweise gruppieren zu können). + tag_visits: Tags des Besuchs + tag_visits_desc: tags dieses Besuchs (um diese beispielsweise gruppieren zu können). format: string: Text integer: Ganzzahl @@ -1870,51 +1870,42 @@ de: dialog: new_planning: Erstellung des neuen Plans läuft. Bitte warten Sie. export_file: - ref_planning: Referenzplan - planning: Plan - ref: Referenz - vehicle: Fahrzeug - route: Strecke order: Auftrag - stop_type: Haltetyp - stop_type_store: speichern - stop_type_visit: Besuch stop_type_rest: Pause + force_position_always_first: immer zuerst + force_position_always_final: immer zuletzt + force_position_never_first: nie zuerst + force_position_neutral: neutral wait_time: warten time: Zeit + departure: Abfahrt distance: Entfernung drive_time: Fahrtzeit - name: Name - street: Straße - detail: Detail - postalcode: Postleitzahl - city: Ort - state: Status - country: Land - lat: Breite - lng: Länge - comment: Bemerkung - tags: Tags - phone_number: Telefon - ref_visit: Referenzbesuch - duration: Dauer - quantity: Menge - quantity_operation: Vorgang orders: Aufträge - active: aktiv - time_window_start_1: öffnen 1 - time_window_end_1: schließen 1 - time_window_start_2: öffnen 2 - time_window_end_2: schließen 2 - priority: Priorität - tags_visit: Tags des Besuchs out_of_window: außerhalb des Besuchszeitfensters out_of_capacity: nicht ausgelastet out_of_drive_time: außerhalb der Schichtzeit des Fahrzeugs + out_of_force_position: erzwungene Position nicht eingehalten out_of_work_time: außerhalb der Arbeitszeit out_of_max_distance: maximal erreichbare Entfernung + out_of_max_ride_distance: maximale Fahrtentfernung + out_of_max_ride_duration: maximale Fahrtdauer + out_of_max_reload: außerhalb des maximalen Nachladens + out_of_relation: Beziehungsverletzung + out_of_skill: Fähigkeit nicht erfüllt status: Status + status_updated_at: Zeitpunkt der Statusänderung eta: eta + custom_attributes_stop: Stoppattribute + stop_size: Anzahl der Stopps + stop_active_size: Anzahl aktiver Stopps + emission: Emission + start: Start + end: Ende + visits_duration: Besuchsdauer + cost_distance: Kosten pro Entfernung + cost_fixed: Fixkosten + cost_time: Kosten pro Zeit form: ref_help: Eine freie Referenz begin_end_date: Gültigkeitszeitraum diff --git a/config/locales/en.yml b/config/locales/en.yml index 7f1f5b6d6..089640667 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -455,6 +455,7 @@ en: confirmed_at: Confirmed at prefered_unit: Preferred unit prefered_currency: Prefered currency + default_display_polylines: Display routes traces by default vehicle: max_distance: Maximum distance max_ride_distance: Maximum ride distance @@ -1508,8 +1509,8 @@ en: without_visit_desc: states that there is no associated visits ref_visit: reference visit ref_visit_desc: reference id of this visit. - tags_visit: categories visit - tags_visit_desc: categories of this visit (allowing to group them for example). + tag_visits: categories visit + tag_visits_desc: categories of this visit (allowing to group them for example). force_position: forced position force_position_desc: forced position of the visit within the route force_position_format: '[always first | never first | neutral | always final]' @@ -2255,55 +2256,18 @@ en: dialog: new_planning: 'Creating the new plan in progress, please wait' export_file: - ref_planning: reference plan - planning: plan - planning_date: date - ref: reference - vehicle: vehicle - route: route order: order - stop_type: stop type - stop_type_store: store - stop_type_visit: visit stop_type_rest: rest - stop_type_store_reload: reload - force_position: forced position force_position_always_first: always first force_position_always_final: always last force_position_never_first: never first force_position_neutral: neutral wait_time: wait time: time + departure: departure time distance: distance drive_time: drive time - name: name - street: street - detail: detail - postalcode: postalcode - city: city - state: state - country: country - lat: lat - lng: lng - comment: comment - tags: categories - phone_number: phone - ref_visit: reference visit - duration: visit duration - destination_duration: destination duration - quantity: quantity - pickup: pickup - delivery: delivery - max_load: max load orders: orders - active: active - time_window_start_1: open 1 - time_window_end_1: close 1 - time_window_start_2: open 2 - time_window_end_2: close 2 - priority: priority - revenue: revenue - tags_visit: categories visit out_of_window: out of visit time window out_of_capacity: out of capacity out_of_drive_time: out of vehicle time shift @@ -2312,32 +2276,22 @@ en: out_of_max_distance: maximum achievable distance out_of_max_ride_distance: maximum ride distance out_of_max_ride_duration: maximum ride duration + out_of_max_reload: out of maximum reload out_of_relation: relation violated out_of_skill: skill not fulfilled status: status status_updated_at: time status changed eta: eta - custom_attributes_visit: visit attributes custom_attributes_stop: stop attributes stop_size: number of stops stop_active_size: number of active stops - time: total time - distance: total distance emission: emission start: start end: end visits_duration: visit duration - wait_time: waiting time - departure: departure time - drive_time: drive time - out_of_window: out of time window - out_of_max_ride_distance: out of maximum ride distance - out_of_max_ride_duration: out of maximum ride duration - out_of_max_reload: out of maximum reload cost_distance: cost per distance cost_fixed: fixed cost cost_time: cost per time - revenue: revenue form: ref_help: A free reference begin_end_date: Validity period diff --git a/config/locales/fa.yml b/config/locales/fa.yml index a6d815019..ef9d15a22 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -906,8 +906,8 @@ fa: without_visit_desc: '' ref_visit: '' ref_visit_desc: '' - tags_visit: '' - tags_visit_desc: '' + tag_visits: '' + tag_visits_desc: '' format: string: '' integer: '' @@ -1253,47 +1253,42 @@ fa: dialog: new_planning: '' export_file: - ref_planning: '' - planning: '' - ref: '' - vehicle: '' - route: '' order: '' - stop_type: '' - stop_type_store: '' - stop_type_visit: '' stop_type_rest: '' + force_position_always_first: '' + force_position_always_final: '' + force_position_never_first: '' + force_position_neutral: '' wait_time: '' time: '' + departure: '' distance: '' drive_time: '' - name: '' - street: '' - detail: '' - postalcode: '' - city: '' - state: '' - country: '' - lat: '' - lng: '' - comment: '' - tags: '' - phone_number: '' - ref_visit: '' - duration: '' - quantity: '' orders: '' - active: '' - time_window_start_1: '' - time_window_end_1: '' - time_window_start_2: '' - time_window_end_2: '' - tags_visit: '' out_of_window: '' out_of_capacity: '' out_of_drive_time: '' + out_of_force_position: '' + out_of_work_time: '' + out_of_max_distance: '' + out_of_max_ride_distance: '' + out_of_max_ride_duration: '' + out_of_max_reload: '' + out_of_relation: '' + out_of_skill: '' status: '' + status_updated_at: '' eta: '' + custom_attributes_stop: '' + stop_size: '' + stop_active_size: '' + emission: '' + start: '' + end: '' + visits_duration: '' + cost_distance: '' + cost_fixed: '' + cost_time: '' form: ref_help: '' begin_end_date: '' diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 7dd8fe11d..2bcc60f0b 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -461,6 +461,7 @@ fr: confirmed_at: Date de confirmation prefered_unit: Choix unité prefered_currency: Choix devise + default_display_polylines: Afficher les tracés des routes par défaut vehicle: max_distance: Distance maximale max_ride_distance: Distance trajet maximale @@ -1565,8 +1566,8 @@ fr: without_visit_desc: précise qu'il n'y a pas de visite associée ref_visit: référence visite ref_visit_desc: référence de la visite. - tags_visit: catégories visite - tags_visit_desc: catégories de la visite (permettant par exemple de les grouper). + tag_visits: catégories visite + tag_visits_desc: catégories de la visite (permettant par exemple de les grouper). force_position: position forcée force_position_desc: position de la visite au sein de la tournée force_position_format: '[début | pas début | neutre | fin]' @@ -2362,87 +2363,42 @@ fr: dialog: new_planning: 'Création du nouveau plan de tournées en cours, merci de patienter' export_file: - ref_planning: référence plan - planning: plan - planning_date: date - ref: référence - vehicle: véhicule - route: tournée order: ordre - stop_type: type arrêt - stop_type_store: site - stop_type_visit: visite stop_type_rest: pause - stop_type_store_reload: rechargement - force_position: position forcée force_position_always_first: début force_position_always_final: fin force_position_never_first: pas début force_position_neutral: neutre wait_time: attente time: heure + departure: horaire de départ distance: distance drive_time: temps trajet - name: nom - street: voie - detail: complément - postalcode: code postal - city: ville - state: état - country: pays - lat: lat - lng: lng - comment: commentaire - tags: catégories - phone_number: téléphone - ref_visit: référence visite - duration: durée visite - destination_duration: durée destination - quantity: quantité - pickup: collecte - delivery: livraison - max_load: charge max orders: commandes - active: livré - time_window_start_1: horaire début 1 - time_window_end_1: horaire fin 1 - time_window_start_2: horaire début 2 - time_window_end_2: horaire fin 2 - priority: priorité - revenue: revenu - tags_visit: catégories visite out_of_window: hors créneau visite out_of_capacity: hors capacité véhicule out_of_drive_time: hors amplitude horaire du véhicule out_of_force_position: position forcée non respectée out_of_work_time: hors horaire de travail out_of_max_distance: hors distance maximale + out_of_max_ride_distance: hors distance trajet maximale + out_of_max_ride_duration: hors durée trajet maximale + out_of_max_reload: hors limite de rechargement maximale out_of_relation: relation non respectée out_of_skill: compétence non respectée status: statut status_updated_at: horaire statut eta: eta - custom_attributes_visit: attributs visite custom_attributes_stop: attributs arrêt stop_size: nombre d'arrêts stop_active_size: nombre d'arrêts actifs - time: durée total - distance: distance totale emission: émission start: début end: fin visits_duration: durée visite - wait_time: temps d'attente - departure: horaire de départ - drive_time: durée conduite - out_of_window: hors amplitude horaire - out_of_max_ride_distance: hors distance trajet maximale - out_of_max_ride_duration: hors durée trajet maximale - out_of_max_reload: hors limite de rechargement maximale cost_distance: coût kilométrique cost_fixed: coût fixe cost_time: coût horaire - revenue: revenu form: ref_help: Référence libre begin_end_date: Période de validité diff --git a/config/locales/pt.yml b/config/locales/pt.yml index a5164beb1..04f66ce87 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -1469,8 +1469,8 @@ pt: without_visit_desc: precisa que não existe nenhuma visita associada ref_visit: visita de referência ref_visit_desc: referência da visita. - tags_visit: categoria visita - tags_visit_desc: 'categorização da visita (permitirá, por exemplo, agrupa-las).' + tag_visits: categoria visita + tag_visits_desc: 'categorização da visita (permitirá, por exemplo, agrupa-las).' format: string: texto integer: numero inteiro @@ -2026,47 +2026,42 @@ pt: dialog: new_planning: Criando o novo plano de rotas. Por favor aguarde export_file: - ref_planning: plano de referência - planning: plano - ref: referência - vehicle: veículo - route: rota order: pedido/encomenda - stop_type: tipo de paragem - stop_type_store: armazém - stop_type_visit: visita stop_type_rest: pausa + force_position_always_first: sempre primeiro + force_position_always_final: sempre último + force_position_never_first: nunca primeiro + force_position_neutral: neutro wait_time: espera time: hora + departure: partida distance: distância - drive_time: empo de trajeto - name: nome - street: rua - detail: complemento - postalcode: código postal - city: localidade - state: estado - country: país - lat: lat - lng: lng - comment: comentário - tags: etiquetas - phone_number: telefone - ref_visit: visita referencia - duration: duração da visita - quantity: quantidade + drive_time: tempo de trajeto orders: encomendas - active: ativo - time_window_start_1: horário de abertura 1 - time_window_end_1: horário de fecho 1 - time_window_start_2: horário de abertura 2 - time_window_end_2: horário de fecho 2 - tags_visit: etiquetas da visita out_of_window: fora do período da visita out_of_capacity: para além da capacidade do veículo out_of_drive_time: fora do período temporal do veículo + out_of_force_position: posição forçada não cumprida + out_of_work_time: fora do tempo de trabalho + out_of_max_distance: distância máxima atingível + out_of_max_ride_distance: distância máxima de viagem + out_of_max_ride_duration: duração máxima de viagem + out_of_max_reload: fora do recarregamento máximo + out_of_relation: relação violada + out_of_skill: competência não preenchida status: estado + status_updated_at: hora da alteração do estado eta: eta + custom_attributes_stop: atributos da paragem + stop_size: número de paragens + stop_active_size: número de paragens ativas + emission: emissão + start: início + end: fim + visits_duration: duração da visita + cost_distance: custo por distância + cost_fixed: custo fixo + cost_time: custo por tempo form: ref_help: Referência livre begin_end_date: Período de validade diff --git a/db/migrate/20260219151139_add_default_display_polylines_to_users.rb b/db/migrate/20260219151139_add_default_display_polylines_to_users.rb new file mode 100644 index 000000000..d55036e4c --- /dev/null +++ b/db/migrate/20260219151139_add_default_display_polylines_to_users.rb @@ -0,0 +1,5 @@ +class AddDefaultDisplayPolylinesToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :default_display_polylines, :boolean, default: true, null: false + end +end diff --git a/db/structure.sql b/db/structure.sql index 089440397..41d72dddb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1386,7 +1386,8 @@ CREATE TABLE public.users ( prefered_unit character varying DEFAULT 'km'::character varying, locale character varying, prefered_currency integer DEFAULT 0, - export_settings jsonb DEFAULT '{}'::jsonb + export_settings jsonb DEFAULT '{}'::jsonb, + default_display_polylines boolean DEFAULT true NOT NULL ); @@ -3666,6 +3667,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260113123734'), ('20260123141829'), ('20260209100651'), -('20260204152038'); +('20260204152038'), +('20260219151139'); diff --git a/docker/dumps/load-dump.sh b/docker/dumps/load-dump.sh new file mode 100755 index 000000000..b5afefda0 --- /dev/null +++ b/docker/dumps/load-dump.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +# Script to load PostgreSQL dump from compressed file +# This script is executed automatically by PostgreSQL container on first startup + +INIT_DIR="/docker-entrypoint-initdb.d" + +# Skip if database already has tables (dump already loaded) +TABLE_COUNT=$(psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'" 2>/dev/null || echo "0") +if [ "$TABLE_COUNT" -gt 0 ] 2>/dev/null; then + echo "Database '$POSTGRES_DB' already has $TABLE_COUNT tables, skipping dump load." + exit 0 +fi + +# Find the dump file: exact match first, then first .pg_dump.gz found +DUMP_FILE="$INIT_DIR/${POSTGRES_DB}.pg_dump.gz" +if [ ! -f "$DUMP_FILE" ]; then + DUMP_FILE=$(find "$INIT_DIR" -maxdepth 1 -name '*.pg_dump.gz' -type f | head -n 1) +fi + +if [ -n "$DUMP_FILE" ] && [ -f "$DUMP_FILE" ]; then + echo "Loading dump from $DUMP_FILE into database '$POSTGRES_DB'..." + + # Detect format by checking file header (first bytes) + FIRST_BYTES=$(head -c 5 "$DUMP_FILE" 2>/dev/null || echo "") + DUMP_TYPE=$(file "$DUMP_FILE" 2>/dev/null || echo "") + + if echo "$FIRST_BYTES" | grep -q "PGDMP" || echo "$DUMP_TYPE" | grep -q "PostgreSQL custom database dump"; then + if echo "$DUMP_TYPE" | grep -q "gzip"; then + echo "Detected gzipped PostgreSQL custom format, decompressing and loading with pg_restore..." + gunzip -c "$DUMP_FILE" | pg_restore -v --no-owner --no-acl --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" + else + echo "Detected PostgreSQL custom format (not compressed), loading directly with pg_restore..." + pg_restore -v --no-owner --no-acl --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" "$DUMP_FILE" + fi + elif echo "$DUMP_TYPE" | grep -q "gzip"; then + echo "Detected gzipped plain SQL format, loading with psql..." + gunzip -c "$DUMP_FILE" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" + else + echo "Detected plain SQL format, loading with psql..." + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < "$DUMP_FILE" + fi + + echo "Dump loaded successfully!" +else + echo "No dump file found at $DUMP_FILE, skipping..." +fi diff --git a/test/controllers/plannings_controller_test.rb b/test/controllers/plannings_controller_test.rb index ebe865fc6..51d66cc52 100644 --- a/test/controllers/plannings_controller_test.rb +++ b/test/controllers/plannings_controller_test.rb @@ -11,7 +11,7 @@ class PlanningsControllerTest < ActionController::TestCase request.host = @reseller.host @planning = plannings(:planning_one) @stop = stops(:stop_one_one) - @export_settings_params = { columns: 'ref_planning|planning|planning_date|route|vehicle|order|stop_type|active|wait_time|time|distance|drive_time|out_of_window|out_of_capacity|out_of_drive_time|out_of_force_position|out_of_work_time|out_of_max_distance|out_of_max_ride_distance|out_of_max_ride_duration|status|status_updated_at|eta|ref|name|street|detail|postalcode|city|country|lat|lng|comment|phone_number|tags|ref_visit|destination_duration|duration|time_window_start_1|time_window_end_1|time_window_start_2|time_window_end_2|priority|revenue|force_position|tags_visit|quantity1', + @export_settings_params = { columns: 'planning_ref|planning_name|planning_date|route|ref_vehicle|order|stop_type|active|wait_time|time|distance|drive_time|out_of_window|out_of_capacity|out_of_drive_time|out_of_force_position|out_of_work_time|out_of_max_distance|out_of_max_ride_distance|out_of_max_ride_duration|status|status_updated_at|eta|ref|name|street|detail|postalcode|city|country|lat|lng|comment|phone_number|tags|ref_visit|destination_duration|duration|time_window_start_1|time_window_end_1|time_window_start_2|time_window_end_2|priority|revenue|force_position|tag_visits|quantity1', skips: '', stops: 'out-of-route|store|rest|inactive'} sign_in users(:user_one) diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index a2765def9..d83da4d40 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -53,6 +53,16 @@ class UsersControllerTest < ActionController::TestCase assert_redirected_to edit_user_path(@user) end + test 'should update default_display_polylines preference' do + assert @user.default_display_polylines + + patch :update, params: { id: @user, user: { default_display_polylines: '0' } } + assert_redirected_to edit_user_path(@user) + + @user.reload + assert_not @user.default_display_polylines + end + test 'should get edit password' do sign_out(@user) user = users(:unconfirmed_user)