diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ad6a2d4..47465aa99 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Gemfile.lock b/Gemfile.lock index 3f822dc80..e15107497 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/api/local_payment_controller.rb b/app/controllers/api/local_payment_controller.rb index 126510dd4..a5c18200f 100644 --- a/app/controllers/api/local_payment_controller.rb +++ b/app/controllers/api/local_payment_controller.rb @@ -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 diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb index 69fbaa70d..14eaf310b 100644 --- a/app/controllers/api/payzen_controller.rb +++ b/app/controllers/api/payzen_controller.rb @@ -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 diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index f5895b07d..52d786cd5 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -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 diff --git a/app/controllers/api/stripe_controller.rb b/app/controllers/api/stripe_controller.rb index 05b3a6e2b..e885c6fd4 100644 --- a/app/controllers/api/stripe_controller.rb +++ b/app/controllers/api/stripe_controller.rb @@ -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( diff --git a/app/controllers/api/user_packs_controller.rb b/app/controllers/api/user_packs_controller.rb index f4df9f611..4c00014ef 100644 --- a/app/controllers/api/user_packs_controller.rb +++ b/app/controllers/api/user_packs_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/accounting_controller.rb b/app/controllers/open_api/v1/accounting_controller.rb index 8e1942a3f..7690108ac 100644 --- a/app/controllers/open_api/v1/accounting_controller.rb +++ b/app/controllers/open_api/v1/accounting_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/concerns/accountings_filters_concern.rb b/app/controllers/open_api/v1/concerns/accountings_filters_concern.rb new file mode 100644 index 000000000..2c5d4930b --- /dev/null +++ b/app/controllers/open_api/v1/concerns/accountings_filters_concern.rb @@ -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] + # @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] + # @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] + # @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] + # @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 diff --git a/app/controllers/open_api/v1/concerns/reservations_filters_concern.rb b/app/controllers/open_api/v1/concerns/reservations_filters_concern.rb new file mode 100644 index 000000000..f011aec0b --- /dev/null +++ b/app/controllers/open_api/v1/concerns/reservations_filters_concern.rb @@ -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] + # @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] + # @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] + # @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] + # @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] + # @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 diff --git a/app/controllers/open_api/v1/concerns/subscriptions_filters_concern.rb b/app/controllers/open_api/v1/concerns/subscriptions_filters_concern.rb new file mode 100644 index 000000000..56972f351 --- /dev/null +++ b/app/controllers/open_api/v1/concerns/subscriptions_filters_concern.rb @@ -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] + # @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] + # @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] + # @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] + # @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 diff --git a/app/controllers/open_api/v1/events_controller.rb b/app/controllers/open_api/v1/events_controller.rb index 3933abefb..3f4c61a12 100644 --- a/app/controllers/open_api/v1/events_controller.rb +++ b/app/controllers/open_api/v1/events_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/invoices_controller.rb b/app/controllers/open_api/v1/invoices_controller.rb index a4e9cf7d8..e2adec7de 100644 --- a/app/controllers/open_api/v1/invoices_controller.rb +++ b/app/controllers/open_api/v1/invoices_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/reservations_controller.rb b/app/controllers/open_api/v1/reservations_controller.rb index 93322bc3c..14c8690c7 100644 --- a/app/controllers/open_api/v1/reservations_controller.rb +++ b/app/controllers/open_api/v1/reservations_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/subscriptions_controller.rb b/app/controllers/open_api/v1/subscriptions_controller.rb index d6feceb8d..bc32d0259 100644 --- a/app/controllers/open_api/v1/subscriptions_controller.rb +++ b/app/controllers/open_api/v1/subscriptions_controller.rb @@ -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 diff --git a/app/controllers/open_api/v1/users_controller.rb b/app/controllers/open_api/v1/users_controller.rb index 0d7b32811..c52109dfc 100644 --- a/app/controllers/open_api/v1/users_controller.rb +++ b/app/controllers/open_api/v1/users_controller.rb @@ -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 diff --git a/app/doc/open_api/v1/events_doc.rb b/app/doc/open_api/v1/events_doc.rb index 7fd90c778..4e3855b02 100644 --- a/app/doc/open_api/v1/events_doc.rb +++ b/app/doc/open_api/v1/events_doc.rb @@ -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" } ] } diff --git a/app/doc/open_api/v1/invoices_doc.rb b/app/doc/open_api/v1/invoices_doc.rb index a2a880d73..ecf8f5f32 100644 --- a/app/doc/open_api/v1/invoices_doc.rb +++ b/app/doc/open_api/v1/invoices_doc.rb @@ -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 diff --git a/app/doc/open_api/v1/reservations_doc.rb b/app/doc/open_api/v1/reservations_doc.rb index 977e1c7e0..b6c4fecf1 100644 --- a/app/doc/open_api/v1/reservations_doc.rb +++ b/app/doc/open_api/v1/reservations_doc.rb @@ -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" + } + ] } ] } diff --git a/app/doc/open_api/v1/subscriptions_doc.rb b/app/doc/open_api/v1/subscriptions_doc.rb index 341285352..fbaef949f 100644 --- a/app/doc/open_api/v1/subscriptions_doc.rb +++ b/app/doc/open_api/v1/subscriptions_doc.rb @@ -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 diff --git a/app/doc/open_api/v1/users_doc.rb b/app/doc/open_api/v1/users_doc.rb index b55e78680..731858985 100644 --- a/app/doc/open_api/v1/users_doc.rb +++ b/app/doc/open_api/v1/users_doc.rb @@ -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", diff --git a/app/frontend/src/javascript/components/authentication-provider/oauth2-form.tsx b/app/frontend/src/javascript/components/authentication-provider/oauth2-form.tsx index bc19f5302..fcf587454 100644 --- a/app/frontend/src/javascript/components/authentication-provider/oauth2-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/oauth2-form.tsx @@ -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 { register: UseFormRegister, @@ -16,10 +17,6 @@ interface Oauth2FormProps { export const Oauth2Form = ({ register, strategyName, formState }: Oauth2FormProps) => { 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 = ({ 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} /> { register: UseFormRegister, @@ -51,10 +52,6 @@ export const OpenidConnectForm = ); }, []); - // 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 =

