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

Fix merge conflicts with dev

This commit is contained in:
Guilherme Chaguri 2023-03-15 16:26:45 -03:00
commit 855df4b552
193 changed files with 3546 additions and 1515 deletions

View File

@ -1,10 +1,57 @@
# Changelog Fab-manager
- Ability to restrict machine reservations per plan
- Ability to restrict machine availabilities per plan
- Fix a security issue: updated webpack to 5.76.0 to fix [CVE-2023-28154](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-28154)
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
## v5.8.2 2023 March 13
- Improved upgrade script
- Keep usage history of prepaid packs
- OpenAPI reservation endpoint can be filtered by date
- OpenAPI users endpoint now returns the ID of the InvoicingProfile
- Fix a bug: URL validation regex was wrong
- Fix a bug: privileged users cannot order free carts for themselves in the store
- Fix a bug: unable to select a new machine for an existing category
- Fix a bug: wrong counting of minutes used when using a prepaid pack
- Fix a bug: empty advanced accounting code is not defaulted to the general setting
- Fix a bug: invalid style in accounting codes settings
- Fix a bug: wrong namespace for task cart_operator
- Fix a security issue: updated rack to 2.2.6.3 to fix [CVE-2023-27530](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-27530)
- [TODO DEPLOY] `rails fablab:fix:cart_operator`
- [TODO DEPLOY] `rails fablab:setup:build_accounting_lines`
- [TODO DEPLOY] `rails fablab:fix:pack_minutes_used`
## v5.8.1 2023 March 03
- Fix a bug: unable to reserve an event
## v5.8.0 2023 March 03
- OpenAPI events endpoint returns category, theme and age_range
- OpenAPI reservation endpoint will return details for the reserved slots
- Display info messages if the user cannot buy prepaid packs
- Fix a bug: some OpenAPI endpoints struggle and expire with timeout
- Fix a bug: OpenAPI events endpoint documentation does not refect the returned data
- Fix a bug: members can't change/cancel their reservations
- Fix a bug: admin events view should default to the list tab
- Fix a bug: event creation form should not allow setting multiple times the same price category
- Fix a bug: MAX_SIZE env varibles should not be quoted (#438)
- Fix a bug: unable to add OIDC scopes without discovery
- [BREAKING CHANGE] GET `open_api/v1/events` will necessarily be paginated
- [BREAKING CHANGE] GET `open_api/v1/invoices` will necessarily be paginated
- [BREAKING CHANGE] GET `open_api/v1/reservations` will necessarily be paginated
- [BREAKING CHANGE] GET `open_api/v1/users` will necessarily be paginated
- [BREAKING CHANGE] GET `open_api/v1/subscriptions` won't return `total_count`, `total_pages`, `page` or `page_siez` anymore. RFC-5988 headers (*Link*, *Total* and *Per-Page*) will continue to provide these same data.
- [BREAKING CHANGE] GET `open_api/v1/subscriptions` will return a `subscriptions` array instead of a `data` array.
## v5.7.2 2023 February 24
- Fix a bug: unable to update recurrent events
- Fix a bug: invalid border color for slots
- Fix a bug: members can change/cancel their reservations
- Fix a bug: members can't change/cancel their reservations
## v5.7.1 2023 February 20

View File

@ -299,7 +299,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.1)
rack (2.2.6.2)
rack (2.2.6.3)
rack-oauth2 (1.19.0)
activesupport
attr_required

View File

@ -8,6 +8,8 @@ class API::LocalPaymentController < API::PaymentsController
authorize LocalPaymentContext.new(cart, price[:amount])
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
render on_payment_success(nil, nil, cart)
end

View File

@ -55,7 +55,7 @@ class API::PayzenController < API::PaymentsController
def check_cart
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
render json: { cart: 'ok' }, status: :ok
end

View File

@ -80,11 +80,13 @@ class API::PlansController < API::ApiController
end
@parameters = @parameters.require(:plan)
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling, :limiting,
:training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id,
:machines_visibility,
plan_file_attributes: %i[id attachment _destroy],
prices_attributes: %i[id amount],
advanced_accounting_attributes: %i[code analytical_section])
advanced_accounting_attributes: %i[code analytical_section],
plan_limitations_attributes: %i[id limitable_id limitable_type limit _destroy])
end
end
end

View File

@ -19,7 +19,7 @@ class API::StripeController < API::PaymentsController
res = nil # json of the API answer
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
begin
amount = debit_amount(cart) # will contains the amount and the details of each invoice lines
@ -73,7 +73,7 @@ class API::StripeController < API::PaymentsController
def setup_subscription
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
service = Stripe::Service.new
method = service.attach_method_as_default(

View File

@ -6,6 +6,9 @@ class API::UserPacksController < API::ApiController
def index
@user_packs = PrepaidPackService.user_packs(user, item)
@history = params[:history] == 'true'
@user_packs = @user_packs.includes(:prepaid_pack_reservations) if @history
end
private

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
require_relative 'concerns/accountings_filters_concern'
# authorized 3rd party softwares can fetch the accounting lines through the OpenAPI
class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include AccountingsFiltersConcern
expose_doc
def index
@ -16,10 +19,10 @@ class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
@lines = AccountingLine.order(date: :desc)
.includes(:invoicing_profile, invoice: :payment_gateway_object)
@lines = @lines.where('date >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@lines = @lines.where('date <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@lines = @lines.where(invoice_id: may_array(params[:invoice_id])) if params[:invoice_id].present?
@lines = @lines.where(line_type: may_array(params[:type])) if params[:type].present?
@lines = filter_by_after(@lines, params)
@lines = filter_by_before(@lines, params)
@lines = filter_by_invoice(@lines, params)
@lines = filter_by_line_type(@lines, params)
@lines = @lines.page(page).per(per_page)
paginate @lines, per_page: per_page

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Filter the list of accounting lines by the given parameters
module AccountingsFiltersConcern
extend ActiveSupport::Concern
included do
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_after(lines, filters)
return lines if filters[:after].blank?
lines.where('date >= ?', Time.zone.parse(filters[:after]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_before(lines, filters)
return lines if filters[:before].blank?
lines.where('date <= ?', Time.zone.parse(filters[:before]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_invoice(lines, filters)
return lines if filters[:invoice_id].blank?
lines.where(invoice_id: may_array(filters[:invoice_id]))
end
# @param lines [ActiveRecord::Relation<AccountingLine>]
# @param filters [ActionController::Parameters]
def filter_by_line_type(lines, filters)
return lines if filters[:type].blank?
lines.where(line_type: may_array(filters[:type]))
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# Filter the list of reservations by the given parameters
module ReservationsFiltersConcern
extend ActiveSupport::Concern
included do
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_after(reservations, filters)
return reservations if filters[:after].blank?
reservations.where('reservations.created_at >= ?', Time.zone.parse(filters[:after]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_before(reservations, filters)
return reservations if filters[:before].blank?
reservations.where('reservations.created_at <= ?', Time.zone.parse(filters[:before]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_user(reservations, filters)
return reservations if filters[:user_id].blank?
reservations.where(statistic_profiles: { user_id: may_array(filters[:user_id]) })
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_reservable_type(reservations, filters)
return reservations if filters[:reservable_type].blank?
reservations.where(reservable_type: format_type(filters[:reservable_type]))
end
# @param reservations [ActiveRecord::Relation<Reservation>]
# @param filters [ActionController::Parameters]
def filter_by_reservable_id(reservations, filters)
return reservations if filters[:reservable_id].blank?
reservations.where(reservable_id: may_array(filters[:reservable_id]))
end
# @param type [String]
def format_type(type)
type.singularize.classify
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Filter the list of subscriptions by the given parameters
module SubscriptionsFiltersConcern
extend ActiveSupport::Concern
included do
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_after(subscriptions, filters)
return subscriptions if filters[:after].blank?
subscriptions.where('created_at >= ?', Time.zone.parse(filters[:after]))
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_before(subscriptions, filters)
return subscriptions if filters[:before].blank?
subscriptions.where('created_at <= ?', Time.zone.parse(filters[:before]))
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_user(subscriptions, filters)
return subscriptions if filters[:user_id].blank?
subscriptions.where(statistic_profiles: { user_id: may_array(filters[:user_id]) })
end
# @param subscriptions [ActiveRecord::Relation<Subscription>]
# @param filters [ActionController::Parameters]
def filter_by_plan(subscriptions, filters)
return subscriptions if filters[:plan_id].blank?
subscriptions.where(plan_id: may_array(filters[:plan_id]))
end
end
end

View File

@ -19,14 +19,16 @@ class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
@events = @events.where(id: may_array(params[:id])) if params[:id].present?
return if params[:page].blank?
@events = @events.page(params[:page]).per(per_page)
@events = @events.page(page).per(per_page)
paginate @events, per_page: per_page
end
private
def page
params[:page] || 1
end
def per_page
params[:per_page] || 20
end

View File

@ -8,13 +8,11 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
def index
@invoices = Invoice.order(created_at: :desc)
.includes(invoicing_profile: :user)
.includes(:payment_gateway_object, :invoicing_profile)
.references(:invoicing_profiles)
@invoices = @invoices.where(invoicing_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
return if params[:page].blank?
@invoices = @invoices.page(params[:page]).per(per_page)
paginate @invoices, per_page: per_page
end

View File

@ -1,30 +1,33 @@
# frozen_string_literal: true
require_relative 'concerns/reservations_filters_concern'
# public API controller for resources of type Reservation
class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include ReservationsFiltersConcern
expose_doc
def index
@reservations = Reservation.order(created_at: :desc)
.includes(statistic_profile: :user)
.includes(slots_reservations: :slot, statistic_profile: :user)
.references(:statistic_profiles)
@reservations = @reservations.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type])) if params[:reservable_type].present?
@reservations = @reservations.where(reservable_id: may_array(params[:reservable_id])) if params[:reservable_id].present?
@reservations = filter_by_after(@reservations, params)
@reservations = filter_by_before(@reservations, params)
@reservations = filter_by_user(@reservations, params)
@reservations = filter_by_reservable_type(@reservations, params)
@reservations = filter_by_reservable_id(@reservations, params)
return if params[:page].blank?
@reservations = @reservations.page(params[:page]).per(per_page)
@reservations = @reservations.page(page).per(per_page)
paginate @reservations, per_page: per_page
end
private
def format_type(type)
type.singularize.classify
def page
params[:page] || 1
end
def per_page

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true
require_relative 'concerns/subscriptions_filters_concern'
# authorized 3rd party softwares can fetch the subscriptions through the OpenAPI
class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
extend OpenAPI::ApiDoc
include Rails::Pagination
include SubscriptionsFiltersConcern
expose_doc
def index
@ -11,13 +14,12 @@ class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
.includes(:plan, statistic_profile: :user)
.references(:statistic_profile, :plan)
@subscriptions = @subscriptions.where('created_at >= ?', Time.zone.parse(params[:after])) if params[:after].present?
@subscriptions = @subscriptions.where('created_at <= ?', Time.zone.parse(params[:before])) if params[:before].present?
@subscriptions = @subscriptions.where(plan_id: may_array(params[:plan_id])) if params[:plan_id].present?
@subscriptions = @subscriptions.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
@subscriptions = filter_by_after(@subscriptions, params)
@subscriptions = filter_by_before(@subscriptions, params)
@subscriptions = filter_by_plan(@subscriptions, params)
@subscriptions = filter_by_user(@subscriptions, params)
@subscriptions = @subscriptions.page(page).per(per_page)
@pageination_meta = pageination_meta
paginate @subscriptions, per_page: per_page
end
@ -30,14 +32,4 @@ class OpenAPI::V1::SubscriptionsController < OpenAPI::V1::BaseController
def per_page
params[:per_page] || 20
end
def pageination_meta
total_count = Subscription.count
{
total_count: total_count,
total_pages: (total_count / per_page.to_f).ceil,
page: page.to_i,
page_size: per_page.to_i
}
end
end

View File

@ -18,12 +18,16 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
return if params[:page].blank?
@users = @users.page(params[:page]).per(per_page)
@users = @users.page(page).per(per_page)
paginate @users, per_page: per_page
end
private
def page
params[:page] || 1
end
def per_page
params[:per_page] || 20
end

View File

@ -16,7 +16,7 @@ class OpenAPI::V1::EventsDoc < OpenAPI::V1::BaseDoc
param_group :pagination
param :id, [Integer, Array], optional: true, desc: 'Scope the request to one or various events.'
param :upcoming, [FalseClass, TrueClass], optional: true, desc: 'Scope for the upcoming events.'
description 'Events index. Order by *created_at* desc.'
description 'Events index, pagniated. Ordered by *created_at* desc.'
example <<-EVENTS
# /open_api/v1/events?page=1&per_page=2
{
@ -29,12 +29,21 @@ class OpenAPI::V1::EventsDoc < OpenAPI::V1::BaseDoc
"created_at": "2016-04-25T10:49:40.055+02:00",
"nb_total_places": 18,
"nb_free_places": 16,
"start_at": "2016-05-02T18:00:00.000+02:00",
"end_at": "2016-05-02T22:00:00.000+02:00",
"category": "Openlab",
"event_image": {
"large_url": "https://example.com/uploads/event_image/3454/large_event_image.jpg",
"medium_url": "https://example.com/uploads/event_image/3454/medium_event_image.jpg",
"small_url": "https://example.com/uploads/event_image/3454/small_event_image.jpg"
},
"prices": {
"normal": {
"name": "Plein tarif",
"amount": 0
}
}
},
"url": "https://example.com/#!/events/183"
},
{
"id": 182,
@ -44,6 +53,19 @@ class OpenAPI::V1::EventsDoc < OpenAPI::V1::BaseDoc
"created_at": "2016-04-11T17:40:15.146+02:00",
"nb_total_places": 8,
"nb_free_places": 0,
"start_at": "2016-05-02T18:00:00.000+01:00",
"end_at": "2026-05-02T22:00:00.000+01:00",
"category": "Atelier",
"themes": [
"DIY",
"Sport"
],
"age_range": "14 - 18 ans",
"event_image": {
"large_url": "https://example.com/uploads/event_image/3453/large_event_image.jpg",
"medium_url": "https://example.com/uploads/event_image/3453/medium_event_image.jpg",
"small_url": "https://example.com/uploads/event_image/3453/small_event_image.jpg"
},
"prices": {
"normal": {
"name": "Plein tarif",
@ -53,7 +75,8 @@ class OpenAPI::V1::EventsDoc < OpenAPI::V1::BaseDoc
"name": "Tarif réduit",
"amount": 4000
},
}
},
"url": "https://example.com/#!/events/182"
}
]
}

View File

@ -13,7 +13,7 @@ class OpenAPI::V1::InvoicesDoc < OpenAPI::V1::BaseDoc
doc_for :index do
api :GET, "/#{API_VERSION}/invoices", 'Invoices index'
description "Index of users' invoices, with optional pagination. Order by *created_at* descendant."
description 'Index of invoices, paginated. Ordered by *created_at* descendant.'
param_group :pagination
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
example <<-INVOICES

View File

@ -13,8 +13,10 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
doc_for :index do
api :GET, "/#{API_VERSION}/reservations", 'Reservations index'
description 'Index of reservations made by users, with optional pagination. Order by *created_at* descendant.'
description 'Index of reservations made by users, paginated. Ordered by *created_at* descendant.'
param_group :pagination
param :after, DateTime, optional: true, desc: 'Filter reservations to those created after the given date.'
param :before, DateTime, optional: true, desc: 'Filter reservations to those created before the given date.'
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
param :reservable_type, %w[Event Machine Space Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
param :reservable_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various reservables.'
@ -42,7 +44,14 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
"description": "A partir de 15 ans : \r\n\r\nDécouvrez le Fab Lab, familiarisez-vous avec les découpeuses laser, les imprimantes 3D, la découpeuse vinyle ... ! Fabriquez un objet simple, à ramener chez vous ! \r\n\r\nAdoptez la Fab Lab attitude !",
"updated_at": "2016-03-21T15:55:56.306+01:00",
"created_at": "2016-03-21T15:55:56.306+01:00"
}
},
"reserved_slots": [
{
"canceled_at": "2016-05-20T09:40:12.201+01:00",
"start_at": "2016-06-03T14:00:00.000+01:00",
"end_at": "2016-06-03T15:00:00.000+01:00"
}
]
},
{
"id": 3252,
@ -63,7 +72,14 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
"description": "A partir de 15 ans : \r\n\r\nDécouvrez le Fab Lab, familiarisez-vous avec les découpeuses laser, les imprimantes 3D, la découpeuse vinyle ... ! Fabriquez un objet simple, à ramener chez vous ! \r\n\r\nAdoptez la Fab Lab attitude !",
"updated_at": "2016-05-03T13:53:47.172+02:00",
"created_at": "2016-03-07T15:58:14.113+01:00"
}
},
"reserved_slots": [
{
"canceled_at": null,
"start_at": "2016-06-02T16:00:00.000+01:00",
"end_at": "2016-06-02T17:00:00.000+01:00"
}
]
},
{
"id": 3251,
@ -84,7 +100,14 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
"description": "A partir de 15 ans : \r\n\r\nDécouvrez le Fab Lab, familiarisez-vous avec les découpeuses laser, les imprimantes 3D, la découpeuse vinyle ... ! Fabriquez un objet simple, à ramener chez vous ! \r\n\r\nAdoptez la Fab Lab attitude !",
"updated_at": "2016-03-21T15:55:56.306+01:00",
"created_at": "2016-03-21T15:55:56.306+01:00"
}
},
"reserved_slots": [
{
"canceled_at": null,
"start_at": "2016-06-03T14:00:00.000+01:00",
"end_at": "2016-06-03T15:00:00.000+01:00"
}
]
}
]
}

