diff --git a/app/assets/stylesheets/order_screen.scss b/app/assets/stylesheets/order_screen.scss index ed42d879b..338190b1f 100644 --- a/app/assets/stylesheets/order_screen.scss +++ b/app/assets/stylesheets/order_screen.scss @@ -10,21 +10,28 @@ height: 100vh; .flash { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + z-index: 1000; display: flex; - align-self: flex-end; - height: 100%; + min-width: 250px; + max-width: 450px; + height: auto; font-size: $font-size-lg; vertical-align: middle; color: $font-color-light; text-align: center; background-color: $gray-800; + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); } .flash-text { display: flex; align-items: center; justify-content: center; - padding: 0 1rem; + padding: 1rem 1.5rem; a { &:hover { @@ -112,6 +119,29 @@ } } + .header-actions { + display: flex; + flex-direction: row-reverse; + align-items: center; + } + + .header-slider { + display: flex; + align-items: center; + width: 150px; + } + + .header-slider .form-range { + flex-grow: 1; + height: 0.5rem; + } + + .header-slider label { + font-size: $font-size-sm; + color: $font-color-dark; + white-space: nowrap; + } + .side-panel { z-index: 4; display: flex; @@ -124,6 +154,12 @@ background-color: $gray-200; border-right: 2px solid $gray-400; box-shadow: 2px 0 4px -2px $transparent-200; + + &.edit-mode-disabled { + pointer-events: none; + user-select: none; + opacity: 0.5; + } } .user-details { @@ -236,19 +272,37 @@ .product-grid { display: grid; grid-area: product-grid; - grid-template-rows: auto; - grid-template-columns: repeat(4, 1fr); + grid-auto-flow: dense; gap: 2px; justify-content: flex-start; background-color: $gray-400; } - .product-grid-product { + .product-grid .grid-tile-back { + grid-row: 1; + grid-column: 1; + } + + .products-container { + display: contents; + } + + .grid-tile-back { + .grid-tile-name { + font-size: $font-size-lg; + color: $white; + text-shadow: none; + } + } + + .grid-tile { display: flex; flex-flow: column nowrap; align-items: center; justify-content: center; cursor: pointer; + outline: none; + border: none; transition: filter 0.2s ease; &:hover { @@ -256,15 +310,38 @@ } } - .product-requires-age { - filter: brightness(0.7); + .grid-tile-product { + color: $font-color-dark; + text-align: center; + + &.product-requires-age { + filter: brightness(0.7); - &:hover { - filter: brightness(0.6); + &:hover { + filter: brightness(0.6); + } + } + } + + .grid-tile-folder, + .grid-tile-back { + color: $white; + background-color: $gray-600; + + .grid-tile-name { + font-size: $font-size-lg; + color: $font-color-dark; + text-shadow: none; } } - .product-grid-product-name { + .grid-tile-name { + font-size: $font-size-lg; + color: $font-color-dark; + text-align: center; + } + + .grid-tile-price { font-size: $font-size-lg; color: $font-color-dark; text-align: center; @@ -279,4 +356,145 @@ grid-area: order-grid; overflow-y: auto; } + + .grid-tile-folder, + .grid-tile-back { + position: relative; + + .folder-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; + font-size: 3.5rem; + } + + .folder-back-arrow { + position: absolute; + right: -0.5rem; + bottom: 0; + padding: 0.2rem; + font-size: 1rem; + color: $white; + background: rgb(0 0 0 / 50%); + border-radius: 50%; + } + + &.edit-mode { + cursor: grab; + } + } + + .grid-tile-back { + pointer-events: auto; + + .folder-icon { + font-size: 2rem; + } + + .grid-tile-name { + font-size: $font-size-lg; + color: $white; + text-shadow: none; + } + } + + .folder-edit-btn { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + font-size: 1.6rem; + color: $gray-700; + cursor: pointer; + background-color: rgb(255 255 255 / 90%); + border-radius: 50%; + transition: all 0.2s ease; + + &:hover { + background-color: $white; + transform: scale(1.1); + } + } + + .draggable { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + .drag-handle { + position: absolute; + top: 8px; + left: 8px; + font-size: 1rem; + color: $gray-700; + cursor: grab; + opacity: 1; + transition: opacity 0.2s ease; + } + + .sortable-ghost { + opacity: 0.4; + } + + .sortable-chosen { + box-shadow: 0 4px 12px rgb(0 0 0 / 30%); + } + + .sortable-drag { + background-color: $white; + } + + .folder-modal-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; + background-color: rgb(0 0 0 / 50%); + } + + .folder-modal { + width: 90%; + max-width: 400px; + background-color: $white; + border-radius: 8px; + box-shadow: 0 4px 20px rgb(0 0 0 / 30%); + } + + .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; + gap: 0.5rem; + justify-content: flex-end; + 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 b8cb77909..ab912f1a3 100644 --- a/app/controllers/activities_controller.rb +++ b/app/controllers/activities_controller.rb @@ -86,17 +86,26 @@ def destroy def order_screen # rubocop:disable Metrics/MethodLength, Metrics/AbcSize authorize Activity - @activity = Activity.includes([:price_list, { price_list: { product_price: :product } }]) + @activity = Activity.includes([:price_list, { price_list: { product_prices: :product } }]) .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 + @price_list_grid_size = @activity.price_list.grid_size + @sumup_key = Rails.application.config.x.sumup_key @sumup_enabled = @sumup_key.present? @@ -174,7 +183,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_prices.includes(:product).order(:position) end def activity_params diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index d82ca2e85..830792772 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -15,8 +15,8 @@ def unacceptable render_error_page('errors/unacceptable', :not_acceptable, '406 Not Acceptable') end - def unprocessable_entity - render_error_page('errors/unprocessable_entity', :unprocessable_entity, '422 Unprocessable Entity') + def unprocessable_content + render_error_page('errors/unprocessable_content', :unprocessable_content, '422 Unprocessable Entity') end def internal_server_error diff --git a/app/controllers/product_price_folders_controller.rb b/app/controllers/product_price_folders_controller.rb new file mode 100644 index 000000000..b10e8db46 --- /dev/null +++ b/app/controllers/product_price_folders_controller.rb @@ -0,0 +1,80 @@ +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] + + after_action :verify_authorized + + def index + authorize ProductPriceFolder, :index? + @folders = @price_list.product_price_folders.order(:position) + render json: @folders + end + + 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_content + end + end + + def update + authorize @folder + + if @folder.update(folder_params) + render json: @folder + else + render json: { errors: @folder.errors.full_messages }, status: :unprocessable_content + end + end + + def destroy + authorize @folder + + orphaned_products = @folder.product_prices + max_position = @folder.price_list.product_prices.without_folder.maximum(:position) || -1 + + orphaned_products.each_with_index do |product_prices, index| + product_prices.update(product_price_folder_id: nil, position: max_position + index + 1) + end + + @folder.destroy + + head :no_content + end + + def reorder # rubocop:disable Metrics/MethodLength + 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_content + 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(policy(ProductPriceFolder.new).permitted_attributes) + end +end diff --git a/app/controllers/product_prices_controller.rb b/app/controllers/product_prices_controller.rb new file mode 100644 index 000000000..dcfe61c14 --- /dev/null +++ b/app/controllers/product_prices_controller.rb @@ -0,0 +1,70 @@ +class ProductPricesController < ApplicationController + before_action :authenticate_user! + before_action :set_product_price, only: %i[assign_folder] + before_action :set_price_list, only: %i[reorder] + + after_action :verify_authorized + + def assign_folder # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + authorize @product_price, :update? + + folder_id = params[:folder_id] + position = params[:position] + + if folder_id.present? + folder = ProductPriceFolder.find_by(id: folder_id) + return render json: { errors: ['Folder not found'] }, status: :unprocessable_content unless folder + 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_content + end + end + + update_params = { product_price_folder_id: folder_id } + update_params[:position] = position if position.present? + + if @product_price.update(update_params) + render json: @product_price, include: product_price_includes + else + render json: { errors: @product_price.errors.full_messages }, status: :unprocessable_content + end + end + + def reorder # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + product_positions = params.require(:product_positions) + + ActiveRecord::Base.transaction do + product_positions.each do |product_data| + product_price = @price_list.product_prices.find(product_data[:id]) + authorize product_price, :update? + + if product_data[:folder_id].present? && !@price_list.product_price_folders.exists?(id: product_data[:folder_id]) + raise ActiveRecord::RecordInvalid.new(product_price), + 'Folder does not belong to this price list' + end + + 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_content + 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/components/orderscreen/GridTile.vue b/app/javascript/components/orderscreen/GridTile.vue new file mode 100644 index 000000000..f495dfdef --- /dev/null +++ b/app/javascript/components/orderscreen/GridTile.vue @@ -0,0 +1,174 @@ + + + \ No newline at end of file diff --git a/app/javascript/order_screen.js b/app/javascript/order_screen.js index f825a479f..ee8d30093 100644 --- a/app/javascript/order_screen.js +++ b/app/javascript/order_screen.js @@ -1,19 +1,25 @@ 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'; import ActivityOrders from './components/orderscreen/ActivityOrders.vue'; +import GridTile from './components/orderscreen/GridTile.vue'; document.addEventListener('turbo:load', () => { const element = document.getElementById('order-screen'); 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; + const priceListGridSize = parseInt(element.dataset.priceListGridSize || '4'); window.flash = function(message, actionText, type) { const event = new CustomEvent('flash', { detail: { message: message, actionText: actionText, type: type } } ); @@ -32,6 +38,7 @@ document.addEventListener('turbo:load', () => { return { users: users, productPrices: productPrices, + folders: folders, activity: activity, selectedUser: null, payWithCash: false, @@ -39,7 +46,21 @@ document.addEventListener('turbo:load', () => { keepUserSelected: false, depositButtonEnabled: depositButtonEnabled, orderRows: [], - isSubmitting: false + isSubmitting: false, + currentFolder: null, + editMode: false, + isTreasurer: isTreasurer, + priceListId: priceListId, + showFolderModal: false, + editingFolder: null, + folderForm: { name: '', color: '#6c757d' }, + draggedItem: null, + draggedItemType: null, + draggedItemHandled: false, + sortableInstance: null, + gridSize: priceListGridSize, + gridSizeUpdateInProgress: false, + gridSizeUpdateTimeout: null }; }, methods: { @@ -51,13 +72,32 @@ document.addEventListener('turbo:load', () => { return `€${parseFloat(price).toFixed(2)}`; }, + isValidHexColor(color) { + return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color); + }, + + leaveOrderScreen() { + if (this.payWithCash) { + this.payWithCash = false; + } else if (this.payWithPin) { + this.payWithPin = false; + } else { + this.selectedUser = null; + } + + this.currentFolder = null; + if (this.editMode) { + this.editMode = false; + this.destroySortable(); + } + }, + setUser(user = null) { if (this.selectedUser === null || user === null || this.selectedUser.id != user.id) { this.orderRows = []; } if (user !== null) { - // Reload user to get latest credit balance api.get(`/users/${user.id}/json?activity_id=${this.activity.id}`).then((response) => { const refreshedUser = response.data; const index = this.users.findIndex((candidate) => candidate.id === refreshedUser.id); @@ -78,6 +118,14 @@ document.addEventListener('turbo:load', () => { this.payWithCash = false; this.payWithPin = false; this.selectedUser = user; + + if (user === null) { + this.currentFolder = null; + if (this.editMode) { + this.editMode = false; + this.destroySortable(); + } + } }, selectCash() { @@ -182,7 +230,6 @@ document.addEventListener('turbo:load', () => { if(!this.keepUserSelected){ this.setUser(null); } else { - // re-set user to update credit this.setUser(response.data.user); this.orderRows = []; } @@ -247,6 +294,306 @@ document.addEventListener('turbo:load', () => { this.handleXHRError(response); }); }, + + handleTileClick(payload) { + if (this.editMode || !payload) return; + + const itemType = payload.itemType; + const item = payload.item; + + if (itemType === 'folder') { + this.currentFolder = item; + } else if (itemType === 'back') { + this.currentFolder = null; + } else if (itemType === 'product') { + this.selectProduct(item); + } + }, + + handleTileEdit(payload) { + if (!this.editMode || !payload) return; + + const itemType = payload.itemType; + const item = payload.item; + + if (itemType === 'folder') { + this.openFolderModal(item); + } + }, + + handleDragOver(payload) { + // Allow drag over for drop targets + if (this.editMode && this.draggedItem && payload && (payload.itemType === 'folder' || payload.itemType === 'back')) { + if (payload.evt && payload.evt.dataTransfer) { + payload.evt.preventDefault(); + payload.evt.dataTransfer.dropEffect = 'move'; + } + } + }, + + toggleEditMode() { + this.editMode = !this.editMode; + if (this.editMode) { + this.$nextTick(() => { + this.initSortable(); + }); + } else { + this.destroySortable(); + } + }, + + initSortable() { + const gridContainer = this.$refs.gridContainer; + if (gridContainer && !this.sortableInstance) { + this.sortableInstance = Sortable.create(gridContainer, { + animation: 250, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + forceFallback: false, + touchStartThreshold: 0, + delayOnTouchOnly: true, + filter: '.grid-tile-back', + draggable: '.grid-tile:not(.grid-tile-back)', + swapThreshold: 0.75, + fallbackOnBody: false, + fallbackTolerance: 0, + onEnd: this.onGridDragEnd.bind(this) + }); + } + }, + + destroySortable() { + if (this.sortableInstance) { + this.sortableInstance.destroy(); + this.sortableInstance = null; + } + }, + + onGridDragEnd(evt) { + // Skip if the dragged item was already handled by onDrop (folder/back drops) + if (this.draggedItemHandled) { + this.draggedItemHandled = false; + return; + } + + const positions = []; + const gridElements = evt.to.querySelectorAll('.grid-tile'); + + gridElements.forEach((el, index) => { + const itemId = el.dataset.itemId; + const itemType = el.dataset.itemType; + + if (itemId && itemId !== 'back') { + if (itemType === 'folder') { + const folder = this.folders.find(f => f.id == itemId); + if (folder) { + folder.position = index; + positions.push({ + id: parseInt(itemId), + position: index, + type: 'folder' + }); + } + } else if (itemType === 'product') { + const productPrice = this.productPrices.find(pp => pp.id == itemId); + if (productPrice) { + productPrice.position = index; + positions.push({ + id: parseInt(itemId), + position: index, + type: 'product', + folder_id: this.currentFolder ? this.currentFolder.id : null + }); + } + } + } + }); + + // Separate folder and product positions for API calls + const folderPositions = positions.filter(p => p.type === 'folder').map(p => ({ id: p.id, position: p.position })); + const productPositions = positions.filter(p => p.type === 'product').map(p => ({ id: p.id, position: p.position, folder_id: p.folder_id })); + + if (folderPositions.length > 0) { + api.patch(`/price_lists/${this.priceListId}/product_price_folders/reorder`, { + folder_positions: folderPositions + }).catch((response) => { + this.handleXHRError(response); + }); + } + + if (productPositions.length > 0) { + api.patch(`/price_lists/${this.priceListId}/product_prices/reorder`, { + product_positions: productPositions + }).catch((response) => { + this.handleXHRError(response); + }); + } + }, + + nextPositionAfter(products) { + const maxPosition = products.reduce((max, pp) => ( + typeof pp.position === 'number' && pp.position > max ? pp.position : max + ), -1); + return maxPosition + 1; + }, + + assignProductToFolder(product, folderId) { + const siblingProducts = this.productPrices.filter((pp) => ( + folderId ? pp.product_price_folder_id == folderId : !pp.product_price_folder_id + )); + const position = this.nextPositionAfter(siblingProducts); + + // Update UI immediately for fast response + product.product_price_folder_id = folderId; + product.position = position; + + // Mark as handled to prevent onGridDragEnd from processing + this.draggedItemHandled = true; + + api.patch(`/product_prices/${product.id}/assign_folder`, { + folder_id: folderId, + position + }).catch((response) => { + this.handleXHRError(response); + }).finally(() => { + this.draggedItemHandled = false; + }); + }, + + onDrop(payload) { + if (!this.draggedItem || !payload || !payload.item) return; + + const draggedItemType = this.draggedItemType; + const draggedItem = this.draggedItem; + const targetItemType = payload.itemType; + const targetItem = payload.item; + + // Ensure we have a valid event object + if (payload.evt && typeof payload.evt.preventDefault === 'function') { + payload.evt.preventDefault(); + payload.evt.stopPropagation(); + } + + // Only allow dropping products onto folders or back button + if (draggedItemType === 'product' && (targetItemType === 'folder' || targetItemType === 'back')) { + const folderId = targetItemType === 'folder' ? parseInt(targetItem.id) : null; + this.assignProductToFolder(draggedItem, folderId); + } + }, + + onDragStart(payload) { + if (payload && payload.item) { + this.draggedItem = payload.item; + this.draggedItemType = payload.itemType; + } + }, + + onDragEnd() { + this.draggedItem = null; + this.draggedItemType = null; + this.draggedItemHandled = false; + }, + + updateGridSize() { + // Prevent rapid consecutive calls + if (this.gridSizeUpdateInProgress) return; + + // Capture the value being sent + const gridSizeToSend = this.gridSize; + this.gridSizeUpdateInProgress = true; + + api.patch(`/price_lists/${this.priceListId}`, { + price_list: { grid_size: gridSizeToSend } + }).catch((response) => { + this.handleXHRError(response); + }).finally(() => { + this.gridSizeUpdateInProgress = false; + // Schedule a retry if gridSize changed during the request + if (this.gridSize !== gridSizeToSend) { + this.gridSizeUpdateTimeout = setTimeout(() => { + this.updateGridSize(); + }, 500); + } + }); + }, + + 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; + } + + const normalizedColor = (this.folderForm.color || '').trim(); + if (!this.isValidHexColor(normalizedColor)) { + this.sendFlash('Voer een geldige hex kleur in (bijv. #6c757d)', '', 'warning'); + return; + } + this.folderForm.color = normalizedColor; + + if (this.editingFolder) { + 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 { + 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(() => { + this.productPrices.forEach(pp => { + if (pp.product_price_folder_id === folder.id) { + pp.product_price_folder_id = null; + } + }); + 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 +642,109 @@ document.addEventListener('turbo:load', () => { isMobile() { return this.isIos || /Android|webOS|Opera Mini/i.test(navigator.userAgent); + }, + + 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; + }, + + gridItems() { + const items = []; + + // Add back button if in folder - always first and fixed + if (this.currentFolder) { + items.push({ + type: 'back', + item: this.currentFolder, + id: 'back', + position: -1000 // Always first, fixed position + }); + } + + // Add folders if not in folder + if (!this.currentFolder) { + this.sortedFolders.forEach(folder => { + items.push({ + type: 'folder', + item: folder, + id: folder.id, + position: folder.position + }); + }); + } + + // Add products + this.visibleProducts.forEach(productPrice => { + items.push({ + type: 'product', + item: productPrice, + id: productPrice.id, + position: productPrice.position + }); + }); + + // Sort: back button first, then folders by position, then products by position + return items.sort((a, b) => { + if (a.type === 'back') return -1; + if (b.type === 'back') return 1; + + // Group folders before products + if (a.type === 'folder' && b.type === 'product') return -1; + if (a.type === 'product' && b.type === 'folder') return 1; + + // Within same type, sort by position + return a.position - b.position; + }); + }, + + productGridStyle() { + return { + gridTemplateColumns: `repeat(${this.gridSize}, 1fr)`, + gridTemplateRows: `repeat(${this.gridSize}, auto)` + }; + } + }, + + watch: { + gridSize: { + handler: function(newVal, oldVal) { + // Only call update if value actually changed + if (newVal !== oldVal) { + // Clear any pending update + if (this.gridSizeUpdateTimeout) { + clearTimeout(this.gridSizeUpdateTimeout); + } + // Debounce the update - always schedule, updateGridSize will handle in-progress + this.gridSizeUpdateTimeout = setTimeout(() => { + this.updateGridSize(); + }, 500); + } + }, + immediate: false } }, @@ -305,12 +755,16 @@ document.addEventListener('turbo:load', () => { }, destroyed: function() { document.removeEventListener('keyup', this.escapeKeyListener); + if (this.gridSizeUpdateTimeout) { + clearTimeout(this.gridSizeUpdateTimeout); + } }, components: { FlashNotification, UserSelection, - ActivityOrders + ActivityOrders, + GridTile }, }); diff --git a/app/models/price_list.rb b/app/models/price_list.rb index 42e050344..548122509 100644 --- a/app/models/price_list.rb +++ b/app/models/price_list.rb @@ -1,9 +1,13 @@ class PriceList < ApplicationRecord - has_many :product_price, dependent: :destroy - has_many :products, through: :product_price, dependent: :restrict_with_exception + has_many :product_prices, dependent: :destroy + has_many :products, through: :product_prices, dependent: :restrict_with_exception has_many :activities, dependent: :restrict_with_exception + has_many :product_price_folders, dependent: :destroy validates :name, presence: true + validates :grid_size, numericality: { only_integer: true, greater_than_or_equal_to: 2, less_than_or_equal_to: 9 }, allow_nil: true + + after_initialize :set_defaults, unless: :persisted? scope :unarchived, -> { where(archived_at: nil) } @@ -15,4 +19,10 @@ def product_price_for(product) def to_s name end + + private + + def set_defaults + self.grid_size ||= 4 + end end diff --git a/app/models/product_price.rb b/app/models/product_price.rb index 1afb21990..a52d46a27 100644 --- a/app/models/product_price.rb +++ b/app/models/product_price.rb @@ -1,9 +1,29 @@ class ProductPrice < ApplicationRecord 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 + + scope :ordered, -> { order(:position) } + + before_validation :set_default_position, on: :create + + scope :without_folder, -> { where(product_price_folder_id: nil) } + + scope :in_folder, ->(folder) { where(product_price_folder: folder) } + + private + + def set_default_position + return if position.present? && position >= 0 + + scope = price_list&.product_prices&.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..49fdf8468 --- /dev/null +++ b/app/models/product_price_folder.rb @@ -0,0 +1,22 @@ +class ProductPriceFolder < ApplicationRecord + belongs_to :price_list + has_many :product_prices, dependent: :nullify + + validates :name, presence: true + validates :color, presence: true, + format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, message: 'must be a valid hexcode (e.g., #FF5733 or #F57)' } + 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/price_list_policy.rb b/app/policies/price_list_policy.rb index cb74229de..54d59cc8c 100644 --- a/app/policies/price_list_policy.rb +++ b/app/policies/price_list_policy.rb @@ -38,6 +38,6 @@ def search? end def permitted_attributes - %i[name] + %i[name grid_size] 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..dddff8431 --- /dev/null +++ b/app/policies/product_price_folder_policy.rb @@ -0,0 +1,35 @@ +class ProductPriceFolderPolicy < ApplicationPolicy + 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 + + def permitted_attributes + %i[name color position] + end + + class Scope < 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..a0d8bf853 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 < 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..2e5e47581 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, price_list_grid_size: @price_list_grid_size, 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 %> @@ -18,11 +18,11 @@ -
+
@@ -66,11 +75,11 @@