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
+