View File

@ -13,14 +13,14 @@ class OpenAPI::V1::SubscriptionsDoc < OpenAPI::V1::BaseDoc
doc_for :index do
api :GET, "/#{API_VERSION}/subscriptions", 'Subscriptions index'
description "Index of users' subscriptions, with optional pagination. Order by *created_at* descendant."
description "Index of users' subscriptions, paginated. Order by *created_at* descendant."
param_group :pagination
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
param :plan_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various plans.'
example <<-SUBSCRIPTIONS
# /open_api/v1/subscriptions?user_id=211&page=1&per_page=3
{
"data": [
"subscriptions": [
{
"id": 2809,
"user_id": 211,
@ -45,11 +45,7 @@ class OpenAPI::V1::SubscriptionsDoc < OpenAPI::V1::BaseDoc
"canceled_at": null,
"plan_id": 1
}
],
"total_pages": 3,
"total_count": 9,
"page": 1,
"page_siez": 3
]
}
SUBSCRIPTIONS
end

View File

@ -13,7 +13,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
doc_for :index do
api :GET, "/#{API_VERSION}/users", 'Users index'
description 'Users index, with optional pagination. Order by *created_at* descendant.'
description 'Users index, paginated. Ordered by *created_at* descendant.'
param_group :pagination
param :email, [String, Array], optional: true, desc: 'Filter users by *email* using strict matching.'
param :user_id, [Integer, Array], optional: true, desc: 'Filter users by *id* using strict matching.'
@ -26,6 +26,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxx@xxxx.com",
"created_at": "2016-05-04T17:21:48.403+02:00",
"invoicing_profile_id": 7824,
"external_id": "J5821-4"
"full_name": "xxxx xxxx",
"first_name": "xxxx",
@ -43,6 +44,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"invoicing_profile_id": 7823,
"external_id": "J5846-4"
"full_name": "xxxxx xxxxx",
"first_name": "xxxxx",
@ -60,6 +62,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1744,
"email": "xxxxxxx@gmail.com",
"created_at": "2016-05-03T13:51:03.223+02:00",
"invoicing_profile_id": 7822,
"external_id": "J5900-1"
"full_name": "xxxxxxx xxxx",
"first_name": "xxxxxxx",
@ -77,6 +80,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1743,
"email": "xxxxxxxx@setecastronomy.eu",
"created_at": "2016-05-03T12:24:38.724+02:00",
"invoicing_profile_id": 7821,
"external_id": "P4172-4"
"full_name": "xxx xxxxxxx",
"first_name": "xxx",
@ -100,6 +104,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1746,
"email": "xxxxxxxxxxxx",
"created_at": "2016-05-04T17:21:48.403+02:00",
"invoicing_profile_id": 7820,
"external_id": "J5500-4"
"full_name": "xxxx xxxxxx",
"first_name": "xxxx",
@ -117,6 +122,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
"id": 1745,
"email": "xxxxxxxxx@gmail.com",
"created_at": "2016-05-03T15:21:13.125+02:00",
"invoicing_profile_id": 7819,
"external_id": null,
"full_name": "xxxxx xxxxxx",
"first_name": "xxxx",

View File

@ -3,6 +3,7 @@ import { UseFormRegister, FormState } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabOutputCopy } from '../base/fab-output-copy';
import ValidationLib from '../../lib/validation';
interface Oauth2FormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -16,10 +17,6 @@ interface Oauth2FormProps<TFieldValues> {
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName, formState }: Oauth2FormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* Build the callback URL, based on the strategy name.
*/
@ -35,26 +32,26 @@ export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strateg
register={register}
placeholder="https://sso.example.net..."
label={t('app.admin.authentication.oauth2_form.common_url')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.authorization_endpoint"
register={register}
placeholder="/oauth2/auth..."
label={t('app.admin.authentication.oauth2_form.authorization_endpoint')}
rules={{ required: true, pattern: endpointRegex }}
rules={{ required: true, pattern: ValidationLib.endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.token_endpoint"
register={register}
placeholder="/oauth2/token..."
label={t('app.admin.authentication.oauth2_form.token_acquisition_endpoint')}
rules={{ required: true, pattern: endpointRegex }}
rules={{ required: true, pattern: ValidationLib.endpointRegex }}
formState={formState} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://exemple.net/user..."
label={t('app.admin.authentication.oauth2_form.profile_edition_url')}
tooltip={t('app.admin.authentication.oauth2_form.profile_edition_url_help')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
formState={formState} />
<FormInput id="providable_attributes.client_id"
register={register}

View File

@ -12,6 +12,7 @@ import SsoClient from '../../api/external/sso';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FormMultiSelect } from '../form/form-multi-select';
import { difference } from 'lodash';
import ValidationLib from '../../lib/validation';
interface OpenidConnectFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
@ -51,10 +52,6 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
}, []);
// regular expression to validate the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* If the discovery endpoint is available, the user will be able to choose to use it or not.
* Otherwise, he will need to end the client configuration manually.
@ -109,7 +106,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
label={t('app.admin.authentication.openid_connect_form.issuer')}
placeholder="https://sso.exemple.com"
tooltip={t('app.admin.authentication.openid_connect_form.issuer_help')}
rules={{ required: true, pattern: urlRegex }}
rules={{ required: true, pattern: ValidationLib.urlRegex }}
onChange={checkForDiscoveryEndpoint}
debounce={400}
warning={!discoveryAvailable && { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }
@ -161,7 +158,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
placeholder="https://sso.exemple.com/my-account"
label={t('app.admin.authentication.openid_connect_form.profile_edition_url')}
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ required: false, pattern: urlRegex }}
rules={{ required: false, pattern: ValidationLib.urlRegex }}
formState={formState} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
@ -178,31 +175,31 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
<FormInput id="providable_attributes.client__authorization_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__authorization_endpoint')}
placeholder="/authorize"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__token_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__token_endpoint')}
placeholder="/token"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
<FormInput id="providable_attributes.client__userinfo_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__userinfo_endpoint')}
placeholder="/userinfo"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
rules={{ required: !currentFormValues?.discovery, pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
{currentFormValues?.client_auth_method === 'jwks' && <FormInput id="providable_attributes.client__jwks_uri"
label={t('app.admin.authentication.openid_connect_form.client__jwks_uri')}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: endpointRegex }}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: ValidationLib.endpointRegex }}
formState={formState}
placeholder="/jwk"
register={register} />}
<FormInput id="providable_attributes.client__end_session_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint_help')}
rules={{ pattern: endpointRegex }}
rules={{ pattern: ValidationLib.endpointRegex }}
formState={formState}
register={register} />
</div>}

View File

@ -5,24 +5,34 @@ import { useTranslation } from 'react-i18next';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
interface EditDestroyButtonsProps {
onDeleteSuccess: (message: string) => void,
type EditDestroyButtonsCommon = {
onError: (message: string) => void,
onEdit: () => void,
itemId: number,
itemType: string,
apiDestroy: (itemId: number) => Promise<void>,
confirmationMessage?: string|ReactNode,
destroy: (itemId: number) => Promise<void>,
className?: string,
iconSize?: number
iconSize?: number,
showEditButton?: boolean,
}
type DeleteSuccess =
{ onDeleteSuccess: (message: string) => void, deleteSuccessMessage: string } |
{ onDeleteSuccess?: never, deleteSuccessMessage?: never }
type DestroyMessages =
({ showDestroyConfirmation?: true } &
({ itemType: string, confirmationTitle?: string, confirmationMessage?: string|ReactNode } |
{ itemType?: never, confirmationTitle: string, confirmationMessage: string|ReactNode })) |
{ showDestroyConfirmation: false, itemType?: never, confirmationTitle?: never, confirmationMessage?: never };
type EditDestroyButtonsProps = EditDestroyButtonsCommon & DeleteSuccess & DestroyMessages;
/**
* This component shows a group of two buttons.
* Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item.
* Edit : triggers the provided function.
*/
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => {
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, destroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true, showDestroyConfirmation = true }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
@ -34,30 +44,41 @@ export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDelete
setDeletionModal(!deletionModal);
};
/**
* Triggered when the user clicks on the 'destroy' button
*/
const handleDestroyRequest = (): void => {
if (showDestroyConfirmation) {
toggleDeletionModal();
} else {
onDeleteConfirmed();
}
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the given item
*/
const onDeleteConfirmed = (): void => {
apiDestroy(itemId).then(() => {
onDeleteSuccess(t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType }));
destroy(itemId).then(() => {
typeof onDeleteSuccess === 'function' && onDeleteSuccess(deleteSuccessMessage || t('app.admin.edit_destroy_buttons.deleted'));
}).catch((error) => {
onError(t('app.admin.edit_destroy_buttons.unable_to_delete', { TYPE: itemType }) + error);
onError(t('app.admin.edit_destroy_buttons.unable_to_delete') + error);
});
toggleDeletionModal();
setDeletionModal(false);
};
return (
<>
<div className={`edit-destroy-buttons ${className || ''}`}>
<FabButton className='edit-btn' onClick={onEdit}>
{showEditButton && <FabButton className='edit-btn' onClick={onEdit}>
<PencilSimple size={iconSize} weight="fill" />
</FabButton>
<FabButton type='button' className='delete-btn' onClick={toggleDeletionModal}>
</FabButton>}
<FabButton type='button' className='delete-btn' onClick={handleDestroyRequest}>
<Trash size={iconSize} weight="fill" />
</FabButton>
</div>
<FabModal title={t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
<FabModal title={confirmationTitle || t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}

View File

@ -17,6 +17,7 @@ import * as React from 'react';
import { User } from '../../../models/user';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { HtmlTranslate } from '../../base/html-translate';
interface PrepaidPacksPanelProps {
user: User,
@ -40,7 +41,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
const { handleSubmit, control, formState } = useForm<{ machine_id: number }>();
useEffect(() => {
UserPackAPI.index({ user_id: user.id })
UserPackAPI.index({ user_id: user.id, history: true })
.then(setUserPacks)
.catch(onError);
SettingAPI.get('renew_pack_threshold')
@ -105,7 +106,7 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
*/
const onPackBoughtSuccess = () => {
togglePacksModal();
UserPackAPI.index({ user_id: user.id })
UserPackAPI.index({ user_id: user.id, history: true })
.then(setUserPacks)
.catch(onError);
};
@ -124,19 +125,21 @@ 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>}
<p className="countdown"><span>{pack.minutes_used / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
<p className="countdown"><span>{(pack.prepaid_pack.minutes - pack.minutes_used) / 60}H</span> / {pack.prepaid_pack.minutes / 60}H</p>
</div>
</div>
{ /* usage history is not saved for now
{pack.history?.length > 0 &&
<div className="prepaid-packs-list is-history">
<span className='prepaid-packs-list-label'>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.history')}</span>
<div className='prepaid-packs-list-item'>
<p className='name'>00{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours')}</p>
<p className="date">00/00/00</p>
</div>
{pack.history.map(prepaidReservation => (
<div className='prepaid-packs-list-item' key={prepaidReservation.id}>
<p className='name'>{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours', { COUNT: prepaidReservation.consumed_minutes / 60 })}</p>
<p className="date">{FormatLib.date(prepaidReservation.reservation_date)}</p>
</div>
))}
</div>
*/ }
}
</div>
))}
@ -159,7 +162,10 @@ const PrepaidPacksPanel: React.FC<PrepaidPacksPanelProps> = ({ user, onError })
onDecline={togglePacksModal}
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) &&
<HtmlTranslate trKey={'app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.reserved_for_subscribers_html'} options={{ LINK: '#!/plans' }} />
}
</FabPanel>
);
};

