1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-30 19:52:20 +01:00

Merge branch 'dev' for release 6.1.0

This commit is contained in:
Nicolas Florentin 2023-09-25 14:42:52 +02:00
commit 6b55a8ae04
284 changed files with 6864 additions and 648 deletions

View File

@ -1,6 +1,19 @@
# Changelog Fab-manager
## next deploy
## v6.1.0 2023 September 25
- improves api/notification controller to avoid failing when there is a notification with wrong notification_type in db
- Add extra_authorize_params to OpenIdConnect config
- Improvement : add a notification to remind users to upload their supporting documents
- Cancel payment schedule subscription after update the payment mean
- admin can see reservations of a member
- Fix a bug: unable to update card for payment schedule
- Fix a bug: user is_allow_contact is actived by default
- Fix a bug: unbale to export projects
- Fix a bug: unbale to update card bank of payment schedule
- Feature: family compte for event
- Feature: pre-registration event
- [TODO DEPLOY] `rails db:seed`
## v6.0.14 2023 September 6

View File

@ -151,3 +151,5 @@ gem 'sentry-rails'
gem 'sentry-ruby'
gem "reverse_markdown"
gem "ancestry"

View File

@ -76,6 +76,8 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0)
afm (0.2.2)
ancestry (4.3.3)
activerecord (>= 5.2.6)
ansi (1.5.0)
api-pagination (4.8.2)
apipie-rails (0.5.17)
@ -536,6 +538,7 @@ DEPENDENCIES
aasm
active_record_query_trace
acts_as_list
ancestry
api-pagination
apipie-rails
awesome_print

View File

@ -99,7 +99,7 @@ class API::AuthProvidersController < API::APIController
providable_attributes: [:id, :issuer, :discovery, :client_auth_method, :prompt, :send_scope_to_token_endpoint,
:client__identifier, :client__secret, :client__authorization_endpoint, :client__token_endpoint,
:client__userinfo_endpoint, :client__jwks_uri, :client__end_session_endpoint, :profile_url,
{ scope: [] }],
:extra_authorize_params, { scope: [] }],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
:_destroy, { transformation: [:type, :format, :true_value, :false_value,
{ mapping: %i[from to] }] }])

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
# API Controller for resources of type Child
# Children are used to provide a way to manage multiple users in the family account
class API::ChildrenController < API::APIController
before_action :authenticate_user!
before_action :set_child, only: %i[show update destroy validate]
def index
authorize Child
user_id = current_user.id
user_id = params[:user_id] if current_user.privileged? && params[:user_id]
@children = Child.where(user_id: user_id).where('birthday >= ?', 18.years.ago).includes(:supporting_document_files).order(:created_at)
end
def show
authorize @child
end
def create
@child = Child.new(child_params)
authorize @child
if ChildService.create(@child)
render status: :created
else
render json: @child.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @child
if ChildService.update(@child, child_params)
render status: :ok
else
render json: @child.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @child
@child.destroy
head :no_content
end
def validate
authorize @child
cparams = params.require(:child).permit(:validated_at)
if ChildService.validate(@child, cparams[:validated_at].present?)
render :show, status: :ok, location: child_path(@child)
else
render json: @child.errors, status: :unprocessable_entity
end
end
private
def set_child
@child = Child.find(params[:id])
end
def child_params
params.require(:child).permit(:first_name, :last_name, :email, :phone, :birthday, :user_id,
supporting_document_files_attributes: %i[id supportable_id supportable_type
supporting_document_type_id
attachment _destroy])
end
end

View File

@ -96,7 +96,8 @@ class API::EventsController < API::APIController
# handle general properties
event_preparams = params.required(:event).permit(:title, :description, :start_date, :start_time, :end_date, :end_time,
:amount, :nb_total_places, :availability_id, :all_day, :recurrence,
:recurrence_end_at, :category_id, :event_theme_ids, :age_range_id,
:recurrence_end_at, :category_id, :event_theme_ids, :age_range_id, :event_type,
:pre_registration, :pre_registration_end_date,
event_theme_ids: [],
event_image_attributes: %i[id attachment],
event_files_attributes: %i[id attachment _destroy],

View File

@ -15,6 +15,7 @@ class API::NotificationsController < API::APIController
def index
loop do
@notifications = current_user.notifications
.with_valid_notification_type
.delivered_in_system(current_user)
.includes(:attached_object)
.page(params[:page])
@ -24,8 +25,8 @@ class API::NotificationsController < API::APIController
break unless delete_obsoletes(@notifications)
end
@totals = {
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
total: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).count,
unread: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end
@ -33,6 +34,7 @@ class API::NotificationsController < API::APIController
def last_unread
loop do
@notifications = current_user.notifications
.with_valid_notification_type
.delivered_in_system(current_user)
.includes(:attached_object)
.where(is_read: false)
@ -42,19 +44,20 @@ class API::NotificationsController < API::APIController
break unless delete_obsoletes(@notifications)
end
@totals = {
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
total: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).count,
unread: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end
def polling
@notifications = current_user.notifications
.where('is_read = false AND created_at >= :date', date: params[:last_poll])
.order('created_at DESC')
.with_valid_notification_type
.where('notifications.is_read = false AND notifications.created_at >= :date', date: params[:last_poll])
.order('notifications.created_at DESC')
@totals = {
total: current_user.notifications.delivered_in_system(current_user).count,
unread: current_user.notifications.delivered_in_system(current_user).where(is_read: false).count
total: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).count,
unread: current_user.notifications.with_valid_notification_type.delivered_in_system(current_user).where(is_read: false).count
}
render :index
end

View File

@ -4,7 +4,7 @@
# Reservations are used for Training, Machine, Space and Event
class API::ReservationsController < API::APIController
before_action :authenticate_user!
before_action :set_reservation, only: %i[show update]
before_action :set_reservation, only: %i[show update confirm_payment]
respond_to :json
def index
@ -34,6 +34,16 @@ class API::ReservationsController < API::APIController
end
end
def confirm_payment
authorize @reservation
invoice = ReservationConfirmPaymentService.new(@reservation, current_user, params[:coupon_code], params[:offered]).call
if invoice
render :show, status: :ok, location: @reservation
else
render json: @reservation.errors, status: :unprocessable_entity
end
end
private
def set_reservation

View File

@ -5,7 +5,7 @@
# availability by Availability.slot_duration, or otherwise globally by Setting.get('slot_duration')
class API::SlotsReservationsController < API::APIController
before_action :authenticate_user!
before_action :set_slots_reservation, only: %i[update cancel]
before_action :set_slots_reservation, only: %i[update cancel validate invalidate]
respond_to :json
def update
@ -23,6 +23,16 @@ class API::SlotsReservationsController < API::APIController
SlotsReservationsService.cancel(@slot_reservation)
end
def validate
authorize @slot_reservation
SlotsReservationsService.validate(@slot_reservation)
end
def invalidate
authorize @slot_reservation
SlotsReservationsService.invalidate(@slot_reservation)
end
private
def set_slots_reservation

View File

@ -7,7 +7,9 @@ class API::SpacesController < API::APIController
respond_to :json
def index
@spaces = Space.includes(:space_image).where(deleted_at: nil)
@spaces = Space.includes(:space_image, :machines).where(deleted_at: nil)
@spaces_indexed_with_parent = @spaces.index_with { |space| @spaces.find { |s| s.id == space.parent_id } }
@spaces_grouped_by_parent_id = @spaces.group_by(&:parent_id)
end
def show
@ -20,6 +22,7 @@ class API::SpacesController < API::APIController
authorize Space
@space = Space.new(space_params)
if @space.save
update_space_children(@space, params[:space][:child_ids])
render :show, status: :created, location: @space
else
render json: @space.errors, status: :unprocessable_entity
@ -29,6 +32,7 @@ class API::SpacesController < API::APIController
def update
authorize @space
if @space.update(space_params)
update_space_children(@space, params[:space][:child_ids])
render :show, status: :ok, location: @space
else
render json: @space.errors, status: :unprocessable_entity
@ -50,8 +54,18 @@ class API::SpacesController < API::APIController
def space_params
params.require(:space).permit(:name, :description, :characteristics, :default_places, :disabled,
machine_ids: [],
space_image_attributes: %i[id attachment],
space_files_attributes: %i[id attachment _destroy],
advanced_accounting_attributes: %i[code analytical_section])
end
def update_space_children(parent_space, child_ids)
Space.transaction do
parent_space.children.each { |child| child.update!(parent: nil) }
child_ids.to_a.select(&:present?).each do |child_id|
Space.find(child_id).update!(parent: parent_space)
end
end
end
end

View File

@ -48,6 +48,6 @@ class API::SupportingDocumentFilesController < API::APIController
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_file_params
params.required(:supporting_document_file).permit(:supporting_document_type_id, :attachment, :user_id)
params.required(:supporting_document_file).permit(:supporting_document_type_id, :attachment, :supportable_id, :supportable_type)
end
end

View File

@ -27,6 +27,7 @@ class API::SupportingDocumentRefusalsController < API::APIController
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_refusal_params
params.required(:supporting_document_refusal).permit(:message, :operator_id, :user_id, supporting_document_type_ids: [])
params.required(:supporting_document_refusal).permit(:message, :operator_id, :supportable_id, :supportable_type,
supporting_document_type_ids: [])
end
end

View File

