diff --git a/app/assets/stylesheets/order_screen.scss b/app/assets/stylesheets/order_screen.scss index ed42d879b..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 { @@ -279,4 +285,170 @@ grid-area: order-grid; overflow-y: auto; } + + // 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: 3.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: $font-color-dark; + font-size: $font-size-lg; + text-shadow: none; + } + + &.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; + } } \ 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..fa5b8c8b6 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 %> @@ -49,12 +49,18 @@ Persoon onthouden + -
+
@@ -66,11 +72,11 @@