View File

@ -5,6 +5,7 @@ import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text';
import { FormInput } from '../form/form-input';
import { SettingName, SettingValue } from '../../models/setting';
import ValidationLib from '../../lib/validation';
export type EditorialKeys = 'active_text_block' | 'text_block' | 'active_cta' | 'cta_label' | 'cta_url';
interface EditorialBlockFormProps {
@ -15,9 +16,6 @@ interface EditorialBlockFormProps {
keys: Record<EditorialKeys, SettingName>
}
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
/**
* Allows to create a formatted text and optional cta button in a form block, to be included in a resource form managed by react-hook-form.
*/
@ -78,7 +76,7 @@ export const EditorialBlockForm: React.FC<EditorialBlockFormProps> = ({ register
formState={formState}
rules={{
required: { value: isActiveCta, message: t('app.admin.editorial_block_form.url_is_required') },
pattern: { value: urlRegex, message: t('app.admin.editorial_block_form.url_must_be_safe') }
pattern: { value: ValidationLib.urlRegex, message: t('app.admin.editorial_block_form.url_must_be_safe') }
}}
label={t('app.admin.editorial_block_form.cta_url')} />
</>}

View File

@ -71,6 +71,19 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
}, []);
useEffect(() => {
// When a new custom price is added to the current event, we mark it as disabled to prevent setting the same category twice
const selectedCategoriesId = output.event_price_categories_attributes
?.filter(epc => !epc._destroy && epc.price_category_id)
?.map(epc => epc.price_category_id) || [];
setPriceCategoriesOptions(priceCategoriesOptions?.map(pco => {
return {
...pco,
disabled: selectedCategoriesId.includes(pco.value)
};
}));
}, [output.event_price_categories_attributes]);
/**
* Callback triggered when the user clicks on the 'remove' button, in the additional prices area
*/
@ -278,12 +291,14 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
type="number"
tooltip={t('app.admin.event_form.seats_help')} />
<FormInput register={register}
id="amount"
formState={formState}
rules={{ required: true }}
label={t('app.admin.event_form.standard_rate')}
tooltip={t('app.admin.event_form.0_equal_free')}
addOn={FormatLib.currencySymbol()} />
type="number"
id="amount"
formState={formState}
rules={{ required: true, min: 0 }}
nullable
label={t('app.admin.event_form.standard_rate')}
tooltip={t('app.admin.event_form.0_equal_free')}
addOn={FormatLib.currencySymbol()} />
{priceCategoriesOptions && <div className="additional-prices">
{fields.map((price, index) => (
@ -293,14 +308,16 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
id={`event_price_categories_attributes.${index}.price_category_id`}
rules={{ required: true }}
formState={formState}
disabled={() => index < fields.length - 1}
label={t('app.admin.event_form.fare_class')} />
<FormInput id={`event_price_categories_attributes.${index}.amount`}
register={register}
type="number"
rules={{ required: true }}
formState={formState}
label={t('app.admin.event_form.price')}
addOn={FormatLib.currencySymbol()} />
register={register}
type="number"
rules={{ required: true, min: 0 }}
nullable
formState={formState}
label={t('app.admin.event_form.price')}
addOn={FormatLib.currencySymbol()} />
<FabButton className="remove-price is-main" onClick={() => handlePriceRemove(price, index)} icon={<Trash size={20} />} />
</div>
))}

View File

@ -136,7 +136,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
if (creatable) {
Object.assign(selectProps, {
formatCreateLabel,
onCreateOption: inputValue => handleCreate(inputValue, value, rhfOnChange)
onCreateOption: inputValue => handleCreate(inputValue, value || [], rhfOnChange)
});
}

View File

@ -58,15 +58,16 @@ export const FormSelect = <TFieldValues extends FieldValues, TContext extends ob
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
value={value === null ? null : options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
onChangeCb(val?.value);
onChange(val?.value);
}}
placeholder={placeholder}
isDisabled={isDisabled}
isClearable={clearable}
options={options} />
options={options}
isOptionDisabled={(option) => option.disabled}/>
} />
</AbstractFormItem>
);

View File

@ -0,0 +1,74 @@
import { FieldArrayWithId } from 'react-hook-form/dist/types/fieldArray';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import React, { ReactNode } from 'react';
import { X } from 'phosphor-react';
import { FormInput } from './form-input';
import { FieldArrayPath } from 'react-hook-form/dist/types/path';
interface FormUnsavedListProps<TFieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues>, TKeyName extends string> {
fields: Array<FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>>,
onRemove: (index: number) => void,
register: UseFormRegister<TFieldValues>,
className?: string,
title: string,
shouldRenderField?: (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>) => boolean,
renderField: (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>) => ReactNode,
formAttributeName: `${string}_attributes`,
formAttributes: Array<keyof FieldArrayWithId<TFieldValues, TFieldArrayName>>,
saveReminderLabel?: string | ReactNode,
cancelLabel?: string | ReactNode
}
/**
* This component render a list of unsaved attributes, created elsewhere than in the form (e.g. in a modal dialog)
* and pending for the form to be saved.
*
* The `renderField` attribute should return a JSX element composed like the following example:
* ```
* <> <!-- empty tag -->
* <div className="group"> <!-- the group class is important -->
* <span>Attribute 1</span> <!-- a span tag for the title -->
* <p>{item.attr1}</p> <!-- a paragraph tag for the value -->
* </div>
* <div className="group">
* ...
* </div>
* </>
* ```
*/
export const FormUnsavedList = <TFieldValues extends FieldValues = FieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps<TFieldValues, TFieldArrayName, TKeyName>) => {
const { t } = useTranslation('shared');
/**
* Render an unsaved field
*/
const renderUnsavedField = (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>, index: number): ReactNode => {
return (
<div key={index} className="unsaved-field">
{renderField(field)}
<p className="cancel-action" onClick={() => onRemove(index)}>
{cancelLabel || t('app.shared.form_unsaved_list.cancel')}
<X size={20} />
</p>
{formAttributes.map((attribute, attrIndex) => (
<FormInput key={attrIndex} id={`${formAttributeName}.${index}.${attribute}`} register={register} type="hidden" />
))}
</div>
);
};
if (fields.filter(shouldRenderField).length === 0) return null;
return (
<div className={`form-unsaved-list ${className || ''}`}>
<span className="title">{title}</span>
<span className="save-notice">{saveReminderLabel || t('app.shared.form_unsaved_list.save_reminder')}</span>
{fields.map((field, index) => {
if (!shouldRenderField(field)) return false;
return renderUnsavedField(field, index);
}).filter(Boolean)}
</div>
);
};

View File

@ -1,43 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import MachineCategoryAPI from '../../api/machine-category';
interface DeleteMachineCategoryModalProps {
isOpen: boolean,
machineCategoryId: number,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* Modal dialog to remove a requested machine category
*/
export const DeleteMachineCategoryModal: React.FC<DeleteMachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, machineCategoryId, onError }) => {
const { t } = useTranslation('admin');
/**
* The user has confirmed the deletion of the requested machine category
*/
const handleDeleteMachineCategory = async (): Promise<void> => {
try {
await MachineCategoryAPI.destroy(machineCategoryId);
onSuccess(t('app.admin.delete_machine_category_modal.deleted'));
} catch (e) {
onError(t('app.admin.delete_machine_category_modal.unable_to_delete') + e);
}
};
return (
<FabModal title={t('app.admin.delete_machine_category_modal.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.admin.delete_machine_category_modal.confirm')}
onConfirm={handleDeleteMachineCategory}
className="delete-machine-category-modal">
<p>{t('app.admin.delete_machine_category_modal.confirm_machine_category')}</p>
</FabModal>
);
};

View File