@ -45,6 +45,6 @@ class API::SupportingDocumentTypesController < API::APIController
end
def supporting_document_type_params
params.require(:supporting_document_type).permit(:name, group_ids: [])
params.require(:supporting_document_type).permit(:name, :document_type, group_ids: [])
end
end

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,73 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<defs><style> .nc-icon-wrapper { display: none } .nc-icon-wrapper:target { display: inline } </style></defs>
<svg viewBox="0 0 24 24">
<g id="lastfm" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-4.322 14.667c-2.972 0-4.003-1.34-4.551-3.005-.764-2.39-1.008-3.951-2.954-3.951-1.05 0-2.114.755-2.114 2.869 0 1.65.844 2.68 2.03 2.68 1.34 0 2.231-.998 2.231-.998l.549 1.496s-.928.91-2.869.91c-2.405 0-3.745-1.412-3.745-4.023 0-2.714 1.34-4.312 3.867-4.312 3.445 0 3.787 1.94 4.725 4.776.412 1.257 1.134 2.166 2.869 2.166 1.167 0 1.786-.258 1.786-.895 0-.933-1.022-1.032-2.34-1.34-1.425-.343-1.992-1.084-1.992-2.25 0-1.876 1.514-2.457 3.057-2.457 1.753 0 2.817.637 2.953 2.184l-1.72.206c-.07-.74-.516-1.05-1.341-1.05-.755 0-1.219.343-1.219.929 0 .515.225.825.98.998 1.533.333 3.365.563 3.365 2.695.005 1.72-1.439 2.372-3.567 2.372z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="instagram" class="nc-icon-wrapper">
<path d="M12 9.5a2.5 2.5 0 1 0 .002 5 2.5 2.5 0 0 0-.002-5zm5.846-1.922a2.532 2.532 0 0 0-1.426-1.426c-.984-.388-3.328-.301-4.42-.301s-3.434-.09-4.42.301a2.531 2.531 0 0 0-1.426 1.426c-.388.984-.302 3.33-.302 4.421 0 1.092-.086 3.435.304 4.423a2.531 2.531 0 0 0 1.425 1.425c.984.389 3.328.302 4.42.302 1.094 0 3.434.09 4.421-.302a2.532 2.532 0 0 0 1.426-1.425c.391-.985.301-3.33.301-4.422 0-1.091.09-3.434-.301-4.422h-.002zM12 15.844a3.844 3.844 0 1 1 0-7.689 3.844 3.844 0 0 1 0 7.689zm4.002-6.952a.897.897 0 1 1 .002.002l-.002-.002zM20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-.802 13.594c-.061 1.201-.335 2.266-1.212 3.14-.877.875-1.94 1.155-3.14 1.212-1.239.07-4.95.07-6.188 0-1.202-.06-2.263-.335-3.141-1.212-.878-.876-1.155-1.941-1.212-3.14-.07-1.239-.07-4.95 0-6.188.06-1.201.332-2.266 1.212-3.14.88-.875 1.944-1.152 3.14-1.209 1.239-.07 4.95-.07 6.188 0 1.202.06 2.266.335 3.14 1.212.876.876 1.155 1.941 1.213 3.143.07 1.234.07 4.942 0 6.182z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="github" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C2.50781 1.5 1.5 2.50781 1.5 3.75V20.25C1.5 21.4922 2.50781 22.5 3.75 22.5H20.25C21.4922 22.5 22.5 21.4922 22.5 20.25V3.75C22.5 2.50781 21.4922 1.5 20.25 1.5ZM14.4984 19.4859C14.1047 19.5563 13.9594 19.3125 13.9594 19.1109C13.9594 18.8578 13.9688 17.5641 13.9688 16.5187C13.9688 15.7875 13.725 15.3234 13.4391 15.0797C15.1734 14.8875 17.0016 14.6484 17.0016 11.6531C17.0016 10.8 16.6969 10.3734 16.2 9.825C16.2797 9.62344 16.5469 8.79375 16.1203 7.71563C15.4687 7.51406 13.9781 8.55469 13.9781 8.55469C13.3594 8.38125 12.6891 8.29219 12.0281 8.29219C11.3672 8.29219 10.6969 8.38125 10.0781 8.55469C10.0781 8.55469 8.5875 7.51406 7.93594 7.71563C7.50938 8.78906 7.77188 9.61875 7.85625 9.825C7.35938 10.3734 7.125 10.8 7.125 11.6531C7.125 14.6344 8.87344 14.8875 10.6078 15.0797C10.3828 15.2813 10.1812 15.6281 10.1109 16.125C9.66562 16.3266 8.52656 16.6734 7.84687 15.4734C7.42031 14.7328 6.65156 14.6719 6.65156 14.6719C5.89219 14.6625 6.6 15.15 6.6 15.15C7.10625 15.3844 7.4625 16.2844 7.4625 16.2844C7.91719 17.6766 10.0922 17.2078 10.0922 17.2078C10.0922 17.8594 10.1016 18.9188 10.1016 19.1109C10.1016 19.3125 9.96094 19.5563 9.5625 19.4859C6.46875 18.45 4.30312 15.5062 4.30312 12.0656C4.30312 7.7625 7.59375 4.49531 11.8969 4.49531C16.2 4.49531 19.6875 7.7625 19.6875 12.0656C19.6922 15.5062 17.5922 18.4547 14.4984 19.4859ZM9.9 16.6219C9.81094 16.6406 9.72656 16.6031 9.71719 16.5422C9.70781 16.4719 9.76875 16.4109 9.85781 16.3922C9.94688 16.3828 10.0312 16.4203 10.0406 16.4813C10.0547 16.5422 9.99375 16.6031 9.9 16.6219ZM9.45469 16.5797C9.45469 16.6406 9.38437 16.6922 9.29062 16.6922C9.1875 16.7016 9.11719 16.65 9.11719 16.5797C9.11719 16.5188 9.1875 16.4672 9.28125 16.4672C9.37031 16.4578 9.45469 16.5094 9.45469 16.5797ZM8.8125 16.5281C8.79375 16.5891 8.7 16.6172 8.62031 16.5891C8.53125 16.5703 8.47031 16.5 8.48906 16.4391C8.50781 16.3781 8.60156 16.35 8.68125 16.3688C8.775 16.3969 8.83594 16.4672 8.8125 16.5281ZM8.23594 16.275C8.19375 16.3266 8.10469 16.3172 8.03437 16.2469C7.96406 16.1859 7.94531 16.0969 7.99219 16.0547C8.03437 16.0031 8.12344 16.0125 8.19375 16.0828C8.25469 16.1438 8.27812 16.2375 8.23594 16.275ZM7.80938 15.8484C7.76719 15.8766 7.6875 15.8484 7.63594 15.7781C7.58437 15.7078 7.58437 15.6281 7.63594 15.5953C7.6875 15.5531 7.76719 15.5859 7.80938 15.6562C7.86094 15.7266 7.86094 15.8109 7.80938 15.8484V15.8484ZM7.50469 15.3937C7.4625 15.4359 7.39219 15.4125 7.34062 15.3656C7.28906 15.3047 7.27969 15.2344 7.32187 15.2016C7.36406 15.1594 7.43437 15.1828 7.48594 15.2297C7.5375 15.2906 7.54688 15.3609 7.50469 15.3937ZM7.19063 15.0469C7.17188 15.0891 7.11094 15.0984 7.05937 15.0656C6.99844 15.0375 6.97031 14.9859 6.98906 14.9437C7.00781 14.9156 7.05938 14.9016 7.12031 14.925C7.18125 14.9578 7.20938 15.0094 7.19063 15.0469Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="echosciences" class="nc-icon-wrapper">
<path d="M3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM15.099 5H16.5742L16.6848 5.14204C18.2776 7.18714 19.1861 9.48429 19.1838 11.9303C19.1837 14.433 18.2315 16.7805 16.5685 18.8616L16.4578 19H14.9691L15.4785 18.3944C17.1447 16.4138 18.0564 14.2227 18.0564 11.93C18.0579 9.6857 17.1869 7.54197 15.5936 5.60226L15.099 5ZM4.81616 8.48674V5.73684H13.5809V8.48674H4.81616ZM12.7682 13.3182H7.98311V15.5169H13.5964V18.2632H4.83164V10.5757H12.7682V13.3182Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="flickr" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zM8.273 14.953a2.975 2.975 0 0 1-2.976-2.976A2.975 2.975 0 0 1 8.273 9a2.975 2.975 0 0 1 2.977 2.977 2.975 2.975 0 0 1-2.977 2.976zm7.454 0a2.975 2.975 0 0 1-2.977-2.976A2.975 2.975 0 0 1 15.727 9a2.975 2.975 0 0 1 2.976 2.977 2.975 2.975 0 0 1-2.976 2.976z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="facebook" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C3.15326 1.5 2.58097 1.73705 2.15901 2.15901C1.73705 2.58097 1.5 3.15326 1.5 3.75L1.5 20.25C1.5 20.8467 1.73705 21.419 2.15901 21.841C2.58097 22.2629 3.15326 22.5 3.75 22.5H10.1836V15.3605H7.23047V12H10.1836V9.43875C10.1836 6.52547 11.918 4.91625 14.5744 4.91625C15.8466 4.91625 17.1769 5.14313 17.1769 5.14313V8.0025H15.7111C14.2669 8.0025 13.8164 8.89875 13.8164 9.81797V12H17.0405L16.5248 15.3605H13.8164V22.5H20.25C20.8467 22.5 21.419 22.2629 21.841 21.841C22.2629 21.419 22.5 20.8467 22.5 20.25V3.75C22.5 3.15326 22.2629 2.58097 21.841 2.15901C21.419 1.73705 20.8467 1.5 20.25 1.5V1.5Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="youtube" class="nc-icon-wrapper">
<path d="M10.2563 9.47344L14.7188 12.0094L10.2563 14.5453V9.47344ZM22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75ZM20.5312 12.0141C20.5312 12.0141 20.5312 9.22031 20.175 7.87969C19.9781 7.13906 19.4016 6.55781 18.6656 6.36094C17.3391 6 12 6 12 6C12 6 6.66094 6 5.33438 6.36094C4.59844 6.55781 4.02187 7.13906 3.825 7.87969C3.46875 9.21563 3.46875 12.0141 3.46875 12.0141C3.46875 12.0141 3.46875 14.8078 3.825 16.1484C4.02187 16.8891 4.59844 17.4469 5.33438 17.6437C6.66094 18 12 18 12 18C12 18 17.3391 18 18.6656 17.6391C19.4016 17.4422 19.9781 16.8844 20.175 16.1437C20.5312 14.8078 20.5312 12.0141 20.5312 12.0141V12.0141Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="viadeo" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-5.592 16.369c-1.988 2.165-5.625 2.184-7.613 0-3.187-3.45-.928-9.192 3.807-9.192.623 0 1.246.098 1.832.314a3.749 3.749 0 0 0-.393 1.27 3.696 3.696 0 0 0-1.44-.281c-2.287 0-3.965 1.954-3.965 4.167 0 2.016 1.336 3.689 3.258 4.026 2.883-1.125 3.417-5.512 3.417-8.203 0-.342 0-.693-.028-1.036-.525-1.542-1.247-3.028-2.072-4.43 1.27.859 1.964 2.93 2.072 4.412v.018a10.15 10.15 0 0 1 .553 3.282c0 2.536-1.027 4.64-3.202 6.009l-.112.01c2.344.046 4.04-1.81 4.04-4.088a4.29 4.29 0 0 0-.323-1.674 3.763 3.763 0 0 0 1.238-.492 5.554 5.554 0 0 1-1.07 5.888zm1.326-6.914c-.623 0-1.176-.333-1.612-.755 1.026-.563 2.325-1.44 2.92-2.484.07-.141.192-.404.211-.563-.586 1.308-2.072 2.335-3.464 2.658a2.092 2.092 0 0 1-.351-1.14c0-.482.243-1.129.604-1.48 1.013-.961 2.485-.399 3.394-2.344 1.523 2.165.614 6.108-1.702 6.108z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="twitter" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-2.292 7.444c.01.131.01.267.01.398 0 4.064-3.095 8.747-8.748 8.747a8.706 8.706 0 0 1-4.72-1.378c.248.028.487.037.74.037 1.44 0 2.762-.487 3.816-1.312a3.078 3.078 0 0 1-2.873-2.133c.473.07.9.07 1.387-.056a3.075 3.075 0 0 1-2.46-3.019v-.037c.407.23.885.37 1.387.389a3.068 3.068 0 0 1-1.369-2.56c0-.572.15-1.097.417-1.551a8.73 8.73 0 0 0 6.338 3.215c-.436-2.086 1.125-3.778 3-3.778.886 0 1.683.37 2.245.97a6.024 6.024 0 0 0 1.95-.74 3.066 3.066 0 0 1-1.35 1.692A6.117 6.117 0 0 0 19.5 7.35a6.471 6.471 0 0 1-1.542 1.594z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="pinterest" class="nc-icon-wrapper">
<path d="M22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H8.7375C9.19687 21.7313 9.7875 20.625 10.0219 19.7203C10.1625 19.1813 10.7391 16.9828 10.7391 16.9828C11.1141 17.7 12.2109 18.3047 13.3781 18.3047C16.8516 18.3047 19.35 15.1125 19.35 11.1469C19.35 7.34531 16.2469 4.5 12.2531 4.5C7.28437 4.5 4.65 7.83281 4.65 11.4656C4.65 13.1531 5.55 15.2531 6.98438 15.9234C7.20469 16.0266 7.31719 15.9797 7.36875 15.7687C7.40625 15.6094 7.60313 14.8266 7.6875 14.4656C7.71563 14.3484 7.70156 14.25 7.60781 14.1375C7.13438 13.5609 6.75 12.5016 6.75 11.5125C6.75 8.97188 8.67187 6.51562 11.9484 6.51562C14.775 6.51562 16.7578 8.44219 16.7578 11.1984C16.7578 14.3109 15.1875 16.4672 13.1391 16.4672C12.0094 16.4672 11.1656 15.5344 11.4328 14.3859C11.7562 13.0172 12.3844 11.5406 12.3844 10.5516C12.3844 8.06719 8.84531 8.40938 8.84531 11.7234C8.84531 12.7406 9.1875 13.4344 9.1875 13.4344C7.71563 19.6594 7.49531 19.7391 7.8 22.4625L7.90313 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="linkedin" class="nc-icon-wrapper">
<path d="M21 1.5H2.995C2.17 1.5 1.5 2.18 1.5 3.014v17.972c0 .834.67 1.514 1.495 1.514H21c.825 0 1.5-.68 1.5-1.514V3.014A1.51 1.51 0 0 0 21 1.5zm-13.153 18H4.734V9.478h3.118V19.5h-.005zM6.29 8.11a1.805 1.805 0 0 1 0-3.61c.993 0 1.804.81 1.804 1.805 0 .998-.806 1.804-1.804 1.804zM19.514 19.5h-3.112v-4.875c0-1.162-.024-2.658-1.618-2.658-1.622 0-1.87 1.266-1.87 2.574V19.5H9.802V9.478h2.986v1.369h.042c.417-.788 1.434-1.617 2.948-1.617 3.15 0 3.736 2.076 3.736 4.776V19.5z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="dailymotion" class="nc-icon-wrapper">
<path d="M15.512 12.516C15.1636 12.3201 14.7697 12.2197 14.37 12.225C13.75 12.225 13.228 12.432 12.803 12.847C12.378 13.261 12.165 13.783 12.165 14.413C12.165 15.074 12.373 15.615 12.788 16.035C13.203 16.455 13.725 16.665 14.354 16.665C14.995 16.665 15.528 16.45 15.954 16.019C16.379 15.589 16.592 15.053 16.594 14.413C16.5962 14.0277 16.4976 13.6485 16.308 13.313C16.1215 12.9788 15.8459 12.7029 15.512 12.516ZM3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM19.064 18.997H16.576V17.877H16.544C16.051 18.696 15.179 19.105 13.929 19.105C13.069 19.105 12.304 18.9 11.638 18.491C10.9765 18.0875 10.4416 17.5065 10.094 16.814C9.731 16.105 9.55 15.31 9.55 14.429C9.55 13.567 9.734 12.783 10.102 12.075C10.452 11.385 10.986 10.804 11.645 10.397C12.307 9.988 13.053 9.784 13.882 9.783C14.3579 9.77641 14.8308 9.85952 15.276 10.028C15.691 10.19 16.082 10.456 16.448 10.824V7.172L19.063 6.605L19.065 18.997H19.064Z" fill="currentColor"></path>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,46 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Child, ChildIndexFilter } from '../models/child';
import ApiLib from '../lib/api';
export default class ChildAPI {
static async index (filters: ChildIndexFilter): Promise<Array<Child>> {
const res: AxiosResponse<Array<Child>> = await apiClient.get(`/api/children${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<Child> {
const res: AxiosResponse<Child> = await apiClient.get(`/api/children/${id}`);
return res?.data;
}
static async create (child: Child): Promise<Child> {
const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']);
const res: AxiosResponse<Child> = await apiClient.post('/api/children', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (child: Child): Promise<Child> {
const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']);
const res: AxiosResponse<Child> = await apiClient.put(`/api/children/${child.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async destroy (childId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/children/${childId}`);
return res?.data;
}
static async validate (child: Child): Promise<Child> {
const res: AxiosResponse<Child> = await apiClient.patch(`/api/children/${child.id}/validate`, { child });
return res?.data;
}
}

View File

@ -34,6 +34,15 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
// this is a workaround for https://github.com/JedWatson/react-select/issues/1879
const [selectKey, setSelectKey] = useState<number>(0);
useEffect(() => {
if (!currentFormValues?.extra_authorize_params) {
setValue(
'providable_attributes.extra_authorize_params' as Path<TFieldValues>,
'{}' as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}
}, []);
// when we have detected a discovery endpoint, we mark it as available
useEffect(() => {
setValue(
@ -160,6 +169,12 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ required: false, pattern: ValidationLib.urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.extra_authorize_params"
register={register}
label={t('app.admin.authentication.openid_connect_form.extra_authorize_params')}
tooltip={t('app.admin.authentication.openid_connect_form.extra_authorize_params_help')}
rules={{ required: false }}
formState={formState} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
label={t('app.admin.authentication.openid_connect_form.client__identifier')}

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import Icons from '../../../../images/icons.svg';
declare const Application: IApplication;
interface FabBadgeProps {
icon: string,
iconWidth: string,
className?: string,
}
/**
* Renders a badge (parent needs to be position: relative)
*/
export const FabBadge: React.FC<FabBadgeProps> = ({ icon, iconWidth, className }) => {
return (
<div className={`fab-badge ${className || ''}`}>
<svg viewBox="0 0 24 24" width={iconWidth}>
<use href={`${Icons}#${icon}`}/>
</svg>
</div>
);
};
const FabBadgeWrapper: React.FC<FabBadgeProps> = ({ icon, iconWidth, className }) => {
return (
<Loader>
<FabBadge icon={icon} iconWidth={iconWidth} className={className} />
</Loader>
);
};
Application.Components.component('fabBadge', react2angular(FabBadgeWrapper, ['icon', 'iconWidth', 'className']));

View File

@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
import { Credit, CreditableType } from '../../../models/credit';
import CreditAPI from '../../../api/credit';
import { HtmlTranslate } from '../../base/html-translate';
import { User } from '../../../models/user';
interface CreditsPanelProps {
userId: number,
currentUser?: User,
onError: (message: string) => void,
reservableType: CreditableType
}
@ -16,7 +18,7 @@ interface CreditsPanelProps {
/**
* List all available credits for the given user and the given resource
*/
const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, onError, reservableType }) => {
const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, currentUser = null, onError, reservableType }) => {
const { t } = useTranslation('logged');
const [credits, setCredits] = useState<Array<Credit>>([]);
@ -37,16 +39,30 @@ const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, onError, reservable
/**
* Display a placeholder when there's no credits to display
*/
const noCredits = (): ReactNode => {
const noCredits = (currentUser: User): ReactNode => {
return (
<div className="fab-alert fab-alert--warning">{t('app.logged.dashboard.reservations_dashboard.credits_panel.no_credits')}</div>
<div className="fab-alert fab-alert--warning">{t(`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.no_credits`) /* eslint-disable-line fabmanager/scoped-translation */ }</div>
);
};
/**
* returns true if there is a currentUser and current user is manager or admin
*/
const currentUserIsAdminOrManager = (currentUser: User): boolean => {
return currentUser && (currentUser.role === 'admin' || currentUser.role === 'manager');
};
/**
* returns translation key prefix
*/
const translationKeyPrefix = (currentUser: User): string => {
return currentUserIsAdminOrManager(currentUser) ? 'credits_panel_as_admin' : 'credits_panel';
};
return (
<FabPanel className="credits-panel">
<p className="title">{t('app.logged.dashboard.reservations_dashboard.credits_panel.title')}</p>
{credits.length !== 0 &&
<p className="title">{t(`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.title`) /* eslint-disable-line fabmanager/scoped-translation */}</p>
{credits.length !== 0 && !currentUserIsAdminOrManager(currentUser) &&
<div className="fab-alert fab-alert--warning">
{t('app.logged.dashboard.reservations_dashboard.credits_panel.info')}
</div>
@ -56,14 +72,14 @@ const CreditsPanel: React.FC<CreditsPanelProps> = ({ userId, onError, reservable
{credits.map(c => <div key={c.id} className="credits-list-item">
<p className="title">{c.creditable.name}</p>
<p>
<HtmlTranslate trKey="app.logged.dashboard.reservations_dashboard.credits_panel.remaining_credits_html" options={{ REMAINING: remainingHours(c) }} /><br />
<HtmlTranslate trKey={`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.remaining_credits_html` /* eslint-disable-line fabmanager/scoped-translation */} options={{ REMAINING: remainingHours(c) }} /><br />
{(c.hours_used && c.hours_used > 0) &&
<HtmlTranslate trKey="app.logged.dashboard.reservations_dashboard.credits_panel.used_credits_html" options={{ USED: c.hours_used }} />
<HtmlTranslate trKey={`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.used_credits_html` /* eslint-disable-line fabmanager/scoped-translation */} options={{ USED: c.hours_used }} />
}
</p>
</div>)}
</div>
{credits.length === 0 && noCredits()}
{credits.length === 0 && noCredits(currentUser)}
</FabPanel>
);
};

View File

@ -21,13 +21,14 @@ import { HtmlTranslate } from '../../base/html-translate';
interface PrepaidPacksPanelProps {
user: User,
currentUser?: User,
onError: (message: string) => void
}
/**
* List all available prepaid packs for the given user
*/
const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError }) => {
const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, currentUser = null, onError }) => {
const { t } = useTranslation('logged');
const [machines, setMachines] = useState<Array<Machine>>([]);
@ -101,6 +102,20 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
return (packs.length > 0 && (!packsForSubscribers || (packsForSubscribers && user.subscribed_plan != null)));
};
/**
* returns true if there is a currentUser and current user is manager or admin
*/
const currentUserIsAdminOrManager = (currentUser: User): boolean => {
return currentUser && (currentUser.role === 'admin' || currentUser.role === 'manager');
};
/**
* returns translation key prefix
*/
const translationKeyPrefix = (currentUser: User): string => {
return currentUserIsAdminOrManager(currentUser) ? 'prepaid_packs_panel_as_admin' : 'prepaid_packs_panel';
};
/**
* Callback triggered when a prepaid pack was successfully bought: refresh the list of packs for the user
*/
@ -113,7 +128,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
return (
<FabPanel className='prepaid-packs-panel'>
<p className="title">{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.title')}</p>
<p className="title">{t(`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.title`) /* eslint-disable-line fabmanager/scoped-translation */}</p>
{userPacks.map(pack => (
<div className={`prepaid-packs ${isLow(pack) ? 'is-low' : ''}`} key={pack.id}>
@ -124,7 +139,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
<div className='prepaid-packs-list-item'>
<p className='name'>{pack.prepaid_pack.priceable.name}</p>
{FormatLib.date(pack.expires_at) && <p className="end">{FormatLib.date(pack.expires_at)}</p>}
{pack.expires_at && FormatLib.date(pack.expires_at) && <p className="end">{FormatLib.date(pack.expires_at)}</p>}
<p className="countdown"><span>{(pack.prepaid_pack.minutes - pack.minutes_used) / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
</div>
</div>
@ -143,7 +158,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
</div>
))}
{canBuyPacks() && <div className='prepaid-packs-cta'>
{canBuyPacks() && !currentUserIsAdminOrManager(currentUser) && <div className='prepaid-packs-cta'>
<p>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.cta_info')}</p>
<form onSubmit={handleSubmit(onBuyPack)}>
<FormSelect options={buildMachinesOptions(machines)} control={control} id="machine_id" rules={{ required: true }} formState={formState} label={t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.select_machine')} />
@ -163,7 +178,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
onSuccess={onPackBoughtSuccess} />}
</div>}
{packs.length === 0 && <p>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.no_packs')}</p>}
{(packsForSubscribers && user.subscribed_plan == null && packs.length > 0) &&
{(packsForSubscribers && user.subscribed_plan == null && packs.length > 0 && !currentUserIsAdminOrManager(currentUser)) &&
<HtmlTranslate trKey={'app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.reserved_for_subscribers_html'} options={{ LINK: '#!/plans' }} />
}
</FabPanel>

View File

@ -14,13 +14,14 @@ declare const Application: IApplication;
interface ReservationsDashboardProps {
onError: (message: string) => void,
user: User
user: User,
currentUser?: User
}
/**
* User dashboard showing everything about his spaces/machine reservations and also remaining credits
*/
const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError, user }) => {
const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError, user, currentUser = null }) => {
const { t } = useTranslation('logged');
const [modules, setModules] = useState<Map<SettingName, string>>();
@ -34,17 +35,17 @@ const ReservationsDashboard: React.FC<ReservationsDashboardProps> = ({ onError,
<div className="reservations-dashboard">
{modules?.get('machines_module') !== 'false' && <div className="section">
<p className="section-title">{t('app.logged.dashboard.reservations_dashboard.machine_section_title')}</p>
<CreditsPanel userId={user.id} onError={onError} reservableType="Machine" />
<PrepaidPacksPanel user={user} onError={onError} />
<ReservationsPanel userId={user.id} onError={onError} reservableType="Machine" />
<CreditsPanel userId={user.id} currentUser={currentUser} onError={onError} reservableType="Machine" />
<PrepaidPacksPanel user={user} currentUser={currentUser} onError={onError} />
<ReservationsPanel userId={user.id} currentUser={currentUser} onError={onError} reservableType="Machine" />
</div>}
{modules?.get('spaces_module') !== 'false' && <div className="section">
<p className="section-title">{t('app.logged.dashboard.reservations_dashboard.space_section_title')}</p>
<CreditsPanel userId={user.id} onError={onError} reservableType="Space" />
<ReservationsPanel userId={user.id} onError={onError} reservableType="Space" />
<CreditsPanel userId={user.id} currentUser={currentUser} onError={onError} reservableType="Space" />
<ReservationsPanel userId={user.id} currentUser={currentUser} onError={onError} reservableType="Space" />
</div>}
</div>
);
};
Application.Components.component('reservationsDashboard', react2angular(ReservationsDashboard, ['onError', 'user']));
Application.Components.component('reservationsDashboard', react2angular(ReservationsDashboard, ['onError', 'user', 'currentUser']));

View File

@ -9,9 +9,11 @@ import { Loader } from '../../base/loader';
import FormatLib from '../../../lib/format';
import _ from 'lodash';
import { FabButton } from '../../base/fab-button';
import { User } from '../../../models/user';
interface SpaceReservationsProps {
userId: number,
currentUser?: User,
onError: (message: string) => void,
reservableType: 'Machine' | 'Space'
}
@ -19,7 +21,7 @@ interface SpaceReservationsProps {
/**
* List all reservations for the given user and the given type
*/
const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError, reservableType }) => {
const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, currentUser = null, onError, reservableType }) => {
const { t } = useTranslation('logged');
const [reservations, setReservations] = useState<Array<Reservation>>([]);
@ -71,6 +73,20 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
return reservation.slots_reservations_attributes.map(sr => sr.canceled_at).every(ca => ca != null);
};
/**
* returns true if there is a currentUser and current user is manager or admin
*/
const currentUserIsAdminOrManager = (currentUser: User): boolean => {
return currentUser && (currentUser.role === 'admin' || currentUser.role === 'manager');
};
/**
* returns translation key prefix
*/
const translationKeyPrefix = (currentUser: User): string => {
return currentUserIsAdminOrManager(currentUser) ? 'reservations_panel_as_admin' : 'reservations_panel';
};
/**
* Render the reservation in a user-friendly way
*/
@ -95,7 +111,7 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
return (
<FabPanel className="reservations-panel">
<p className="title">{t('app.logged.dashboard.reservations_dashboard.reservations_panel.title')}</p>
<p className="title">{t(`app.logged.dashboard.reservations_dashboard.${translationKeyPrefix(currentUser)}.title`) /* eslint-disable-line fabmanager/scoped-translation */}</p>
<div className="reservations">
{futur.length === 0
? noReservations()

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import * as React from 'react';
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption } from '../../models/event';
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption, EventType } from '../../models/event';
import EventAPI from '../../api/event';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
@ -40,7 +40,7 @@ interface EventFormProps {
* Form to edit or create events
*/
export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, onSuccess }) => {
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: { ...event } });
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: Object.assign({ event_type: 'standard' }, event) });
const output = useWatch<Event>({ control });
const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' });
@ -54,6 +54,9 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState<boolean>(false);
const [updatingEvent, setUpdatingEvent] = useState<Event>(null);
const [isActiveAccounting, setIsActiveAccounting] = useState<boolean>(false);
const [isActiveFamilyAccount, setIsActiveFamilyAccount] = useState<boolean>(false);
const [isAcitvePreRegistration, setIsActivePreRegistration] = useState<boolean>(event?.pre_registration);
const [submitting, setSubmitting] = useState<boolean>(false);
useEffect(() => {
EventCategoryAPI.index()
@ -69,6 +72,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
.then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c))))
.catch(onError);
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
SettingAPI.get('family_account').then(res => setIsActiveFamilyAccount(res.value === 'true')).catch(onError);
}, []);
useEffect(() => {
@ -97,6 +101,11 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
* Callback triggered when the user validates the machine form: handle create or update
*/
const onSubmit: SubmitHandler<Event> = (data: Event) => {
setSubmitting(true);
if (submitting) return;
if (data.pre_registration_end_date?.toString() === 'Invalid Date' || !data.pre_registration) {
data.pre_registration_end_date = null;
}
if (action === 'update') {
if (event?.recurrence_events?.length > 0) {
setUpdatingEvent(data);
@ -108,7 +117,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
EventAPI.create(data).then(res => {
onSuccess(t(`app.admin.event_form.${action}_success`));
window.location.href = `/#!/events/${res.id}`;
}).catch(onError);
}).catch(onError).finally(() => setSubmitting(false));
}
};
@ -168,11 +177,25 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
];
};
/**
* This method provides event type options
*/
const buildEventTypeOptions = (): Array<SelectOption<EventType>> => {
const options = [
{ label: t('app.admin.event_form.event_types.standard'), value: 'standard' as EventType },
{ label: t('app.admin.event_form.event_types.nominative'), value: 'nominative' as EventType }
];
if (isActiveFamilyAccount) {
options.push({ label: t('app.admin.event_form.event_types.family'), value: 'family' as EventType });
}
return options;
};
return (
<div className="event-form">
<header>
<h2>{t('app.admin.event_form.ACTION_title', { ACTION: action })}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className="fab-button save-btn is-main">
<FabButton onClick={handleSubmit(onSubmit)} disabled={submitting} className="fab-button save-btn is-main">
{t('app.admin.event_form.save')}
</FabButton>
</header>
@ -203,6 +226,12 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
label={t('app.admin.event_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect id="event_type"
control={control}
formState={formState}
label={t('app.admin.event_form.event_type')}
options={buildEventTypeOptions()}
rules={{ required: true }} />
<FormSelect id="category_id"
control={control}
formState={formState}
@ -219,6 +248,19 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
formState={formState}
options={ageRangeOptions}
label={t('app.admin.event_form.age_range')} />}
<FormSwitch control={control}
id="pre_registration"
label={t('app.admin.event_form.pre_registration')}
formState={formState}
tooltip={t('app.admin.event_form.pre_registration_help')}
onChange={setIsActivePreRegistration} />
{isAcitvePreRegistration &&
<FormInput id="pre_registration_end_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.pre_registration_end_date')} />
}
</div>
</section>

View File

@ -0,0 +1,109 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import _ from 'lodash';
import { Reservation } from '../../models/reservation';
import FormatLib from '../../lib/format';
import { IApplication } from '../../models/application';
declare const Application: IApplication;
interface EventReservationItemProps {
reservation: Reservation;
}
/**
* event reservation item component
*/
export const EventReservationItem: React.FC<EventReservationItemProps> = ({ reservation }) => {
const { t } = useTranslation('logged');
/**
* Return the formatted localized date of the event
*/
const formatDate = (): string => {
return `${FormatLib.date(reservation.start_at)} ${FormatLib.time(reservation.start_at)} - ${FormatLib.time(reservation.end_at)}`;
};
/**
* Build the ticket for event price category user reservation
*/
const buildTicket = (ticket) => {
return (
<>
<label>{t('app.logged.event_reservation_item.NUMBER_of_NAME_places_reserved', { NUMBER: ticket.booked, NAME: ticket.price_category.name })}</label>
{reservation.booking_users_attributes.filter(u => u.event_price_category_id === ticket.event_price_category_id).map(u => {
return (
<p key={u.id} className='name'>{u.name}</p>
);
})}
</>
);
};
/**
* Return the pre-registration status
*/
const preRegistrationStatus = () => {
if (!_.isBoolean(reservation.is_valid) && !reservation.canceled_at && !reservation.is_paid) {
return t('app.logged.event_reservation_item.in_the_process_of_validation');
} else if (reservation.is_valid && !reservation.canceled_at && !reservation.is_paid && reservation.amount !== 0) {
return t('app.logged.event_reservation_item.settle_your_payment');
} else if (reservation.is_valid && !reservation.canceled_at && !reservation.is_paid && reservation.amount === 0) {
return t('app.logged.event_reservation_item.registered');
} else if (!reservation.is_valid && !reservation.canceled_at) {
return t('app.logged.event_reservation_item.not_validated');
} else if (reservation.is_paid && !reservation.canceled_at && reservation.amount !== 0) {
return t('app.logged.event_reservation_item.paid');
} else if (reservation.is_paid && !reservation.canceled_at && reservation.amount === 0) {
return t('app.logged.event_reservation_item.present');
} else if (reservation.canceled_at) {
return t('app.logged.event_reservation_item.canceled');
}
};
return (
<div className="event-reservation-item">
<div className="event-reservation-item__event">
<div className="infos">
<label>{t('app.logged.event_reservation_item.event')}</label>
<p>{reservation.event_title}</p>
<span className='date'>{formatDate()}</span>
</div>
<div className="types">
{/* {reservation.event_type === 'family' &&
<span className="">{t('app.logged.event_reservation_item.family')}</span>
}
{reservation.event_type === 'nominative' &&
<span className="">{t('app.logged.event_reservation_item.nominative')}</span>
} */}
{reservation.event_pre_registration &&
// eslint-disable-next-line fabmanager/no-bootstrap, fabmanager/no-utilities
<span className="badge text-xs bg-info">{t('app.logged.event_reservation_item.pre_registration')}</span>
}
</div>
</div>
<div className="event-reservation-item__reservation">
<div className='list'>
<label>{t('app.logged.event_reservation_item.NUMBER_normal_places_reserved', { NUMBER: reservation.nb_reserve_places })}</label>
{reservation.booking_users_attributes.filter(u => !u.event_price_category_id).map(u => {
return (
<p key={u.id} className='name'>{u.name}</p>
);
})}
{reservation.tickets.map(ticket => {
return buildTicket(ticket);
})}
</div>
{reservation.event_pre_registration &&
<div className='status'>
<label>{t('app.logged.event_reservation_item.tracking_your_reservation')}</label>
<p className="">{preRegistrationStatus()}</p>
</div>
}
</div>
</div>
);
};
Application.Components.component('eventReservationItem', react2angular(EventReservationItem, ['reservation']));

View File

@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import { Child } from '../../models/child';
import { FormInput } from '../form/form-input';
import { FabButton } from '../base/fab-button';
import { FormFileUpload } from '../form/form-file-upload';
import { FileType } from '../../models/file';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { User } from '../../models/user';
import { SupportingDocumentsRefusalModal } from '../supporting-documents/supporting-documents-refusal-modal';
import { FabAlert } from '../base/fab-alert';
interface ChildFormProps {
child: Child;
operator: User;
onSubmit: (data: Child) => void;
supportingDocumentsTypes: Array<SupportingDocumentType>;
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* A form for creating or editing a child.
*/
export const ChildForm: React.FC<ChildFormProps> = ({ child, onSubmit, supportingDocumentsTypes, operator, onSuccess, onError }) => {
const { t } = useTranslation('public');
const { register, formState, handleSubmit, setValue, control } = useForm<Child>({
defaultValues: child
});
const output = useWatch<Child>({ control }); // eslint-disable-line
const [refuseModalIsOpen, setRefuseModalIsOpen] = useState<boolean>(false);
/**
* get the name of the supporting document type by id
*/
const getSupportingDocumentsTypeName = (id: number): string => {
const supportingDocumentType = supportingDocumentsTypes.find((supportingDocumentType) => supportingDocumentType.id === id);
return supportingDocumentType ? supportingDocumentType.name : '';
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator?.role === 'admin' || operator?.role === 'manager');
};
/**
* Open/closes the modal dialog to refuse the documents
*/
const toggleRefuseModal = (): void => {
setRefuseModalIsOpen(!refuseModalIsOpen);
};
/**
* Callback triggered when the refusal was successfully saved
*/
const onSaveRefusalSuccess = (message: string): void => {
setRefuseModalIsOpen(false);
onSuccess(message);
};
return (
<div className="child-form">
{!isPrivileged() &&
<FabAlert level='info'>
<p>{t('app.public.child_form.child_form_info')}</p>
</FabAlert>
}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grp">
<FormInput id="first_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.first_name')}
/>
<FormInput id="last_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.last_name')}
/>
</div>
<div className="grp">
<FormInput id="birthday"
register={register}
rules={{ required: true, validate: (value) => moment(value).isAfter(moment().subtract(18, 'year')) }}
formState={formState}
label={t('app.public.child_form.birthday')}
type="date"
max={moment().format('YYYY-MM-DD')}
min={moment().subtract(18, 'year').format('YYYY-MM-DD')}
/>
<FormInput id="phone"
register={register}
formState={formState}
label={t('app.public.child_form.phone')}
type="tel"
/>
</div>
<FormInput id="email"
register={register}
formState={formState}
label={t('app.public.child_form.email')}
/>
{!isPrivileged() && supportingDocumentsTypes?.length > 0 && <>
<h3 className="missing-file">{t('app.public.child_form.supporting_documents')}</h3>
{output.supporting_document_files_attributes.map((sf, index) => {
return (
<FormFileUpload key={index}
defaultFile={sf as FileType}
id={`supporting_document_files_attributes.${index}`}
accept="application/pdf"
rules={{ required: !sf.attachment }}
setValue={setValue}
label={getSupportingDocumentsTypeName(sf.supporting_document_type_id)}
showRemoveButton={false}
register={register}
formState={formState} />
);
})}
</>}
<div className="actions">
<FabButton type="button" className='is-secondary' onClick={handleSubmit(onSubmit)}>
{t('app.public.child_form.save')}
</FabButton>
</div>
{isPrivileged() && supportingDocumentsTypes?.length > 0 && <>
<h3 className="missing-file">{t('app.public.child_form.supporting_documents')}</h3>
<div className="document-list">
{output.supporting_document_files_attributes.map((sf, index) => {
return (
<div key={index} className="document-list-item">
<span className="type">{getSupportingDocumentsTypeName(sf.supporting_document_type_id)}</span>
{sf.attachment_url && (
<div className='file'>
<p>{sf.attachment}</p>
<a href={sf.attachment_url} target="_blank" rel="noreferrer" className='fab-button is-black'>
<span className="fab-button--icon-only"><i className="fas fa-eye"></i></span>
</a>
</div>
)}
{!sf.attachment_url && (
<div className="missing">
<p>{t('app.public.child_form.to_complete')}</p>
</div>
)}
</div>
);
})}
</div>
</>}
{isPrivileged() && supportingDocumentsTypes?.length > 0 && <>
<FabAlert level='info'>
<p>{t('app.public.child_form.refuse_documents_info')}</p>
</FabAlert>
<div className="actions">
<FabButton className="refuse-btn is-secondary" onClick={toggleRefuseModal}>{t('app.public.child_form.refuse_documents')}</FabButton>
<SupportingDocumentsRefusalModal
isOpen={refuseModalIsOpen}
proofOfIdentityTypes={supportingDocumentsTypes}
toggleModal={toggleRefuseModal}
operator={operator}
supportable={child}
documentType="Child"
onError={onError}
onSuccess={onSaveRefusalSuccess} />
</div>
</>}
</form>
</div>
);
};

View File

@ -0,0 +1,71 @@
import React from 'react';
import { Child } from '../../models/child';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import FormatLib from '../../lib/format';
import { DeleteChildModal } from './delete-child-modal';
import ChildAPI from '../../api/child';
import { PencilSimple, Trash, UserSquare } from 'phosphor-react';
interface ChildItemProps {
child: Child;
size: 'sm' | 'lg';
onEdit: (child: Child) => void;
onDelete: (child: Child, error: string) => void;
onError: (error: string) => void;
}
/**
* A child item.
*/
export const ChildItem: React.FC<ChildItemProps> = ({ child, size, onEdit, onDelete, onError }) => {
const { t } = useTranslation('public');
const [isOpenDeleteChildModal, setIsOpenDeleteChildModal] = React.useState<boolean>(false);
/**
* Toggle the delete child modal
*/
const toggleDeleteChildModal = () => setIsOpenDeleteChildModal(!isOpenDeleteChildModal);
/**
* Delete a child
*/
const deleteChild = () => {
ChildAPI.destroy(child.id).then(() => {
toggleDeleteChildModal();
onDelete(child, t('app.public.child_item.deleted'));
}).catch((e) => {
console.error(e);
onError(t('app.public.child_item.unable_to_delete'));
});
};
return (
<div className={`child-item ${size} ${child.validated_at ? 'is-validated' : ''}`}>
<div className='status'>
<UserSquare size={24} weight="light" />
</div>
<div>
<span>{t('app.public.child_item.last_name')}</span>
<p>{child.last_name}</p>
</div>
<div>
<span>{t('app.public.child_item.first_name')}</span>
<p>{child.first_name}</p>
</div>
<div>
<span>{t('app.public.child_item.birthday')}</span>
<p>{FormatLib.date(child.birthday)}</p>
</div>
<div className="actions edit-destroy-buttons">
<FabButton onClick={() => onEdit(child)} className="edit-btn">
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton onClick={toggleDeleteChildModal} className="delete-btn">
<Trash size={20} weight="fill" />
</FabButton>
<DeleteChildModal isOpen={isOpenDeleteChildModal} toggleModal={toggleDeleteChildModal} child={child} onDelete={deleteChild} />
</div>
</div>
);
};

View File

@ -0,0 +1,65 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from '../base/fab-modal';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { ChildForm } from './child-form';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { ChildValidation } from './child-validation';
import { User } from '../../models/user';
interface ChildModalProps {
child?: Child;
operator: User;
isOpen: boolean;
toggleModal: () => void;
onSuccess: (child: Child, msg: string) => void;
onError: (error: string) => void;
supportingDocumentsTypes: Array<SupportingDocumentType>;
}
/**
* A modal for creating or editing a child.
*/
export const ChildModal: React.FC<ChildModalProps> = ({ child, isOpen, toggleModal, onSuccess, onError, supportingDocumentsTypes, operator }) => {
const { t } = useTranslation('public');
/**
* Save the child to the API
*/
const handleSaveChild = async (data: Child): Promise<void> => {
let c: Child = data;
try {
if (child?.id) {
c = await ChildAPI.update(data);
} else {
c = await ChildAPI.create(data);
}
toggleModal();
onSuccess(c, '');
} catch (error) {
onError(error);
}
};
return (
<FabModal title={t(`app.public.child_modal.${child?.id ? 'edit' : 'new'}_child`)}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={false} >
{(operator?.role === 'admin' || operator?.role === 'manager') &&
<ChildValidation child={child} onSuccess={onSuccess} onError={onError} />
}
<ChildForm
child={child}
onSubmit={handleSaveChild}
supportingDocumentsTypes={supportingDocumentsTypes}
operator={operator}
onSuccess={(msg) => onSuccess(child, msg)}
onError={onError}
/>
</FabModal>
);
};

View File

@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import * as React from 'react';
import Switch from 'react-switch';
import _ from 'lodash';
import { useTranslation } from 'react-i18next';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { TDateISO } from '../../typings/date-iso';
interface ChildValidationProps {
child: Child
onSuccess: (child: Child, msg: string) => void;
onError: (message: string) => void,
}
/**
* This component allows to configure boolean value for a setting.
*/
export const ChildValidation: React.FC<ChildValidationProps> = ({ child, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [value, setValue] = useState<boolean>(!!(child?.validated_at));
useEffect(() => {
setValue(!!(child?.validated_at));
}, [child]);
/**
* Callback triggered when the 'switch' is changed.
*/
const handleChanged = (_value: boolean) => {
setValue(_value);
const _child = _.clone(child);
if (_value) {
_child.validated_at = new Date().toISOString() as TDateISO;
} else {
_child.validated_at = null;
}
ChildAPI.validate(_child)
.then((c) => {
onSuccess(c, t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_success`));
}).catch(err => {
setValue(!_value);
onError(t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_error`) + err);
});
};
return (
<div className="child-validation">
<label htmlFor="child-validation-switch">{t('app.admin.child_validation.validate_child')}</label>
<Switch checked={value} id="child-validation-switch" onChange={handleChanged} className="switch"></Switch>
</div>
);
};

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { react2angular } from 'react2angular';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { User } from '../../models/user';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ChildModal } from './child-modal';
import { ChildItem } from './child-item';
import { FabButton } from '../base/fab-button';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
declare const Application: IApplication;
interface ChildrenDashboardProps {
user: User;
operator: User;
adminPanel?: boolean;
onSuccess: (error: string) => void;
onError: (error: string) => void;
}
/**
* A list of children belonging to the current user.
*/
export const ChildrenDashboard: React.FC<ChildrenDashboardProps> = ({ user, operator, adminPanel, onError, onSuccess }) => {
const { t } = useTranslation('public');
const [children, setChildren] = useState<Array<Child>>([]);
const [isOpenChildModal, setIsOpenChildModal] = useState<boolean>(false);
const [child, setChild] = useState<Child>();
const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState<Array<SupportingDocumentType>>([]);
useEffect(() => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
SupportingDocumentTypeAPI.index({ document_type: 'Child' }).then(tData => {
setSupportingDocumentsTypes(tData);
});
}, [user]);
/**
* Open the add child modal
*/
const addChild = () => {
setIsOpenChildModal(true);
setChild({
user_id: user.id,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
return { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Open the edit child modal
*/
const editChild = (child: Child) => {
setIsOpenChildModal(true);
setChild({
...child,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
const file = child.supporting_document_files_attributes.find(f => f.supporting_document_type_id === t.id);
return file || { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Delete a child
*/
const handleDeleteChildSuccess = (_child: Child, msg: string) => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
onSuccess(msg);
};
/**
* Handle save child success from the API
*/
const handleSaveChildSuccess = (_data: Child, msg: string) => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
if (msg) {
onSuccess(msg);
}
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator?.role === 'admin' || operator?.role === 'manager');
};
return (
<section className='children-dashboard'>
<header>
{adminPanel
? <h2>{t('app.public.children_dashboard.heading')}</h2>
: <h2>{t('app.public.children_dashboard.member_heading')}</h2>
}
{!isPrivileged() && (
<div className="grpBtn">
<FabButton className="main-action-btn" onClick={addChild}>
{t('app.public.children_dashboard.add_child')}
</FabButton>
</div>
)}
</header>
<div className="children-list">
{children.map(child => (
<ChildItem key={child.id} child={child} size='lg' onEdit={editChild} onDelete={handleDeleteChildSuccess} onError={onError} />
))}
</div>
<ChildModal child={child} isOpen={isOpenChildModal} toggleModal={() => setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} />
</section>
);
};
const ChildrenDashboardWrapper: React.FC<ChildrenDashboardProps> = (props) => {
return (
<Loader>
<ChildrenDashboard {...props} />
</Loader>
);
};
Application.Components.component('childrenDashboard', react2angular(ChildrenDashboardWrapper, ['user', 'operator', 'adminPanel', 'onSuccess', 'onError']));

View File

@ -0,0 +1,37 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import { Child } from '../../models/child';
interface DeleteChildModalProps {
isOpen: boolean,
toggleModal: () => void,
child: Child,
onDelete: (child: Child) => void,
}
/**
* Modal dialog to remove a requested child
*/
export const DeleteChildModal: React.FC<DeleteChildModalProps> = ({ isOpen, toggleModal, onDelete, child }) => {
const { t } = useTranslation('public');
/**
* Callback triggered when the child confirms the deletion
*/
const handleDeleteChild = () => {
onDelete(child);
};
return (
<FabModal title={t('app.public.delete_child_modal.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.public.delete_child_modal.confirm')}
onConfirm={handleDeleteChild}
className="delete-child-modal">
<p>{t('app.public.delete_child_modal.confirm_delete_child')}</p>
</FabModal>
);
};

View File

@ -19,12 +19,13 @@ type FormFileUploadProps<TFieldValues> = FormComponent<TFieldValues> & AbstractF
accept?: string,
onFileChange?: (value: FileType) => void,
onFileRemove?: () => void,
showRemoveButton?: boolean,
}
/**
* This component allows to upload file, in forms managed by react-hook-form.
*/
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, showRemoveButton = true }: FormFileUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<FileType>(defaultFile);
@ -74,9 +75,10 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, re
return (
<div className={`form-file-upload ${label ? 'with-label' : ''} ${classNames}`}>
{hasFile() && (
<span>{file.attachment_name}</span>
)}
{hasFile()
? <span>{file.attachment_name}</span>
: <span className='placeholder'>{t('app.shared.form_file_upload.placeholder')}</span>
}
<div className="actions">
{file?.id && file?.attachment_url && (
<a href={file.attachment_url}
@ -100,7 +102,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, re
id={`${id}[attachment_files]`}
onChange={onFileSelected}
placeholder={placeholder()}/>
{hasFile() &&
{showRemoveButton && hasFile() &&
<FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />
}
</div>

View File

@ -22,13 +22,15 @@ type FormInputProps<TFieldValues, TInputType> = FormComponent<TFieldValues> & Ab
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
nullable?: boolean,
ariaLabel?: string,
maxLength?: number
maxLength?: number,
max?: number | string,
min?: number | string,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel, maxLength }: FormInputProps<TFieldValues, TInputType>) => {
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel, maxLength, max, min }: FormInputProps<TFieldValues, TInputType>) => {
const [characterCount, setCharacterCount] = useState<number>(0);
/**
@ -100,7 +102,9 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
placeholder={placeholder}
accept={accept}
maxLength={maxLength} />
maxLength={maxLength}
max={max}
min={min}/>
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
{maxLength && <span className='countdown'>{characterCount} / {maxLength}</span>}
{addOn && addOnAction && <button aria-label={addOnAriaLabel} type="button" onClick={addOnAction} className={`addon ${addOnClassName || ''} is-btn`}>{addOn}</button>}

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import { ReserveButton } from './reserve-button';
import { User } from '../../models/user';
import { FabBadge } from '../base/fab-badge';
interface MachineCardProps {
user?: User,
@ -57,6 +58,7 @@ const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine,
return (
<div className={`machine-card ${loading ? 'loading' : ''} ${machine.disabled ? 'disabled' : ''} ${!machine.reservable ? 'unreservable' : ''}`}>
{machinePicture()}
{machine.space && user.role === 'admin' && <FabBadge icon='pin-map' iconWidth='3rem' /> }
<div className="machine-name">
{machine.name}
</div>

View File

@ -140,7 +140,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
try {
const { result } = await PayZenKR.current.validateForm();
if (result === null) {
if (!order) {
if (!order && !updateCard) {
await PayzenAPI.checkCart(cart, customer);
}
await PayZenKR.current.onSubmit(onPaid);

View File

@ -3,7 +3,7 @@ import { FormState, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { User } from '../../models/user';
import { SocialNetwork } from '../../models/social-network';
import Icons from '../../../../images/social-icons.svg';
import Icons from '../../../../images/icons.svg';
import { FormInput } from '../form/form-input';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';

View File

@ -8,7 +8,7 @@ import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { SettingName } from '../../models/setting';
import Icons from '../../../../images/social-icons.svg';
import Icons from '../../../../images/icons.svg';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';

View File

@ -14,9 +14,13 @@ import { FormSwitch } from '../form/form-switch';
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
import { FabButton } from '../base/fab-button';
import { Space } from '../../models/space';
import { Machine } from '../../models/machine';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
import SettingAPI from '../../api/setting';
import { FabAlert } from '../base/fab-alert';
import MachineAPI from '../../api/machine';
import { FormMultiSelect } from '../form/form-multi-select';
import { SelectOption } from '../../models/select';
declare const Application: IApplication;
@ -41,6 +45,41 @@ export const SpaceForm: React.FC<SpaceFormProps> = ({ action, space, onError, on
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
}, []);
/**
* Asynchronously load the full list of machines to display in the drop-down select field
*/
const loadMachines = (inputValue: string, callback: (options: Array<SelectOption<number>>) => void): void => {
MachineAPI.index().then(data => {
callback(data.map(m => machineToOption(m)));
}).catch(error => onError(error));
};
/**
* Convert a machine to an option usable by react-select
*/
const machineToOption = (machine: Machine): SelectOption<number> => {
return { value: machine.id, label: machine.name };
};
/**
* Asynchronously load the full list of spaces to display in the drop-down select field
*/
const loadSpaces = (inputValue: string, callback: (options: Array<SelectOption<number>>) => void): void => {
SpaceAPI.index().then(data => {
if (space) {
data = data.filter((d) => d.id !== space.id);
}
callback(data.map(m => spaceToOption(m)));
}).catch(error => onError(error));
};
/**
* Convert a space to an option usable by react-select
*/
const spaceToOption = (space: Space): SelectOption<number> => {
return { value: space.id, label: space.name };
};
/**
* Callback triggered when the user validates the machine form: handle create or update
*/
@ -106,6 +145,29 @@ export const SpaceForm: React.FC<SpaceFormProps> = ({ action, space, onError, on
</div>
</section>
<section>
<header>
<p className="title">
{t('app.admin.space_form.associated_objects')}
</p>
<p className="description">
{t('app.admin.space_form.associated_objects_warning')}
</p>
</header>
<div className="content">
<FormMultiSelect control={control}
id="child_ids"
formState={formState}
label={t('app.admin.space_form.children_spaces')}
loadOptions={loadSpaces} />
<FormMultiSelect control={control}
id="machine_ids"
formState={formState}
label={t('app.admin.space_form.associated_machines')}
loadOptions={loadMachines} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.space_form.attachments')}</p>

View File

@ -49,7 +49,7 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
SupportingDocumentTypeAPI.index({ group_id: currentUser.group_id }).then(tData => {
setSupportingDocumentsTypes(tData);
});
SupportingDocumentFileAPI.index({ user_id: currentUser.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: currentUser.id, supportable_type: 'User' }).then(fData => {
setSupportingDocumentsFiles(fData);
});
}, []);
@ -106,7 +106,8 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
for (const proofOfIdentityTypeId of Object.keys(files)) {
const formData = new FormData();
formData.append('supporting_document_file[user_id]', currentUser.id.toString());
formData.append('supporting_document_file[supportable_id]', currentUser.id.toString());
formData.append('supporting_document_file[supportable_type]', 'User');
formData.append('supporting_document_file[supporting_document_type_id]', proofOfIdentityTypeId);
formData.append('supporting_document_file[attachment]', files[proofOfIdentityTypeId]);
const proofOfIdentityFile = getSupportingDocumentsFileByType(parseInt(proofOfIdentityTypeId, 10));
@ -117,7 +118,7 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
}
}
if (Object.keys(files).length > 0) {
SupportingDocumentFileAPI.index({ user_id: currentUser.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: currentUser.id, supportable_type: 'User' }).then(fData => {
setSupportingDocumentsFiles(fData);
setFiles({});
onSuccess(t('app.logged.dashboard.supporting_documents_files.file_successfully_uploaded'));

View File

@ -5,6 +5,7 @@ import { FabModal } from '../base/fab-modal';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { SupportingDocumentRefusal } from '../../models/supporting-document-refusal';
import { User } from '../../models/user';
import { Child } from '../../models/child';
import SupportingDocumentRefusalAPI from '../../api/supporting-document-refusal';
import { SupportingDocumentsRefusalForm } from './supporting-documents-refusal-form';
@ -15,19 +16,21 @@ interface SupportingDocumentsRefusalModalProps {
onError: (message: string) => void,
proofOfIdentityTypes: Array<SupportingDocumentType>,
operator: User,
member: User
supportable: User | Child,
documentType: 'User' | 'Child',
}
/**
* Modal dialog to notify the member that his documents are refused
*/
export const SupportingDocumentsRefusalModal: React.FC<SupportingDocumentsRefusalModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, member, onError }) => {
export const SupportingDocumentsRefusalModal: React.FC<SupportingDocumentsRefusalModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, supportable, onError, documentType }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<SupportingDocumentRefusal>({
id: null,
operator_id: operator.id,
user_id: member.id,
supportable_id: supportable.id,
supportable_type: documentType,
supporting_document_type_ids: [],
message: ''
});

View File

@ -63,13 +63,15 @@ export const SupportingDocumentsTypeForm: React.FC<SupportingDocumentsTypeFormPr
{t('app.admin.settings.account.supporting_documents_type_form.type_form_info')}
</div>
<form name="supportingDocumentTypeForm">
<div className="field">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.account.supporting_documents_type_form.select_group')}
onChange={handleGroupsChange}
options={buildOptions()}
isMulti />
</div>
{supportingDocumentType?.document_type === 'User' &&
<div className="field">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.account.supporting_documents_type_form.select_group')}
onChange={handleGroupsChange}
options={buildOptions()}
isMulti />
</div>
}
<div className="field">
<FabInput id="supporting_document_type_name"
icon={<i className="fa fa-edit" />}

View File

@ -14,18 +14,19 @@ interface SupportingDocumentsTypeModalProps {
onError: (message: string) => void,
groups: Array<Group>,
proofOfIdentityType?: SupportingDocumentType,
documentType: 'User' | 'Child',
}
/**
* Modal dialog to create/edit a supporting documents type
*/
export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModalProps> = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups }) => {
export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModalProps> = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups, documentType }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<SupportingDocumentType>({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
const [data, setData] = useState<SupportingDocumentType>({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '', document_type: documentType });
useEffect(() => {
setData({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
setData({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '', document_type: documentType });
}, [proofOfIdentityType]);
/**
@ -63,7 +64,7 @@ export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModal
* Check if the form is valid (not empty)
*/
const isPreventedSaveType = (): boolean => {
return !data.name || data.group_ids.length === 0;
return !data.name || (documentType === 'User' && data.group_ids.length === 0);
};
return (

View File

@ -15,18 +15,20 @@ import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
import { FabPanel } from '../base/fab-panel';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
import { PencilSimple, Trash } from 'phosphor-react';
declare const Application: IApplication;
interface SupportingDocumentsTypesListProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
documentType: 'User' | 'Child',
}
/**
* This component shows a list of all types of supporting documents (e.g. student ID, Kbis extract, etc.)
*/
const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps> = ({ onSuccess, onError }) => {
const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps> = ({ onSuccess, onError, documentType }) => {
const { t } = useTranslation('admin');
// list of displayed supporting documents type
@ -48,7 +50,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
useEffect(() => {
GroupAPI.index({ disabled: false }).then(data => {
setGroups(data);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(pData);
});
});
@ -91,7 +93,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
*/
const onSaveTypeSuccess = (message: string): void => {
setModalIsOpen(false);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(orderTypes(pData, supportingDocumentsTypeOrder));
onSuccess(message);
}).catch((error) => {
@ -121,7 +123,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
*/
const onDestroySuccess = (message: string): void => {
setDestroyModalIsOpen(false);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(pData);
setSupportingDocumentsTypes(orderTypes(pData, supportingDocumentsTypeOrder));
onSuccess(message);
@ -190,83 +192,138 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
window.location.href = '/#!/admin/members?tabs=1';
};
return (
<FabPanel className="supporting-documents-types-list" header={<div>
<span>{t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')}</span>
</div>}>
<div className="types-list">
<div className="groups">
<p>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}</p>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_groups_info" />
<FabButton onClick={addGroup}>{t('app.admin.settings.account.supporting_documents_types_list.create_groups')}</FabButton>
</FabAlert>
if (documentType === 'User') {
return (
<FabPanel className="supporting-documents-types-list" header={<div>
<span>{t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')}</span>
</div>}>
<div className="types-list">
<div className="groups">
<p>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}</p>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_groups_info" />
<FabButton onClick={addGroup}>{t('app.admin.settings.account.supporting_documents_types_list.create_groups')}</FabButton>
</FabAlert>
</div>
<div className="title">
<h3>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}</h3>
<FabButton onClick={addType}>{t('app.admin.settings.account.supporting_documents_types_list.add_type')}</FabButton>
</div>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
documentType={documentType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteSupportingDocumentsTypeModal isOpen={destroyModalIsOpen}
proofOfIdentityTypeId={supportingDocumentsTypeId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table>
<thead>
<tr>
<th className="group-name">
<a onClick={setTypeOrder('group_name')}>
{t('app.admin.settings.account.supporting_documents_types_list.group_name')}
<i className={orderClassName('group_name')} />
</a>
</th>
<th className="name">
<a onClick={setTypeOrder('name')}>
{t('app.admin.settings.account.supporting_documents_types_list.name')}
<i className={orderClassName('name')} />
</a>
</th>
<th className="actions"></th>
</tr>
</thead>
<tbody>
{supportingDocumentsTypes.map(poit => {
return (
<tr key={poit.id}>
<td>{getGroupsNames(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<div className="edit-destroy-buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<Trash size={20} weight="fill" />
</FabButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</div>
</FabPanel>
);
} else if (documentType === 'Child') {
return (
<div className="supporting-documents-types-list">
<div className="types-list">
<div className="title">
<h3>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}</h3>
<FabButton onClick={addType}>{t('app.admin.settings.account.supporting_documents_types_list.add_type')}</FabButton>
</div>
<div className="title">
<h3>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}</h3>
<FabButton onClick={addType}>{t('app.admin.settings.account.supporting_documents_types_list.add_type')}</FabButton>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
documentType={documentType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteSupportingDocumentsTypeModal isOpen={destroyModalIsOpen}
proofOfIdentityTypeId={supportingDocumentsTypeId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<div className="document-list">
{supportingDocumentsTypes.map(poit => {
return (
<div key={poit.id} className="document-list-item">
<div className='file'>
<p>{poit.name}</p>
<div className="edit-destroy-buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<Trash size={20} weight="fill" />
</FabButton>
</div>
</div>
</div>
);
})}
</div>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</div>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteSupportingDocumentsTypeModal isOpen={destroyModalIsOpen}
proofOfIdentityTypeId={supportingDocumentsTypeId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table>
<thead>
<tr>
<th className="group-name">
<a onClick={setTypeOrder('group_name')}>
{t('app.admin.settings.account.supporting_documents_types_list.group_name')}
<i className={orderClassName('group_name')} />
</a>
</th>
<th className="name">
<a onClick={setTypeOrder('name')}>
{t('app.admin.settings.account.supporting_documents_types_list.name')}
<i className={orderClassName('name')} />
</a>
</th>
<th className="actions"></th>
</tr>
</thead>
<tbody>
{supportingDocumentsTypes.map(poit => {
return (
<tr key={poit.id}>
<td>{getGroupsNames(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<div className="buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<i className="fa fa-edit" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<i className="fa fa-trash" />
</FabButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</div>
</FabPanel>
);
);
} else {
return null;
}
};
const SupportingDocumentsTypesListWrapper: React.FC<SupportingDocumentsTypesListProps> = (props) => {
@ -277,4 +334,4 @@ const SupportingDocumentsTypesListWrapper: React.FC<SupportingDocumentsTypesList
);
};
Application.Components.component('supportingDocumentsTypesList', react2angular(SupportingDocumentsTypesListWrapper, ['onSuccess', 'onError']));
Application.Components.component('supportingDocumentsTypesList', react2angular(SupportingDocumentsTypesListWrapper, ['onSuccess', 'onError', 'documentType']));

View File

@ -19,6 +19,7 @@ declare const Application: IApplication;
interface SupportingDocumentsValidationProps {
operator: User,
member: User
documentType: 'User' | 'Child',
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
@ -26,7 +27,7 @@ interface SupportingDocumentsValidationProps {
/**
* This component shows a list of supporting documents file of member, admin can download and valid
**/
const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps> = ({ operator, member, onSuccess, onError }) => {
const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps> = ({ operator, member, onSuccess, onError, documentType }) => {
const { t } = useTranslation('admin');
// list of supporting documents type
@ -39,7 +40,7 @@ const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps
SupportingDocumentTypeAPI.index({ group_id: member.group_id }).then(tData => {
setDocumentsTypes(tData);
});
SupportingDocumentFileAPI.index({ user_id: member.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: member.id, supportable_type: 'User' }).then(fData => {
setDocumentsFiles(fData);
});
}, []);
@ -112,7 +113,8 @@ const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps
proofOfIdentityTypes={documentsTypes}
toggleModal={toggleModal}
operator={operator}
member={member}
supportable={member}
documentType={documentType}
onError={onError}
onSuccess={onSaveRefusalSuccess}/>
</FabPanel>
@ -131,4 +133,4 @@ const SupportingDocumentsValidationWrapper: React.FC<SupportingDocumentsValidati
export { SupportingDocumentsValidationWrapper as SupportingDocumentsValidation };
Application.Components.component('supportingDocumentsValidation', react2angular(SupportingDocumentsValidationWrapper, ['operator', 'member', 'onSuccess', 'onError']));
Application.Components.component('supportingDocumentsValidation', react2angular(SupportingDocumentsValidationWrapper, ['operator', 'member', 'onSuccess', 'onError', 'documentType']));

View File

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Member } from '../../models/member';
import { Child } from '../../models/child';
import { FabButton } from '../base/fab-button';
import { CaretDown, User, Users, PencilSimple, Trash } from 'phosphor-react';
import { ChildItem } from '../family-account/child-item';
interface MembersListItemProps {
member: Member,
onError: (message: string) => void,
onSuccess: (message: string) => void
onEditChild: (child: Child) => void;
onDeleteChild: (child: Child, error: string) => void;
onDeleteMember: (memberId: number) => void;
}
/**
* Members list
*/
export const MembersListItem: React.FC<MembersListItemProps> = ({ member, onError, onEditChild, onDeleteChild, onDeleteMember }) => {
const { t } = useTranslation('admin');
const [childrenList, setChildrenList] = useState(false);
/**
* Redirect to the given user edition page
*/
const toMemberEdit = (memberId: number): void => {
window.location.href = `/#!/admin/members/${memberId}/edit`;
};
/**
* member and all his children are validated
*/
const memberIsValidated = (): boolean => {
return member.validated_at && member.children.every((child) => child.validated_at);
};
return (
<div key={member.id} className={`members-list-item ${memberIsValidated() ? 'is-validated' : ''} ${member.need_completion ? 'is-incomplet' : ''}`}>
<div className="left-col">
<div className='status'>
{(member.children.length > 0)
? <Users size={24} weight="bold" />
: <User size={24} weight="bold" />
}
</div>
{(member.children.length > 0) &&
<FabButton onClick={() => setChildrenList(!childrenList)} className={`toggle ${childrenList ? 'open' : ''}`}>
<CaretDown size={24} weight="bold" />
</FabButton>
}
</div>
<div className="member">
<div className="member-infos">
<div>
<span>{t('app.admin.members_list_item.surname')}</span>
<p>{member.profile.last_name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.first_name')}</span>
<p>{member.profile.first_name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.phone')}</span>
<p>{member.profile.phone || '---'}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.email')}</span>
<p>{member.email}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.group')}</span>
<p>{member.group.name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.subscription')}</span>
<p>{member.subscribed_plan?.name || '---'}</p>
</div>
</div>
<div className="member-actions edit-destroy-buttons">
<FabButton onClick={() => toMemberEdit(member.id)} className="edit-btn">
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton onClick={() => onDeleteMember(member.id)} className="delete-btn">
<Trash size={20} weight="fill" />
</FabButton>
</div>
</div>
{ (member.children.length > 0) &&
<div className={`member-children ${childrenList ? 'open' : ''}`}>
<hr />
{member.children.map((child: Child) => (
<ChildItem key={child.id} child={child} size='sm' onEdit={onEditChild} onDelete={onDeleteChild} onError={onError} />
))}
</div>
}
</div>
);
};

View File

@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { Member } from '../../models/member';
import { MembersListItem } from './members-list-item';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
import { Child } from '../../models/child';
import { ChildModal } from '../family-account/child-modal';
import { User } from '../../models/user';
declare const Application: IApplication;
interface MembersListProps {
members: Member[],
operator: User,
onError: (message: string) => void,
onSuccess: (message: string) => void
onDeleteMember: (memberId: number) => void;
onDeletedChild: (memberId: number, childId: number) => void;
onUpdatedChild: (memberId: number, child: Child) => void;
}
/**
* Members list
*/
export const MembersList: React.FC<MembersListProps> = ({ members, onError, onSuccess, operator, onDeleteMember, onDeletedChild, onUpdatedChild }) => {
const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState<Array<SupportingDocumentType>>([]);
const [child, setChild] = useState<Child>();
const [isOpenChildModal, setIsOpenChildModal] = useState<boolean>(false);
useEffect(() => {
SupportingDocumentTypeAPI.index({ document_type: 'Child' }).then(tData => {
setSupportingDocumentsTypes(tData);
});
}, []);
/**
* Open the edit child modal
*/
const editChild = (child: Child) => {
setIsOpenChildModal(true);
setChild({
...child,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
const file = child.supporting_document_files_attributes.find(f => f.supporting_document_type_id === t.id);
return file || { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Delete a child
*/
const handleDeleteChildSuccess = (c: Child, msg: string) => {
onDeletedChild(c.user_id, c.id);
onSuccess(msg);
};
/**
* Handle save child success from the API
*/
const handleSaveChildSuccess = (c: Child, msg: string) => {
onUpdatedChild(c.user_id, c);
if (msg) {
onSuccess(msg);
}
};
return (
<div className="members-list">
{members.map(member => (
<MembersListItem key={member.id} member={member} onError={onError} onSuccess={onSuccess} onDeleteMember={onDeleteMember} onEditChild={editChild} onDeleteChild={handleDeleteChildSuccess} />
))}
<ChildModal child={child} isOpen={isOpenChildModal} toggleModal={() => setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} />
</div>
);
};
const MembersListWrapper: React.FC<MembersListProps> = (props) => {
return (
<Loader>
<MembersList {...props} />
</Loader>
);
};
Application.Components.component('membersList', react2angular(MembersListWrapper, ['members', 'onError', 'onSuccess', 'operator', 'onDeleteMember', 'onDeletedChild', 'onUpdatedChild']));

View File

@ -436,7 +436,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
/**
* Controller used in the reservations listing page for a specific event
*/
Application.Controllers.controller('ShowEventReservationsController', ['$scope', 'eventPromise', 'reservationsPromise', function ($scope, eventPromise, reservationsPromise) {
Application.Controllers.controller('ShowEventReservationsController', ['$scope', 'eventPromise', 'reservationsPromise', 'dialogs', 'SlotsReservation', 'growl', '_t', 'Price', 'Wallet', '$uibModal', 'Event', function ($scope, eventPromise, reservationsPromise, dialogs, SlotsReservation, growl, _t, Price, Wallet, $uibModal, Event) {
// retrieve the event from the ID provided in the current URL
$scope.event = eventPromise;
@ -451,6 +451,219 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope',
$scope.isCancelled = function (reservation) {
return !!(reservation.slots_reservations_attributes[0].canceled_at);
};
/**
* Test if the provided reservation has been validated
* @param reservation {Reservation}
* @returns {boolean}
*/
$scope.isValidated = function (reservation) {
return reservation.slots_reservations_attributes[0].is_valid === true || reservation.slots_reservations_attributes[0].is_valid === 'true';
};
/**
* Test if the provided reservation has been invalidated
* @param reservation {Reservation}
* @returns {boolean}
*/
$scope.isInvalidated = function (reservation) {
return reservation.slots_reservations_attributes[0].is_valid === false || reservation.slots_reservations_attributes[0].is_valid === 'false';
};
/**
* Get the price of a reservation
* @param reservation {Reservation}
*/
$scope.reservationAmount = function (reservation) {
let amount = 0;
for (const user of reservation.booking_users_attributes) {
if (user.event_price_category_id) {
const price_category = _.find($scope.event.event_price_categories_attributes, { id: user.event_price_category_id });
if (price_category) {
amount += price_category.amount;
}
} else {
amount += $scope.event.amount;
}
}
return amount;
};
/**
* Callback to validate a reservation
* @param reservation {Reservation}
*/
$scope.validateReservation = function (reservation) {
SlotsReservation.validate({
id: reservation.slots_reservations_attributes[0].id
}, () => { // successfully validated
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_validated'));
const index = $scope.reservations.indexOf(reservation);
$scope.reservations[index].slots_reservations_attributes[0].is_valid = true;
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
});
}, () => {
growl.warning(_t('app.admin.event_reservations.validation_failed'));
});
};
/**
* Callback to invalidate a reservation
* @param reservation {Reservation}
*/
$scope.invalidateReservation = function (reservation) {
SlotsReservation.invalidate({
id: reservation.slots_reservations_attributes[0].id
}, () => { // successfully validated
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_invalidated'));
const index = $scope.reservations.indexOf(reservation);
$scope.reservations[index].slots_reservations_attributes[0].is_valid = false;
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
});
}, () => {
growl.warning(_t('app.admin.event_reservations.invalidation_failed'));
});
};
const mkCartItems = function (reservation, coupon) {
return {
customer_id: reservation.user_id,
items: [{
reservation: {
...reservation,
slots_reservations_attributes: reservation.slots_reservations_attributes.map(sr => ({ slot_id: sr.slot_id })),
tickets_attributes: reservation.tickets_attributes.map(t => ({ booked: t.booked, event_price_category_id: t.event_price_category.id })),
booking_users_attributes: reservation.booking_users_attributes.map(bu => (
{ name: bu.name, event_price_category_id: bu.event_price_category_id, booked_id: bu.booked_id, booked_type: bu.booked_type }
))
}
}],
coupon_code: ((coupon ? coupon.code : undefined)),
payment_method: ''
};
};
$scope.payReservation = function (reservation) {
const modalInstance = $uibModal.open({
templateUrl: '/admin/events/pay_reservation_modal.html',
size: 'sm',
resolve: {
event () {
return $scope.event;
},
reservation () {
return reservation;
},
price () {
return Price.compute(mkCartItems(reservation)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
cartItems () {
return mkCartItems(reservation);
}
},
controller: ['$scope', '$uibModalInstance', 'reservation', 'price', 'wallet', 'cartItems', 'helpers', '$filter', '_t', 'Reservation', 'event',
function ($scope, $uibModalInstance, reservation, price, wallet, cartItems, helpers, $filter, _t, Reservation, event) {
$scope.event = event;
// User's wallet amount
$scope.wallet = wallet;
// Price
$scope.price = price;
// Cart items
$scope.cartItems = cartItems;
// price to pay
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation
$scope.reservation = reservation;
$scope.coupon = { applied: null };
$scope.offered = false;
$scope.payment = false;
// Button label
$scope.setValidButtonName = function () {
if ($scope.amount > 0 && !$scope.offered) {
$scope.validButtonName = _t('app.admin.event_reservations.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
} else {
$scope.validButtonName = _t('app.shared.buttons.confirm');
}
};
/**
* Compute the total amount for the current reservation according to the previously set parameters
*/
$scope.computeEventAmount = function () {
Price.compute(mkCartItems(reservation, $scope.coupon.applied), function (res) {
$scope.price = res;
$scope.amount = helpers.getAmountToPay($scope.price.price, wallet.amount);
$scope.setValidButtonName();
});
};
// Callback to validate the payment
$scope.ok = function () {
$scope.attempting = true;
return Reservation.confirm_payment({
id: reservation.id,
coupon_code: $scope.coupon.applied ? $scope.coupon.applied.code : null,
offered: $scope.offered
}, function (res) {
$uibModalInstance.close(res);
return $scope.attempting = true;
}
, function (response) {
$scope.alerts = [];
angular.forEach(response, function (v, k) {
angular.forEach(v, function (err) {
$scope.alerts.push({
msg: k + ': ' + err,
type: 'danger'
});
});
});
return $scope.attempting = false;
});
};
// Callback to cancel the payment
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
$scope.$watch('coupon.applied', function (newValue, oldValue) {
if ((newValue !== null) || (oldValue !== null)) {
return $scope.computeEventAmount();
}
});
$scope.setValidButtonName();
}]
});
modalInstance.result.then(function (reservation) {
$scope.reservations = $scope.reservations.map((r) => {
if (r.id === reservation.id) {
return reservation;
}
if ($scope.reservationAmount(reservation) === 0) {
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_present'));
} else {
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_paid'));
}
return r;
});
}, function () {
console.log('Pay reservation modal dismissed at: ' + new Date());
});
};
}]);
/**

View File

@ -293,7 +293,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
Member.delete(
{ id: memberId },
function () {
$scope.members.splice(findItemIdxById($scope.members, memberId), 1);
$scope.members = _.filter($scope.members, function (m) { return m.id !== memberId; });
return growl.success(_t('app.admin.members.member_successfully_deleted'));
},
function (error) {
@ -305,6 +305,31 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
);
};
$scope.onDeletedChild = function (memberId, childId) {
$scope.members = $scope.members.map(function (member) {
if (member.id === memberId) {
member.children = _.filter(member.children, function (c) { return c.id !== childId; });
return member;
}
return member;
});
};
$scope.onUpdatedChild = function (memberId, child) {
$scope.members = $scope.members.map(function (member) {
if (member.id === memberId) {
member.children = member.children.map(function (c) {
if (c.id === child.id) {
return child;
}
return c;
});
return member;
}
return member;
});
};
/**
* Ask for confirmation then delete the specified administrator
* @param admins {Array} full list of administrators
@ -590,6 +615,20 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
}
};
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* PRIVATE SCOPE */
/**
@ -1027,6 +1066,7 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
// Default member's profile parameters
$scope.user = {
plan_interval: '',
is_allow_contact: false,
invoicing_profile_attributes: {},
statistic_profile_attributes: {}
};

View File

@ -0,0 +1,23 @@
'use strict';
Application.Controllers.controller('ChildrenController', ['$scope', 'memberPromise', 'growl',
function ($scope, memberPromise, growl) {
// Current user's profile
$scope.user = memberPromise;
/**
* Callback used to display a error message
*/
$scope.onError = function (message) {
console.error(message);
growl.error(message);
};
/**
* Callback used to display a success message
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
}
]);

View File

@ -136,8 +136,8 @@ Application.Controllers.controller('EventsController', ['$scope', '$state', 'Eve
}
]);
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'SlotsReservation', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, SlotsReservation, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment) {
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'SlotsReservation', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment', 'Child',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, SlotsReservation, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment, Child) {
/* PUBLIC SCOPE */
// reservations for the currently shown event
@ -150,6 +150,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.ctrl =
{ member: {} };
// children for the member
$scope.children = [];
// parameters for a new reservation
$scope.reserve = {
nbPlaces: {
@ -160,7 +163,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
toReserve: false,
amountTotal: 0,
totalNoCoupon: 0,
totalSeats: 0
totalSeats: 0,
bookingUsers: {
normal: []
},
};
// Discount coupon to apply to the basket, if any
@ -195,6 +201,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// Global config: is the user validation required ?
$scope.enableUserValidationRequired = settingsPromise.user_validation_required === 'true';
// Global config: is the child validation required ?
$scope.enableChildValidationRequired = settingsPromise.child_validation_required === 'true';
// online payments (by card)
$scope.onlinePayment = {
showModal: false,
@ -226,9 +235,23 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Callback to call when the number of tickets to book changes in the current booking
*/
$scope.changeNbPlaces = function () {
$scope.changeNbPlaces = function (priceType) {
let reservedPlaces = 0;
if ($scope.event.event_type === 'family') {
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
reservedPlaces = reservations.reduce((sum, reservation) => {
return sum + reservation.booking_users_attributes.length;
}, 0);
}
let nb_free_places = $scope.event.nb_free_places;
if ($scope.event.event_type === 'family') {
const maxPlaces = $scope.children.length + 1 - reservedPlaces;
nb_free_places = Math.min(maxPlaces, $scope.event.nb_free_places);
}
// compute the total remaining places
let remain = $scope.event.nb_free_places - $scope.reserve.nbReservePlaces;
let remain = nb_free_places - $scope.reserve.nbReservePlaces;
for (let ticket in $scope.reserve.tickets) {
remain -= $scope.reserve.tickets[ticket];
}
@ -247,17 +270,41 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}
if ($scope.event.event_type === 'nominative' || $scope.event.event_type === 'family') {
const nbBookingUsers = $scope.reserve.bookingUsers[priceType].length;
const nbReservePlaces = priceType === 'normal' ? $scope.reserve.nbReservePlaces : $scope.reserve.tickets[priceType];
if (nbReservePlaces > nbBookingUsers) {
_.times(nbReservePlaces - nbBookingUsers, () => {
$scope.reserve.bookingUsers[priceType].push({ event_price_category_id: priceType === 'normal' ? null : priceType, bookedUsers: buildBookedUsersOptions() });
});
} else {
_.times(nbBookingUsers - nbReservePlaces, () => {
$scope.reserve.bookingUsers[priceType].pop();
});
}
}
// recompute the total price
return $scope.computeEventAmount();
};
$scope.changeBookedUser = function () {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
user.bookedUsers = buildBookedUsersOptions(user.booked);
}
}
}
/**
* Callback to reset the current reservation parameters
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
*/
$scope.cancelReserve = function (e) {
e.preventDefault();
return resetEventReserve();
resetEventReserve();
updateNbReservePlaces();
return;
};
$scope.isUserValidatedByType = () => {
@ -265,10 +312,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
};
$scope.isShowReserveEventButton = () => {
return $scope.event.nb_free_places > 0 &&
const bookable = $scope.event.nb_free_places > 0 &&
!$scope.reserve.toReserve &&
$scope.now.isBefore($scope.eventEndDateTime) &&
helpers.isUserValidatedByType($scope.ctrl.member, $scope.settings, 'event');
if ($scope.event.pre_registration) {
const endDate = $scope.event.pre_registration_end_date || $scope.event.end_date
return bookable && $scope.now.isSameOrBefore(endDate, 'day');
} else {
return bookable;
}
};
/**
@ -321,7 +374,11 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
if ($scope.ctrl.member) {
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id);
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id).then(function () {
getChildren($scope.ctrl.member.id).then(function (children) {
updateNbReservePlaces();
});
});
});
}
};
@ -399,8 +456,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
let index;
growl.success(_t('app.public.events_show.reservation_was_successfully_cancelled'));
index = $scope.reservations.indexOf(reservation);
$scope.event.nb_free_places = $scope.event.nb_free_places + reservation.total_booked_seats;
$scope.reservations[index].slots_reservations_attributes[0].canceled_at = new Date();
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id).then(function () {
updateNbReservePlaces();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
});
resetEventReserve();
});
}, function(error) {
growl.warning(_t('app.public.events_show.cancellation_failed'));
});
@ -583,6 +648,38 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
growl.error(message);
};
/**
* Checks if the reservation of current event is valid
*/
$scope.reservationIsValid = () => {
if ($scope.event.event_type === 'nominative') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (!_.trim(user.name)) {
return false;
}
}
}
}
if ($scope.event.event_type === 'family') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (!user.booked) {
return false;
}
if ($scope.enableChildValidationRequired && user.booked.type === 'Child' && !user.booked.validated_at) {
return false;
}
}
}
}
return true;
}
$scope.isUnder18YearsAgo = (date) => {
return moment(date).isAfter(moment().subtract(18, 'year'));
}
/* PRIVATE SCOPE */
/**
@ -601,7 +698,11 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// get the current user's reservations into $scope.reservations
if ($scope.currentUser) {
getReservations($scope.event.id, 'Event', $scope.currentUser.id);
getReservations($scope.event.id, 'Event', $scope.currentUser.id).then(function () {
getChildren($scope.currentUser.id).then(function (children) {
updateNbReservePlaces();
});
});
}
// watch when a coupon is applied to re-compute the total price
@ -619,13 +720,98 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* @param user_id {number} the user's id (current or managed)
*/
const getReservations = function (reservable_id, reservable_type, user_id) {
Reservation.query({
return Reservation.query({
reservable_id,
reservable_type,
user_id
}).$promise.then(function (reservations) { $scope.reservations = reservations; });
};
/**
* Retrieve the children for the user
* @param user_id {number} the user's id (current or managed)
*/
const getChildren = function (user_id) {
return Child.query({
user_id
}).$promise.then(function (children) {
$scope.children = children;
return $scope.children;
});
};
/**
* Update the number of places reserved by the current user
*/
const hasBookedUser = function (userKey) {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (user.booked && user.booked.key === userKey) {
return true;
}
}
}
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
for (const r of reservations) {
for (const user of r.booking_users_attributes) {
const key = user.booked_type === 'User' ? `user_${user.booked_id}` : `child_${user.booked_id}`;
if (key === userKey) {
return true;
}
}
}
return false;
};
/**
* Build the list of options for the select box of the booked users
* @param booked {object} the booked user
*/
const buildBookedUsersOptions = function (booked) {
const options = [];
const userKey = `user_${$scope.ctrl.member.id}`;
if ((booked && booked.key === userKey) || !hasBookedUser(userKey)) {
options.push({ key: userKey, name: $scope.ctrl.member.name, type: 'User', id: $scope.ctrl.member.id });
}
for (const child of $scope.children) {
const key = `child_${child.id}`;
if ((booked && booked.key === key) || !hasBookedUser(key)) {
options.push({
key,
name: child.first_name + ' ' + child.last_name,
id: child.id,
type: 'Child',
validated_at: child.validated_at,
birthday: child.birthday
});
}
}
return options;
};
/**
* update number of places available for each price category for the family event
*/
const updateNbReservePlaces = function () {
if ($scope.event.event_type === 'family' && $scope.ctrl.member.id) {
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
const reservedPlaces = reservations.reduce((sum, reservation) => {
return sum + reservation.booking_users_attributes.length;
}, 0);
const maxPlaces = $scope.children.length + 1 - reservedPlaces;
if ($scope.event.nb_free_places > maxPlaces) {
$scope.reserve.nbPlaces.normal = __range__(0, maxPlaces, true);
for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) {
$scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, maxPlaces, true);
}
}
}
};
/**
* Create a hash map implementing the Reservation specs
* @param reserve {Object} Reservation parameters (places...)
@ -638,7 +824,8 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
reservable_type: 'Event',
slots_reservations_attributes: [],
nb_reserve_places: reserve.nbReservePlaces,
tickets_attributes: []
tickets_attributes: [],
booking_users_attributes: []
};
reservation.slots_reservations_attributes.push({
@ -656,6 +843,19 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}
if (event.event_type === 'nominative' || event.event_type === 'family') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
reservation.booking_users_attributes.push({
event_price_category_id: user.event_price_category_id,
name: user.booked ? user.booked.name : _.trim(user.name),
booked_id: user.booked ? user.booked.id : undefined,
booked_type: user.booked ? user.booked.type : undefined,
});
}
}
}
return { reservation };
};
@ -688,11 +888,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
tickets: {},
toReserve: false,
amountTotal: 0,
totalSeats: 0
totalSeats: 0,
bookingUsers: {
normal: [],
},
};
for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) {
$scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, $scope.event.nb_free_places, true);
$scope.reserve.bookingUsers[evt_px_cat.id] = [];
$scope.reserve.tickets[evt_px_cat.id] = 0;
}
@ -810,13 +1014,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* @param invoice {Object} the invoice for the booked reservation
*/
const afterPayment = function (invoice) {
Reservation.get({ id: invoice.main_object.id }, function (reservation) {
$scope.event.nb_free_places = $scope.event.nb_free_places - reservation.total_booked_seats;
$scope.reservations.push(reservation);
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id).then(function () {
updateNbReservePlaces();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
});
resetEventReserve();
});
resetEventReserve();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
if ($scope.currentUser.role === 'admin') {
return $scope.ctrl.member = null;
}

View File

@ -35,6 +35,9 @@ export default class ApiLib {
if (file?.is_main) {
data.set(`${name}[${attr}][${i}][is_main]`, file.is_main.toString());
}
if (file?.supporting_document_type_id) {
data.set(`${name}[${attr}][${i}][supporting_document_type_id]`, file.supporting_document_type_id.toString());
}
});
} else {
if (object[attr]?.attachment_files && object[attr]?.attachment_files[0]) {

View File

@ -61,7 +61,8 @@ export interface OpenIdConnectProvider {
client__userinfo_endpoint?: string,
client__jwks_uri?: string,
client__end_session_endpoint?: string,
profile_url?: string
profile_url?: string,
extra_authorize_parameters?: string,
}
export interface MappingFields {

View File

@ -0,0 +1,27 @@
import { TDateISODate, TDateISO } from '../typings/date-iso';
import { ApiFilter } from './api';
export interface ChildIndexFilter extends ApiFilter {
user_id: number,
}
export interface Child {
id?: number,
last_name: string,
first_name: string,
email?: string,
phone?: string,
birthday: TDateISODate,
user_id: number,
validated_at?: TDateISO,
supporting_document_files_attributes?: Array<{
id?: number,
supportable_id?: number,
supportable_type?: 'User' | 'Child',
supporting_document_type_id: number,
attachment?: File,
attachment_name?: string,
attachment_url?: string,
_destroy?: boolean
}>,
}

View File

@ -11,6 +11,7 @@ export interface EventPriceCategoryAttributes {
}
export type RecurrenceOption = 'none' | 'day' | 'week' | 'month' | 'year';
export type EventType = 'standard' | 'nominative' | 'family';
export interface Event {
id?: number,
@ -63,7 +64,10 @@ export interface Event {
}>,
recurrence: RecurrenceOption,
recurrence_end_at: Date,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
event_type: EventType,
pre_registration?: boolean,
pre_registration_end_date?: TDateISODate | Date,
}
export interface EventDecoration {

View File

@ -33,5 +33,8 @@ export interface Machine {
slug: string,
}>,
advanced_accounting_attributes?: AdvancedAccounting,
machine_category_id?: number
machine_category_id?: number,
space: {
name: string
}
}

View File

@ -0,0 +1,49 @@
import { TDateISO } from '../typings/date-iso';
import { Child } from './child';
export interface Member {
maxMembers: number
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
phone: string
}
need_completion?: boolean
group: {
name: string
}
subscribed_plan?: Plan
validated_at: TDateISO
children: Child[]
}
interface Plan {
id: number
base_name: string
name: string
amount: number
interval: string
interval_count: number
training_credit_nb: number
training_credits: [
{
training_id: number
},
{
training_id: number
}
]
machine_credits: [
{
machine_id: number
hours: number
},
{
machine_id: number
hours: number
}
]
}

View File

@ -74,6 +74,7 @@ export const notificationTypeNames = [
'notify_user_is_validated',
'notify_user_is_invalidated',
'notify_user_supporting_document_refusal',
'notify_user_supporting_document_reminder',
'notify_admin_user_supporting_document_refusal',
'notify_user_order_is_ready',
'notify_user_order_is_canceled',

View File

@ -27,10 +27,11 @@ export interface Reservation {
slots_reservations_attributes: Array<SlotsReservation>,
reservable?: {
id: number,
name: string
name: string,
amount?: number
},
nb_reserve_places?: number,
tickets_attributes?: {
tickets_attributes?: Array<{
event_price_category_id: number,
event_price_category?: {
id: number,
@ -40,11 +41,40 @@ export interface Reservation {
name: string
}
},
booked: boolean,
booked: number,
created_at?: TDateISO
},
}>,
tickets?: Array<{
event_price_category_id: number,
event_price_category?: {
id: number,
price_category_id: number,
price_category: {
id: number,
name: string
}
},
booked: number,
created_at?: TDateISO
}>,
total_booked_seats?: number,
created_at?: TDateISO,
booking_users_attributes?: Array<{
id: number,
name: string,
event_price_category_id: number,
booked_id: number,
booked_type: string,
}>,
start_at: TDateISO,
end_at: TDateISO,
event_type?: string,
event_title?: string,
event_pre_registration?: boolean,
canceled_at?: TDateISO,
is_valid?: boolean,
is_paid?: boolean,
amount?: number
}
export interface ReservationIndexFilter extends ApiFilter {

View File

@ -179,7 +179,8 @@ export const accountSettings = [
'external_id',
'user_change_group',
'user_validation_required',
'user_validation_required_list'
'user_validation_required_list',
'family_account'
] as const;
export const analyticsSettings = [

View File

@ -8,6 +8,7 @@ export interface Slot {
end: TDateISO,
is_reserved: boolean,
is_completed: boolean,
is_blocked?: boolean,
backgroundColor: 'white',
availability_id: number,

View File

@ -1,12 +1,14 @@
import { ApiFilter } from './api';
export interface SupportingDocumentFileIndexFilter extends ApiFilter {
user_id: number,
supportable_id: number,
supportable_type?: 'User' | 'Child',
}
export interface SupportingDocumentFile {
id?: number,
attachment?: string,
user_id?: number,
supportable_id?: number,
supportable_type?: 'User' | 'Child',
supporting_document_type_id: number,
}

View File

@ -1,13 +1,15 @@
import { ApiFilter } from './api';
export interface SupportingDocumentRefusalIndexFilter extends ApiFilter {
user_id: number,
supportable_id: number,
supportable_type: 'User' | 'Child',
}
export interface SupportingDocumentRefusal {
id: number,
message: string,
user_id: number,
supportable_id: number,
supportable_type: 'User' | 'Child',
operator_id: number,
supporting_document_type_ids: Array<number>,
}

View File

@ -2,10 +2,12 @@ import { ApiFilter } from './api';
export interface SupportingDocumentTypeIndexfilter extends ApiFilter {
group_id?: number,
document_type?: 'User' | 'Child'
}
export interface SupportingDocumentType {
id: number,
name: string,
group_ids: Array<number>
group_ids: Array<number>,
document_type: 'User' | 'Child'
}

View File

@ -28,9 +28,9 @@ angular.module('application.router', ['ui.router'])
logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }],
sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }],
modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module', 'spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module', 'store_module']" }).$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden', 'family_account']" }).$promise; }]
},
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) {
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'settingsPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, settingsPromise, CSRF) {
// Retrieve Anti-CSRF tokens from cookies
CSRF.setMetaTags();
// Application logo
@ -47,6 +47,9 @@ angular.module('application.router', ['ui.router'])
publicAgenda: (modulesPromise.public_agenda_module === 'true'),
statistics: (modulesPromise.statistics_module === 'true')
};
$rootScope.settings = {
familyAccount: (settingsPromise.family_account === 'true')
};
}]
})
.state('app.public', {
@ -151,6 +154,15 @@ angular.module('application.router', ['ui.router'])
}
}
})
.state('app.logged.dashboard.children', {
url: '/children',
views: {
'main@': {
templateUrl: '/dashboard/children.html',
controller: 'ChildrenController'
}
}
})
.state('app.logged.dashboard.settings', {
url: '/settings',
views: {
@ -624,7 +636,7 @@ angular.module('application.router', ['ui.router'])
resolve: {
eventPromise: ['Event', '$transition$', function (Event, $transition$) { return Event.get({ id: $transition$.params().id }).$promise; }],
priceCategoriesPromise: ['PriceCategory', function (PriceCategory) { return PriceCategory.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list', 'child_validation_required']" }).$promise; }]
}
})

View File

@ -0,0 +1,11 @@
'use strict';
Application.Services.factory('Child', ['$resource', function ($resource) {
return $resource('/api/children/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
}
}
);
}]);

View File

@ -5,6 +5,11 @@ Application.Services.factory('Reservation', ['$resource', function ($resource) {
{ id: '@id' }, {
update: {
method: 'PUT'
},
confirm_payment: {
method: 'POST',
url: '/api/reservations/confirm_payment',
isArray: false
}
}
);

View File

@ -9,6 +9,14 @@ Application.Services.factory('SlotsReservation', ['$resource', function ($resour
cancel: {
method: 'PUT',
url: '/api/slots_reservations/:id/cancel'
},
validate: {
method: 'PUT',
url: '/api/slots_reservations/:id/validate'
},
invalidate: {
method: 'PUT',
url: '/api/slots_reservations/:id/invalidate'
}
}
);

View File

@ -989,6 +989,11 @@ p, .widget p {
cursor: pointer;
}
.list-none {
list-style-type: none;
padding-inline-start: 0;
}
@media screen and (min-width: $screen-lg-min) {
.b-r-lg {
border-right: 1px solid $border-color;

View File

@ -27,6 +27,7 @@
@import "modules/base/edit-destroy-buttons";
@import "modules/base/editorial-block";
@import "modules/base/fab-alert";
@import "modules/base/fab-badge";
@import "modules/base/fab-button";
@import "modules/base/fab-input";
@import "modules/base/fab-modal";
@ -47,11 +48,16 @@
@import "modules/dashboard/reservations/prepaid-packs-panel";
@import "modules/dashboard/reservations/reservations-dashboard";
@import "modules/dashboard/reservations/reservations-panel";
@import "modules/events/event";
@import "modules/events/events";
@import "modules/events/event-form";
@import "modules/events/event-reservation";
@import "modules/events/event";
@import "modules/events/events-dashboard";
@import "modules/events/events-settings";
@import "modules/events/events";
@import "modules/events/update-recurrent-modal";
@import "modules/events/events-settings.scss";
@import "modules/family-account/child-form";
@import "modules/family-account/child-item";
@import "modules/family-account/children-dashboard";
@import "modules/form/abstract-form-item";
@import "modules/form/form-input";
@import "modules/form/form-multi-file-upload";

View File

@ -1,6 +1,9 @@
.edit-destroy-buttons {
width: max-content;
flex-shrink: 0;
border-radius: var(--border-radius-sm);
overflow: hidden;
button {
@include btn;
border-radius: 0;

View File

@ -0,0 +1,15 @@
.fab-badge {
position: absolute;
top: 0;
right: 1.5rem;
padding: 0.8rem;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--secondary);
color: var(--secondary-text-color);
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
z-index: 1;
pointer-events: none;
}

View File

@ -67,12 +67,12 @@
@include colorVariant(var(--information), var(--gray-soft-lightest));
}
&.is-secondary {
@include colorVariant(var(--secondary), var(--gray-hard-darkest));
@include colorVariant(var(--secondary), var(--secondary-text-color));
}
&.is-black {
@include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest));
}
&.is-main {
@include colorVariant(var(--main), var(--gray-soft-lightest));
@include colorVariant(var(--main), var(--main-text-color));
}
}

View File

@ -30,6 +30,7 @@
animation: 0.3s ease-out slideInFromTop;
position: relative;
top: 90px;
max-width: 100vw;
margin: auto;
opacity: 1;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);

View File

@ -0,0 +1,60 @@
.event-reservation {
display: flex;
flex-direction: column;
gap: 1.6rem;
&-item {
padding: 1.6rem 1.6rem 0;
display: flex;
flex-direction: column;
background-color: var(--gray-soft-lightest);
border-radius: var(--border-radius);
label {
margin: 0;
@include text-xs;
color: var(--gray-hard-light);
}
p {
margin: 0;
@include text-base(600);
}
.date { @include text-sm; }
&__event {
padding-bottom: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.6rem;
border-bottom: 1px solid var(--gray-soft-dark);
}
&__reservation {
display: flex;
& > div {
padding: 1.2rem 1.6rem 1.2rem 0;
flex: 1;
}
.list {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
label:not(:first-of-type) {
margin-top: 1rem;
}
}
.name { @include text-sm(500); }
.status {
padding-left: 1.6rem;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--gray-soft-dark);
}
}
}
}

View File

@ -0,0 +1,5 @@
.events-dashboard {
max-width: 1600px;
margin: 0 auto;
padding-bottom: 6rem;
}

View File

@ -0,0 +1,43 @@
.child-form {
.grp {
display: flex;
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item:first-child { margin-right: 2.4rem; }
}
hr { width: 100%; }
.actions {
align-self: flex-end;
}
.document-list {
margin-bottom: 1.6rem;
display: flex;
flex-direction: column;
gap: 1.6rem;
&-item {
display: flex;
flex-direction: column;
gap: 0.8rem;
.type {
@include text-sm;
}
.file,
.missing {
padding: 0.8rem 0.8rem 0.8rem 1.6rem;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
p { margin: 0; }
}
.missing {
background-color: var(--gray-soft-light);
}
}
}
}

View File

@ -0,0 +1,62 @@
.child-item {
width: 100%;
display: grid;
grid-template-columns: min-content 1fr;
align-items: flex-start;
gap: 1.6rem 2.4rem;
background-color: var(--gray-soft-lightest);
&.lg {
padding: 1.6rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
}
&.sm {
.actions button {
height: 3rem !important;
min-height: auto;
}
}
& > div:not(.actions) {
display: flex;
flex-direction: column;
span {
@include text-xs;
color: var(--gray-hard-light);
}
}
p {
margin: 0;
@include text-base(600);
}
&.sm p {
@include text-sm(500);
}
.status {
grid-row: 1/5;
align-self: stretch;
display: flex;
align-items: center;
}
&.is-validated .status svg {
color: var(--success-dark);
}
.actions {
align-self: center;
justify-self: flex-end;
}
@media (min-width: 768px) {
grid-template-columns: min-content repeat(3, 1fr);
.status { grid-row: auto; }
.actions {
grid-column-end: -1;
display: flex;
}
}
@media (min-width: 1024px) {
grid-template-columns: min-content repeat(3, 1fr) max-content;
}
}

View File

@ -0,0 +1,20 @@
.children-dashboard {
max-width: 1600px;
margin: 0 auto;
padding-bottom: 6rem;
@include grid-col(12);
gap: 3.2rem;
align-items: flex-start;
header {
@include header();
padding-bottom: 0;
grid-column: 2 / -2;
}
.children-list {
grid-column: 2 / -2;
display: flex;
flex-direction: column;
gap: 1.6rem;
}
}

View File

@ -13,6 +13,8 @@
margin-bottom: 1.6rem;
}
.placeholder { color: var(--gray-soft-darkest); }
.actions {
margin-left: auto;
display: flex;

View File

@ -1,4 +1,109 @@
.promote-member img {
width: 16px;
height: 21px;
}
.members-list {
width: 100%;
margin: 2.4rem 0;
display: flex;
flex-direction: column;
gap: 2.4rem;
&-item {
width: 100%;
padding: 1.6rem;
display: grid;
grid-template-columns: 48px 1fr;
gap: 0 2.4rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
&.is-validated .left-col .status svg { color: var(--success-dark); }
&.is-incomplet .left-col .status svg { color: var(--alert); }
.left-col {
grid-row: span 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
.status {
display: flex;
align-items: center;
}
.toggle {
height: fit-content;
background-color: var(--gray-soft);
border: none;
svg { transition: transform 0.5s ease-in-out; }
&.open svg { transform: rotate(-180deg); }
}
}
.member {
display: flex;
flex-direction: column;
gap: 2.4rem;
&-infos {
flex: 1;
display: grid;
gap: 1.6rem;
& > div:not(.actions) {
display: flex;
flex-direction: column;
span {
@include text-xs;
color: var(--gray-hard-light);
}
}
p {
margin: 0;
@include text-base(600);
line-height: 1.5;
}
}
&-actions {
align-self: flex-end;
}
}
.member-children {
max-height: 0;
display: flex;
flex-direction: column;
gap: 1.6rem;
overflow-y: hidden;
transition: max-height 0.5s ease-in-out;
&.open {
max-height: 17rem;
overflow-y: auto;
}
hr { margin: 1.6rem 0 0; }
.child-item:last-of-type { padding-bottom: 0; }
}
@media (min-width: 768px) {
.member-infos {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.member {
flex-direction: row;
&-actions {
align-self: center;
}
}
}
@media (min-width: 1220px) {
.member-infos {
grid-template-columns: repeat(3, 1fr);
}
}
}
}

View File

@ -11,6 +11,94 @@
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 3.2rem;
.panel { margin-bottom: 0; }
.panel {
position: relative;
margin-bottom: 0;
}
}
&-relations {
padding: 1.6rem;
display: flex;
flex-direction: column;
align-items: flex-start;
row-gap: 1.6rem;
background-color: var(--gray-soft-light);
border-radius: var(--border-radius);
.space-parent {
@include text-lg(500);
color: var(--gray-hard-light);
margin: 0;
}
.space-current {
display: flex;
align-items: center;
gap: 0.8rem;
&.has-parent::before {
content: "";
display: block;
width: 1rem;
height: 1rem;
border-bottom: 1px solid var(--gray-hard-lightest);
border-left: 1px solid var(--gray-hard-lightest);
}
&-name {
padding: 0.8rem;
display: flex;
align-items: center;
gap: 0.8rem;
@include text-lg(600);
color: var(--main);
background-color: var(--gray-soft-lightest);
border-radius: var(--border-radius-sm);
svg { color: var(--gray-hard-darkest); }
}
}
.related-machines,
.related-spaces {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.8rem;
list-style-type: none;
}
.related-spaces {
position: relative;
padding-inline-start: 2.6rem;
@include text-lg(500);
color: var(--gray-hard-light);
&::before {
position: absolute;
top: 0.5rem;
left: 0.8rem;
content: "";
display: block;
width: 1rem;
height: 1rem;
border-bottom: 1px solid var(--gray-hard-lightest);
border-left: 1px solid var(--gray-hard-lightest);
}
}
.related-machines {
position: relative;
&::before {
position: absolute;
top: 0.4rem;
left: 1.6rem;
content: "";
display: block;
width: 0.6rem;
height: 0.6rem;
border-bottom: 1px solid var(--gray-hard-lightest);
border-left: 1px solid var(--gray-hard-lightest);
}
}
}
}

View File

@ -37,6 +37,7 @@
}
.title {
margin-bottom: 1.6rem;
display: flex;
flex-direction: row;
justify-content: space-between;
@ -64,12 +65,28 @@
width: 20%;
}
}
}
tbody {
.buttons {
.edit-btn {
margin-right: 5px;
}
.document-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min-content, 50rem));
gap: 1.6rem;
&-item {
display: flex;
flex-direction: column;
gap: 0.8rem;
.type {
@include text-sm;
}
.file {
padding: 0.8rem 0.8rem 0.8rem 1.6rem;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
p { margin: 0; }
}
}
}
@ -78,4 +95,4 @@
text-align: center;
}
}
}
}

View File

@ -1,4 +1,4 @@
.user-validation {
.user-validation, .child-validation {
label {
margin-bottom: 0;
vertical-align: middle;
@ -9,3 +9,7 @@
vertical-align: middle;
}
}
.child-validation {
margin: 0 0 2rem;
text-align: center;
}

View File

@ -12,8 +12,9 @@
<tr>
<th style="width:30%" translate>{{ 'app.admin.events.title' }}</th>
<th style="width:30%" translate>{{ 'app.admin.events.dates' }}</th>
<th style="width:10%" translate>{{ 'app.admin.events.booking' }}</th>
<th style="width:30%"></th>
<th style="width:15%" translate>{{ 'app.admin.events.types' }}</th>
<th style="width:10%" translate>{{ 'app.admin.events.booking' }}</th>
<th style="width:15%"></th>
</tr>
</thead>
<tbody>
@ -48,8 +49,16 @@
</span>
</td>
<td>
<span ng-if="event.event_type === 'standard'" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.events.event_type.standard' }}</span>
<span ng-if="event.event_type === 'nominative'" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.events.event_type.nominative' }}</span>
<span ng-if="event.event_type === 'family'" class="v-middle badge text-sm bg-atelier" translate="">{{ 'app.admin.events.event_type.family' }}</span>
<span ng-if="event.pre_registration" class="v-middle badge text-sm bg-info" translate="">{{ 'app.admin.events.pre_registration' }}</span>
</td>
<td style="vertical-align:middle">
<span class="ng-binding" ng-if="event.nb_total_places > 0">{{ event.nb_total_places - event.nb_free_places }} / {{ event.nb_total_places }}</span>
<div class="ng-binding" ng-if="event.pre_registration">{{'app.admin.events.NUMBER_pre_registered' | translate:{NUMBER:event.nb_places_for_pre_registration} }}</div>
<span class="badge font-sbold cancelled" ng-if="event.nb_total_places == -1" translate>{{ 'app.admin.events.cancelled' }}</span>
<span class="badge font-sbold" ng-if="!event.nb_total_places" translate>{{ 'app.admin.events.without_reservation' }}</span>
</td>
@ -57,10 +66,10 @@
<td style="vertical-align:middle">
<div class="buttons">
<a class="btn btn-default" ui-sref="app.admin.event_reservations({id: event.id})">
<i class="fa fa-bookmark"></i> {{ 'app.admin.events.view_reservations' | translate }}
<i class="fa fa-eye"></i>
</a>
<a class="btn btn-default" ui-sref="app.admin.events_edit({id: event.id})">
<i class="fa fa-edit"></i> {{ 'app.shared.buttons.edit' | translate }}
<i class="fa fa-edit"></i>
</a>
</div>
</td>

View File

@ -0,0 +1,52 @@
<div class="modal-header">
<img ng-src="{{logoBlack.custom_asset_file_attributes.attachment_url}}" alt="{{logo.custom_asset_file_attributes.attachment}}" class="modal-logo"/>
<h1 translate ng-show="reservation && amount !== 0">{{ 'app.admin.event_reservations.confirm_payment' }}</h1>
<h1 translate ng-show="reservation && amount === 0">{{ 'app.admin.event_reservations.confirm_present' }}</h1>
</div>
<div class="modal-body">
<div ng-show="amount !== 0">
<div class="row" ng-show="!offered">
<wallet-info current-user="currentUser"
cart="cartItems"
price="price.price"
wallet="wallet"/>
</div>
<div class="row m-b">
<div class="col-md-12">
<label for="offerSlot" class="control-label m-r" translate>{{ 'app.admin.event_reservations.offer_this_reservation' }}</label>
<input bs-switch
ng-model="offered"
id="offerSlot"
type="checkbox"
class="form-control"
switch-on-text="{{ 'app.shared.buttons.yes' | translate }}"
switch-off-text="{{ 'app.shared.buttons.no' | translate }}"
switch-animate="true"
ng-change="computeEventAmount()"/>
</div>
</div>
<coupon show="true" coupon="coupon.applied" total="price.price_without_coupon" user-id="{{reservation.user_id}}"></coupon>
<div class="row">
<div class="form-group col-sm-12">
<div class="checkbox-group">
<input type="checkbox"
name="paymentReceived"
id="paymentReceived"
ng-model="payment" />
<label for="paymentReceived" translate>{{ 'app.admin.event_reservations.i_have_received_the_payment' }}</label>
</div>
</div>
</div>
</div>
<div ng-if="amount === 0">
<p translate>{{ 'app.admin.event_reservations.confirm_present_info' }}</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-info" ng-if="amount !== 0" ng-click="ok()" ng-disabled="attempting || !payment" ng-bind-html="validButtonName"></button>
<button class="btn btn-info" ng-if="amount === 0" ng-click="ok()" ng-disabled="attempting" ng-bind-html="validButtonName"></button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -20,10 +20,13 @@
<table class="table" ng-if="reservations.length > 0">
<thead>
<tr>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.user' }}</th>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.payment_date' }}</th>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.reserved_tickets' }}</th>
<th style="width:25%"></th>
<th translate>{{ 'app.admin.event_reservations.booked_by' }}</th>
<th translate>{{ 'app.admin.event_reservations.reservations' }}</th>
<th translate>{{ 'app.admin.event_reservations.date' }}</th>
<th translate>{{ 'app.admin.event_reservations.reserved_tickets' }}</th>
<th ng-if="event.pre_registration" translate>{{ 'app.admin.event_reservations.status' }}</th>
<th ng-if="event.pre_registration" translate>{{ 'app.admin.event_reservations.validation' }}</th>
<th></th>
</tr>
</thead>
<tbody>
@ -31,16 +34,49 @@
<td class="text-c">
<a ui-sref="app.logged.members_show({id: reservation.user_id})">{{ reservation.user_full_name }} </a>
</td>
<td>
<span ng-if="event.event_type === 'standard'">{{ reservation.user_full_name }} </span>
<div ng-repeat="bu in reservation.booking_users_attributes">
<span>{{bu.name}}</span>
<span ng-if="bu.booked_type === 'Child'" class="m-l-sm">({{ 'app.admin.event_reservations.age' | translate:{NUMBER: bu.age} }})</span>
</div>
</td>
<td>{{ reservation.created_at | amDateFormat:'LL LTS' }}</td>
<td>
<span ng-if="reservation.nb_reserve_places > 0">{{ 'app.admin.event_reservations.full_price_' | translate }} {{reservation.nb_reserve_places}}<br/></span>
<span ng-repeat="ticket in reservation.tickets_attributes">{{ticket.event_price_category.price_category.name}} : {{ticket.booked}}</span>
<div ng-show="isCancelled(reservation)" class="canceled-marker" translate>{{ 'app.admin.event_reservations.canceled' }}</div>
</td>
<td ng-if="event.pre_registration">
<span ng-if="!isValidated(reservation) && !isInvalidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-info" translate="">{{ 'app.admin.event_reservations.event_status.pre_registered' }}</span>
<span ng-if="reservationAmount(reservation) !== 0 && isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.event_reservations.event_status.to_pay' }}</span>
<span ng-if="reservationAmount(reservation) === 0 && isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.event_reservations.event_status.registered' }}</span>
<span ng-if="isInvalidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.event_reservations.event_status.not_validated' }}</span>
<span ng-if="reservationAmount(reservation) !== 0 && reservation.is_paid && !isCancelled(reservation)" class="v-middle badge text-sm bg-success" translate="">{{ 'app.admin.event_reservations.event_status.paid' }}</span>
<span ng-if="reservationAmount(reservation) === 0 && reservation.is_paid && !isCancelled(reservation)" class="v-middle badge text-sm bg-success" translate="">{{ 'app.admin.event_reservations.event_status.present' }}</span>
<span ng-if="isCancelled(reservation)" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.event_reservations.event_status.canceled' }}</span>
</td>
<td ng-if="event.pre_registration">
<div>
<div ng-if="!isCancelled(reservation) && !reservation.is_paid">
<label class="m-r-sm">
<span translate>{{ 'app.admin.event_reservations.negative' }}</span>
<input type="radio" name="invalidate-{{reservation.id}}" ng-value="false" ng-click="invalidateReservation(reservation)" ng-model="reservation.slots_reservations_attributes[0].is_valid" ng-disabled="reservation.total_booked_seats > event.nb_free_places && !reservation.slots_reservations_attributes[0].is_valid">
</label>
<label>
<span translate>{{ 'app.admin.event_reservations.affirmative' }}</span>
<input type="radio" name="validate-{{reservation.id}}" ng-value="true" ng-click="validateReservation(reservation)" ng-model="reservation.slots_reservations_attributes[0].is_valid" ng-disabled="reservation.total_booked_seats > event.nb_free_places && !reservation.slots_reservations_attributes[0].is_valid" >
</label>
</div>
<button class="btn btn-default" ng-click="payReservation(reservation)" ng-if="isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid">
<span ng-if="reservationAmount(reservation) !== 0" translate>{{ 'app.admin.event_reservations.pay' }}</span>
<span ng-if="reservationAmount(reservation) === 0" translate>{{ 'app.admin.event_reservations.present' }}</span>
</button>
</div>
</td>
<td>
<div class="buttons">
<button class="btn btn-default" ui-sref="app.public.events_show({id: event.id})">
<i class="fa fa-tag"></i> {{ 'app.admin.event_reservations.show_the_event' | translate }}
<i class="fa fa-eye"></i>
</button>
</div>
</td>

View File

@ -62,10 +62,19 @@
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.reservations' | translate }}">
<reservations-dashboard user="user" current-user="currentUser" on-error="onError" />
</uib-tab>
<uib-tab heading="{{ 'app.shared.user_admin.children' | translate }}" ng-if="$root.settings.familyAccount">
<children-dashboard user="user" operator="currentUser" admin-panel="true" on-success="onSuccess" on-error="onError" />
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.supporting_documents' | translate }}" ng-show="hasProofOfIdentityTypes">
<supporting-documents-validation
operator="currentUser"
member="user"
document-type="User"
on-error="onError"
on-success="onSuccess" />
</uib-tab>
@ -202,19 +211,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.admin.members_edit.next_events' | translate }}</h4>
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<a class="font-sbold" ui-sref="app.public.events_show({id: r.reservable.id})">{{r.reservable.title}}</a> - <span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
<span ng-if="r.nb_reserve_places > 0">
<br/>
<span translate translate-values="{ NUMBER: r.nb_reserve_places }">{{ 'app.admin.members_edit.NUMBER_full_price_tickets_reserved' }}</span>
</span>
<span ng-repeat="ticket in r.tickets">
<br/>
<span translate translate-values="{ NUMBER: ticket.booked, NAME: ticket.price_category.name }">{{ 'app.admin.members_edit.NUMBER_NAME_tickets_reserved' }}</span>
</span>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'future').length == 0" translate>{{ 'app.admin.members_edit.no_upcoming_events' }}</div>
</div>
</div>
@ -225,11 +226,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.admin.members_edit.passed_events' | translate }}</h4>
</div>
<div class="widget-content bg-light auto wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<span class="font-sbold">{{r.reservable.title}}</span> - <span class="label label-info text-white wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'passed').length == 0" translate>{{ 'app.admin.members_edit.no_passed_events' }}</div>
</div>
</div>

View File

@ -17,11 +17,12 @@
</div>
</div>
</div>
<div class="col-md-12">
<button type="button" class="btn btn-warning m-t m-b" ui-sref="app.admin.members_new" translate>
<button type="button" class="btn btn-warning m-b" ui-sref="app.admin.members_new" translate>
{{ 'app.admin.members.add_a_new_member' }}
</button>
<div class="pull-right exports-buttons" ng-show="isAuthorized('admin')">
<div class="pull-right exports-buttons m-b" ng-show="isAuthorized('admin')">
<a class="btn btn-default" ng-href="api/members/export_members.xlsx" target="export-frame" ng-click="alertExport('members')">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.members' | translate }}
</a>
@ -34,46 +35,10 @@
<iframe name="export-frame" height="0" width="0" class="none"></iframe>
</div>
<table class="table members-list">
<thead>
<tr>
<th style="width:4%" class="hidden-xs" ng-if="enableUserValidationRequired"></th>
<th style="width:8%" ng-show="displayUsername"><a ng-click="setOrderMember('username')">{{ 'app.admin.members.username' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='username', 'fa fa-sort-alpha-desc': member.order=='-username', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('last_name')">{{ 'app.admin.members.surname' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='last_name', 'fa fa-sort-alpha-desc': member.order=='-last_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('first_name')">{{ 'app.admin.members.first_name' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='first_name', 'fa fa-sort-alpha-desc': member.order=='-first_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="hidden-xs"><a ng-click="setOrderMember('email')">{{ 'app.admin.members.email' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='email', 'fa fa-sort-alpha-desc': member.order=='-email', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:8%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('phone')">{{ 'app.admin.members.phone' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-numeric-asc': member.order=='phone', 'fa fa-sort-numeric-desc': member.order=='-phone', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm"><a ng-click="setOrderMember('group')">{{ 'app.admin.members.user_type' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='group', 'fa fa-sort-alpha-desc': member.order=='-group', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('plan')">{{ 'app.admin.members.subscription' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='plan', 'fa fa-sort-alpha-desc': member.order=='-plan', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:12%" class="buttons-col"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="m in members">
<td class="text-center" ng-if="enableUserValidationRequired">
<span ng-class="{ 'text-success': !!m.validated_at }"><i class="fa fa-user-check"></i></span>
</td>
<td class="text-c" ng-show="displayUsername">{{ m.username }}</td>
<td class="text-c">{{ m.profile.last_name }}</td>
<td class="text-c">{{ m.profile.first_name }}</td>
<td class="hidden-xs">{{ m.email }}</td>
<td class="hidden-xs hidden-sm hidden-md">{{ m.profile.phone }}</td>
<td class="text-u-c text-sm hidden-xs hidden-sm">{{ m.group.name }}</td>
<td class="hidden-xs hidden-sm hidden-md">{{ m.subscribed_plan | humanReadablePlanName }}</td>
<td>
<div class="buttons">
<button class="btn btn-default edit-member" ui-sref="app.admin.members_edit({id: m.id})">
<i class="fa fa-edit"></i>
</button>
<button class="btn btn-danger delete-member" ng-click="deleteMember(m.id)" ng-show="isAuthorized('admin')">
<i class="fa fa-trash"></i>
</button>
<span class="label label-danger text-white" ng-show="m.need_completion" translate>{{ 'app.shared.user_admin.incomplete_profile' }}</span>
</div>
</td>
</tr>
</tbody>
</table>
<div>
<members-list members="members" on-success="onSuccess" on-error="onError" operator="currentUser" on-delete-member="deleteMember" on-deleted-child="onDeletedChild" on-updated-child="onUpdatedChild" />
</div>
<div class="text-center">
<button class="btn btn-warning show-more" ng-click="showNextMembers()" ng-hide="member.noMore"><i class="fa fa-search-plus" aria-hidden="true"></i> {{ 'app.admin.members.display_more_users' | translate }}</button>
</div>

View File

@ -51,6 +51,7 @@
<user-validation-setting on-success="onSuccess" on-error="onError" />
</div>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.captcha' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.captcha_info_html' | translate"></p>
@ -73,6 +74,37 @@
</div>
</div>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.family_account' }}</span>
</div>
<div class="panel-body">
<div class="row">
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.family_account_info_html' | translate"></p>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="'family_account'"
settings="allSettings"
label="'app.admin.settings.enable_family_account' | translate"
on-success="onSuccess"
on-error="onError">
</div>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="'child_validation_required'"
settings="allSettings"
label="'app.admin.settings.child_validation_required_label' | translate"
on-success="onSuccess"
on-error="onError">
</div>
</div>
<div class="row">
<div class="col-md-12">
<supporting-documents-types-list on-success="onSuccess" on-error="onError" document-type="'Child'" />
</div>
</div>
</div>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.accounts_management' }}</span>
@ -156,4 +188,4 @@
</div>
<supporting-documents-types-list on-success="onSuccess" on-error="onError"/>
<supporting-documents-types-list on-success="onSuccess" on-error="onError" document-type="'User'" />

View File

@ -288,7 +288,7 @@
<td ng-repeat="field in selectedIndex.additional_fields">
<ng-switch on="field.data_type">
<span ng-switch-when="date">{{formatDate(datum._source[field.key])}}</span>
<ul ng-switch-when="list">
<ul ng-switch-when="list" class="list-none">
<li ng-repeat="elem in uniq(datum._source[field.key])">{{elem.name}}</li>
</ul>
<span ng-switch-default>{{datum._source[field.key]}}</span>

View File

@ -0,0 +1,11 @@
<div>
<section class="heading">
<div class="row no-gutter">
<ng-include src="'/dashboard/nav.html'"></ng-include>
</div>
</section>
<children-dashboard user="currentUser" operator="currentUser" on-success="onSuccess" on-error="onError" />
</div>

View File

@ -8,7 +8,7 @@
</section>
<div class="row no-gutter">
<div class="row events-dashboard">
<div class="col-md-6">
<div class="widget panel b-a m m-t-lg">
@ -16,25 +16,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.logged.dashboard.events.your_next_events' | translate }}</h4>
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<a class="font-sbold" ui-sref="app.public.events_show({id: r.reservable.id})">{{r.reservable.title}}</a>
-
<span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
<br/>
<span translate
translate-values="{NUMBER: r.nb_reserve_places}">
{{ 'app.logged.dashboard.events.NUMBER_normal_places_reserved' }}
</span>
<span ng-repeat="ticket in r.tickets">
<br/>
<span translate
translate-values="{NUMBER: ticket.booked, NAME: ticket.price_category.name}">
{{ 'app.logged.dashboard.events.NUMBER_of_NAME_places_reserved' }}
</span>
</span>
</li>
</ul>
<div class="list-unstyled event-reservation" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'future').length == 0" translate>{{ 'app.logged.dashboard.events.no_events_to_come' }}</div>
</div>
</div>
@ -45,11 +31,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.logged.dashboard.events.your_previous_events' | translate }}</h4>
</div>
<div class="widget-content bg-light auto wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<span class="font-sbold">{{r.reservable.title}}</span> - <span class="label label-info text-white wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'passed').length == 0" translate>{{ 'app.logged.dashboard.events.no_passed_events' }}</div>
</div>
</div>

View File

@ -11,6 +11,7 @@
<h4 class="m-l text-sm" translate>{{ 'app.public.common.dashboard' }}</h4>
<ul class="nav-page nav nav-pills text-u-c text-sm">
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.profile" translate>{{ 'app.public.common.my_profile' }}</a></li>
<li ng-show="$root.settings.familyAccount" ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.children" translate>{{ 'app.public.common.my_children' }}</a></li>
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.settings" translate>{{ 'app.public.common.my_settings' }}</a></li>
<li ng-if="!isAuthorized(['admin', 'manager']) && hasProofOfIdentityTypes" ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.supporting_document_files" translate>{{ 'app.public.common.my_supporting_documents_files' }}</a></li>
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.projects" translate>{{ 'app.public.common.my_projects' }}</a></li>

View File

@ -49,7 +49,7 @@
<div class="col-sm-12 col-md-12 col-lg-4">
<section class="widget panel b-a m" ng-if="event.event_files_attributes">
<section class="widget panel b-a m" ng-if="event.event_files_attributes.length">
<div class="panel-heading b-b">
<span class="badge bg-warning pull-right">{{event.event_files_attributes.length}}</span>
<h3 translate>{{ 'app.public.events_show.downloadable_documents' }}</h3>
@ -72,8 +72,12 @@
</div>
<div class="panel-content wrapper">
<div>
<span ng-if="event.event_type === 'nominative'" class="v-middle badge text-xs bg-event" translate="">{{ 'app.public.events_show.event_type.nominative' }}</span>
<span ng-if="event.event_type === 'family'" class="v-middle badge text-xs bg-event" translate="">{{ 'app.public.events_show.event_type.family' }}</span>
<span ng-if="event.pre_registration" class="v-middle badge text-xs bg-info" translate="">{{ 'app.public.events_show.pre_registration' }}</span>
</div>
<h5>{{event.category.name}}</h5>
<dl class="text-sm">
<dt ng-repeat="theme in event.event_themes">
<i class="fa fa-tags" aria-hidden="true"></i> {{theme.name}}
@ -86,6 +90,8 @@
<dt><i class="fas fa-clock"></i> {{ 'app.public.events_show.opening_hours' | translate }}</dt>
<dd ng-if="event.all_day"><span translate>{{ 'app.public.events_show.all_day' }}</span></dd>
<dd ng-if="!event.all_day">{{ 'app.public.events_show.from_time' | translate }} <span class="text-u-l">{{event.start_time}}</span> {{ 'app.public.events_show.to_time' | translate }} <span class="text-u-l">{{event.end_time}}</span></dd>
<dt ng-if="event.pre_registration_end_date"><i class="fa fa-calendar" aria-hidden="true"></i> {{ 'app.public.events_show.pre_registration_end_date' | translate }}</dt>
<dd ng-if="event.pre_registration_end_date"><span class="text-u-l">{{event.pre_registration_end_date | amDateFormat:'L'}}</span></dd>
</dl>
<div class="text-sm" ng-if="event.amount">
@ -116,19 +122,81 @@
<div class="row">
<label class="col-sm-6 control-label">{{ 'app.public.events_show.full_price_' | translate }} <span class="text-blue">{{event.amount | currency}}</span></label>
<div class="col-sm-6">
<select ng-model="reserve.nbReservePlaces" ng-change="changeNbPlaces()" ng-options="i for i in reserve.nbPlaces.normal">
<select ng-model="reserve.nbReservePlaces" ng-change="changeNbPlaces('normal')" ng-options="i for i in reserve.nbPlaces.normal">
</select> {{ 'app.public.events_show.ticket' | translate:{NUMBER:reserve.nbReservePlaces} }}
</div>
<div class="col-sm-12 m-b" ng-if="event.event_type === 'nominative' && reserve.nbReservePlaces > 0">
<div ng-repeat="user in reserve.bookingUsers.normal">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<input type="text" class="form-control" ng-model="user.name" ng-required="true">
</div>
</div>
<div class="col-sm-12 m-b" ng-if="ctrl.member.id && event.event_type === 'family' && reserve.nbReservePlaces > 0">
<div ng-repeat="user in reserve.bookingUsers.normal">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<select ng-model="user.booked"
ng-options="option.name for option in user.bookedUsers track by option.key"
ng-change="changeBookedUser()"
name="booked"
ng-required="true"
class="form-control">
<option value=""></option>
</select>
<uib-alert type="danger" ng-if="enableChildValidationRequired && user.booked && user.booked.type === 'Child' && !user.booked.validated_at" style="margin-bottom: 0.8rem;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_validation_required_alert' }}</span>
</span>
</uib-alert>
<uib-alert type="danger" ng-if="user.booked && user.booked.type === 'Child' && !isUnder18YearsAgo(user.booked.birthday)" style="margin-bottom: 0.8rem;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_birthday_must_be_under_18_years_ago_alert' }}</span>
</span>
</uib-alert>
</div>
</div>
</div>
<div class="row" ng-repeat="price in event.event_price_categories_attributes">
<label class="col-sm-6 control-label">{{price.category.name}} : <span class="text-blue">{{price.amount | currency}}</span></label>
<div class="col-sm-6">
<select ng-model="reserve.tickets[price.id]" ng-change="changeNbPlaces()" ng-options="i for i in reserve.nbPlaces[price.id]">
<select ng-model="reserve.tickets[price.id]" ng-change="changeNbPlaces(price.id)" ng-options="i for i in reserve.nbPlaces[price.id]">
</select> {{ 'app.public.events_show.ticket' | translate:{NUMBER:reserve.tickets[price.id]} }}
</div>
<div class="col-sm-12 m-b" ng-if="event.event_type === 'nominative' && reserve.tickets[price.id] > 0">
<div ng-repeat="user in reserve.bookingUsers[price.id]">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<input type="text" class="form-control" ng-model="user.name" ng-required="true">
</div>
</div>
<div class="col-sm-12 m-b" ng-if="ctrl.member.id && event.event_type === 'family' && reserve.tickets[price.id] > 0">
<div ng-repeat="user in reserve.bookingUsers[price.id]">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<select ng-model="user.booked"
ng-options="option.name for option in user.bookedUsers track by option.key"
ng-change="changeBookedUser()"
name="booked"
ng-required="true"
class="form-control">
<option value=""></option>
</select>
<uib-alert type="danger" ng-if="enableChildValidationRequired && user.booked && user.booked.type === 'Child' && !user.booked.validated_at">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_validation_required_alert' }}</span>
</p>
</uib-alert>
<uib-alert type="danger" ng-if="user.booked && user.booked.type === 'Child' && !isUnder18YearsAgo(user.booked.birthday)">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_birthday_must_be_under_18_years_ago_alert' }}</span>
</p>
</uib-alert>
</div>
</div>
</div>
<div ng-show="currentUser.role == 'admin'" class="m-t">
<div ng-show="currentUser.role == 'admin' && !event.pre_registration" class="m-t">
<label for="offerSlot" class="control-label m-r" translate>{{ 'app.public.events_show.make_a_gift_of_this_reservation' }}</label>
<input bs-switch
ng-model="event.offered"
@ -150,14 +218,18 @@
<a class="pull-right m-t-xs text-u-l ng-scope" ng-click="cancelReserve($event)" ng-show="reserve.toReserve" translate>{{ 'app.shared.buttons.cancel' }}</a>
</div>
<div ng-if="reserveSuccess" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_payment_has_been_successfully_registered' | translate }}<br>
{{ 'app.public.events_show.you_can_find_your_reservation_s_details_on_your_' | translate }} <a ui-sref="app.logged.dashboard.invoices" translate>{{ 'app.public.events_show.dashboard' }}</a>
<div ng-if="reserveSuccess && !event.pre_registration" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_payment_has_been_successfully_registered' | translate }}<br>
{{ 'app.public.events_show.you_can_find_your_reservation_s_details_on_your_' | translate }} <a ui-sref="app.logged.dashboard.events" translate>{{ 'app.public.events_show.dashboard' }}</a>
</div>
<div ng-if="reserveSuccess && event.pre_registration" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_pre_registration_has_been_successfully_saved' | translate }}<br>
{{ 'app.public.events_show.informed_by_email_your_pre_registration' | translate }}
</div>
<div class="m-t-sm" ng-if="reservations && !reserve.toReserve" ng-repeat="reservation in reservations">
<div ng-hide="isCancelled(reservation)" class="well well-warning">
<div class="font-sbold text-u-c text-sm">{{ 'app.public.events_show.you_booked_DATE' | translate:{DATE:(reservation.created_at | amDateFormat:'L LT')} }}</div>
<div class="font-sbold text-u-c text-sm" ng-if="!event.pre_registration">{{ 'app.public.events_show.you_booked_DATE' | translate:{DATE:(reservation.created_at | amDateFormat:'L LT')} }}</div>
<div class="font-sbold text-u-c text-sm" ng-if="event.pre_registration">{{ 'app.public.events_show.you_pre_booked_DATE' | translate:{DATE:(reservation.created_at | amDateFormat:'L LT')} }}</div>
<div class="font-sbold text-sm" ng-if="reservation.nb_reserve_places > 0">{{ 'app.public.events_show.full_price_' | translate }} {{reservation.nb_reserve_places}} {{ 'app.public.events_show.ticket' | translate:{NUMBER:reservation.nb_reserve_places} }}</div>
<div class="font-sbold text-sm" ng-repeat="ticket in reservation.tickets">
<div class="font-sbold text-sm" ng-repeat="ticket in reservation.tickets_attributes">
{{ticket.event_price_category.price_category.name}} : {{ticket.booked}} {{ 'app.public.events_show.ticket' | translate:{NUMBER:ticket.booked} }}
</div>
<div class="clear" ng-if="event.recurrence_events.length > 0 && reservationCanModify(reservation)">
@ -178,7 +250,10 @@
<span ng-show="reservations.length > 0" translate>{{ 'app.public.events_show.thanks_for_coming' }}</span>
<a ui-sref="app.public.events_list" translate>{{ 'app.public.events_show.view_event_list' }}</a>
</div>
<button class="btn btn-warning-full rounded btn-block text-sm" ng-click="reserveEvent()" ng-show="isShowReserveEventButton()">{{ 'app.public.events_show.book' | translate }}</button>
<button class="btn btn-warning-full rounded btn-block text-sm" ng-click="reserveEvent()" ng-show="isShowReserveEventButton()">
<span ng-if="event.pre_registration">{{ 'app.public.events_show.pre_book' | translate }}</span>
<span ng-if="!event.pre_registration">{{ 'app.public.events_show.book' | translate }}</span>
</button>
<uib-alert type="danger" ng-if="ctrl.member.id && !isUserValidatedByType()">
<p class="text-sm">
<i class="fa fa-warning"></i>
@ -186,15 +261,15 @@
</p>
</uib-alert>
<coupon show="reserve.totalSeats > 0 && ctrl.member" coupon="coupon.applied" total="reserve.totalNoCoupon" user-id="{{ctrl.member.id}}"></coupon>
<coupon show="reserve.totalSeats > 0 && ctrl.member && !event.pre_registration" coupon="coupon.applied" total="reserve.totalNoCoupon" user-id="{{ctrl.member.id}}"></coupon>
</div>
</div>
<div class="panel-footer no-padder ng-scope" ng-if="event.amount">
<div class="panel-footer no-padder ng-scope" ng-if="!event.pre_registration && event.amount && reservationIsValid()">
<button class="btn btn-valid btn-info btn-block p-l btn-lg text-u-c r-b text-base" ng-click="payEvent()" ng-if="reserve.totalSeats > 0">{{ 'app.public.events_show.confirm_and_pay' | translate }} {{reserve.amountTotal | currency}}</button>
</div>
<div class="panel-footer no-padder ng-scope" ng-if="event.amount == 0">
<div class="panel-footer no-padder ng-scope" ng-if="(event.pre_registration || event.amount == 0) && reservationIsValid()">
<button class="btn btn-valid btn-info btn-block p-l btn-lg text-u-c r-b text-base" ng-click="validReserveEvent()" ng-if="reserve.totalSeats > 0" ng-disabled="attempting">{{ 'app.shared.buttons.confirm' | translate }}</button>
</div>

View File

@ -12,8 +12,11 @@
</ui-select-choices>
</ui-select>
{{member}}
<div class="alert alert-danger m-t" style="margin-bottom: 0 !important;" ng-if="enableUserValidationRequired && ctrl.member.id && !ctrl.member.validated_at">
<span translate>{{ 'app.shared.member_select.member_not_validated' }}</span>
</div>
<uib-alert type="danger" ng-if="enableUserValidationRequired && ctrl.member.id && !ctrl.member.validated_at" style="margin-bottom: 0;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.member_select.member_not_validated' }}</span>
</span>
</uib-alert>
</div>
</div>

View File

@ -40,6 +40,7 @@
</a>
<ul uib-dropdown-menu class="animated fadeInRight">
<li><a ui-sref="app.logged.dashboard.profile" translate>{{ 'app.public.common.my_profile' }}</a></li>
<li ng-if="$root.settings.familyAccount"><a ui-sref="app.logged.dashboard.children" translate>{{ 'app.public.common.my_children' }}</a></li>
<li><a ui-sref="app.logged.dashboard.settings" translate>{{ 'app.public.common.my_settings' }}</a></li>
<li ng-if="!isAuthorized(['admin', 'manager']) && hasProofOfIdentityTypes"><a ui-sref="app.logged.dashboard.supporting_document_files" translate>{{ 'app.public.common.my_supporting_documents_files' }}</a></li>
<li><a ui-sref="app.logged.dashboard.projects" translate>{{ 'app.public.common.my_projects' }}</a></li>

View File

@ -48,6 +48,7 @@
<div class="spaces-grid">
<div ng-class="{'disabled-reservable' : space.disabled && spaceFiltering === 'all'}" ng-repeat="space in spaces | filterDisabled:spaceFiltering">
<div class="widget panel panel-default">
<fab-badge ng-if="isAuthorized('admin') && (space.parent || space.children.length)" icon="'pin-map'" icon-width="'3rem'"></fab-badge>
<div class="panel-heading picture" ng-if="!space.space_image_attributes" ng-click="showSpace(space)">
<img src="data:image/png;base64," data-src="holder.js/100%x100%/text:&#xf03e;/font:'Font Awesome 5 Free'/icon" bs-holder class="img-responsive">
</div>

View File

@ -41,6 +41,23 @@
</div>
<div class="col-sm-12 col-md-12 col-lg-4">
<div class="spaces-relations m" ng-show="space.parent || space.children.length || space.machines.length">
<p ng-show="space.parent" class="space-parent">{{ space.parent.name }}</p>
<div class="space-current" ng-class="{'has-parent': space.parent}">
<span class="space-current-name">
<svg viewBox="0 0 24 24" width="3rem">
<use href="../../images/icons.svg#pin-map"/>
</svg>
{{ space.name }}
</span>
</div>
<ul ng-show="space.machines.length" class="related-machines">
<li ng-repeat="machine in space.machines" class="">{{ machine.name }}</li>
</ul>
<ul ng-show="space.children.length" class="related-spaces">
<li ng-repeat="child_space in space.children" class="">{{ child_space.name }}</li>
</ul>
</div>
<div class="widget panel b-a m m-t-lg" ng-show="space.characteristics">
<div class="panel-heading b-b small">

View File

@ -8,6 +8,7 @@ module AvailabilityHelper
EVENT_COLOR = '#dd7e6b'
IS_RESERVED_BY_CURRENT_USER = '#b2e774'
IS_FULL = '#eeeeee'
IS_BLOCKED = '#b2e774' # same color as IS_RESERVED_BY_CURRENT_USER for simplicity
def availability_border_color(availability)
case availability.available_type
@ -38,6 +39,8 @@ module AvailabilityHelper
IS_RESERVED_BY_CURRENT_USER
elsif slot.full?
IS_FULL
elsif slot.is_blocked
IS_BLOCKED
else
SPACE_COLOR
end

View File

@ -167,6 +167,8 @@ module SettingsHelper
user_validation_required
user_validation_required_list
show_username_in_admin_list
family_account
child_validation_required
store_module
store_withdrawal_instructions
store_hidden

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# BookingUser is a class for save the booking info of reservation
# booked can be a User or a Child (polymorphic)
class BookingUser < ApplicationRecord
belongs_to :reservation
belongs_to :booked, polymorphic: true
belongs_to :event_price_category
end

Some files were not shown because too many files have changed in this diff Show More