+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+
+
+ {{ formattedPrice }}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@