@ -1,15 +1,13 @@
import React, { useEffect, useState } from 'react';
import { MachineCategory } from '../../models/machine-category';
import { Machine } from '../../models/machine';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import MachineCategoryAPI from '../../api/machine-category';
import MachineAPI from '../../api/machine';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { MachineCategoryModal } from './machine-category-modal';
import { DeleteMachineCategoryModal } from './delete-machine-category-modal';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
declare const Application: IApplication;
@ -26,25 +24,16 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
// shown machine categories
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// all machines, for assign to category
const [machines, setMachines] = useState<Array<Machine>>([]);
// creation/edition modal
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
// currently added/edited category
const [machineCategory, setMachineCategory] = useState<MachineCategory>(null);
// deletion modal
const [destroyModalIsOpen, setDestroyModalIsOpen] = useState<boolean>(false);
// currently deleted machine category
const [machineCategoryId, setMachineCategoryId] = useState<number>(null);
// retrieve the full list of machine categories on component mount
useEffect(() => {
MachineCategoryAPI.index()
.then(setMachineCategories)
.catch(onError);
MachineAPI.index({ category: 'none' })
.then(setMachines)
.catch(onError);
}, []);
/**
@ -59,7 +48,6 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
*/
const onSaveTypeSuccess = (message: string): void => {
setModalIsOpen(false);
MachineAPI.index({ category: 'none' }).then(setMachines).catch(onError);
MachineCategoryAPI.index().then(data => {
setMachineCategories(data);
onSuccess(message);
@ -86,28 +74,10 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
};
};
/**
* Init the process of deleting a machine category (ask for confirmation)
*/
const destroyMachineCategory = (id: number): () => void => {
return (): void => {
setMachineCategoryId(id);
setDestroyModalIsOpen(true);
};
};
/**
* Open/closes the confirmation before deletion modal
*/
const toggleDestroyModal = (): void => {
setDestroyModalIsOpen(!destroyModalIsOpen);
};
/**
* Callback triggred when the current machine category was successfully deleted
*/
const onDestroySuccess = (message: string): void => {
setDestroyModalIsOpen(false);
MachineCategoryAPI.index().then(data => {
setMachineCategories(data);
onSuccess(message);
@ -125,16 +95,10 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
</div>
</header>
<MachineCategoryModal isOpen={modalIsOpen}
machines={machines}
machineCategory={machineCategory}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteMachineCategoryModal isOpen={destroyModalIsOpen}
machineCategoryId={machineCategoryId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table className="machine-categories-table">
<thead>
<tr>
@ -155,12 +119,12 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
</td>
<td>
<div className="buttons">
<FabButton className="edit-btn" onClick={editMachineCategory(category)}>
<i className="fa fa-edit" /> {t('app.admin.machine_categories_list.edit')}
</FabButton>
<FabButton className="delete-btn" onClick={destroyMachineCategory(category.id)}>
<i className="fa fa-trash" />
</FabButton>
<EditDestroyButtons onDeleteSuccess={onDestroySuccess}
onError={onError}
onEdit={editMachineCategory(category)}
itemId={category.id}
itemType={t('app.admin.machine_categories_list.machine_category')}
destroy={MachineCategoryAPI.destroy} />
</div>
</td>
</tr>

View File

@ -1,25 +1,36 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from '../base/fab-modal';
import { MachineCategory } from '../../models/machine-category';
import { Machine } from '../../models/machine';
import MachineCategoryAPI from '../../api/machine-category';
import { MachineCategoryForm } from './machine-category-form';
import MachineAPI from '../../api/machine';
interface MachineCategoryModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
machines: Array<Machine>,
machineCategory?: MachineCategory,
}
/**
* Modal dialog to create/edit a machine category
*/
export const MachineCategoryModal: React.FC<MachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, onError, machines, machineCategory }) => {
export const MachineCategoryModal: React.FC<MachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, onError, machineCategory }) => {
const { t } = useTranslation('admin');
// all machines, to assign to the category
const [machines, setMachines] = useState<Array<Machine>>([]);
// retrieve the full list of machines on component mount
useEffect(() => {
if (!isOpen) return;
MachineAPI.index({ category: [machineCategory?.id, 'none'] })
.then(setMachines)
.catch(onError);
}, [isOpen]);
/**
* Save the current machine category to the API

View File

@ -113,7 +113,7 @@ const StripeKeysForm: React.FC<StripeKeysFormProps> = ({ onValidKeys, onInvalidK
}, reason => {
if (!mounted.current) return;
if (reason.response.status === 401) {
if (reason.response?.status === 401) {
setSecretKeyAddOn(<i className="fa fa-times" />);
setSecretKeyAddOnClassName('key-invalid');
}

View File

@ -24,6 +24,10 @@ import { UserPlus } from 'phosphor-react';
import { PartnerModal } from './partner-modal';
import { PlanPricingForm } from './plan-pricing-form';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
import { FabTabs } from '../base/fab-tabs';
import { PlanLimitForm } from './plan-limit-form';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
@ -33,13 +37,14 @@ interface PlanFormProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
beforeSubmit?: (data: Plan) => void,
uiRouter: UIRouter
}
/**
* Form to edit or create subscription plans
*/
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess, beforeSubmit }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<Plan>({ defaultValues: { ...plan } });
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => {
const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm<Plan>({ defaultValues: { ...plan } });
const output = useWatch<Plan>({ control }); // eslint-disable-line
const { t } = useTranslation('admin');
@ -51,13 +56,19 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
useEffect(() => {
GroupAPI.index({ disabled: false })
.then(res => setGroups(res.map(g => { return { value: g.id, label: g.name }; })))
.then(res => setGroups(res.map(g => {
return { value: g.id, label: g.name };
})))
.catch(onError);
PlanCategoryAPI.index()
.then(res => setCategories(res.map(c => { return { value: c.id, label: c.name }; })))
.then(res => setCategories(res.map(c => {
return { value: c.id, label: c.name };
})))
.catch(onError);
UserAPI.index({ role: 'partner' })
.then(res => setPartners(res.map(p => { return { value: p.id, label: p.name }; })))
.then(res => setPartners(res.map(p => {
return { value: p.id, label: p.name };
})))
.catch(onError);
}, []);
@ -101,7 +112,9 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
* Return the available options for the plan period
*/
const buildPeriodsOptions = (): Array<SelectOption<string>> => {
return ['week', 'month', 'year'].map(d => { return { value: d, label: t(`app.admin.plan_form.${d}`) }; });
return ['week', 'month', 'year'].map(d => {
return { value: d, label: t(`app.admin.plan_form.${d}`) };
});
};
/**
@ -130,140 +143,225 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
setValue('partner_id', user.id);
};
return (
<div className="plan-form">
<form onSubmit={handleSubmit(onSubmit)}>
<h4>{t('app.admin.plan_form.general_information')}</h4>
<FormInput register={register}
id="base_name"
formState={formState}
rules={{
required: true,
maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') }
}}
label={t('app.admin.plan_form.name')} />
{action === 'create' && <FormSwitch control={control}
formState={formState}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />}
{!allGroups && groups && <FormSelect options={groups}
formState={formState}
control={control}
rules={{ required: !allGroups }}
disabled={action === 'update'}
label={t('app.admin.plan_form.group')}
id="group_id" />}
{categories?.length > 0 && <FormSelect options={categories}
formState={formState}
control={control}
id="plan_category_id"
tooltip={t('app.admin.plan_form.category_help')}
label={t('app.admin.plan_form.category')} />}
{action === 'update' && <FabAlert level="warning">
{t('app.admin.plan_form.edit_amount_info')}
</FabAlert>}
<FormInput register={register}
formState={formState}
id="amount"
type="number"
step={0.01}
addOn={FormatLib.currencySymbol()}
rules={{ required: true, min: 0 }}
label={t('app.admin.plan_form.subscription_price')} />
<FormInput register={register}
formState={formState}
id="ui_weight"
type="number"
label={t('app.admin.plan_form.visual_prominence')}
tooltip={t('app.admin.plan_form.visual_prominence_help')} />
<FormSwitch control={control}
formState={formState}
id="is_rolling"
label={t('app.admin.plan_form.rolling_subscription')}
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.rolling_subscription_help')} />
<FormSwitch control={control}
formState={formState}
id="monthly_payment"
label={t('app.admin.plan_form.monthly_payment')}
disabled={action === 'update' || output.interval === 'week'}
tooltip={t('app.admin.plan_form.monthly_payment_help')} />
<FormRichText control={control}
formState={formState}
id="description"
label={t('app.admin.plan_form.description')}
limit={200}
heading link blockquote />
<FormFileUpload setValue={setValue}
register={register}
formState={formState}
defaultFile={output.plan_file_attributes}
id="plan_file_attributes"
className="plan-sheet"
label={t('app.admin.plan_form.information_sheet')} />
<FormSwitch control={control}
formState={formState}
id="disabled"
defaultValue={false}
label={t('app.admin.plan_form.disabled')}
tooltip={t('app.admin.plan_form.disabled_help')} />
<h4>{t('app.admin.plan_form.duration')}</h4>
<div className="duration">
/**
* Render the content of the 'subscriptions settings' tab
*/
const renderSettingsTab = () => (
<div className="plan-form-content">
<section>
<header>
<p className="title">{t('app.admin.plan_form.description')}</p>
</header>
<div className="content">
<FormInput register={register}
rules={{ required: true, min: 1 }}
disabled={action === 'update'}
id="base_name"
formState={formState}
label={t('app.admin.plan_form.number_of_periods')}
type="number"
id="interval_count" />
<FormSelect options={buildPeriodsOptions()}
control={control}
disabled={action === 'update'}
onChange={handlePeriodUpdate}
id="interval"
label={t('app.admin.plan_form.period')}
formState={formState}
rules={{ required: true }} />
rules={{
required: true,
maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') }
}}
label={t('app.admin.plan_form.name')} />
<FormRichText control={control}
formState={formState}
id="description"
label={t('app.admin.plan_form.description')}
limit={200}
heading link blockquote />
<FormFileUpload setValue={setValue}
register={register}
formState={formState}
defaultFile={output.plan_file_attributes}
id="plan_file_attributes"
className="plan-sheet"
label={t('app.admin.plan_form.information_sheet')} />
</div>
<h4>{t('app.admin.plan_form.partnership')}</h4>
<div className="partnership">
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.general_settings')}</p>
<p className="description">{t('app.admin.plan_form.general_settings_info')}</p>
</header>
<div className="content">
{action === 'create' && <FormSwitch control={control}
formState={formState}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />}
{!allGroups && groups && <FormSelect options={groups}
formState={formState}
control={control}
rules={{ required: !allGroups }}
disabled={action === 'update'}
label={t('app.admin.plan_form.group')}
id="group_id" />}
<div className="grp">
<FormInput register={register}
rules={{ required: true, min: 1 }}
disabled={action === 'update'}
formState={formState}
label={t('app.admin.plan_form.number_of_periods')}
type="number"
id="interval_count" />
<FormSelect options={buildPeriodsOptions()}
control={control}
disabled={action === 'update'}
onChange={handlePeriodUpdate}
id="interval"
label={t('app.admin.plan_form.period')}
formState={formState}
rules={{ required: true }} />
</div>
{action === 'update' && <FabAlert level="info">
{t('app.admin.plan_form.edit_amount_info')}
</FabAlert>}
<FormInput register={register}
formState={formState}
id="amount"
type="number"
step={0.01}
addOn={FormatLib.currencySymbol()}
rules={{ required: true, min: 0 }}
label={t('app.admin.plan_form.subscription_price')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.activation_and_payment')}</p>
</header>
<div className="content">
<FormSwitch control={control}
formState={formState}
id="disabled"
defaultValue={false}
label={t('app.admin.plan_form.disabled')}
tooltip={t('app.admin.plan_form.disabled_help')} />
<FormSwitch control={control}
formState={formState}
id="is_rolling"
label={t('app.admin.plan_form.rolling_subscription')}
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.rolling_subscription_help')} />
<FormSwitch control={control}
formState={formState}
id="monthly_payment"
label={t('app.admin.plan_form.monthly_payment')}
disabled={action === 'update' || output.interval === 'week'}
tooltip={t('app.admin.plan_form.monthly_payment_help')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.partnership')}</p>
<p className="description">{t('app.admin.plan_form.partner_plan_help')}</p>
</header>
<div className="content">
<FormSwitch control={control}
id="partnership"
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.partner_plan_help')}
defaultValue={plan?.type === 'PartnerPlan'}
onChange={handlePartnershipChange}
formState={formState}
label={t('app.admin.plan_form.partner_plan')} />
<FormInput register={register} type="hidden" id="type" defaultValue="Plan" />
{output.type === 'PartnerPlan' && <div className="partner">
<FabButton className="add-partner is-info" icon={<UserPlus size={20} />} onClick={tooglePartnerModal}>
{t('app.admin.plan_form.new_user')}
</FabButton>
{partners && <FormSelect id="partner_id"
options={partners}
control={control}
formState={formState}
rules={{ required: output.type === 'PartnerPlan' }}
tooltip={t('app.admin.plan_form.alert_partner_notification')}
label={t('app.admin.plan_form.notified_partner')} />}
{output.partner_id && <FabAlert level="info">
{t('app.admin.plan_form.alert_partner_notification')}
</FabAlert>}
<FabButton className="is-secondary" icon={<UserPlus size={20} />} onClick={tooglePartnerModal}>
{t('app.admin.plan_form.new_user')}
</FabButton>
</div>}
</div>
<AdvancedAccountingForm register={register} onError={onError} />
{action === 'update' && <PlanPricingForm formState={formState}
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.slots_visibility')}</p>
<p className="description">{t('app.admin.plan_form.slots_visibility_help')}</p>
</header>
<div className="content">
<FormInput register={register}
formState={formState}
nullable
id="machines_visibility"
type="number"
label={t('app.admin.plan_form.machines_visibility')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.display')} </p>
</header>
<div className="content">
{categories?.length > 0 && <FormSelect options={categories}
formState={formState}
control={control}
onError={onError}
setValue={setValue}
register={register} />}
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.plan_form.ACTION_plan', { ACTION: action })}
</FabButton>
id="plan_category_id"
tooltip={t('app.admin.plan_form.category_help')}
label={t('app.admin.plan_form.category')} />}
<FormInput register={register}
formState={formState}
id="ui_weight"
type="number"
label={t('app.admin.plan_form.visual_prominence')}
tooltip={t('app.admin.plan_form.visual_prominence_help')} />
</div>
</section>
<section>
<AdvancedAccountingForm register={register} onError={onError} />
</section>
{action === 'update' && <PlanPricingForm formState={formState}
control={control}
onError={onError}
setValue={setValue}
register={register} />}
</div>
);
return (
<div className="plan-form">
<header>
<h2>{t('app.admin.plan_form.ACTION_title', { ACTION: action })}</h2>
<div className="grpBtn">
<FabButton type="submit" onClick={handleSubmit(onSubmit)} className="fab-button is-main">
{t('app.admin.plan_form.save')}
</FabButton>
</div>
</header>
<form onSubmit={handleSubmit(onSubmit)}>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<FabTabs tabs={[
{
id: 'settings',
title: t('app.admin.plan_form.tab_settings'),
content: renderSettingsTab()
},
{
id: 'usageLimits',
title: t('app.admin.plan_form.tab_usage_limits'),
content: <PlanLimitForm control={control}
register={register}
formState={formState}
onError={onError}
getValues={getValues}
resetField={resetField} />
}
]} />
</form>
<PartnerModal isOpen={isOpenPartnerModal}
toggleModal={tooglePartnerModal}
onError={onError}
@ -281,4 +379,4 @@ const PlanFormWrapper: React.FC<PlanFormProps> = (props) => {
</Loader>
);
};
Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess']));
Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess', 'uiRouter']));

View File

@ -0,0 +1,261 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { Control, FormState, UseFormGetValues, UseFormResetField } from 'react-hook-form/dist/types/form';
import { FormSwitch } from '../form/form-switch';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { PlanLimitModal } from './plan-limit-modal';
import { Plan, PlanLimitation } from '../../models/plan';
import { useFieldArray, UseFormRegister, useWatch } from 'react-hook-form';
import { Machine } from '../../models/machine';
import { MachineCategory } from '../../models/machine-category';
import MachineAPI from '../../api/machine';
import MachineCategoryAPI from '../../api/machine-category';
import { FormUnsavedList } from '../form/form-unsaved-list';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
import { X } from 'phosphor-react';
interface PlanLimitFormProps<TContext extends object> {
register: UseFormRegister<Plan>,
control: Control<Plan, TContext>,
formState: FormState<Plan>,
onError: (message: string) => void,
getValues: UseFormGetValues<Plan>,
resetField: UseFormResetField<Plan>
}
/**
* Form tab to manage a subscription's usage limit
*/
export const PlanLimitForm = <TContext extends object> ({ register, control, formState, onError, getValues, resetField }: PlanLimitFormProps<TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove, update } = useFieldArray<Plan, 'plan_limitations_attributes'>({ control, name: 'plan_limitations_attributes' });
const limiting = useWatch<Plan>({ control, name: 'limiting' });
const [isOpen, setIsOpen] = useState<boolean>(false);
const [machines, setMachines] = useState<Array<Machine>>([]);
const [categories, setCategories] = useState<Array<MachineCategory>>([]);
const [edited, setEdited] = useState<{index: number, limitation: PlanLimitation}>(null);
useEffect(() => {
MachineAPI.index({ disabled: false })
.then(setMachines)
.catch(onError);
MachineCategoryAPI.index()
.then(setCategories)
.catch(onError);
}, []);
/**
* Opens/closes the product stock edition modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Triggered when the user clicks on 'add a limitation'
*/
const onAddLimitation = (): void => {
setEdited(null);
toggleModal();
};
/**
* Triggered when a new limit was added or an existing limit was modified
*/
const onLimitationSuccess = (limitation: PlanLimitation): void => {
const id = getValues(`plan_limitations_attributes.${edited?.index}.id`);
if (id) {
update(edited.index, { ...limitation, id });
setEdited(null);
} else {
append({ ...limitation, id });
}
};
/**
* Triggered when an unsaved limit was removed from the "pending" list.
*/
const onRemoveUnsaved = (index: number): void => {
const id = getValues(`plan_limitations_attributes.${index}.id`);
if (id) {
// will reset the field to its default values
resetField(`plan_limitations_attributes.${index}`);
// unmount and remount the field
update(index, getValues(`plan_limitations_attributes.${index}`));
} else {
remove(index);
}
};
/**
* Callback triggered when a saved limitation is requested to be deleted
*/
const handleLimitationDelete = (index: number): () => Promise<void> => {
return () => {
return new Promise<void>((resolve) => {
update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true });
resolve();
});
};
};
/**
* Triggered when the user clicks on "cancel" for a limitated previsouly marked as deleted
*/
const cancelDeletion = (index: number): (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void => {
return (event) => {
event.preventDefault();
update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: false });
};
};
/**
* Callback triggered when the user wants to modify a limitation. Return a callback
*/
const onEditLimitation = (limitation: PlanLimitation, index: number): () => void => {
return () => {
setEdited({ index, limitation });
toggleModal();
};
};
/**
* Render an unsaved limitation of use
*/
const renderOngoingLimit = (limit: PlanLimitation): ReactNode => (
<>
{(limit.limitable_type === 'MachineCategory' && <div className="group">
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories?.find(c => c.id === limit.limitable_id)?.name}</p>
</div>) ||
<div className="group">
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines?.find(m => m.id === limit.limitable_id)?.name}</p>
</div>}
<div className="group">
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limit.limit}</p>
</div>
</>
);
return (
<div className="plan-limit-form">
<section>
<header>
<p className="title">{t('app.admin.plan_limit_form.usage_limitation')}</p>
<p className="description">{t('app.admin.plan_limit_form.usage_limitation_info')}</p>
</header>
<div className="content">
<FormSwitch control={control}
formState={formState}
defaultValue={false}
label={t('app.admin.plan_limit_form.usage_limitation_switch')}
id="limiting" />
</div>
</section>
{limiting && <div className="plan-limit-grp">
<header>
<p>{t('app.admin.plan_limit_form.all_limitations')}</p>
<div className="grpBtn">
<FabButton onClick={onAddLimitation} className="is-main">
{t('app.admin.plan_limit_form.new_usage_limitation')}
</FabButton>
</div>
</header>
<FormUnsavedList fields={fields}
onRemove={onRemoveUnsaved}
register={register}
title={t('app.admin.plan_limit_form.ongoing_limitations')}
shouldRenderField={(limit: PlanLimitation) => limit._modified}
formAttributeName="plan_limitations_attributes"
formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']}
renderField={renderOngoingLimit}
cancelLabel={t('app.admin.plan_limit_form.cancel')} />
{fields.filter(f => f._modified).length > 0 &&
<p className="title">{t('app.admin.plan_limit_form.saved_limitations')}</p>
}
{fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_category')}</p>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false;
return (
<div className={`plan-limit-item ${limitation._destroy ? 'is-destroying' : ''}`} key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories.find(c => c.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
<div className='actions'>
<EditDestroyButtons onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={getValues(`plan_limitations_attributes.${index}.id`)}
showDestroyConfirmation={false}
destroy={handleLimitationDelete(index)} />
</div>
</div>
);
}).filter(Boolean)}
</div>
}
{fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_machine')}</p>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'Machine' || limitation._modified) return false;
return (
<div className={`plan-limit-item ${limitation._destroy ? 'is-destroying' : ''}`} key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines.find(m => m.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
{limitation._destroy && <div className="marker">{t('app.admin.plan_limit_form.ongoing_deletion')}</div>}
</div>
<div className='actions'>
{(limitation._destroy &&
<p className="cancel-action" onClick={cancelDeletion(index)}>
{t('app.admin.plan_limit_form.cancel_deletion')}
<X size={14} />
</p>) ||
<EditDestroyButtons onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={getValues(`plan_limitations_attributes.${index}.id`)}
showDestroyConfirmation={false}
destroy={handleLimitationDelete(index)} />}
</div>
</div>
);
}).filter(Boolean)}
</div>
}
</div>}
<PlanLimitModal isOpen={isOpen}
machines={machines}
categories={categories}
toggleModal={toggleModal}
onSuccess={onLimitationSuccess}
limitation={edited?.limitation}
existingLimitations={fields} />
</div>
);
};