{t('app.admin.authentication.openid_connect_form.client_options')}

{currentFormValues?.client_auth_method === 'jwks' && } } diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index 16ca23496..3add0e256 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -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, - confirmationMessage?: string|ReactNode, + destroy: (itemId: number) => Promise, 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 = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, destroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true, showDestroyConfirmation = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -34,30 +44,41 @@ export const EditDestroyButtons: React.FC = ({ 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 ( <>
- + {showEditButton && - - + } +
- = ({ 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 = ({ 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 = ({ user, onError })

{pack.prepaid_pack.priceable.name}

{FormatLib.date(pack.expires_at) &&

{FormatLib.date(pack.expires_at)}

} -

{pack.minutes_used / 60}H / {pack.prepaid_pack.minutes / 60}H

+

{(pack.prepaid_pack.minutes - pack.minutes_used) / 60}H / {pack.prepaid_pack.minutes / 60}H

- { /* usage history is not saved for now + {pack.history?.length > 0 &&
{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.history')} -
-

00{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours')}

-

00/00/00

-
+ {pack.history.map(prepaidReservation => ( +
+

{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.consumed_hours', { COUNT: prepaidReservation.consumed_minutes / 60 })}

+

{FormatLib.date(prepaidReservation.reservation_date)}

+
+ ))}
- */ } + } ))} @@ -159,7 +162,10 @@ const PrepaidPacksPanel: React.FC = ({ user, onError }) onDecline={togglePacksModal} onSuccess={onPackBoughtSuccess} />} } - + {packs.length === 0 &&

{t('app.logged.dashboard.reservations_dashboard.prepaid_packs_panel.no_packs')}

} + {(packsForSubscribers && user.subscribed_plan == null && packs.length > 0) && + + } ); }; diff --git a/app/frontend/src/javascript/components/editorial-block/editorial-block-form.tsx b/app/frontend/src/javascript/components/editorial-block/editorial-block-form.tsx index 8b7a69edd..3f8649621 100644 --- a/app/frontend/src/javascript/components/editorial-block/editorial-block-form.tsx +++ b/app/frontend/src/javascript/components/editorial-block/editorial-block-form.tsx @@ -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 } -// 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 = ({ 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')} /> } diff --git a/app/frontend/src/javascript/components/events/event-form.tsx b/app/frontend/src/javascript/components/events/event-form.tsx index 6052208c3..e8a89c29f 100644 --- a/app/frontend/src/javascript/components/events/event-form.tsx +++ b/app/frontend/src/javascript/components/events/event-form.tsx @@ -71,6 +71,19 @@ export const EventForm: React.FC = ({ 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 = ({ action, event, onError, on type="number" tooltip={t('app.admin.event_form.seats_help')} /> + 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 &&
{fields.map((price, index) => ( @@ -293,14 +308,16 @@ export const EventForm: React.FC = ({ 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')} /> + register={register} + type="number" + rules={{ required: true, min: 0 }} + nullable + formState={formState} + label={t('app.admin.event_form.price')} + addOn={FormatLib.currencySymbol()} /> handlePriceRemove(price, index)} icon={} />
))} diff --git a/app/frontend/src/javascript/components/form/form-multi-select.tsx b/app/frontend/src/javascript/components/form/form-multi-select.tsx index 8d337e894..5a41b3309 100644 --- a/app/frontend/src/javascript/components/form/form-multi-select.tsx +++ b/app/frontend/src/javascript/components/form/form-multi-select.tsx @@ -136,7 +136,7 @@ export const FormMultiSelect = handleCreate(inputValue, value, rhfOnChange) + onCreateOption: inputValue => handleCreate(inputValue, value || [], rhfOnChange) }); } diff --git a/app/frontend/src/javascript/components/form/form-select.tsx b/app/frontend/src/javascript/components/form/form-select.tsx index 9559e2b7a..76512dd29 100644 --- a/app/frontend/src/javascript/components/form/form-select.tsx +++ b/app/frontend/src/javascript/components/form/form-select.tsx @@ -58,15 +58,16 @@ export const FormSelect = 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}/> } /> ); diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx new file mode 100644 index 000000000..7a1099d24 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -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, TKeyName extends string> { + fields: Array>, + onRemove: (index: number) => void, + register: UseFormRegister, + className?: string, + title: string, + shouldRenderField?: (field: FieldArrayWithId) => boolean, + renderField: (field: FieldArrayWithId) => ReactNode, + formAttributeName: `${string}_attributes`, + formAttributes: Array>, + 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: + * ``` + * <> + *
+ * Attribute 1 + *

{item.attr1}

+ *
+ *
+ * ... + *
+ * + * ``` + */ +export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { + const { t } = useTranslation('shared'); + + /** + * Render an unsaved field + */ + const renderUnsavedField = (field: FieldArrayWithId, index: number): ReactNode => { + return ( +
+ {renderField(field)} +

onRemove(index)}> + {cancelLabel || t('app.shared.form_unsaved_list.cancel')} + +

+ {formAttributes.map((attribute, attrIndex) => ( + + ))} +
+ ); + }; + + if (fields.filter(shouldRenderField).length === 0) return null; + + return ( +
+ {title} + {saveReminderLabel || t('app.shared.form_unsaved_list.save_reminder')} + {fields.map((field, index) => { + if (!shouldRenderField(field)) return false; + return renderUnsavedField(field, index); + }).filter(Boolean)} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx b/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx deleted file mode 100644 index 141670895..000000000 --- a/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx +++ /dev/null @@ -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 = ({ isOpen, toggleModal, onSuccess, machineCategoryId, onError }) => { - const { t } = useTranslation('admin'); - - /** - * The user has confirmed the deletion of the requested machine category - */ - const handleDeleteMachineCategory = async (): Promise => { - 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 ( - -

{t('app.admin.delete_machine_category_modal.confirm_machine_category')}

-
- ); -}; diff --git a/app/frontend/src/javascript/components/machines/machine-categories-list.tsx b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx index f85cc10c0..430783fbb 100644 --- a/app/frontend/src/javascript/components/machines/machine-categories-list.tsx +++ b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx @@ -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 = ({ on // shown machine categories const [machineCategories, setMachineCategories] = useState>([]); - // all machines, for assign to category - const [machines, setMachines] = useState>([]); // creation/edition modal const [modalIsOpen, setModalIsOpen] = useState(false); // currently added/edited category const [machineCategory, setMachineCategory] = useState(null); - // deletion modal - const [destroyModalIsOpen, setDestroyModalIsOpen] = useState(false); - // currently deleted machine category - const [machineCategoryId, setMachineCategoryId] = useState(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 = ({ 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 = ({ 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 = ({ on - @@ -155,12 +119,12 @@ export const MachineCategoriesList: React.FC = ({ on diff --git a/app/frontend/src/javascript/components/machines/machine-category-modal.tsx b/app/frontend/src/javascript/components/machines/machine-category-modal.tsx index 024977c1b..ee656d929 100644 --- a/app/frontend/src/javascript/components/machines/machine-category-modal.tsx +++ b/app/frontend/src/javascript/components/machines/machine-category-modal.tsx @@ -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, machineCategory?: MachineCategory, } /** * Modal dialog to create/edit a machine category */ -export const MachineCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, machines, machineCategory }) => { +export const MachineCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, machineCategory }) => { const { t } = useTranslation('admin'); + // all machines, to assign to the category + const [machines, setMachines] = useState>([]); + + // 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 diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx index 73466f652..4f6db1b5d 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx @@ -113,7 +113,7 @@ const StripeKeysForm: React.FC = ({ onValidKeys, onInvalidK }, reason => { if (!mounted.current) return; - if (reason.response.status === 401) { + if (reason.response?.status === 401) { setSecretKeyAddOn(); setSecretKeyAddOnClassName('key-invalid'); } diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index 44c4cbe6d..7384b17ba 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -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 = ({ action, plan, onError, onSuccess, beforeSubmit }) => { - const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...plan } }); +export const PlanForm: React.FC = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => { + const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm({ defaultValues: { ...plan } }); const output = useWatch({ control }); // eslint-disable-line const { t } = useTranslation('admin'); @@ -51,13 +56,19 @@ export const PlanForm: React.FC = ({ 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 = ({ action, plan, onError, onSuc * Return the available options for the plan period */ const buildPeriodsOptions = (): Array> => { - 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 = ({ action, plan, onError, onSuc setValue('partner_id', user.id); }; - return ( -
-
-

{t('app.admin.plan_form.general_information')}

- - {action === 'create' && } - {!allGroups && groups && } - {categories?.length > 0 && } - {action === 'update' && - {t('app.admin.plan_form.edit_amount_info')} - } - - - - - - - -

{t('app.admin.plan_form.duration')}

-
+ /** + * Render the content of the 'subscriptions settings' tab + */ + const renderSettingsTab = () => ( +
+
+
+

{t('app.admin.plan_form.description')}

+
+
- + rules={{ + required: true, + maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') } + }} + label={t('app.admin.plan_form.name')} /> + +
-

{t('app.admin.plan_form.partnership')}

-
+
+ +
+
+

{t('app.admin.plan_form.general_settings')}

+

{t('app.admin.plan_form.general_settings_info')}

+
+
+ {action === 'create' && } + {!allGroups && groups && } +
+ + +
+ {action === 'update' && + {t('app.admin.plan_form.edit_amount_info')} + } + +
+
+ +
+
+

{t('app.admin.plan_form.activation_and_payment')}

+
+
+ + + +
+
+ +
+
+

{t('app.admin.plan_form.partnership')}

+

{t('app.admin.plan_form.partner_plan_help')}

+
+
{output.type === 'PartnerPlan' &&
- } onClick={tooglePartnerModal}> - {t('app.admin.plan_form.new_user')} - {partners && } - {output.partner_id && - {t('app.admin.plan_form.alert_partner_notification')} - } + } onClick={tooglePartnerModal}> + {t('app.admin.plan_form.new_user')} +
}
- - {action === 'update' && + +
+
+

{t('app.admin.plan_form.slots_visibility')}

+

{t('app.admin.plan_form.slots_visibility_help')}

+
+
+ +
+
+ +
+
+

{t('app.admin.plan_form.display')}

+
+
+ {categories?.length > 0 && } - - {t('app.admin.plan_form.ACTION_plan', { ACTION: action })} - + id="plan_category_id" + tooltip={t('app.admin.plan_form.category_help')} + label={t('app.admin.plan_form.category')} />} + +
+
+ +
+ +
+ + {action === 'update' && } +
+ ); + + return ( +
+
+

{t('app.admin.plan_form.ACTION_title', { ACTION: action })}

+
+ + {t('app.admin.plan_form.save')} + +
+
+ + + + + } + ]} /> + = (props) => { ); }; -Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess'])); +Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx new file mode 100644 index 000000000..601dcce53 --- /dev/null +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -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 { + register: UseFormRegister, + control: Control, + formState: FormState, + onError: (message: string) => void, + getValues: UseFormGetValues, + resetField: UseFormResetField +} + +/** + * Form tab to manage a subscription's usage limit + */ +export const PlanLimitForm = ({ register, control, formState, onError, getValues, resetField }: PlanLimitFormProps) => { + const { t } = useTranslation('admin'); + const { fields, append, remove, update } = useFieldArray({ control, name: 'plan_limitations_attributes' }); + const limiting = useWatch({ control, name: 'limiting' }); + + const [isOpen, setIsOpen] = useState(false); + const [machines, setMachines] = useState>([]); + const [categories, setCategories] = useState>([]); + 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 => { + return () => { + return new Promise((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) => 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' &&
+ {t('app.admin.plan_limit_form.category')} +

{categories?.find(c => c.id === limit.limitable_id)?.name}

+
) || +
+ {t('app.admin.plan_limit_form.machine')} +

{machines?.find(m => m.id === limit.limitable_id)?.name}

+
} +
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limit.limit}

+
+ + ); + + return ( +
+
+
+

{t('app.admin.plan_limit_form.usage_limitation')}

+

{t('app.admin.plan_limit_form.usage_limitation_info')}

+
+
+ +
+
+ + {limiting &&
+
+

{t('app.admin.plan_limit_form.all_limitations')}

+
+ + {t('app.admin.plan_limit_form.new_usage_limitation')} + +
+
+ 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 && +

{t('app.admin.plan_limit_form.saved_limitations')}

+ } + + {fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 && +
+

{t('app.admin.plan_limit_form.by_category')}

+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false; + + return ( +
+
+
+ {t('app.admin.plan_limit_form.category')} +

{categories.find(c => c.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
+ +
+ +
+
+ ); + }).filter(Boolean)} +
+ } + + {fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 && +
+

{t('app.admin.plan_limit_form.by_machine')}

+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'Machine' || limitation._modified) return false; + + return ( +
+
+
+ {t('app.admin.plan_limit_form.machine')} +

{machines.find(m => m.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+ {limitation._destroy &&
{t('app.admin.plan_limit_form.ongoing_deletion')}
} +
+ +
+ {(limitation._destroy && +

+ {t('app.admin.plan_limit_form.cancel_deletion')} + +

) || + } +
+
+ ); + }).filter(Boolean)} +
+ } +
} + + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx new file mode 100644 index 000000000..2ae2dc62a --- /dev/null +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -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 + categories: Array, + limitation?: PlanLimitation, + existingLimitations: Array; +} + +/** + * Form to manage subscriptions limitations of use + */ +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation, existingLimitations = [] }) => { + const { t } = useTranslation('admin'); + + const { register, control, formState, setValue, handleSubmit, reset } = useForm({ 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, 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) => { + 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> => { + 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 ( + reset({ limitable_type: 'MachineCategory' })} + closeButton> +
+

{t('app.admin.plan_limit_modal.limit_reservations')}

+
+ + +
+ {limitType === 'Machine' ? t('app.admin.plan_limit_modal.machine_info') : t('app.admin.plan_limit_modal.categories_info')} + + + + + {t('app.admin.plan_limit_modal.confirm')} + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx index 637ecfe76..247b9b60b 100644 --- a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx @@ -92,32 +92,37 @@ export const PlanPricingForm = ({ register, control, fo }; return ( -
-

{t('app.admin.plan_pricing_form.prices')}

- {plans && } - { { - 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) - } - ]} />} -
+
+
+

{t('app.admin.plan_pricing_form.prices')}

+

{t('app.admin.plan_pricing_form.about_prices')}

+
+
+ {plans && } + { { + 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) + } + ]} />} +
+
); }; diff --git a/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx index 3637f4117..de1126bad 100644 --- a/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx @@ -75,7 +75,7 @@ export const ConfigurePacksButton: React.FC = ({ 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 = ({ pack onEdit={() => handleRequestEdit(p)} itemId={p.id} itemType={t('app.admin.configure_packs_button.pack')} - apiDestroy={PrepaidPackAPI.destroy}/> + destroy={PrepaidPackAPI.destroy}/> { register: UseFormRegister, @@ -21,8 +22,6 @@ interface EditSocialsProps { */ export const EditSocials = ({ register, setValue, networks, formState, disabled }: EditSocialsProps) => { 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 = ({ register, setVal register={register} rules= {{ pattern: { - value: urlRegex, + value: ValidationLib.urlRegex, message: t('app.shared.edit_socials.website_invalid') } }} diff --git a/app/frontend/src/javascript/components/socials/fab-socials.tsx b/app/frontend/src/javascript/components/socials/fab-socials.tsx index eb56a1d95..e1bd046e1 100644 --- a/app/frontend/src/javascript/components/socials/fab-socials.tsx +++ b/app/frontend/src/javascript/components/socials/fab-socials.tsx @@ -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 = ({ 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 = ({ show = false, onError, o register={register} rules={{ pattern: { - value: urlRegex, + value: ValidationLib.urlRegex, message: t('app.shared.fab_socials.website_invalid') } }} diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx index a8e252304..ba49ccd4d 100644 --- a/app/frontend/src/javascript/components/store/product-item.tsx +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -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 = ({ product, onEdit, onDelete }) => { +export const ProductItem: React.FC = ({ product, onEdit, onDelete, onError }) => { const { t } = useTranslation('admin'); /** @@ -34,15 +35,6 @@ export const ProductItem: React.FC = ({ 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 = ({ product, onEdit, onDel
-
- - - - - - -
+
); diff --git a/app/frontend/src/javascript/components/store/product-stock-form.tsx b/app/frontend/src/javascript/components/store/product-stock-form.tsx index bf12a121f..b2cb4cc0a 100644 --- a/app/frontend/src/javascript/components/store/product-stock-form.tsx +++ b/app/frontend/src/javascript/components/store/product-stock-form.tsx @@ -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 { currentFormValues: Product, @@ -159,6 +160,25 @@ export const ProductStockForm = ({ currentFormValues, } }; + /** + * Render an attribute of an unsaved stock movement + */ + const renderOngoingStockMovement = (movement: ProductStockMovement): ReactNode => ( + <> +
+

{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(movement.reason)}`)}

+
+
+ {t(`app.admin.store.product_stock_form.${movement.stock_type}`)} +

{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}

+
+
+ {t('app.admin.store.product_stock_form.reason')} +

{t(ProductLib.stockMovementReasonTrKey(movement.reason))}

+
+ + ); + return (

{t('app.admin.store.product_stock_form.stock_up_to_date')}  @@ -178,36 +198,19 @@ export const ProductStockForm = ({ currentFormValues, {t('app.admin.store.product_stock_form.external')}

{currentFormValues?.stock?.external}

- } className="is-black">Modifier + } className="is-black">{t('app.admin.store.product_stock_form.edit')}
- {fields.length > 0 &&
- {t('app.admin.store.product_stock_form.ongoing_operations')} - {t('app.admin.store.product_stock_form.save_reminder')} - {fields.map((newMovement, index) => ( -
-
-

{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}

-
-
- {t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)} -

{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}

-
-
- {t('app.admin.store.product_stock_form.reason')} -

{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}

-
-

remove(index)}> - {t('app.admin.store.product_stock_form.cancel')} - -

- - - -
- ))} -
} +
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 2f94b9367..1f55aa2c5 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -111,14 +111,9 @@ const Products: React.FC = ({ onSuccess, onError, uiRouter }) => }; /** Delete a product */ - const deleteProduct = async (productId: number): Promise => { - 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 => { + await fetchProducts(); + onSuccess(message); }; /** Goto new product page */ @@ -244,6 +239,7 @@ const Products: React.FC = ({ onSuccess, onError, uiRouter }) => diff --git a/app/frontend/src/javascript/components/trainings/trainings.tsx b/app/frontend/src/javascript/components/trainings/trainings.tsx index 3ff49b787..f510507d2 100644 --- a/app/frontend/src/javascript/components/trainings/trainings.tsx +++ b/app/frontend/src/javascript/components/trainings/trainings.tsx @@ -199,7 +199,7 @@ export const Trainings: React.FC = ({ onError, onSuccess }) => { onEdit={() => toTrainingEdit(training)} itemId={training.id} itemType={t('app.admin.trainings.training')} - apiDestroy={TrainingAPI.destroy}/> + destroy={TrainingAPI.destroy}/> ))} diff --git a/app/frontend/src/javascript/components/user/user-profile-form.tsx b/app/frontend/src/javascript/components/user/user-profile-form.tsx index 768980bc9..f5125fb23 100644 --- a/app/frontend/src/javascript/components/user/user-profile-form.tsx +++ b/app/frontend/src/javascript/components/user/user-profile-form.tsx @@ -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 = ({ 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({ defaultValues: { ...user } }); const output = useWatch({ control }); @@ -215,7 +212,7 @@ export const UserProfileForm: React.FC = ({ 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 = ({ action, size, register={register} rules={{ pattern: { - value: urlRegex, + value: ValidationLib.urlRegex, message: t('app.shared.user_profile_form.website_invalid') } }} diff --git a/app/frontend/src/javascript/controllers/admin/events.js b/app/frontend/src/javascript/controllers/admin/events.js index 61a2bd6c3..6ce0cadee 100644 --- a/app/frontend/src/javascript/controllers/admin/events.js +++ b/app/frontend/src/javascript/controllers/admin/events.js @@ -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 diff --git a/app/frontend/src/javascript/controllers/admin/plans.js b/app/frontend/src/javascript/controllers/admin/plans.js index ab80d0824..5ef815c29 100644 --- a/app/frontend/src/javascript/controllers/admin/plans.js +++ b/app/frontend/src/javascript/controllers/admin/plans.js @@ -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 */ diff --git a/app/frontend/src/javascript/lib/validation.ts b/app/frontend/src/javascript/lib/validation.ts new file mode 100644 index 000000000..3d5f0882a --- /dev/null +++ b/app/frontend/src/javascript/lib/validation.ts @@ -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}$/; +} diff --git a/app/frontend/src/javascript/models/event.ts b/app/frontend/src/javascript/models/event.ts index 5e4fe83b5..5cca1d9e8 100644 --- a/app/frontend/src/javascript/models/event.ts +++ b/app/frontend/src/javascript/models/event.ts @@ -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; diff --git a/app/frontend/src/javascript/models/machine.ts b/app/frontend/src/javascript/models/machine.ts index c9469adf2..74cad1fcd 100644 --- a/app/frontend/src/javascript/models/machine.ts +++ b/app/frontend/src/javascript/models/machine.ts @@ -5,7 +5,7 @@ import { AdvancedAccounting } from './advanced-accounting'; export interface MachineIndexFilter extends ApiFilter { disabled?: boolean, - category?: number | 'none' + category?: number | 'none' | Array } export interface Machine { diff --git a/app/frontend/src/javascript/models/plan.ts b/app/frontend/src/javascript/models/plan.ts index 6b7656521..f8d1b93c3 100644 --- a/app/frontend/src/javascript/models/plan.ts +++ b/app/frontend/src/javascript/models/plan.ts @@ -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, - advanced_accounting_attributes?: AdvancedAccounting + advanced_accounting_attributes?: AdvancedAccounting, + plan_limitations_attributes?: Array } export interface PlansDuration { diff --git a/app/frontend/src/javascript/models/select.ts b/app/frontend/src/javascript/models/select.ts index e3dab0e1b..ce5938697 100644 --- a/app/frontend/src/javascript/models/select.ts +++ b/app/frontend/src/javascript/models/select.ts @@ -2,7 +2,7 @@ * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ -export type SelectOption = { value: TOptionValue, label: TOptionLabel } +export type SelectOption = { value: TOptionValue, label: TOptionLabel, disabled?: boolean } /** * Checklist Option format diff --git a/app/frontend/src/javascript/models/user-pack.ts b/app/frontend/src/javascript/models/user-pack.ts index 9bacafe93..467ff8dd7 100644 --- a/app/frontend/src/javascript/models/user-pack.ts +++ b/app/frontend/src/javascript/models/user-pack.ts @@ -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 + }> } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 22fc9555c..b70e769c6 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -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"; diff --git a/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss b/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss index a02925969..6adaece60 100644 --- a/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss +++ b/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss @@ -1,4 +1,6 @@ .edit-destroy-buttons { + border-radius: var(--border-radius-sm); + overflow: hidden; button { @include btn; border-radius: 0; @@ -12,4 +14,4 @@ } } -} \ No newline at end of file +} diff --git a/app/frontend/src/stylesheets/modules/base/fab-tabs.scss b/app/frontend/src/stylesheets/modules/base/fab-tabs.scss index c53fabd24..ecffcfd65 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-tabs.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-tabs.scss @@ -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; } } } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-text-editor.scss b/app/frontend/src/stylesheets/modules/base/fab-text-editor.scss index 430c0c383..5350c1ed6 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-text-editor.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-text-editor.scss @@ -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); diff --git a/app/frontend/src/stylesheets/modules/events/event-form.scss b/app/frontend/src/stylesheets/modules/events/event-form.scss index 76fb5ec71..92d91a1ae 100644 --- a/app/frontend/src/stylesheets/modules/events/event-form.scss +++ b/app/frontend/src/stylesheets/modules/events/event-form.scss @@ -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; } } } diff --git a/app/frontend/src/stylesheets/modules/events/events.scss b/app/frontend/src/stylesheets/modules/events/events.scss index 6d6339fa4..e73fcaf30 100644 --- a/app/frontend/src/stylesheets/modules/events/events.scss +++ b/app/frontend/src/stylesheets/modules/events/events.scss @@ -1,4 +1,4 @@ -.events { +.events-list-page { max-width: 1600px; margin: 2rem; padding-bottom: 6rem; diff --git a/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss b/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss new file mode 100644 index 000000000..f7b1a37dc --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss @@ -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; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/machines/machine-categories.scss b/app/frontend/src/stylesheets/modules/machines/machine-categories.scss index 15d2f803e..82ed41414 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-categories.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-categories.scss @@ -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 { diff --git a/app/frontend/src/stylesheets/modules/machines/machine-form.scss b/app/frontend/src/stylesheets/modules/machines/machine-form.scss index 90c099238..28364b434 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-form.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/modules/plans/plan-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-form.scss index 8366b0aee..7dde64762 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-form.scss @@ -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; - } } diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss new file mode 100644 index 000000000..ef264a663 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -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; + } + } + } + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss new file mode 100644 index 000000000..08d11d0eb --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss new file mode 100644 index 000000000..68a9e5756 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/spaces/space-form.scss b/app/frontend/src/stylesheets/modules/spaces/space-form.scss index 0db00f748..f8caba094 100644 --- a/app/frontend/src/stylesheets/modules/spaces/space-form.scss +++ b/app/frontend/src/stylesheets/modules/spaces/space-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss index 5290c7a80..1e9a2bd78 100644 --- a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss @@ -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; diff --git a/app/frontend/src/stylesheets/modules/trainings/training-form.scss b/app/frontend/src/stylesheets/modules/trainings/training-form.scss index adf701132..1eeb90524 100644 --- a/app/frontend/src/stylesheets/modules/trainings/training-form.scss +++ b/app/frontend/src/stylesheets/modules/trainings/training-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/variables/layout.scss b/app/frontend/src/stylesheets/variables/layout.scss index 1717818c5..b61ccb975 100644 --- a/app/frontend/src/stylesheets/variables/layout.scss +++ b/app/frontend/src/stylesheets/variables/layout.scss @@ -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) { diff --git a/app/frontend/templates/admin/plans/edit.html b/app/frontend/templates/admin/plans/edit.html index d0b2a1555..9ad6077a8 100644 --- a/app/frontend/templates/admin/plans/edit.html +++ b/app/frontend/templates/admin/plans/edit.html @@ -10,24 +10,7 @@

{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ suscriptionPlan.base_name }}

- - -
-
- -
-
- -
-
- -
-
+ diff --git a/app/frontend/templates/admin/plans/new.html b/app/frontend/templates/admin/plans/new.html index 9a2b14257..7315fe38b 100644 --- a/app/frontend/templates/admin/plans/new.html +++ b/app/frontend/templates/admin/plans/new.html @@ -14,14 +14,4 @@ -
-
- -
-
- -
-
- -
-
+ diff --git a/app/frontend/templates/events/index.html b/app/frontend/templates/events/index.html index d851bea07..90de341dc 100644 --- a/app/frontend/templates/events/index.html +++ b/app/frontend/templates/events/index.html @@ -19,7 +19,7 @@ -
+
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1d4c1c61c..662b95671 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index efa589618..126164938 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -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 diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 812e91498..48d72f892 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -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=>ArrayInteger,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] + # @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} + # @return [Hash{Symbol => Array}] 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 diff --git a/app/models/machine.rb b/app/models/machine.rb index bd4e71854..a3f81cfa1 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -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 diff --git a/app/models/machine_category.rb b/app/models/machine_category.rb index 9fe2e9de0..47c670640 100644 --- a/app/models/machine_category.rb +++ b/app/models/machine_category.rb @@ -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 diff --git a/app/models/o_auth2_provider.rb b/app/models/o_auth2_provider.rb index 3064b265d..215db9c6e 100644 --- a/app/models/o_auth2_provider.rb +++ b/app/models/o_auth2_provider.rb @@ -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 diff --git a/app/models/open_id_connect_provider.rb b/app/models/open_id_connect_provider.rb index 3243a5bf8..e91095edc 100644 --- a/app/models/open_id_connect_provider.rb +++ b/app/models/open_id_connect_provider.rb @@ -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 diff --git a/app/models/plan.rb b/app/models/plan.rb index d3b8b6e39..0a508f8e2 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -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 diff --git a/app/models/plan_limitation.rb b/app/models/plan_limitation.rb new file mode 100644 index 000000000..9bdabecbf --- /dev/null +++ b/app/models/plan_limitation.rb @@ -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] + def reservables + return limitable.machines if limitable_type == 'MachineCategory' + + [limitable] + end +end diff --git a/app/models/prepaid_pack_reservation.rb b/app/models/prepaid_pack_reservation.rb new file mode 100644 index 000000000..0cd261402 --- /dev/null +++ b/app/models/prepaid_pack_reservation.rb @@ -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 diff --git a/app/models/reservation.rb b/app/models/reservation.rb index c3a07e700..d0472354b 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -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 diff --git a/app/models/shopping_cart.rb b/app/models/shopping_cart.rb index 01b2c6046..31ed97e43 100644 --- a/app/models/shopping_cart.rb +++ b/app/models/shopping_cart.rb @@ -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,FalseClass] def check_user_validation(items) user_validation_required = Setting.get('user_validation_required') user_validation_required_list = Setting.get('user_validation_required_list') diff --git a/app/models/statistic_profile_prepaid_pack.rb b/app/models/statistic_profile_prepaid_pack.rb index bcc6e2060..cd8fcc16e 100644 --- a/app/models/statistic_profile_prepaid_pack.rb +++ b/app/models/statistic_profile_prepaid_pack.rb @@ -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 diff --git a/app/services/accounting/accounting_code_service.rb b/app/services/accounting/accounting_code_service.rb index 7cd6d75c3..5a8be8ae3 100644 --- a/app/services/accounting/accounting_code_service.rb +++ b/app/services/accounting/accounting_code_service.rb @@ -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 diff --git a/app/services/availabilities/availabilities_service.rb b/app/services/availabilities/availabilities_service.rb index e261ccffd..c2621ec4c 100644 --- a/app/services/availabilities/availabilities_service.rb +++ b/app/services/availabilities/availabilities_service.rb @@ -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 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 diff --git a/app/services/availabilities/visibility_service.rb b/app/services/availabilities/visibility_service.rb new file mode 100644 index 000000000..092c1de06 --- /dev/null +++ b/app/services/availabilities/visibility_service.rb @@ -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] 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 diff --git a/app/services/cart_service.rb b/app/services/cart_service.rb index aea1c834d..6c6588e4b 100644 --- a/app/services/cart_service.rb +++ b/app/services/cart_service.rb @@ -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 diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb index 5f8c12615..96a5df497 100644 --- a/app/services/checkout/payment_service.rb +++ b/app/services/checkout/payment_service.rb @@ -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? diff --git a/app/services/machine_service.rb b/app/services/machine_service.rb index 9e4088e3a..61d2e563c 100644 --- a/app/services/machine_service.rb +++ b/app/services/machine_service.rb @@ -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] # @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 diff --git a/app/services/prepaid_pack_service.rb b/app/services/prepaid_pack_service.rb index caa72c24e..7a03ecaef 100644 --- a/app/services/prepaid_pack_service.rb +++ b/app/services/prepaid_pack_service.rb @@ -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] 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] 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 diff --git a/app/services/reservation_limit_service.rb b/app/services/reservation_limit_service.rb new file mode 100644 index 000000000..64ae9b7dc --- /dev/null +++ b/app/services/reservation_limit_service.rb @@ -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] + # @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] + # @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 diff --git a/app/services/subscriptions/expire_service.rb b/app/services/subscriptions/expire_service.rb index 7eaa1adbf..e84d8c8f4 100644 --- a/app/services/subscriptions/expire_service.rb +++ b/app/services/subscriptions/expire_service.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Services around subscriptions +module Subscriptions; end + # Expire the given subscription class Subscriptions::ExpireService class << self diff --git a/app/services/subscriptions/extension_after_reservation.rb b/app/services/subscriptions/extension_after_reservation.rb index b6d9f77ab..0bd7463bf 100644 --- a/app/services/subscriptions/extension_after_reservation.rb +++ b/app/services/subscriptions/extension_after_reservation.rb @@ -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 diff --git a/app/views/api/availabilities/_slot.json.jbuilder b/app/views/api/availabilities/_slot.json.jbuilder index 70f3b3f2c..b9afa9923 100644 --- a/app/views/api/availabilities/_slot.json.jbuilder +++ b/app/views/api/availabilities/_slot.json.jbuilder @@ -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| diff --git a/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder b/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder new file mode 100644 index 000000000..f90b16e39 --- /dev/null +++ b/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder @@ -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)) diff --git a/app/views/api/plans/_plan.json.jbuilder b/app/views/api/plans/_plan.json.jbuilder index ddffd567c..30e090760 100644 --- a/app/views/api/plans/_plan.json.jbuilder +++ b/app/views/api/plans/_plan.json.jbuilder @@ -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 + diff --git a/app/views/api/plans/show.json.jbuilder b/app/views/api/plans/show.json.jbuilder index 799730b4d..edba9aaf4 100644 --- a/app/views/api/plans/show.json.jbuilder +++ b/app/views/api/plans/show.json.jbuilder @@ -1 +1,3 @@ +# frozen_string_literal: true + json.partial! 'api/plans/plan', plan: @plan diff --git a/app/views/api/user_packs/index.json.jbuilder b/app/views/api/user_packs/index.json.jbuilder index 4df777b53..fb75e69b9 100644 --- a/app/views/api/user_packs/index.json.jbuilder +++ b/app/views/api/user_packs/index.json.jbuilder @@ -8,4 +8,10 @@ json.array!(@user_packs) do |user_pack| json.extract! user_pack.prepaid_pack.priceable, :name end end + if @history + json.history user_pack.prepaid_pack_reservations do |ppr| + json.extract! ppr, :id, :consumed_minutes, :reservation_id + json.reservation_date ppr.reservation.created_at + end + end end diff --git a/app/views/application/sso_redirect.html.erb b/app/views/application/sso_redirect.html.erb index 1d57b5c15..3ae666c26 100644 --- a/app/views/application/sso_redirect.html.erb +++ b/app/views/application/sso_redirect.html.erb @@ -7,7 +7,7 @@ <% param = @authorization_token ? "?auth_token=#{@authorization_token}" : '' %> - <% url_path = File.join(root_url, "users/auth/#{@active_provider.strategy_name}#{param}") %> + <% url_path = URI.join("#{ENV.fetch('DEFAULT_PROTOCOL')}://#{ENV.fetch('DEFAULT_HOST')}", "users/auth/#{@active_provider.strategy_name}#{param}") %>
<%= hidden_field_tag :authenticity_token, @authentication_token %>
- - {t('app.admin.machine_categories_list.edit')} - - - - +