From 7775c9b8010628cadf35a9d175ebcad0bcce6664 Mon Sep 17 00:00:00 2001 From: Lodewiges Date: Wed, 21 Jan 2026 11:32:08 +0100 Subject: [PATCH 1/4] first shot --- app/assets/stylesheets/order_screen.scss | 197 ++++++++++++++ app/controllers/activities_controller.rb | 10 +- .../product_price_folders_controller.rb | 78 ++++++ app/controllers/product_prices_controller.rb | 61 +++++ app/javascript/order_screen.js | 246 +++++++++++++++++- app/models/price_list.rb | 1 + app/models/product_price.rb | 24 ++ app/models/product_price_folder.rb | 23 ++ app/policies/product_price_folder_policy.rb | 32 +++ app/policies/product_price_policy.rb | 18 ++ app/views/activities/order_screen.html.erb | 133 +++++++++- config/routes.rb | 17 ++ ...0121000001_create_product_price_folders.rb | 16 ++ ...d_folder_and_position_to_product_prices.rb | 8 + package.json | 1 + spec/factories/product_price_folder.rb | 8 + 16 files changed, 865 insertions(+), 8 deletions(-) create mode 100644 app/controllers/product_price_folders_controller.rb create mode 100644 app/controllers/product_prices_controller.rb create mode 100644 app/models/product_price_folder.rb create mode 100644 app/policies/product_price_folder_policy.rb create mode 100644 db/migrate/20260121000001_create_product_price_folders.rb create mode 100644 db/migrate/20260121000002_add_folder_and_position_to_product_prices.rb create mode 100644 spec/factories/product_price_folder.rb diff --git a/app/assets/stylesheets/order_screen.scss b/app/assets/stylesheets/order_screen.scss index ed42d879b..ac3ae47a7 100644 --- a/app/assets/stylesheets/order_screen.scss +++ b/app/assets/stylesheets/order_screen.scss @@ -279,4 +279,201 @@ grid-area: order-grid; overflow-y: auto; } + + // Edit mode toggle button + .edit-mode-toggle { + position: absolute; + top: 90px; + right: 10px; + z-index: 10; + } + + // Folder styles + .folder-container { + display: contents; + } + + .folder-tile { + position: relative; + background-color: $gray-600; + color: $white; + + .folder-icon { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; + font-size: 2.5rem; + position: relative; + } + + .folder-back-arrow { + position: absolute; + font-size: 1rem; + bottom: 0; + right: -0.5rem; + color: $white; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + padding: 0.2rem; + } + + .product-grid-product-name { + color: $white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + + &.edit-mode { + cursor: grab; + } + } + + .folder-edit-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 50%; + cursor: pointer; + font-size: 0.8rem; + color: $gray-700; + transition: all 0.2s ease; + + &:hover { + background-color: $white; + transform: scale(1.1); + } + } + + .add-folder-tile { + background-color: $gray-400; + border: 2px dashed $gray-600; + + .folder-icon { + color: $gray-600; + } + + .product-grid-product-name { + color: $gray-600; + } + + &:hover { + background-color: $gray-300; + } + } + + .drop-home-tile { + background-color: $gray-500; + border: 2px dashed $gray-700; + } + + .back-button-tile { + .folder-icon { + font-size: 2rem; + } + } + + // Draggable styles + .draggable { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + .drag-handle { + position: absolute; + top: 8px; + left: 8px; + color: rgba(0, 0, 0, 0.3); + font-size: 1rem; + } + + .sortable-ghost { + opacity: 0.4; + } + + .sortable-chosen { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + + .sortable-drag { + background-color: $white; + } + + // Folder modal styles + .folder-modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; + } + + .folder-modal { + background-color: $white; + border-radius: 8px; + width: 90%; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + } + + .folder-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid $gray-300; + + h5 { + margin: 0; + } + } + + .folder-modal-body { + padding: 1rem; + } + + .folder-modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid $gray-300; + } + + .color-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .color-swatch { + width: 32px; + height: 32px; + border-radius: 4px; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + + &.selected { + border-color: $gray-900; + box-shadow: 0 0 0 2px $white, 0 0 0 4px $gray-900; + } + } } \ No newline at end of file diff --git a/app/controllers/activities_controller.rb b/app/controllers/activities_controller.rb index 6fa3c8684..b7180d7f6 100644 --- a/app/controllers/activities_controller.rb +++ b/app/controllers/activities_controller.rb @@ -90,13 +90,21 @@ def order_screen # rubocop:disable Metrics/MethodLength, Metrics/AbcSize .find(params[:id]) @product_prices_json = sorted_product_price(@activity).to_json( + only: %i[id price position product_price_folder_id], include: { product: { only: %i[id name category color], methods: %i[requires_age] } } ) + @folders_json = @activity.price_list.product_price_folders.order(:position).to_json( + only: %i[id name position color] + ) + @users_json = users_hash.to_json @activity_json = @activity.to_json(only: %i[id title start_time end_time]) + @is_treasurer = current_user.treasurer? + @price_list_id = @activity.price_list_id + @sumup_key = Rails.application.config.x.sumup_key @sumup_enabled = @sumup_key.present? @@ -174,7 +182,7 @@ def users_hash end def sorted_product_price(activity) - activity.price_list.product_price.sort_by { |p| p.product.id } + activity.price_list.product_price.includes(:product).order(:position) end def permitted_attributes diff --git a/app/controllers/product_price_folders_controller.rb b/app/controllers/product_price_folders_controller.rb new file mode 100644 index 000000000..70326e5ec --- /dev/null +++ b/app/controllers/product_price_folders_controller.rb @@ -0,0 +1,78 @@ +class ProductPriceFoldersController < ApplicationController + before_action :authenticate_user! + before_action :set_price_list, only: %i[index create reorder] + before_action :set_folder, only: %i[update destroy] + + # GET /price_lists/:price_list_id/product_price_folders + def index + authorize ProductPriceFolder + @folders = @price_list.product_price_folders.order(:position) + render json: @folders + end + + # POST /price_lists/:price_list_id/product_price_folders + def create + @folder = @price_list.product_price_folders.new(folder_params) + authorize @folder + + if @folder.save + render json: @folder, status: :created + else + render json: { errors: @folder.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /product_price_folders/:id + def update + authorize @folder + + if @folder.update(folder_params) + render json: @folder + else + render json: { errors: @folder.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /product_price_folders/:id + def destroy + authorize @folder + + # Move all products in this folder back to home screen (nullify folder_id) + @folder.product_prices.update_all(product_price_folder_id: nil) + @folder.destroy + + head :no_content + end + + # PATCH /price_lists/:price_list_id/product_price_folders/reorder + def reorder + authorize ProductPriceFolder, :reorder? + + folder_positions = params.require(:folder_positions) + + ActiveRecord::Base.transaction do + folder_positions.each do |folder_data| + folder = @price_list.product_price_folders.find(folder_data[:id]) + folder.update!(position: folder_data[:position]) + end + end + + render json: { success: true } + rescue ActiveRecord::RecordInvalid => e + render json: { errors: [e.message] }, status: :unprocessable_entity + end + + private + + def set_price_list + @price_list = PriceList.find(params[:price_list_id]) + end + + def set_folder + @folder = ProductPriceFolder.find(params[:id]) + end + + def folder_params + params.require(:product_price_folder).permit(:name, :color, :position) + end +end diff --git a/app/controllers/product_prices_controller.rb b/app/controllers/product_prices_controller.rb new file mode 100644 index 000000000..9b773abb6 --- /dev/null +++ b/app/controllers/product_prices_controller.rb @@ -0,0 +1,61 @@ +class ProductPricesController < ApplicationController + before_action :authenticate_user! + before_action :set_product_price, only: %i[assign_folder] + before_action :set_price_list, only: %i[reorder] + + # PATCH /product_prices/:id/assign_folder + def assign_folder + authorize @product_price, :update? + + folder_id = params[:folder_id] + + # Validate folder belongs to same price list if provided + if folder_id.present? + folder = ProductPriceFolder.find(folder_id) + unless folder.price_list_id == @product_price.price_list_id + return render json: { errors: ['Folder does not belong to the same price list'] }, status: :unprocessable_entity + end + end + + if @product_price.update(product_price_folder_id: folder_id) + render json: @product_price, include: product_price_includes + else + render json: { errors: @product_price.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /price_lists/:price_list_id/product_prices/reorder + def reorder + authorize ProductPrice, :update? + + product_positions = params.require(:product_positions) + + ActiveRecord::Base.transaction do + product_positions.each do |product_data| + product_price = @price_list.product_price.find(product_data[:id]) + product_price.update!( + position: product_data[:position], + product_price_folder_id: product_data[:folder_id] + ) + end + end + + render json: { success: true } + rescue ActiveRecord::RecordInvalid => e + render json: { errors: [e.message] }, status: :unprocessable_entity + end + + private + + def set_product_price + @product_price = ProductPrice.find(params[:id]) + end + + def set_price_list + @price_list = PriceList.find(params[:price_list_id]) + end + + def product_price_includes + { product: { only: %i[id name category color], methods: %i[requires_age] } } + end +end diff --git a/app/javascript/order_screen.js b/app/javascript/order_screen.js index f825a479f..82cc83622 100644 --- a/app/javascript/order_screen.js +++ b/app/javascript/order_screen.js @@ -1,6 +1,7 @@ import Vue from 'vue/dist/vue.esm'; import api from './api/axiosInstance'; import * as bootstrap from 'bootstrap'; +import Sortable from 'sortablejs'; import FlashNotification from './components/FlashNotification.vue'; import UserSelection from './components/orderscreen/UserSelection.vue'; @@ -11,9 +12,12 @@ document.addEventListener('turbo:load', () => { if (element != null) { const users = JSON.parse(element.dataset.users); const productPrices = JSON.parse(element.dataset.productPrices); + const folders = JSON.parse(element.dataset.folders || '[]'); const activity = JSON.parse(element.dataset.activity); const flashes = JSON.parse(element.dataset.flashes); const depositButtonEnabled = element.dataset.depositButtonEnabled === 'true'; + const isTreasurer = element.dataset.isTreasurer === 'true'; + const priceListId = element.dataset.priceListId; window.flash = function(message, actionText, type) { const event = new CustomEvent('flash', { detail: { message: message, actionText: actionText, type: type } } ); @@ -32,6 +36,7 @@ document.addEventListener('turbo:load', () => { return { users: users, productPrices: productPrices, + folders: folders, activity: activity, selectedUser: null, payWithCash: false, @@ -39,7 +44,21 @@ document.addEventListener('turbo:load', () => { keepUserSelected: false, depositButtonEnabled: depositButtonEnabled, orderRows: [], - isSubmitting: false + isSubmitting: false, + // Folder navigation + currentFolder: null, + // Edit mode (treasurer only) + editMode: false, + isTreasurer: isTreasurer, + priceListId: priceListId, + // Folder modal + showFolderModal: false, + editingFolder: null, + folderForm: { name: '', color: '#6c757d' }, + // Drag state + draggedItem: null, + sortableInstance: null, + folderSortableInstance: null }; }, methods: { @@ -247,6 +266,202 @@ document.addEventListener('turbo:load', () => { this.handleXHRError(response); }); }, + + // Folder navigation methods + enterFolder(folder) { + if (!this.editMode) { + this.currentFolder = folder; + } + }, + + exitFolder() { + this.currentFolder = null; + }, + + // Edit mode methods + toggleEditMode() { + this.editMode = !this.editMode; + if (this.editMode) { + this.$nextTick(() => { + this.initSortable(); + }); + } else { + this.destroySortable(); + } + }, + + initSortable() { + const productGrid = this.$el.querySelector('.product-grid'); + if (productGrid && !this.sortableInstance) { + this.sortableInstance = Sortable.create(productGrid, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + filter: '.folder-tile, .back-button-tile, .add-folder-tile', + onEnd: this.onProductDragEnd.bind(this) + }); + } + + const folderContainer = this.$el.querySelector('.folder-container'); + if (folderContainer && !this.folderSortableInstance) { + this.folderSortableInstance = Sortable.create(folderContainer, { + animation: 150, + ghostClass: 'sortable-ghost', + filter: '.add-folder-tile', + onEnd: this.onFolderDragEnd.bind(this) + }); + } + }, + + destroySortable() { + if (this.sortableInstance) { + this.sortableInstance.destroy(); + this.sortableInstance = null; + } + if (this.folderSortableInstance) { + this.folderSortableInstance.destroy(); + this.folderSortableInstance = null; + } + }, + + onProductDragEnd(evt) { + const productPriceId = evt.item.dataset.productPriceId; + const targetFolderId = evt.to.dataset.folderId || null; + + // Update position via API + const productPrice = this.productPrices.find(p => p.id == productPriceId); + if (productPrice) { + this.assignProductToFolder(productPrice, targetFolderId, evt.newIndex); + } + }, + + onFolderDragEnd(evt) { + // Update folder positions + const folderPositions = []; + const folderElements = evt.to.querySelectorAll('.folder-tile'); + folderElements.forEach((el, index) => { + const folderId = el.dataset.folderId; + if (folderId) { + folderPositions.push({ id: parseInt(folderId), position: index }); + const folder = this.folders.find(f => f.id == folderId); + if (folder) folder.position = index; + } + }); + + if (folderPositions.length > 0) { + api.patch(`/price_lists/${this.priceListId}/product_price_folders/reorder`, { + folder_positions: folderPositions + }).catch((response) => { + this.handleXHRError(response); + }); + } + }, + + assignProductToFolder(productPrice, folderId, newPosition = 0) { + api.patch(`/product_prices/${productPrice.id}/assign_folder`, { + folder_id: folderId + }).then(() => { + productPrice.product_price_folder_id = folderId ? parseInt(folderId) : null; + productPrice.position = newPosition; + }).catch((response) => { + this.handleXHRError(response); + }); + }, + + // Drop product on folder + onDropOnFolder(evt, folder) { + evt.preventDefault(); + const productPriceId = evt.dataTransfer.getData('productPriceId'); + const productPrice = this.productPrices.find(p => p.id == productPriceId); + if (productPrice) { + this.assignProductToFolder(productPrice, folder ? folder.id : null); + } + }, + + onDragStartProduct(evt, productPrice) { + evt.dataTransfer.setData('productPriceId', productPrice.id); + this.draggedItem = productPrice; + }, + + onDragEnd() { + this.draggedItem = null; + }, + + // Folder CRUD methods + openFolderModal(folder = null) { + this.editingFolder = folder; + if (folder) { + this.folderForm = { name: folder.name, color: folder.color }; + } else { + this.folderForm = { name: '', color: '#6c757d' }; + } + this.showFolderModal = true; + }, + + closeFolderModal() { + this.showFolderModal = false; + this.editingFolder = null; + this.folderForm = { name: '', color: '#6c757d' }; + }, + + saveFolder() { + if (!this.folderForm.name.trim()) { + this.sendFlash('Voer een mapnaam in', '', 'warning'); + return; + } + + if (this.editingFolder) { + // Update existing folder + api.patch(`/product_price_folders/${this.editingFolder.id}`, { + product_price_folder: this.folderForm + }).then((response) => { + const index = this.folders.findIndex(f => f.id === this.editingFolder.id); + if (index !== -1) { + this.$set(this.folders, index, response.data); + } + this.sendFlash('Map bijgewerkt', '', 'success'); + this.closeFolderModal(); + }).catch((response) => { + this.handleXHRError(response); + }); + } else { + // Create new folder + api.post(`/price_lists/${this.priceListId}/product_price_folders`, { + product_price_folder: this.folderForm + }).then((response) => { + this.folders.push(response.data); + this.sendFlash('Map aangemaakt', '', 'success'); + this.closeFolderModal(); + }).catch((response) => { + this.handleXHRError(response); + }); + } + }, + + deleteFolder(folder) { + if (!confirm(`Map "${folder.name}" verwijderen? Producten worden terug naar het hoofdscherm verplaatst.`)) { + return; + } + + api.delete(`/product_price_folders/${folder.id}`).then(() => { + // Move products back to home + this.productPrices.forEach(pp => { + if (pp.product_price_folder_id === folder.id) { + pp.product_price_folder_id = null; + } + }); + // Remove folder from list + const index = this.folders.findIndex(f => f.id === folder.id); + if (index !== -1) { + this.folders.splice(index, 1); + } + this.sendFlash('Map verwijderd', '', 'success'); + this.closeFolderModal(); + }).catch((response) => { + this.handleXHRError(response); + }); + }, }, computed: { @@ -295,6 +510,35 @@ document.addEventListener('turbo:load', () => { isMobile() { return this.isIos || /Android|webOS|Opera Mini/i.test(navigator.userAgent); + }, + + // Folder computed properties + sortedFolders() { + return [...this.folders].sort((a, b) => a.position - b.position); + }, + + productsWithoutFolder() { + return this.productPrices + .filter(pp => !pp.product_price_folder_id) + .sort((a, b) => a.position - b.position); + }, + + productsInCurrentFolder() { + if (!this.currentFolder) return []; + return this.productPrices + .filter(pp => pp.product_price_folder_id === this.currentFolder.id) + .sort((a, b) => a.position - b.position); + }, + + visibleProducts() { + if (this.currentFolder) { + return this.productsInCurrentFolder; + } + return this.productsWithoutFolder; + }, + + isInFolder() { + return this.currentFolder !== null; } }, diff --git a/app/models/price_list.rb b/app/models/price_list.rb index 42e050344..c31590201 100644 --- a/app/models/price_list.rb +++ b/app/models/price_list.rb @@ -2,6 +2,7 @@ class PriceList < ApplicationRecord has_many :product_price, dependent: :destroy has_many :products, through: :product_price, dependent: :restrict_with_exception has_many :activities, dependent: :restrict_with_exception + has_many :product_price_folders, dependent: :destroy validates :name, presence: true diff --git a/app/models/product_price.rb b/app/models/product_price.rb index 1afb21990..d9cd2e599 100644 --- a/app/models/product_price.rb +++ b/app/models/product_price.rb @@ -1,9 +1,33 @@ class ProductPrice < ApplicationRecord + acts_as_paranoid + belongs_to :product belongs_to :price_list + belongs_to :product_price_folder, optional: true validates :price, presence: true, inclusion: { in: 0..100 } validates :product_id, uniqueness: { scope: %i[price_list_id deleted_at] } + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } delegate :name, to: :product + + default_scope { order(:position) } + + before_validation :set_default_position, on: :create + + # Scope for products without a folder (shown on home screen) + scope :without_folder, -> { where(product_price_folder_id: nil) } + + # Scope for products in a specific folder + scope :in_folder, ->(folder) { where(product_price_folder: folder) } + + private + + def set_default_position + return if position.present? && position > 0 + + scope = price_list&.product_price&.where(product_price_folder_id: product_price_folder_id) + max_position = scope&.maximum(:position) || -1 + self.position = max_position + 1 + end end diff --git a/app/models/product_price_folder.rb b/app/models/product_price_folder.rb new file mode 100644 index 000000000..a777d073d --- /dev/null +++ b/app/models/product_price_folder.rb @@ -0,0 +1,23 @@ +class ProductPriceFolder < ApplicationRecord + acts_as_paranoid + + belongs_to :price_list + has_many :product_prices, dependent: :nullify + + validates :name, presence: true + validates :color, presence: true + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + default_scope { order(:position) } + + before_validation :set_default_position, on: :create + + private + + def set_default_position + return if position.present? && position > 0 + + max_position = price_list&.product_price_folders&.maximum(:position) || -1 + self.position = max_position + 1 + end +end diff --git a/app/policies/product_price_folder_policy.rb b/app/policies/product_price_folder_policy.rb new file mode 100644 index 000000000..cad88bf42 --- /dev/null +++ b/app/policies/product_price_folder_policy.rb @@ -0,0 +1,32 @@ +class ProductPriceFolderPolicy < ApplicationPolicy + # Only treasurers can manage folders + def index? + user&.treasurer? || user&.renting_manager? || user&.main_bartender? + end + + def show? + index? + end + + def create? + user&.treasurer? + end + + def update? + user&.treasurer? + end + + def destroy? + user&.treasurer? + end + + def reorder? + user&.treasurer? + end + + class Scope < ApplicationPolicy::Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/product_price_policy.rb b/app/policies/product_price_policy.rb index c813a1eb0..9da225ff8 100644 --- a/app/policies/product_price_policy.rb +++ b/app/policies/product_price_policy.rb @@ -1,5 +1,23 @@ class ProductPricePolicy < ApplicationPolicy + def update? + user&.treasurer? + end + def destroy? user&.treasurer? end + + def assign_folder? + update? + end + + def reorder? + update? + end + + class Scope < ApplicationPolicy::Scope + def resolve + scope.all + end + end end diff --git a/app/views/activities/order_screen.html.erb b/app/views/activities/order_screen.html.erb index ebd8b2c2f..93a2a0fee 100644 --- a/app/views/activities/order_screen.html.erb +++ b/app/views/activities/order_screen.html.erb @@ -3,7 +3,7 @@ <%= javascript_include_tag "order_screen", "data-turbo-track": "reload", defer: true %> <% end %> <%= content_tag :div, id: 'order-screen', class: 'order-screen', - data: {users: @users_json, product_prices: @product_prices_json, activity: @activity_json, sumup_callback: sumup_callback_activity_url, sumup_key: @sumup_key, flashes: flash, site_name: Rails.application.config.x.site_short_name, deposit_button_enabled: Rails.application.config.x.deposit_button_enabled} do + data: {users: @users_json, product_prices: @product_prices_json, folders: @folders_json, activity: @activity_json, is_treasurer: @is_treasurer, price_list_id: @price_list_id, sumup_callback: sumup_callback_activity_url, sumup_key: @sumup_key, flashes: flash, site_name: Rails.application.config.x.site_short_name, deposit_button_enabled: Rails.application.config.x.deposit_button_enabled} do %> @@ -164,16 +164,88 @@
-
+ +
+ +
+ + +
+ + + + + + Terug + +
+ + + + + +
+ + + {{productPrice.product.name}} @@ -181,11 +253,60 @@ {{doubleToCurrency(productPrice.price)}}
- + + +
+ + + + + Naar hoofdscherm + +
+ + Nog geen producten beschikbaar in de prijslijst <%= @activity.price_list.name %> . Klik <%= link_to 'hier', price_lists_path %> om prijzen aan te maken. + + +
+
+
+
{{ editingFolder ? 'Map bewerken' : 'Nieuwe map' }}
+ +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4e21b9945..db30c1505 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,23 @@ post :archive post :unarchive end + resources :product_price_folders, only: %i[index create] do + collection do + patch :reorder + end + end + resources :product_prices, only: [] do + collection do + patch :reorder + end + end + end + + resources :product_price_folders, only: %i[update destroy] + resources :product_prices, only: [] do + member do + patch :assign_folder + end end resources :users, only: %i[index show create update] do diff --git a/db/migrate/20260121000001_create_product_price_folders.rb b/db/migrate/20260121000001_create_product_price_folders.rb new file mode 100644 index 000000000..b19988a52 --- /dev/null +++ b/db/migrate/20260121000001_create_product_price_folders.rb @@ -0,0 +1,16 @@ +class CreateProductPriceFolders < ActiveRecord::Migration[7.2] + def change + create_table :product_price_folders do |t| + t.references :price_list, null: false, foreign_key: true + t.string :name, null: false + t.integer :position, null: false, default: 0 + t.string :color, null: false, default: '#6c757d' + t.datetime :deleted_at + + t.timestamps + end + + add_index :product_price_folders, :deleted_at + add_index :product_price_folders, %i[price_list_id position] + end +end diff --git a/db/migrate/20260121000002_add_folder_and_position_to_product_prices.rb b/db/migrate/20260121000002_add_folder_and_position_to_product_prices.rb new file mode 100644 index 000000000..7f5062250 --- /dev/null +++ b/db/migrate/20260121000002_add_folder_and_position_to_product_prices.rb @@ -0,0 +1,8 @@ +class AddFolderAndPositionToProductPrices < ActiveRecord::Migration[7.2] + def change + add_reference :product_prices, :product_price_folder, foreign_key: true, null: true + add_column :product_prices, :position, :integer, null: false, default: 0 + + add_index :product_prices, %i[price_list_id product_price_folder_id position], name: 'index_product_prices_on_folder_and_position' + end +end diff --git a/package.json b/package.json index 8374c3eec..e11e26aca 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "popper.js": "^1.16.1", "puppeteer": "^24.35.0", "remove-accents": "^0.5.0", + "sortablejs": "^1.15.0", "vue": "^2.7.16", "vue-loader": "^15.11.1", "vue-template-compiler": "^2.7.16", diff --git a/spec/factories/product_price_folder.rb b/spec/factories/product_price_folder.rb new file mode 100644 index 000000000..c625cb043 --- /dev/null +++ b/spec/factories/product_price_folder.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :product_price_folder do + association :price_list + sequence(:name) { |n| "Folder #{n}" } + sequence(:position) { |n| n } + color { '#6c757d' } + end +end From a884ff709665e51cba65274d2b4c95c7b010165f Mon Sep 17 00:00:00 2001 From: Lodewiges Date: Wed, 21 Jan 2026 11:32:34 +0100 Subject: [PATCH 2/4] run yarn install --- yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/yarn.lock b/yarn.lock index 8532950f9..77260b3d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6659,6 +6659,7 @@ __metadata: puppeteer: "npm:^24.35.0" remove-accents: "npm:^0.5.0" sass: "npm:^1.97.2" + sortablejs: "npm:^1.15.0" stylelint: "npm:^17.0.0" stylelint-config-recess-order: "npm:^7.4.0" stylelint-config-standard-scss: "npm:^17.0.0" @@ -6674,6 +6675,13 @@ __metadata: languageName: unknown linkType: soft +"sortablejs@npm:^1.15.0": + version: 1.15.6 + resolution: "sortablejs@npm:1.15.6" + checksum: 10c0/a75dcf53e5613b4106d46434e40114830f9c6449b3b439bc1925c1fbf0a0c1f044727a8f3d4ae1759fa7beaa33e7eb0c4a413e6aa88d6026577b59f3658ff727 + languageName: node + linkType: hard + "source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" From cb7b4ed0cf641d3f2e8a37f28d5a156f04c6c39f Mon Sep 17 00:00:00 2001 From: Lodewiges Date: Wed, 21 Jan 2026 11:45:49 +0100 Subject: [PATCH 3/4] run the migrations --- db/schema.rb | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 8e64bb1a8..6fc21a8df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_12_12_000001) do +ActiveRecord::Schema[7.2].define(version: 2026_01_21_000002) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -117,6 +117,19 @@ t.datetime "archived_at" end + create_table "product_price_folders", force: :cascade do |t| + t.bigint "price_list_id", null: false + t.string "name", null: false + t.integer "position", default: 0, null: false + t.string "color", default: "#6c757d", null: false + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["deleted_at"], name: "index_product_price_folders_on_deleted_at" + t.index ["price_list_id", "position"], name: "index_product_price_folders_on_price_list_id_and_position" + t.index ["price_list_id"], name: "index_product_price_folders_on_price_list_id" + end + create_table "product_prices", force: :cascade do |t| t.bigint "product_id" t.bigint "price_list_id" @@ -124,9 +137,13 @@ t.datetime "deleted_at", precision: nil t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.bigint "product_price_folder_id" + t.integer "position", default: 0, null: false + t.index ["price_list_id", "product_price_folder_id", "position"], name: "index_product_prices_on_folder_and_position" t.index ["price_list_id"], name: "index_product_prices_on_price_list_id" t.index ["product_id", "price_list_id", "deleted_at"], name: "index_product_prices_on_product_id_and_price_list_id", unique: true t.index ["product_id"], name: "index_product_prices_on_product_id" + t.index ["product_price_folder_id"], name: "index_product_prices_on_product_price_folder_id" end create_table "products", force: :cascade do |t| @@ -202,5 +219,7 @@ add_foreign_key "activities", "users", column: "locked_by_id" add_foreign_key "credit_mutations", "users", column: "created_by_id" add_foreign_key "orders", "users", column: "created_by_id" + add_foreign_key "product_price_folders", "price_lists" + add_foreign_key "product_prices", "product_price_folders" add_foreign_key "sofia_accounts", "users" end From b010cf4041e62cff26029e238af320a44f3a6845 Mon Sep 17 00:00:00 2001 From: Lodewiges Date: Wed, 21 Jan 2026 14:16:35 +0100 Subject: [PATCH 4/4] fix edit mode button --- app/assets/stylesheets/order_screen.scss | 45 +++---------- app/views/activities/order_screen.html.erb | 75 ++++++++++------------ 2 files changed, 43 insertions(+), 77 deletions(-) diff --git a/app/assets/stylesheets/order_screen.scss b/app/assets/stylesheets/order_screen.scss index ac3ae47a7..9c5f00fe4 100644 --- a/app/assets/stylesheets/order_screen.scss +++ b/app/assets/stylesheets/order_screen.scss @@ -124,6 +124,12 @@ background-color: $gray-200; border-right: 2px solid $gray-400; box-shadow: 2px 0 4px -2px $transparent-200; + + &.edit-mode-disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; + } } .user-details { @@ -280,14 +286,6 @@ overflow-y: auto; } - // Edit mode toggle button - .edit-mode-toggle { - position: absolute; - top: 90px; - right: 10px; - z-index: 10; - } - // Folder styles .folder-container { display: contents; @@ -303,7 +301,7 @@ align-items: center; justify-content: center; margin-bottom: 0.5rem; - font-size: 2.5rem; + font-size: 3.5rem; position: relative; } @@ -319,8 +317,9 @@ } .product-grid-product-name { - color: $white; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + color: $font-color-dark; + font-size: $font-size-lg; + text-shadow: none; } &.edit-mode { @@ -452,28 +451,4 @@ padding: 1rem; border-top: 1px solid $gray-300; } - - .color-swatches { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .color-swatch { - width: 32px; - height: 32px; - border-radius: 4px; - cursor: pointer; - border: 2px solid transparent; - transition: all 0.2s ease; - - &:hover { - transform: scale(1.1); - } - - &.selected { - border-color: $gray-900; - box-shadow: 0 0 0 2px $white, 0 0 0 4px $gray-900; - } - } } \ No newline at end of file diff --git a/app/views/activities/order_screen.html.erb b/app/views/activities/order_screen.html.erb index 93a2a0fee..fa5b8c8b6 100644 --- a/app/views/activities/order_screen.html.erb +++ b/app/views/activities/order_screen.html.erb @@ -49,12 +49,18 @@ Persoon onthouden +
-
+
@@ -66,11 +72,11 @@