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:
commit
855df4b552
49
CHANGELOG.md
49
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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>}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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')} />
|
||||
</>}
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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']));
|
||||
|
261
app/frontend/src/javascript/components/plans/plan-limit-form.tsx
Normal file
261
app/frontend/src/javascript/components/plans/plan-limit-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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')}
|
||||
|
@ -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')
|
||||
}
|
||||
}}
|
||||
|
@ -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')
|
||||
}
|
||||
}}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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')}
|
||||
@ -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 />
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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')
|
||||
}
|
||||
}}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
*/
|
||||
|
6
app/frontend/src/javascript/lib/validation.ts
Normal file
6
app/frontend/src/javascript/lib/validation.ts
Normal 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}$/;
|
||||
}
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}>
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -1,4 +1,6 @@
|
||||
.edit-destroy-buttons {
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
button {
|
||||
@include btn;
|
||||
border-radius: 0;
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
.events {
|
||||
.events-list-page {
|
||||
max-width: 1600px;
|
||||
margin: 2rem;
|
||||
padding-bottom: 6rem;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -15,8 +15,6 @@
|
||||
flex-direction: column;
|
||||
gap: 3.2rem;
|
||||
|
||||
.fab-alert { margin: 0; }
|
||||
|
||||
section { @include layout-settings; }
|
||||
.save-btn { align-self: flex-start; }
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -15,8 +15,6 @@
|
||||
flex-direction: column;
|
||||
gap: 3.2rem;
|
||||
|
||||
.fab-alert { margin: 0; }
|
||||
|
||||
section { @include layout-settings; }
|
||||
.save-btn { align-self: flex-start; }
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -15,8 +15,6 @@
|
||||
flex-direction: column;
|
||||
gap: 3.2rem;
|
||||
|
||||
.fab-alert { margin: 0; }
|
||||
|
||||
section { @include layout-settings; }
|
||||
.save-btn { align-self: flex-start; }
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
20
app/models/plan_limitation.rb
Normal file
20
app/models/plan_limitation.rb
Normal 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
|
7
app/models/prepaid_pack_reservation.rb
Normal file
7
app/models/prepaid_pack_reservation.rb
Normal 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
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
72
app/services/availabilities/visibility_service.rb
Normal file
72
app/services/availabilities/visibility_service.rb
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
97
app/services/reservation_limit_service.rb
Normal file
97
app/services/reservation_limit_service.rb
Normal 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
|
@ -1,5 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Services around subscriptions
|
||||
module Subscriptions; end
|
||||
|
||||
# Expire the given subscription
|
||||
class Subscriptions::ExpireService
|
||||
class << self
|
||||
|
@ -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
|
||||
|
@ -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|
|
||||
|
@ -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))
|
@ -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
Loading…
x
Reference in New Issue
Block a user