View File

@ -0,0 +1,124 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useForm, useWatch } from 'react-hook-form';
import { FormSelect } from '../form/form-select';
import { FormInput } from '../form/form-input';
import { LimitableType, PlanLimitation } from '../../models/plan';
import { Machine } from '../../models/machine';
import { MachineCategory } from '../../models/machine-category';
import { SelectOption } from '../../models/select';
import { FabButton } from '../base/fab-button';
import { useEffect } from 'react';
interface PlanLimitModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (limit: PlanLimitation) => void,
machines: Array<Machine>
categories: Array<MachineCategory>,
limitation?: PlanLimitation,
existingLimitations: Array<PlanLimitation>;
}
/**
* Form to manage subscriptions limitations of use
*/
export const PlanLimitModal: React.FC<PlanLimitModalProps> = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation, existingLimitations = [] }) => {
const { t } = useTranslation('admin');
const { register, control, formState, setValue, handleSubmit, reset } = useForm<PlanLimitation>({ defaultValues: limitation || { limitable_type: 'MachineCategory' } });
const limitType = useWatch({ control, name: 'limitable_type' });
useEffect(() => {
reset(limitation);
}, [limitation]);
/**
* Toggle the form between 'categories' and 'machine'
*/
const toggleLimitType = (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>, type: LimitableType) => {
evt.preventDefault();
setValue('limitable_type', type);
setValue('limitable_id', null);
};
/**
* Callback triggered when the user validates the new limit.
* We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
return handleSubmit((data: PlanLimitation) => {
onSuccess({ ...data, _modified: true });
reset({ limitable_type: 'MachineCategory', limitable_id: null, limit: null });
toggleModal();
})(event);
};
/**
* Creates options to the react-select format
*/
const buildOptions = (): Array<SelectOption<number>> => {
if (limitType === 'MachineCategory') {
return categories
.filter(c => limitation || !existingLimitations.filter(l => l.limitable_type === 'MachineCategory').map(l => l.limitable_id).includes(c.id))
.map(cat => {
return { value: cat.id, label: cat.name };
});
} else {
return machines
.filter(m => limitation || !existingLimitations.filter(l => l.limitable_type === 'Machine').map(l => l.limitable_id).includes(m.id))
.map(machine => {
return { value: machine.id, label: machine.name };
});
}
};
return (
<FabModal title={t('app.admin.plan_limit_modal.title')}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
onClose={() => reset({ limitable_type: 'MachineCategory' })}
closeButton>
<form className='plan-limit-modal' onSubmit={onSubmit}>
<p className='subtitle'>{t('app.admin.plan_limit_modal.limit_reservations')}</p>
<div className="grp">
<button onClick={evt => toggleLimitType(evt, 'MachineCategory')}
className={limitType === 'MachineCategory' ? 'is-active' : ''}
disabled={!!limitation}>
{t('app.admin.plan_limit_modal.by_category')}
</button>
<button onClick={evt => toggleLimitType(evt, 'Machine')}
className={limitType === 'Machine' ? 'is-active' : ''}
disabled={!!limitation}>
{t('app.admin.plan_limit_modal.by_machine')}
</button>
</div>
<FabAlert level='info'>{limitType === 'Machine' ? t('app.admin.plan_limit_modal.machine_info') : t('app.admin.plan_limit_modal.categories_info')}</FabAlert>
<FormInput register={register} id="id" type="hidden" />
<FormInput register={register} id="limitable_type" type="hidden" />
<FormSelect options={buildOptions()}
disabled={!!limitation}
control={control}
id="limitable_id"
rules={{ required: true }}
formState={formState}
label={t('app.admin.plan_limit_modal.machine')} />
<FormInput id="limit"
type="number"
register={register}
rules={{ required: true, min: 1 }}
nullable
step={1}
formState={formState}
label={t('app.admin.plan_limit_modal.max_hours_per_day')} />
<FabButton type="submit">{t('app.admin.plan_limit_modal.confirm')}</FabButton>
</form>
</FabModal>
);
};

View File

@ -92,32 +92,37 @@ export const PlanPricingForm = <TContext extends object>({ register, control, fo
};
return (
<div data-testid="plan-pricing-form">
<h4>{t('app.admin.plan_pricing_form.prices')}</h4>
{plans && <FormSelect options={plans}
label={t('app.admin.plan_pricing_form.copy_prices_from')}
tooltip={t('app.admin.plan_pricing_form.copy_prices_from_help')}
control={control}
onChange={handleCopyPrices}
id="parent_plan_id" />}
{<FabTabs tabs={[
machines && {
id: 'machines',
title: t('app.admin.plan_pricing_form.machines'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Machine') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
},
spaces && {
id: 'spaces',
title: t('app.admin.plan_pricing_form.spaces'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Space') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
}
]} />}
</div>
<section className="plan-pricing-form" data-testid="plan-pricing-form">
<header>
<p className="title">{t('app.admin.plan_pricing_form.prices')}</p>
<p className="description">{t('app.admin.plan_pricing_form.about_prices')}</p>
</header>
<div className="content">
{plans && <FormSelect options={plans}
control={control}
label={t('app.admin.plan_pricing_form.copy_prices_from')}
tooltip={t('app.admin.plan_pricing_form.copy_prices_from_help')}
onChange={handleCopyPrices}
id="parent_plan_id" />}
{<FabTabs tabs={[
machines && {
id: 'machines',
title: t('app.admin.plan_pricing_form.machines'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Machine') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
},
spaces && {
id: 'spaces',
title: t('app.admin.plan_pricing_form.spaces'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Space') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
}
]} />}
</div>
</section>
);
};

View File

@ -75,7 +75,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
};
/**
* When the user clicks on the edition button, query the full data of the current pack from the API, then open te edition modal
* When the user clicks on the edition button, query the full data of the current pack from the API, then open the edition modal
*/
const handleRequestEdit = (pack: PrepaidPack): void => {
PrepaidPackAPI.get(pack.id)
@ -114,7 +114,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
onEdit={() => handleRequestEdit(p)}
itemId={p.id}
itemType={t('app.admin.configure_packs_button.pack')}
apiDestroy={PrepaidPackAPI.destroy}/>
destroy={PrepaidPackAPI.destroy}/>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.configure_packs_button.edit_pack')}

View File

@ -7,6 +7,7 @@ import Icons from '../../../../images/social-icons.svg';
import { FormInput } from '../form/form-input';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import ValidationLib from '../../lib/validation';
interface EditSocialsProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -21,8 +22,6 @@ interface EditSocialsProps<TFieldValues> {
*/
export const EditSocials = <TFieldValues extends FieldValues>({ register, setValue, networks, formState, disabled }: EditSocialsProps<TFieldValues>) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
const initSelectedNetworks = networks.filter(el => !['', null, undefined].includes(el.url));
const [selectedNetworks, setSelectedNetworks] = useState(initSelectedNetworks);
@ -72,7 +71,7 @@ export const EditSocials = <TFieldValues extends FieldValues>({ register, setVal
register={register}
rules= {{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.edit_socials.website_invalid')
}
}}

View File

@ -12,6 +12,7 @@ import Icons from '../../../../images/social-icons.svg';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import ValidationLib from '../../lib/validation';
declare const Application: IApplication;
@ -26,8 +27,6 @@ interface FabSocialsProps {
*/
export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, onSuccess }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, setValue, formState } = useForm();
@ -109,7 +108,7 @@ export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, o
register={register}
rules={{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.fab_socials.website_invalid')
}
}}

View File

@ -1,22 +1,23 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { Product } from '../../models/product';
import { PencilSimple, Trash } from 'phosphor-react';
import noImage from '../../../../images/no_image.png';
import { FabStateLabel } from '../base/fab-state-label';
import { ProductPrice } from './product-price';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
import ProductAPI from '../../api/product';
interface ProductItemProps {
product: Product,
onEdit: (product: Product) => void,
onDelete: (productId: number) => void,
onDelete: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a product item in the admin view
*/
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete }) => {
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete, onError }) => {
const { t } = useTranslation('admin');
/**
@ -34,15 +35,6 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
};
};
/**
* Init the process of delete the given product
*/
const deleteProduct = (productId: number): () => void => {
return (): void => {
onDelete(productId);
};
};
/**
* Returns CSS class from stock status
*/
@ -80,14 +72,13 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
<ProductPrice product={product} className="price" />
</div>
<div className='actions'>
<div className='manage'>
<FabButton className='edit-btn' onClick={editProduct(product)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className='delete-btn' onClick={deleteProduct(product.id)}>
<Trash size={20} weight="fill" />
</FabButton>
</div>
<EditDestroyButtons onDeleteSuccess={onDelete}
className="manage"
onError={onError}
onEdit={editProduct(product)}
itemId={product.id}
itemType={t('app.admin.store.product_item.product')}
destroy={ProductAPI.destroy} />
</div>
</div>
);

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import Select from 'react-select';
import { PencilSimple, X } from 'phosphor-react';
import { PencilSimple } from 'phosphor-react';
import { useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { useTranslation } from 'react-i18next';
import {
Product,
Product, ProductStockMovement,
stockMovementAllReasons, StockMovementIndex, StockMovementIndexFilter,
StockMovementReason,
StockType
@ -20,6 +20,7 @@ import FormatLib from '../../lib/format';
import ProductLib from '../../lib/product';
import { useImmer } from 'use-immer';
import { FabPagination } from '../base/fab-pagination';
import { FormUnsavedList } from '../form/form-unsaved-list';
interface ProductStockFormProps<TContext extends object> {
currentFormValues: Product,
@ -159,6 +160,25 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
}
};
/**
* Render an attribute of an unsaved stock movement
*/
const renderOngoingStockMovement = (movement: ProductStockMovement): ReactNode => (
<>
<div className="group">
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(movement.reason)}`)}</p>
</div>
<div className="group">
<span>{t(`app.admin.store.product_stock_form.${movement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(movement.reason))}</p>
</div>
</>
);
return (
<div className='product-stock-form'>
<h4>{t('app.admin.store.product_stock_form.stock_up_to_date')}&nbsp;
@ -178,36 +198,19 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
<span>{t('app.admin.store.product_stock_form.external')}</span>
<p>{currentFormValues?.stock?.external}</p>
</div>
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">{t('app.admin.store.product_stock_form.edit')}</FabButton>
</div>
{fields.length > 0 && <div className="ongoing-stocks">
<span className="title">{t('app.admin.store.product_stock_form.ongoing_operations')}</span>
<span className="save-notice">{t('app.admin.store.product_stock_form.save_reminder')}</span>
{fields.map((newMovement, index) => (
<div key={index} className="unsaved-stock-movement stock-item">
<div className="group">
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}</p>
</div>
<div className="group">
<span>{t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}</p>
</div>
<p className="cancel-action" onClick={() => remove(index)}>
{t('app.admin.store.product_stock_form.cancel')}
<X size={20} />
</p>
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register}
type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
</div>
))}
</div>}
<FormUnsavedList fields={fields}
className="ongoing-stocks"
onRemove={remove}
register={register}
title={t('app.admin.store.product_stock_form.ongoing_operations')}
formAttributeName="product_stock_movements_attributes"
formAttributes={['stock_type', 'quantity', 'reason']}
renderField={renderOngoingStockMovement}
saveReminderLabel={t('app.admin.store.product_stock_form.save_reminder')}
cancelLabel={t('app.admin.store.product_stock_form.cancel')} />
<hr />

View File

@ -111,14 +111,9 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError, uiRouter }) =>
};
/** Delete a product */
const deleteProduct = async (productId: number): Promise<void> => {
try {
await ProductAPI.destroy(productId);
await fetchProducts();
onSuccess(t('app.admin.store.products.successfully_deleted'));
} catch (e) {
onError(t('app.admin.store.products.unable_to_delete') + e);
}
const deleteProduct = async (message: string): Promise<void> => {
await fetchProducts();
onSuccess(message);
};
/** Goto new product page */
@ -244,6 +239,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError, uiRouter }) =>
<ProductItem
key={product.id}
product={product}
onError={onError}
onEdit={editProduct}
onDelete={deleteProduct}
/>

View File

@ -199,7 +199,7 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
onEdit={() => toTrainingEdit(training)}
itemId={training.id}
itemType={t('app.admin.trainings.training')}
apiDestroy={TrainingAPI.destroy}/>
destroy={TrainingAPI.destroy}/>
</div>
</div>
))}

View File

@ -32,6 +32,7 @@ import { ProfileCustomField } from '../../models/profile-custom-field';
import { SettingName } from '../../models/setting';
import SettingAPI from '../../api/setting';
import { SelectOption } from '../../models/select';
import ValidationLib from '../../lib/validation';
declare const Application: IApplication;
@ -55,10 +56,6 @@ interface UserProfileFormProps {
export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size, user, operator, className, onError, onSuccess, showGroupInput, showTermsAndConditionsInput, showTrainingsInput, showTagsInput }) => {
const { t } = useTranslation('shared');
// regular expression to validate the input fields
const phoneRegex = /^((00|\+)\d{2,3})?[\d -]{4,14}$/;
const urlRegex = /^(https?:\/\/)([^.]+)\.(.{2,30})(\/.*)*\/?$/;
const { handleSubmit, register, control, formState, setValue, reset } = useForm<User>({ defaultValues: { ...user } });
const output = useWatch<User>({ control });
@ -215,7 +212,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
register={register}
rules={{
pattern: {
value: phoneRegex,
value: ValidationLib.phoneRegex,
message: t('app.shared.user_profile_form.phone_number_invalid')
},
required: fieldsSettings.get('phone_required') === 'true'
@ -314,7 +311,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
register={register}
rules={{
pattern: {
value: urlRegex,
value: ValidationLib.urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}

View File

@ -105,7 +105,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
{ selected: '' };
// default tab: events list
$scope.tabs = { active: 0 };
$scope.tabs = { active: 1 };
/**
* Adds a bucket of events to the bottom of the page, grouped by month

View File

@ -21,11 +21,14 @@
/**
* Controller used in the plan creation form
*/
Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', 'planCategories',
function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, planCategories) {
Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', '$uiRouter',
function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, $uiRouter) {
// protection against request forgery
CSRF.setMetaTags();
// the following item is used by the UnsavedFormAlert component to detect a page change
$scope.uiRouter = $uiRouter;
/**
* Shows an error message forwarded from a child component
*/
@ -46,13 +49,16 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
/**
* Controller used in the plan edition form
*/
Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', 'Plan', 'planCategories',
function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, Plan, planCategories) {
Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', '$uiRouter',
function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, $uiRouter) {
// protection against request forgery
CSRF.setMetaTags();
$scope.suscriptionPlan = cleanPlan(planPromise);
// the following item is used by the UnsavedFormAlert component to detect a page change
$scope.uiRouter = $uiRouter;
/**
* Shows an error message forwarded from a child component
*/

View File

@ -0,0 +1,6 @@
// Provides regular expressions to validate user inputs
export default class ValidationLib {
static urlRegex = /^(https?:\/\/)(([^.]+)\.)+(.{2,30})(\/.*)*\/?$/;
static endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
static phoneRegex = /^((00|\+)\d{2,3})?[\d -]{4,14}$/;
}

View File

@ -69,7 +69,7 @@ export interface Event {
export interface EventDecoration {
id?: number,
name: string,
related_to?: number
related_to?: number // report the count of events related to the given decoration
}
export type EventTheme = EventDecoration;

View File

@ -5,7 +5,7 @@ import { AdvancedAccounting } from './advanced-accounting';
export interface MachineIndexFilter extends ApiFilter {
disabled?: boolean,
category?: number | 'none'
category?: number | 'none' | Array<number|'none'>
}
export interface Machine {

View File

@ -12,6 +12,16 @@ export interface Partner {
email: string
}
export type LimitableType = 'Machine'|'MachineCategory';
export interface PlanLimitation {
id?: number,
limitable_id: number,
limitable_type: LimitableType,
limit: number,
_modified?: boolean,
_destroy?: boolean,
}
export interface Plan {
id?: number,
base_name: string,
@ -34,8 +44,10 @@ export interface Plan {
plan_file_url?: string,
partner_id?: number,
partnership?: boolean,
limiting?: boolean,
partners?: Array<Partner>,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
plan_limitations_attributes?: Array<PlanLimitation>
}
export interface PlansDuration {

View File

@ -2,7 +2,7 @@
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
export type SelectOption<TOptionValue, TOptionLabel = string> = { value: TOptionValue, label: TOptionLabel }
export type SelectOption<TOptionValue, TOptionLabel = string> = { value: TOptionValue, label: TOptionLabel, disabled?: boolean }
/**
* Checklist Option format

View File

@ -4,7 +4,8 @@ import { ApiFilter } from './api';
export interface UserPackIndexFilter extends ApiFilter {
user_id: number,
priceable_type?: string,
priceable_id?: number
priceable_id?: number,
history?: boolean
}
export interface UserPack {
@ -17,5 +18,11 @@ export interface UserPack {
priceable: {
name: string
}
}
},
history?: Array<{
id: number,
consumed_minutes: number,
reservation_id: number,
reservation_date: TDateISO
}>
}

View File

@ -62,6 +62,7 @@
@import "modules/form/form-checklist";
@import "modules/form/form-file-upload";
@import "modules/form/form-image-upload";
@import "modules/form/form-unsaved-list";
@import "modules/group/change-group";
@import "modules/invoices/invoices-settings-panel";
@import "modules/invoices/vat-settings-modal";
@ -97,6 +98,9 @@
@import "modules/plan-categories/plan-categories-list";
@import "modules/plans/plan-card";
@import "modules/plans/plan-form";
@import "modules/plans/plan-limit-form";
@import "modules/plans/plan-limit-modal";
@import "modules/plans/plan-pricing-form";
@import "modules/plans/plans-filter";
@import "modules/plans/plans-list";
@import "modules/prepaid-packs/packs-summary";

View File

@ -1,4 +1,6 @@
.edit-destroy-buttons {
border-radius: var(--border-radius-sm);
overflow: hidden;
button {
@include btn;
border-radius: 0;
@ -12,4 +14,4 @@
}
}
}
}

View File

@ -12,16 +12,21 @@
text-align: center;
color: var(--main);
border-bottom: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
&:hover {
background-color: var(--gray-soft-light);
cursor: pointer;
}
&.react-tabs__tab--selected {
color: var(--gray-hard-dark);
border: 1px solid var(--gray-soft-dark);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
&:hover {
cursor: pointer;
&:hover {
background-color: var(--gray-soft-lightest);
cursor: default;
}
&:focus { outline: none; }
}
}
}

View File

@ -1,6 +1,6 @@
.fab-text-editor {
position: relative;
margin-bottom: 1.6rem;
//margin-bottom: 1.6rem;
padding-bottom: 1.6rem;
background-color: var(--gray-soft-lightest);
border: 1px solid var(--gray-soft-dark);

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}
@ -25,7 +23,7 @@
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2.4rem;
gap: 1.6rem 2.4rem;
.add-price {
max-width: fit-content;
@ -37,6 +35,8 @@
align-items: flex-end;
gap: 0 2.4rem;
.form-item { margin: 0; }
.remove-price {
align-items: center;
display: flex;
@ -61,8 +61,7 @@
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item:first-child {
margin-right: 2.4rem;
}
.form-item { margin: 0; }
.form-item:first-child { margin-right: 2.4rem; }
}
}

View File

@ -1,4 +1,4 @@
.events {
.events-list-page {
max-width: 1600px;
margin: 2rem;
padding-bottom: 6rem;

View File

@ -0,0 +1,47 @@
.form-unsaved-list {
.save-notice {
@include text-xs;
margin-left: 1rem;
color: var(--alert);
}
.unsaved-field {
background-color: var(--gray-soft-light);
border: 0;
padding: 1.2rem;
margin-top: 1rem;width: 100%;
display: flex;
gap: 4.8rem;
justify-items: flex-start;
align-items: center;
border-radius: var(--border-radius);
& > * { flex: 1 1 45%; }
p {
margin: 0;
@include text-base;
}
.title {
@include text-base(600);
flex: 1 1 100%;
}
.group {
span {
@include text-xs;
color: var(--gray-hard-light);
}
p { @include text-base(600); }
}
.cancel-action {
&:hover {
text-decoration: underline;
cursor: pointer;
}
svg {
margin-left: 1rem;
vertical-align: middle;
}
}
}
}

View File

@ -14,18 +14,6 @@
display: flex;
justify-content: flex-end;
align-items: center;
button {
border-radius: 5;
&:hover { opacity: 0.75; }
}
.edit-btn {
color: var(--gray-hard-darkest);
margin-right: 10px;
}
.delete-btn {
color: var(--gray-soft-lightest);
background: var(--main);
}
}
.machine-categories-table {

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -1,25 +1,40 @@
.plan-form {
.plan-sheet {
margin-top: 4rem;
max-width: 1260px;
margin: 2.4rem auto 0;
padding: 0 3rem 6rem;
display: flex;
flex-direction: column;
& > header {
padding-bottom: 0;
@include header($sticky: true);
gap: 2.4rem;
}
.duration {
display: flex;
flex-direction: row;
.form-item:first-child {
margin-right: 32px;
}
}
.partner {
&-content {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3.2rem;
.fab-alert {
width: 100%;
section { @include layout-settings; }
.grp {
display: flex;
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item { margin: 0; }
.form-item:first-child { margin-right: 2.4rem; }
}
.partner {
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
gap: 0 2.4rem;
@media (min-width: 640px) {
flex-direction: row;
button { margin-bottom: 1.6rem; }
}
}
}
.submit-btn {
float: right;
}
}

View File

@ -0,0 +1,85 @@
.plan-limit-form {
display: flex;
flex-direction: column;
gap: 3.2rem 0;
section { @include layout-settings; }
.plan-limit-grp {
header {
@include header();
p {
@include title-base;
margin: 0;
}
}
.form-unsaved-list {
margin-bottom: 6.4rem;
}
.plan-limit-list {
max-height: 65vh;
display: flex;
flex-direction: column;
overflow-y: auto;
& > .title { @include text-base(500); }
.plan-limit-item {
width: 100%;
margin-bottom: 2.4rem;
padding: 1.6rem;
display: flex;
justify-content: space-between;
gap: 3.2rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
span {
@include text-xs;
color: var(--gray-hard-light);
}
p {
margin: 0;
@include text-base(600);
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
}
@media (min-width: 540px) {
.grp {
flex: 1;
display: flex;
justify-content: space-between;
& > * {
flex: 1;
}
}
}
&.is-destroying {
background-color: var(--alert-lightest);
.marker {
text-align: center;
font-weight: 500;
color: var(--alert-dark);
margin: auto;
}
.actions > .cancel-action {
font-weight: normal;
svg {
vertical-align: middle;
margin-left: 1rem;
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
}
}
}
}

View File

@ -0,0 +1,30 @@
.plan-limit-modal {
.grp {
margin-bottom: 3.2rem;
display: flex;
justify-content: space-between;
align-items: center;
button {
flex: 1;
padding: 1.6rem;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--gray-soft-lightest);
border: 1px solid var(--gray-soft-dark);
color: var(--gray-soft-darkest);
@include text-base;
&.is-active {
border: 1px solid var(--gray-soft-darkest);
background-color: var(--gray-hard-darkest);
color: var(--gray-soft-lightest);
}
}
button:first-of-type {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
button:last-of-type {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
}
}

View File

@ -0,0 +1,12 @@
.plan-pricing-form {
.fab-tabs .tabs li {
margin-bottom: 1.6rem;
&:hover { background-color: var(--gray-soft); }
&.react-tabs__tab--selected:hover { background-color: transparent; }
}
.react-tabs__tab-panel {
max-height: 50vh;
overflow-y: auto;
}
}

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -3,28 +3,6 @@
.ongoing-stocks {
margin: 2.4rem 0;
.save-notice {
@include text-xs;
margin-left: 1rem;
color: var(--alert);
}
.unsaved-stock-movement {
background-color: var(--gray-soft-light);
border: 0;
padding: 1.2rem;
margin-top: 1rem;
.cancel-action {
&:hover {
text-decoration: underline;
cursor: pointer;
}
svg {
margin-left: 1rem;
vertical-align: middle;
}
}
}
}
.store-list {
margin-top: 2.4rem;

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -29,10 +29,15 @@
}
& > .content {
display: flex;
flex-direction: column;
padding: 1.6rem;
background-color: var(--gray-soft-light);
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
& > * { margin-bottom: 0; }
& > *:not(:last-child) { margin-bottom: 3.2rem; }
.fab-alert { margin: 0 0 1.6rem; }
}
@media (min-width: 1024px) {

View File

@ -10,24 +10,7 @@
<h1>{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ suscriptionPlan.base_name }}</h1>
</section>
</div>
<div class="col-md-3">
<section class="heading-actions wrapper">
<a class="btn btn-lg btn-block btn-default m-t-xs" ui-sref="app.admin.pricing" translate>{{ 'app.shared.buttons.cancel' }}</a>
</section>
</div>
</div>
</section>
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<plan-form action="'update'" plan="suscriptionPlan" on-error="onError" on-success="onSuccess"></plan-form>
</div>
</div>
</div>
</div>
<plan-form action="'update'" plan="suscriptionPlan" on-error="onError" on-success="onSuccess" ui-router="uiRouter"></plan-form>

View File

@ -14,14 +14,4 @@
</div>
</section>
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<plan-form action="'create'" on-error="onError" on-success="onSuccess"></plan-form>
</div>
</div>
</div>
</div>
<plan-form action="'create'" on-error="onError" on-success="onSuccess" ui-router="uiRouter"></plan-form>

View File

@ -19,7 +19,7 @@
</div>
<section class="events">
<section class="events-list-page">
<events-editorial-block on-error="onError"></events-editorial-block>
<div class="row">

View File

@ -74,7 +74,7 @@ module ApplicationHelper
def may_array(param)
return param if param.is_a?(Array)
return param unless param&.chars&.first == '[' && param&.chars&.last == ']'
return param unless param.chars&.first == '[' && param.chars&.last == ']'
param.gsub(/[\[\]]/i, '').split(',')
end

View File

@ -41,10 +41,10 @@ class AuthProvider < ApplicationRecord
provider = find_by(status: 'active')
return local if provider.nil?
return provider
provider
rescue ActiveRecord::StatementInvalid
# we fall here on database creation because the table "active_providers" still does not exists at the moment
return local
local
end
end
@ -59,7 +59,7 @@ class AuthProvider < ApplicationRecord
parsed = /^([^-]+)-(.+)$/.match(strategy_name)
ret = nil
all.each do |strategy|
all.find_each do |strategy|
if strategy.provider_type == parsed[1] && strategy.name.downcase.parameterize == parsed[2]
ret = strategy
break
@ -70,13 +70,13 @@ class AuthProvider < ApplicationRecord
## Return the name that should be registered in OmniAuth for the corresponding strategy
def strategy_name
provider_type + '-' + name.downcase.parameterize
"#{provider_type}-#{name.downcase.parameterize}"
end
## Return the provider type name without the "Provider" part.
## eg. DatabaseProvider will return 'database'
def provider_type
providable_type[0..-9].downcase
providable_type[0..-9]&.downcase
end
## Return the user's profile fields that are currently managed from the SSO
@ -84,7 +84,7 @@ class AuthProvider < ApplicationRecord
def sso_fields
fields = []
auth_provider_mappings.each do |mapping|
fields.push(mapping.local_model + '.' + mapping.local_field)
fields.push("#{mapping.local_model}.#{mapping.local_field}")
end
fields
end
@ -96,10 +96,10 @@ class AuthProvider < ApplicationRecord
end
def safe_destroy
if status != 'active'
destroy
else
if status == 'active'
false
else
destroy
end
end

View File

@ -13,18 +13,22 @@ class CartItem::Reservation < CartItem::BaseItem
nil
end
# @return [Plan,NilClass]
def plan
nil
end
# @return [User]
def operator
operator_profile.user
end
# @return [User]
def customer
customer_profile.user
end
# @return [Hash{Symbol=>Integer,Hash{Symbol=>Array<Hash{Symbol=>Integer,Float,Boolean,Time}>}}]
def price
is_privileged = operator.privileged? && operator.id != customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(customer, reservable) }
@ -48,48 +52,33 @@ class CartItem::Reservation < CartItem::BaseItem
{ elements: elements, amount: amount }
end
# @return [String,NilClass]
def name
reservable&.name
end
# @param all_items [Array<CartItem::BaseItem>]
# @return [Boolean]
def valid?(all_items = [])
pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) }
plan = pending_subscription&.plan || customer&.subscribed_plan
reservation_deadline = reservation_deadline_minutes.minutes.since
unless ReservationLimitService.authorized?(plan, customer, self, all_items)
errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', {
HOURS: ReservationLimitService.limit(plan, reservable),
RESERVABLE: reservable.name
}))
return false
end
cart_item_reservation_slots.each do |sr|
slot = sr.slot
if slot.nil?
errors.add(:slot, I18n.t('cart_item_validation.slot'))
return false
end
availability = slot.availability
if availability.nil?
errors.add(:availability, I18n.t('cart_item_validation.availability'))
return false
end
if slot.full?
errors.add(:slot, I18n.t('cart_item_validation.full'))
return false
end
if slot.start_at < reservation_deadline && !operator.privileged?
errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes }))
return false
end
next if availability.plan_ids.empty?
next if required_subscription?(availability, pending_subscription)
errors.add(:availability, I18n.t('cart_item_validation.restricted'))
return false
return false unless validate_slot_reservation(sr, pending_subscription, errors)
end
true
end
# @return [Reservation]
def to_object
::Reservation.new(
reservable_id: reservable_id,
@ -106,7 +95,7 @@ class CartItem::Reservation < CartItem::BaseItem
end
# Group the slots by date, if the extended_prices_in_same_day option is set to true
# @return Hash{Symbol => Array<CartItem::ReservationSlot>}
# @return [Hash{Symbol => Array<CartItem::ReservationSlot>}]
def grouped_slots
return { all: cart_item_reservation_slots } unless Setting.get('extended_prices_in_same_day')
@ -124,7 +113,7 @@ class CartItem::Reservation < CartItem::BaseItem
# @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided
# @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default)
# @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer
# @return [Float]
# @return [Float,Integer]
def get_slot_price_from_prices(prices, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
@ -151,7 +140,7 @@ class CartItem::Reservation < CartItem::BaseItem
# @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided
# @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default)
# @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer
# @return [Float] price of the slot
# @return [Float,Integer] price of the slot
def get_slot_price(hourly_rate, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
@ -244,10 +233,10 @@ class CartItem::Reservation < CartItem::BaseItem
cart_item_reservation_slots.map { |sr| { id: sr.slots_reservation_id, slot_id: sr.slot_id, offered: sr.offered } }
end
##
# Check if the given availability requires a valid subscription. If so, check if the current customer
# has the required susbcription, otherwise, check if the operator is privileged
##
# @param availability [Availability]
# @param pending_subscription [CartItem::Subscription, NilClass]
def required_subscription?(availability, pending_subscription)
(customer.subscribed_plan && availability.plan_ids.include?(customer.subscribed_plan.id)) ||
(pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) ||
@ -260,5 +249,39 @@ class CartItem::Reservation < CartItem::BaseItem
##
def reservation_deadline_minutes
return 0
# @param reservation_slot [CartItem::ReservationSlot]
# @param pending_subscription [CartItem::Subscription, NilClass]
# @param errors [ActiveModel::Errors]
# @return [Boolean]
def validate_slot_reservation(reservation_slot, pending_subscription, errors)
slot = reservation_slot.slot
if slot.nil?
errors.add(:slot, I18n.t('cart_item_validation.slot'))
return false
end
availability = slot.availability
if availability.nil?
errors.add(:availability, I18n.t('cart_item_validation.availability'))
return false
end
if slot.full?
errors.add(:slot, I18n.t('cart_item_validation.full'))
return false
end
if slot.start_at < reservation_deadline_minutes.minutes.since && !operator.privileged?
errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline }))
return false
end
if availability.plan_ids.any? && !required_subscription?(availability, pending_subscription)
errors.add(:availability, I18n.t('cart_item_validation.restricted'))
return false
end
true
end
end

View File

@ -42,6 +42,8 @@ class Machine < ApplicationRecord
belongs_to :machine_category
has_many :plan_limitations, dependent: :destroy, inverse_of: :machine, foreign_type: 'limitable_type', foreign_key: 'limitable_id'
after_create :create_statistic_subtype
after_create :create_machine_prices
after_create :update_gateway_product

View File

@ -4,4 +4,5 @@
class MachineCategory < ApplicationRecord
has_many :machines, dependent: :nullify
accepts_nested_attributes_for :machines, allow_destroy: true
has_many :plan_limitations, dependent: :destroy, inverse_of: :machine_category, foreign_type: 'limitable_type', foreign_key: 'limitable_id'
end

View File

@ -3,6 +3,5 @@
# OAuth2Provider is a special type of AuthProvider which provides authentication through an external SSO server using
# the oAuth 2.0 protocol.
class OAuth2Provider < ApplicationRecord
has_one :auth_provider, as: :providable
has_one :auth_provider, as: :providable, dependent: :destroy
end

View File

@ -3,7 +3,7 @@
# OpenIdConnectProvider is a special type of AuthProvider which provides authentication through an external SSO server using
# the OpenID Connect protocol.
class OpenIdConnectProvider < ApplicationRecord
has_one :auth_provider, as: :providable
has_one :auth_provider, as: :providable, dependent: :destroy
validates :issuer, presence: true
validates :client__identifier, presence: true
@ -28,8 +28,8 @@ class OpenIdConnectProvider < ApplicationRecord
end
def client_config
OpenIdConnectProvider.columns.map(&:name).filter { |n| n.start_with?('client__') }.map do |n|
OpenIdConnectProvider.columns.map(&:name).filter { |n| n.start_with?('client__') }.to_h do |n|
[n.sub('client__', ''), send(n)]
end.to_h
end
end
end

View File

@ -21,6 +21,9 @@ class Plan < ApplicationRecord
has_many :cart_item_subscriptions, class_name: 'CartItem::Subscription', dependent: :destroy
has_many :cart_item_payment_schedules, class_name: 'CartItem::PaymentSchedule', dependent: :destroy
has_many :plan_limitations, dependent: :destroy
accepts_nested_attributes_for :plan_limitations, allow_destroy: true
extend FriendlyId
friendly_id :base_name, use: :slugged

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Allows to set booking limits on some resources, per plan.
class PlanLimitation < ApplicationRecord
belongs_to :plan
belongs_to :limitable, polymorphic: true
belongs_to :machine, foreign_type: 'Machine', foreign_key: 'limitable_id', inverse_of: :plan_limitations
belongs_to :machine_category, foreign_type: 'MachineCategory', foreign_key: 'limitable_id', inverse_of: :plan_limitations
validates :limitable_id, :limitable_type, :limit, :plan_id, presence: true
validates :limitable_id, uniqueness: { scope: %i[limitable_type plan_id] }
# @return [Array<Machine,Event,Space,Training>]
def reservables
return limitable.machines if limitable_type == 'MachineCategory'
[limitable]
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# Save the association between a Reservation and a PrepaidPack to keep the usage history.
class PrepaidPackReservation < ApplicationRecord
belongs_to :statistic_profile_prepaid_pack
belongs_to :reservation
end

View File

@ -21,6 +21,8 @@ class Reservation < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
has_one :payment_schedule_object, as: :object, dependent: :destroy
has_many :prepaid_pack_reservations, dependent: :destroy
validates :reservable_id, :reservable_type, presence: true
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }
validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) }
@ -30,6 +32,7 @@ class Reservation < ApplicationRecord
after_commit :notify_member_create_reservation, on: :create
after_commit :notify_admin_member_create_reservation, on: :create
after_commit :extend_subscription, on: :create
after_commit :notify_member_limitation_reached, on: :create
delegate :user, to: :statistic_profile
@ -121,7 +124,7 @@ class Reservation < ApplicationRecord
end
def extend_subscription
Subscriptions::ExtensionAfterReservation.new(self).extend_subscription_if_eligible
::Subscriptions::ExtensionAfterReservation.new(self).extend_subscription_if_eligible
end
def notify_member_create_reservation
@ -135,4 +138,14 @@ class Reservation < ApplicationRecord
receiver: User.admins_and_managers,
attached_object: self
end
def notify_member_limitation_reached
date = ReservationLimitService.reached_limit_date(self)
return if date.nil?
NotificationCenter.call type: 'notify_member_reservation_limit_reached',
receiver: user,
attached_object: ReservationLimitService.limit(user.subscribed_plan, reservable),
meta_data: { date: date }
end
end

View File

@ -160,6 +160,7 @@ class ShoppingCart
# Check if the current cart needs the user to have been validated, and if the condition is satisfied.
# Return an array of errors, if any; false otherwise
# @return [Array<String>,FalseClass]
def check_user_validation(items)
user_validation_required = Setting.get('user_validation_required')
user_validation_required_list = Setting.get('user_validation_required_list')

View File

@ -8,6 +8,7 @@ class StatisticProfilePrepaidPack < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
has_one :payment_schedule_object, as: :object, dependent: :destroy
has_many :prepaid_pack_reservations, dependent: :restrict_with_error
before_create :set_expiration_date

View File

@ -44,7 +44,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting&.send(section) : nil
return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.nil? && section == :code
return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.blank? && section == :code
item_code
else
@ -58,7 +58,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.plan.advanced_accounting&.send(section) : nil
return Setting.get('accounting_subscription_code') if item_code.nil? && section == :code
return Setting.get('accounting_subscription_code') if item_code.blank? && section == :code
item_code
else
@ -72,7 +72,7 @@ class Accounting::AccountingCodeService
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.orderable.advanced_accounting&.send(section) : nil
return Setting.get('accounting_Product_code') if item_code.nil? && section == :code
return Setting.get('accounting_Product_code') if item_code.blank? && section == :code
item_code
else

View File

@ -6,16 +6,6 @@ class Availabilities::AvailabilitiesService
# @param level [String] 'slot' | 'availability'
def initialize(current_user, level = 'slot')
@current_user = current_user
@maximum_visibility = {
year: Setting.get('visibility_yearly').to_i.months.since,
other: Setting.get('visibility_others').to_i.months.since
}
@minimum_visibility = {
machine: Setting.get('machine_reservation_deadline').to_i.minutes.since,
training: Setting.get('training_reservation_deadline').to_i.minutes.since,
event: Setting.get('event_reservation_deadline').to_i.minutes.since,
space: Setting.get('space_reservation_deadline').to_i.minutes.since
}
@level = level
end
@ -130,37 +120,17 @@ class Availabilities::AvailabilitiesService
# @param range_end [ActiveSupport::TimeWithZone]
# @return ActiveRecord::Relation<Availability>
def availabilities(availabilities, type, user, range_start, range_end)
# who made the request?
# 1) an admin (he can see all availabilities from 1 month ago to anytime in the future)
if @current_user&.admin? || @current_user&.manager?
window_start = [range_start, 1.month.ago].max
availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', range_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, range_end)
# 2) an user (he cannot see past availabilities neither those further than 1 (or 3) months in the future)
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if subscription_year?(user) && type != 'training'
end_at = @maximum_visibility[:year] if show_more_trainings?(user) && type == 'training'
minimum_visibility = 0.minutes.since
minimum_visibility = @minimum_visibility[:machine] if type == 'machines'
minimum_visibility = @minimum_visibility[:training] if type == 'training'
minimum_visibility = @minimum_visibility[:event] if type == 'event'
minimum_visibility = @minimum_visibility[:space] if type == 'space'
print(minimum_visibility)
print(@minimum_visibility[:machine])
window_end = [end_at, range_end].min
window_start = [range_start, minimum_visibility].max
availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, window_end)
.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil]))
.where(lock: false)
window = Availabilities::VisibilityService.new.visibility(@current_user, type, range_start, range_end)
qry = availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window[1], window[0], type)
.where('slots.start_at > ? AND slots.end_at < ?', window[0], window[1])
unless @current_user&.privileged?
# non priviledged users cannot see availabilities with tags different than their own and locked tags
qry = qry.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil]))
.where(lock: false)
end
qry
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
# Return the maximum available visibility for a user
class Availabilities::VisibilityService
def initialize
@maximum_visibility = {
year: Setting.get('visibility_yearly').to_i.months.since,
other: Setting.get('visibility_others').to_i.months.since
}
@minimum_visibility = {
machine: Setting.get('machine_reservation_deadline').to_i.minutes.since,
training: Setting.get('training_reservation_deadline').to_i.minutes.since,
event: Setting.get('event_reservation_deadline').to_i.minutes.since,
space: Setting.get('space_reservation_deadline').to_i.minutes.since
}
end
# @param user [User,NilClass]
# @param available_type [String] 'training', 'space', 'machine' or 'event'
# @param range_start [ActiveSupport::TimeWithZone]
# @param range_end [ActiveSupport::TimeWithZone]
# @return [Array<ActiveSupport::TimeWithZone,Date,Time>] as: [start,end]
def visibility(user, available_type, range_start, range_end)
if user&.privileged?
window_start = [range_start, 1.month.ago].max
window_end = range_end
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if subscription_year?(user) && available_type != 'training'
end_at = @maximum_visibility[:year] if show_more_trainings?(user) && available_type == 'training'
end_at = subscription_visibility(user, available_type) || end_at
minimum_visibility = 0.minutes.since
minimum_visibility = @minimum_visibility[:machine] if available_type == 'machines'
minimum_visibility = @minimum_visibility[:training] if available_type == 'training'
minimum_visibility = @minimum_visibility[:event] if available_type == 'event'
minimum_visibility = @minimum_visibility[:space] if available_type == 'space'
window_end = [end_at, range_end].min
window_start = [range_start, minimum_visibility].max
end
[window_start, window_end]
end
private
# @param user [User,NilClass]
def subscription_year?(user)
user&.subscribed_plan &&
(user&.subscribed_plan&.interval == 'year' ||
(user&.subscribed_plan&.interval == 'month' && user.subscribed_plan.interval_count >= 12))
end
# @param user [User,NilClass]
# @param available_type [String] 'training', 'space', 'machine' or 'event'
# @return [Time,NilClass]
def subscription_visibility(user, available_type)
return nil unless user&.subscribed_plan
return nil unless available_type == 'machine'
machines = user&.subscribed_plan&.machines_visibility
machines&.hours&.since
end
# members must have validated at least 1 training and must have a valid yearly subscription to view
# the trainings further in the futur. This is used to prevent users with a rolling subscription to take
# their first training in a very long delay.
# @param user [User,NilClass]
def show_more_trainings?(user)
user&.trainings&.size&.positive? && subscription_year?(user)
end
end

View File

@ -6,10 +6,9 @@ class CartService
@operator = operator
end
##
# For details about the expected hash format
# @see app/frontend/src/javascript/models/payment.ts > interface ShoppingCart
##
# @return [ShoppingCart]
def from_hash(cart_items)
cart_items.permit! if cart_items.is_a? ActionController::Parameters

View File

@ -17,7 +17,7 @@ class Checkout::PaymentService
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)
amount = debit_amount(order)
amount = debit_amount(order, coupon_code)
if (operator.privileged? && operator != order.statistic_profile.user) || amount.zero?
Payments::LocalService.new.payment(order, coupon_code)
elsif Stripe::Helper.enabled? && payment_id.present?

View File

@ -3,6 +3,8 @@
# Provides methods for Machines
class MachineService
class << self
include ApplicationHelper
# @param filters [ActionController::Parameters]
def list(filters)
sort_by = Setting.get('machines_sort_by') || 'default'
@ -15,7 +17,7 @@ class MachineService
machines = machines.where(deleted_at: nil)
machines = filter_by_disabled(machines, filters)
filter_by_category(machines, filters)
filter_by_categories(machines, filters)
end
private
@ -31,12 +33,10 @@ class MachineService
# @param machines [ActiveRecord::Relation<Machine>]
# @param filters [ActionController::Parameters]
def filter_by_category(machines, filters)
def filter_by_categories(machines, filters)
return machines if filters[:category].blank?
return machines.where(machine_category_id: nil) if filters[:category] == 'none'
machines.where(machine_category_id: filters[:category])
machines.where(machine_category_id: filters[:category].split(',').map { |id| id == 'none' ? nil : id })
end
end
end

View File

@ -7,6 +7,7 @@ class PrepaidPackService
# @option filters [Integer] :group_id
# @option filters [Integer] :priceable_id
# @option filters [String] :priceable_type 'Machine' | 'Space'
# @return [ActiveRecord::Relation<PrepaidPack>]
def list(filters)
packs = PrepaidPack.where(nil)
@ -25,6 +26,7 @@ class PrepaidPackService
# return the not expired packs for the given item bought by the given user
# @param user [User]
# @param priceable [Machine,Space,NilClass]
# @return [ActiveRecord::Relation<StatisticProfilePrepaidPack>]
def user_packs(user, priceable = nil)
sppp = StatisticProfilePrepaidPack.includes(:prepaid_pack)
.references(:prepaid_packs)
@ -60,18 +62,18 @@ class PrepaidPackService
packs = user_packs(user, reservation.reservable).order(minutes_used: :desc)
packs.each do |pack|
pack_available = pack.prepaid_pack.minutes - pack.minutes_used
remaining = pack_available - consumed
remaining = 0 if remaining.negative?
pack_consumed = pack.prepaid_pack.minutes - remaining
pack.update(minutes_used: pack_consumed)
remaining = consumed > pack_available ? 0 : pack_available - consumed
pack.update(minutes_used: pack.prepaid_pack.minutes - remaining)
pack_consumed = consumed > pack_available ? pack_available : consumed
consumed -= pack_consumed
PrepaidPackReservation.create!(statistic_profile_prepaid_pack: pack, reservation: reservation, consumed_minutes: pack_consumed)
end
end
# Total number of prepaid minutes available
# @param user [User]
# @param priceable [Machine,Space]
# @param priceable [Machine,Space,NilClass]
def minutes_available(user, priceable)
return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
# Check if a user if allowed to book a reservation without exceeding the limits set by his plan
class ReservationLimitService
class << self
# @param plan [Plan,NilClass]
# @param customer [User]
# @param reservation [CartItem::Reservation]
# @param cart_items [Array<CartItem::BaseItem>]
# @return [Boolean]
def authorized?(plan, customer, reservation, cart_items)
return true if plan.nil? || !plan.limiting
return true if reservation.nil? || !reservation.is_a?(CartItem::Reservation)
limit = limit(plan, reservation.reservable)
return true if limit.nil?
reservation.cart_item_reservation_slots.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots|
daily_duration = reservations_duration(customer, date, reservation, cart_items) +
(reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0)
return false if Rational(daily_duration / 3600).to_f > limit.limit
end
true
end
# @param reservation [Reservation]
# @return [Date,NilClass]
def reached_limit_date(reservation)
user = reservation.user
plan = user.subscribed_plan
return nil if plan.nil? || !plan.limiting
limit = limit(plan, reservation.reservable)
return nil if limit.nil?
reservation.slots_reservations.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots|
daily_duration = saved_reservations_durations(user, reservation.reservable, date, reservation) +
(reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0)
return date if Rational(daily_duration / 3600).to_f >= limit.limit
end
nil
end
# @param plan [Plan,NilClass]
# @param reservable [Machine,Event,Space,Training]
# @return [PlanLimitation] in hours
def limit(plan, reservable)
return nil unless plan&.limiting
limitations = plan&.plan_limitations&.filter { |limit| limit.reservables.include?(reservable) }
limitations&.find { |limit| limit.limitable_type != 'MachineCategory' } || limitations&.first
end
private
# @param customer [User]
# @param date [Date]
# @param reservation [CartItem::Reservation]
# @param cart_items [Array<CartItem::BaseItem>]
# @return [Integer] in seconds
def reservations_duration(customer, date, reservation, cart_items)
daily_reservations_hours = saved_reservations_durations(customer, reservation.reservable, date)
cart_daily_reservations = cart_items.filter do |item|
item.is_a?(CartItem::Reservation) &&
item != reservation &&
item.reservable == reservation.reservable &&
item.cart_item_reservation_slots
.includes(:slot)
.where("date_trunc('day', slots.start_at) = :date", date: date)
end
daily_reservations_hours +
(cart_daily_reservations.map { |r| r.cart_item_reservation_slots.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0)
end
# @param customer [User]
# @param reservable [Machine,Event,Space,Training]
# @param date [Date]
# @param reservation [Reservation]
# @return [Integer] in seconds
def saved_reservations_durations(customer, reservable, date, reservation = nil)
daily_reservations = customer.reservations
.includes(slots_reservations: :slot)
.where(reservable: reservable)
.where(slots_reservations: { canceled_at: nil })
.where("date_trunc('day', slots.start_at) = :date", date: date)
daily_reservations = daily_reservations.where.not(id: reservation.id) unless reservation.nil?
(daily_reservations.map { |r| r.slots_reservations.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0)
end
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Services around subscriptions
module Subscriptions; end
# Expire the given subscription
class Subscriptions::ExpireService
class << self

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
# Services around subscriptions
module Subscriptions; end
# Extend the user's current subscription after his first training reservation if
# he subscribed to a rolling plan
class Subscriptions::ExtensionAfterReservation

View File

@ -10,7 +10,7 @@ json.is_completed slot.full?(reservable)
json.backgroundColor 'white'
json.availability_id slot.availability_id
json.slots_reservations_ids Slots::ReservationsService.user_reservations(slot, user, reservable)[:reservations]
json.slots_reservations_ids Slots::ReservationsService.user_reservations(slot, user, reservable)[:reservations].map(&:id)
json.tag_ids slot.availability.tag_ids
json.tags slot.availability.tags do |t|

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
json.title notification.notification_type
json.description t('.limit_reached',
HOURS: notification.attached_object.limit,
ITEM: notification.attached_object.limitable.name,
DATE: I18n.l(notification.get_meta_data(:date).to_date))

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type,
:ui_weight, :disabled, :monthly_payment, :plan_category_id
:ui_weight, :disabled, :monthly_payment, :plan_category_id, :limiting, :machines_visibility
json.amount plan.amount / 100.00
json.prices_attributes plan.prices, partial: 'api/prices/price', as: :price
if plan.plan_file
@ -27,3 +27,7 @@ if plan.advanced_accounting
end
end
json.plan_limitations_attributes plan.plan_limitations do |limitation|
json.extract! limitation, :id, :limitable_id, :limitable_type, :limit
end

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