diff --git a/.eslintrc b/.eslintrc index 1dd62ba79..6b09f6097 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,7 +6,8 @@ ], "rules": { "semi": ["error", "always"], - "no-use-before-define": "off" + "no-use-before-define": "off", + "no-case-declarations": "off" }, "globals": { "Application": true, diff --git a/.rubocop.yml b/.rubocop.yml index c48eca5a3..c781751ab 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,9 +7,9 @@ Layout/LineLength: Metrics/MethodLength: Max: 35 Metrics/CyclomaticComplexity: - Max: 13 + Max: 14 Metrics/PerceivedComplexity: - Max: 11 + Max: 14 Metrics/AbcSize: Max: 45 Metrics/ClassLength: @@ -34,3 +34,7 @@ Style/AndOr: EnforcedStyle: conditionals Style/FormatString: EnforcedStyle: sprintf +Rails/RedundantPresenceValidationOnBelongsTo: + Enabled: false +Rails/UnknownEnv: + Environments: development, test, staging, production diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a4d142e..4f47c2e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog Fab-manager +- Allow searching by username (#401) +- Fix a bug: adding a new event without updating the dates results in internal server error (undefined method `div' for nil) +- Fix a bug: portuguese time formatting (#405) +- Fix a bug: admin users groups being overriden by SSO group_id (#404) +- Fix a bug: no statistics on trainings and spaces reservations +- Fix a bug: invalid ventilation for amount coupons +- Fix a bug: invalid VAT for invoices using amount coupons +- Fix a bug: invalid 1 cent rounding for invoices using coupons +- Fix a bug: plans list error when there was no plan for the user's group +- Fix a security issue: updated nokogiri to 1.13.9 to fix [GHSA-2qc6-mcvw-92cw](https://github.com/advisories/GHSA-2qc6-mcvw-92cw) +- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2021,6]` +- [TODO DEPLOY] `rails fablab:setup:set_admins_group` + ## v5.4.25 2022 October 19 - Fix a bug: unable apply a coupon if this coupon has used by an user removed @@ -12,6 +25,10 @@ ## v5.4.23 2022 October 12 - Fix a bug: unable to build docker image +- Fablab's store module +- Fix a bug: missing translations in PayZen configuration screens +- Fix a bug: wrong translation key prevents the display of the schedule deadline's payment mean +- [TODO DEPLOY] `rails db:seed` ## v5.4.22 2022 October 10 diff --git a/Gemfile b/Gemfile index aef064099..70c41bdce 100644 --- a/Gemfile +++ b/Gemfile @@ -145,3 +145,5 @@ gem 'tzinfo-data' gem 'sassc', '= 2.1.0' gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index a5358f468..16aad51dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (1.0.4) + activerecord (>= 4.2) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) @@ -234,7 +236,7 @@ GEM multi_xml (0.6.0) multipart-post (2.1.1) nio4r (2.5.8) - nokogiri (1.13.8) + nokogiri (1.13.9) mini_portile2 (~> 2.8.0) racc (~> 1.4) notify_with (0.0.2) @@ -500,6 +502,7 @@ DEPENDENCIES aasm actionpack-page_caching (= 1.2.2) active_record_query_trace + acts_as_list api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/admins_controller.rb b/app/controllers/api/admins_controller.rb index 651e457fd..31a5a285f 100644 --- a/app/controllers/api/admins_controller.rb +++ b/app/controllers/api/admins_controller.rb @@ -35,7 +35,7 @@ class API::AdminsController < API::ApiController def admin_params params.require(:admin).permit( - :username, :email, + :username, :email, :group_id, profile_attributes: %i[first_name last_name phone], invoicing_profile_attributes: [address_attributes: [:address]], statistic_profile_attributes: %i[gender birthday] diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb new file mode 100644 index 000000000..5b150a70e --- /dev/null +++ b/app/controllers/api/cart_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# API Controller for manage user's cart +class API::CartController < API::ApiController + include API::OrderConcern + + before_action :current_order, except: %i[create] + before_action :ensure_order, except: %i[create] + + def create + authorize :cart, :create? + @order ||= Cart::FindOrCreateService.new(current_user).call(order_token) + render 'api/orders/show' + end + + def add_item + authorize @current_order, policy_class: CartPolicy + @order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity]) + render 'api/orders/show' + end + + def remove_item + authorize @current_order, policy_class: CartPolicy + @order = Cart::RemoveItemService.new.call(@current_order, orderable) + render 'api/orders/show' + end + + def set_quantity + authorize @current_order, policy_class: CartPolicy + @order = Cart::SetQuantityService.new.call(@current_order, orderable, cart_params[:quantity]) + render 'api/orders/show' + end + + def set_offer + authorize CartContext.new(params[:customer_id], cart_params[:is_offered]) + @order = Cart::SetOfferService.new.call(@current_order, orderable, cart_params[:is_offered]) + render 'api/orders/show' + end + + def refresh_item + authorize @current_order, policy_class: CartPolicy + @order = Cart::RefreshItemService.new.call(@current_order, orderable) + render 'api/orders/show' + end + + def validate + authorize @current_order, policy_class: CartPolicy + @order_errors = Cart::CheckCartService.new.call(@current_order) + render json: @order_errors + end + + private + + def orderable + Product.find(cart_params[:orderable_id]) + end +end diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb new file mode 100644 index 000000000..f76c78759 --- /dev/null +++ b/app/controllers/api/checkout_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'stripe/helper' +require 'pay_zen/helper' + +# API Controller for cart checkout +class API::CheckoutController < API::ApiController + include ::API::OrderConcern + before_action :authenticate_user! + before_action :current_order + before_action :ensure_order + + def payment + authorize @current_order, policy_class: CheckoutPolicy + if @current_order.statistic_profile_id.nil? && current_user.privileged? + user = User.find(params[:customer_id]) + @current_order.statistic_profile = user.statistic_profile + end + res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:coupon_code], + params[:payment_id]) + render json: res + rescue Stripe::StripeError => e + render json: Stripe::Helper.human_error(e), status: :unprocessable_entity + rescue PayzenError => e + render json: PayZen::Helper.human_error(e), status: :unprocessable_entity + rescue StandardError => e + render json: e, status: :unprocessable_entity + end + + def confirm_payment + authorize @current_order, policy_class: CheckoutPolicy + res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:coupon_code], params[:payment_id]) + render json: res + rescue StandardError => e + render json: e, status: :unprocessable_entity + end +end diff --git a/app/controllers/api/coupons_controller.rb b/app/controllers/api/coupons_controller.rb index 3d7b37703..8081a7bf7 100644 --- a/app/controllers/api/coupons_controller.rb +++ b/app/controllers/api/coupons_controller.rb @@ -3,7 +3,7 @@ # API Controller for resources of type Coupon # Coupons are used in payments class API::CouponsController < API::ApiController - before_action :authenticate_user! + before_action :authenticate_user!, except: %i[validate] before_action :set_coupon, only: %i[show update destroy] # Number of notifications added to the page when the user clicks on 'load next notifications' @@ -31,18 +31,18 @@ class API::CouponsController < API::ApiController if @coupon.nil? render json: { status: 'rejected' }, status: :not_found else - _user_id = if !current_user.admin? - current_user.id - else + _user_id = if current_user&.admin? params[:user_id] + else + current_user&.id end amount = params[:amount].to_f * 100.0 status = @coupon.status(_user_id, amount) - if status != 'active' - render json: { status: status }, status: :unprocessable_entity - else + if status == 'active' render :validate, status: :ok, location: @coupon + else + render json: { status: status }, status: :unprocessable_entity end end end diff --git a/app/controllers/api/groups_controller.rb b/app/controllers/api/groups_controller.rb index 03b2d1c3d..7276f54e7 100644 --- a/app/controllers/api/groups_controller.rb +++ b/app/controllers/api/groups_controller.rb @@ -6,7 +6,7 @@ class API::GroupsController < API::ApiController before_action :authenticate_user!, except: :index def index - @groups = GroupService.list(current_user, params) + @groups = GroupService.list(params) end def create diff --git a/app/controllers/api/members_controller.rb b/app/controllers/api/members_controller.rb index 12cb86743..fa619106d 100644 --- a/app/controllers/api/members_controller.rb +++ b/app/controllers/api/members_controller.rb @@ -157,7 +157,7 @@ class API::MembersController < API::ApiController end def search - @members = Members::ListService.search(current_user, params[:query], params[:subscription], params[:include_admins]) + @members = Members::ListService.search(current_user, params[:query], params[:subscription]) end def mapping diff --git a/app/controllers/api/orders_controller.rb b/app/controllers/api/orders_controller.rb new file mode 100644 index 000000000..ff453e8df --- /dev/null +++ b/app/controllers/api/orders_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# API Controller for resources of type Order +# Orders are used in store +class API::OrdersController < API::ApiController + before_action :authenticate_user!, except: %i[withdrawal_instructions] + before_action :set_order, only: %i[show update destroy withdrawal_instructions] + + def index + @result = ::Orders::OrderService.list(params, current_user) + end + + def show; end + + def update + authorize @order + + @order = ::Orders::OrderService.update_state(@order, current_user, order_params[:state], order_params[:note]) + render :show + end + + def destroy + authorize @order + @order.destroy + head :no_content + end + + def withdrawal_instructions + authorize @order + + render html: ::Orders::OrderService.withdrawal_instructions(@order) + end + + private + + def set_order + @order = Order.find(params[:id]) + end + + def order_params + params.require(:order).permit(:state, :note) + end +end diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index b994c8eb7..21968918c 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -8,7 +8,7 @@ class API::PaymentSchedulesController < API::ApiController # retrieve all payment schedules for the current user, paginated def index - @payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id) + @payment_schedules = PaymentSchedule.where(invoicing_profile_id: current_user.invoicing_profile.id) .includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects) .joins(:invoicing_profile) .order('payment_schedules.created_at DESC') @@ -34,14 +34,14 @@ class API::PaymentSchedulesController < API::ApiController def download authorize @payment_schedule - send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment' + send_file Rails.root.join(@payment_schedule.file), type: 'application/pdf', disposition: 'attachment' end def cash_check authorize @payment_schedule_item.payment_schedule PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check') attrs = { state: 'paid', payment_method: 'check' } - @payment_schedule_item.update_attributes(attrs) + @payment_schedule_item.update(attrs) render json: attrs, status: :ok end @@ -50,7 +50,7 @@ class API::PaymentSchedulesController < API::ApiController authorize @payment_schedule_item.payment_schedule PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer') attrs = { state: 'paid', payment_method: 'transfer' } - @payment_schedule_item.update_attributes(attrs) + @payment_schedule_item.update(attrs) render json: attrs, status: :ok end diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb new file mode 100644 index 000000000..dc9ce614b --- /dev/null +++ b/app/controllers/api/product_categories_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# API Controller for resources of type ProductCategory +# ProductCategories are used to group Products +class API::ProductCategoriesController < API::ApiController + before_action :authenticate_user!, except: :index + before_action :set_product_category, only: %i[update destroy position] + + def index + @product_categories = ProductCategoryService.list + end + + def show + @product_category = ProductCategory.friendly.find(params[:id]) + end + + def create + authorize ProductCategory + @product_category = ProductCategory.new(product_category_params) + if @product_category.save + render status: :created + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product_category + + if @product_category.update(product_category_params) + render status: :ok + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def position + authorize @product_category + render json: @product_category, status: :not_modified and return if @product_category.position == params[:position] + + if @product_category.insert_at(params[:position]) + render :show + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product_category + ProductCategoryService.destroy(@product_category) + head :no_content + end + + private + + def set_product_category + @product_category = ProductCategory.find(params[:id]) + end + + def product_category_params + params.require(:product_category).permit(:name, :parent_id, :slug) + end +end diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb new file mode 100644 index 000000000..30584fd5e --- /dev/null +++ b/app/controllers/api/products_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# API Controller for resources of type Product +# Products are used in store +class API::ProductsController < API::ApiController + before_action :authenticate_user!, except: %i[index show] + before_action :set_product, only: %i[update clone destroy] + + def index + @products = ProductService.list(params, current_user) + end + + def show + @product = Product.includes(:product_images, :product_files).friendly.find(params[:id]) + end + + def create + authorize Product + @product = ProductService.create(product_params, params[:product][:product_stock_movements_attributes]) + if @product.save + render status: :created + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product + + @product = ProductService.update(@product, product_params, params[:product][:product_stock_movements_attributes]) + if @product.save + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def clone + authorize @product + + @product = ProductService.clone(@product, product_params) + if @product.save + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product + begin + ProductService.destroy(@product) + head :no_content + rescue StandardError => e + render json: e, status: :unprocessable_entity + end + end + + def stock_movements + authorize Product + @movements = ProductService.stock_movements(params) + end + + private + + def set_product + @product = Product.find(params[:id]) + end + + def product_params + params.require(:product).permit(:name, :slug, :sku, :description, :is_active, + :product_category_id, :amount, :quantity_min, + :low_stock_alert, :low_stock_threshold, + machine_ids: [], + product_files_attributes: %i[id attachment _destroy], + product_images_attributes: %i[id attachment is_main _destroy]) + end +end diff --git a/app/controllers/api/settings_controller.rb b/app/controllers/api/settings_controller.rb index 44804b584..ddb022015 100644 --- a/app/controllers/api/settings_controller.rb +++ b/app/controllers/api/settings_controller.rb @@ -26,13 +26,13 @@ class API::SettingsController < API::ApiController authorize Setting @settings = [] - may_transaction(params[:transactional]) do + may_transaction params[:transactional] do params[:settings].each do |setting| next if !setting[:name] || !setting[:value] db_setting = Setting.find_or_initialize_by(name: setting[:name]) if !SettingService.before_update(db_setting) - db_setting.errors[:-] << I18n.t("settings.#{setting[:name]}") + ': ' + I18n.t('settings.locked_setting') + db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}") elsif db_setting.save db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile) SettingService.after_update(db_setting) @@ -66,7 +66,7 @@ class API::SettingsController < API::ApiController first_val = setting.history_values.order(created_at: :asc).limit(1).first new_val = HistoryValue.create!( setting_id: setting.id, - value: first_val.value, + value: first_val&.value, invoicing_profile_id: current_user.invoicing_profile.id ) SettingService.after_update(setting) @@ -84,11 +84,9 @@ class API::SettingsController < API::ApiController end # run the given block in a transaction if `should` is true. Just run it normally otherwise - def may_transaction(should) + def may_transaction(should, &block) if should == 'true' - ActiveRecord::Base.transaction do - yield - end + ActiveRecord::Base.transaction(&block) else yield end diff --git a/app/controllers/api/statistics_controller.rb b/app/controllers/api/statistics_controller.rb index 545572c9f..d657df10a 100644 --- a/app/controllers/api/statistics_controller.rb +++ b/app/controllers/api/statistics_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# API Controller for resources of type Space +# API Controller for various statistical resources (gateway to elasticsearch DB) class API::StatisticsController < API::ApiController before_action :authenticate_user! @@ -9,49 +9,25 @@ class API::StatisticsController < API::ApiController @statistics = StatisticIndex.all end - %w[account event machine project subscription training user space].each do |path| + %w[account event machine project subscription training user space order].each do |path| class_eval %{ - def #{path} - authorize :statistic, :#{path}? + def #{path} # def account + authorize :statistic, :#{path}? # authorize :statistic, :account + render json: Statistics::QueryService.query('#{path}', request) # render json: Statistics::QueryService.query('account', request) + end # end - # remove additional parameters - statistic_type = request.query_parameters.delete('stat-type') - custom_query = request.query_parameters.delete('custom-query') - start_date = request.query_parameters.delete('start-date') - end_date = request.query_parameters.delete('end-date') + def export_#{path} # def export_account + authorize :statistic, :export_#{path}? # authorize :statistic, :export_account? - # run main query in elasticSearch - query = MultiJson.load(request.body.read) - results = Stats::#{path.classify}.search(query, request.query_parameters.symbolize_keys).response - - # run additional custom aggregations, if any - CustomAggregationService.new.("#{path}", statistic_type, start_date, end_date, custom_query, results) - - # return result - render json: results - end - }, __FILE__, __LINE__ - 20 - end - - %w[account event machine project subscription training user space].each do |path| - class_eval %{ - def export_#{path} - authorize :statistic, :export_#{path}? - - export = Export.where(category:'statistics', export_type: '#{path}', query: params[:body], key: params[:type_key]).last - if export.nil? || !FileTest.exist?(export.file) - @export = Export.new(category:'statistics', - export_type: '#{path}', - user: current_user, - query: params[:body], - key: params[:type_key]) + @export = Statistics::QueryService.export('#{path}', params) # @export = Statistics::QueryService.export('account', params) + if @export.is_a?(Export) if @export.save - render json: {export_id: @export.id}, status: :ok + render json: { export_id: @export.id }, status: :ok else render json: @export.errors, status: :unprocessable_entity end else - send_file File.join(Rails.root, export.file), + send_file @export, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' end @@ -62,16 +38,15 @@ class API::StatisticsController < API::ApiController def export_global authorize :statistic, :export_global? - export = Export.where(category: 'statistics', export_type: 'global', query: params[:body]).last - if export.nil? || !FileTest.exist?(export.file) - @export = Export.new(category: 'statistics', export_type: 'global', user: current_user, query: params[:body]) + @export = Statistics::QueryService.export(global, params) + if @export.is_a?(Export) if @export.save render json: { export_id: @export.id }, status: :ok else render json: @export.errors, status: :unprocessable_entity end else - send_file File.join(Rails.root, export.file), + send_file @export, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' end diff --git a/app/controllers/api/wallet_controller.rb b/app/controllers/api/wallet_controller.rb index 75e450e55..159ee0a7e 100644 --- a/app/controllers/api/wallet_controller.rb +++ b/app/controllers/api/wallet_controller.rb @@ -18,7 +18,7 @@ class API::WalletController < API::ApiController end def credit - return head 422 unless Setting.get('wallet_module') + return head :unprocessable_entity unless Setting.get('wallet_module') @wallet = Wallet.find(credit_params[:id]) authorize @wallet @@ -28,7 +28,7 @@ class API::WalletController < API::ApiController service.create_avoir(transaction, credit_params[:avoir_date], credit_params[:avoir_description]) if credit_params[:avoir] render :show else - head 422 + head :unprocessable_entity end end diff --git a/app/controllers/concerns/api/order_concern.rb b/app/controllers/concerns/api/order_concern.rb new file mode 100644 index 000000000..6c0b753c1 --- /dev/null +++ b/app/controllers/concerns/api/order_concern.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Concern for CartController and CheckoutController +module API::OrderConcern + private + + def order_token + request.headers['X-Fablab-Order-Token'] || cart_params[:order_token] + end + + def current_order + @current_order = Order.find_by(token: order_token, state: 'cart') + end + + def ensure_order + raise ActiveRecord::RecordNotFound if @current_order.nil? + end + + def cart_params + params.permit(:order_token, :orderable_id, :quantity, :user_id, :is_offered) + end +end diff --git a/app/exceptions/cannot_delete_product_error.rb b/app/exceptions/cannot_delete_product_error.rb new file mode 100644 index 000000000..85135513f --- /dev/null +++ b/app/exceptions/cannot_delete_product_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when deleting a product, if this product is used in orders +class CannotDeleteProductError < StandardError +end diff --git a/app/exceptions/cart/inactive_product_error.rb b/app/exceptions/cart/inactive_product_error.rb new file mode 100644 index 000000000..dff476462 --- /dev/null +++ b/app/exceptions/cart/inactive_product_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::InactiveProductError < StandardError +end diff --git a/app/exceptions/cart/item_amount_error.rb b/app/exceptions/cart/item_amount_error.rb new file mode 100644 index 000000000..9085d68ce --- /dev/null +++ b/app/exceptions/cart/item_amount_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the item's amount != product's amount +class Cart::ItemAmountError < StandardError +end diff --git a/app/exceptions/cart/out_stock_error.rb b/app/exceptions/cart/out_stock_error.rb new file mode 100644 index 000000000..effaaad54 --- /dev/null +++ b/app/exceptions/cart/out_stock_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the product is out of stock +class Cart::OutStockError < StandardError +end diff --git a/app/exceptions/cart/quantity_min_error.rb b/app/exceptions/cart/quantity_min_error.rb new file mode 100644 index 000000000..f34e4c1b2 --- /dev/null +++ b/app/exceptions/cart/quantity_min_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when the item's quantity < product's quantity min +class Cart::QuantityMinError < StandardError +end diff --git a/app/exceptions/cart/zero_price_error.rb b/app/exceptions/cart/zero_price_error.rb new file mode 100644 index 000000000..7ac80e19f --- /dev/null +++ b/app/exceptions/cart/zero_price_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when order amount = 0 +class Cart::ZeroPriceError < StandardError +end diff --git a/app/exceptions/update_order_state_error.rb b/app/exceptions/update_order_state_error.rb new file mode 100644 index 000000000..d21610051 --- /dev/null +++ b/app/exceptions/update_order_state_error.rb @@ -0,0 +1,3 @@ +# Raised when update order state error +class UpdateOrderStateError < StandardError +end diff --git a/app/frontend/images/default-image.png b/app/frontend/images/default-image.png new file mode 100644 index 000000000..ea4f8a803 Binary files /dev/null and b/app/frontend/images/default-image.png differ diff --git a/app/frontend/images/no_avatar.png b/app/frontend/images/no_avatar.png old mode 100755 new mode 100644 index dbbb16222..bdde27828 Binary files a/app/frontend/images/no_avatar.png and b/app/frontend/images/no_avatar.png differ diff --git a/app/frontend/images/no_image.png b/app/frontend/images/no_image.png new file mode 100644 index 000000000..9d77d405f Binary files /dev/null and b/app/frontend/images/no_image.png differ diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts new file mode 100644 index 000000000..1f5627cd9 --- /dev/null +++ b/app/frontend/src/javascript/api/cart.ts @@ -0,0 +1,40 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Order, OrderErrors } from '../models/order'; + +export default class CartAPI { + static async create (token?: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/cart', { order_token: token }); + return res?.data; + } + + static async addItem (order: Order, orderableId: number, quantity: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity }); + return res?.data; + } + + static async removeItem (order: Order, orderableId: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId }); + return res?.data; + } + + static async setQuantity (order: Order, orderableId: number, quantity: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity }); + return res?.data; + } + + static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id }); + return res?.data; + } + + static async refreshItem (order: Order, orderableId: number): Promise { + const res: AxiosResponse = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId }); + return res?.data; + } + + static async validate (order: Order): Promise { + const res: AxiosResponse = await apiClient.post('/api/cart/validate', { order_token: order.token }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/checkout.ts b/app/frontend/src/javascript/api/checkout.ts new file mode 100644 index 000000000..0a7e8f34a --- /dev/null +++ b/app/frontend/src/javascript/api/checkout.ts @@ -0,0 +1,25 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { OrderPayment, Order } from '../models/order'; + +export default class CheckoutAPI { + static async payment (order: Order, paymentId?: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/payment', { + order_token: order.token, + coupon_code: order.coupon?.code, + payment_id: paymentId, + customer_id: order.user.id + }); + return res?.data; + } + + static async confirmPayment (order: Order, paymentId: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/confirm_payment', { + order_token: order.token, + coupon_code: order.coupon?.code, + payment_id: paymentId, + customer_id: order.user.id + }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/clients/api-client.ts b/app/frontend/src/javascript/api/clients/api-client.ts index 393f4631f..c8ddfc7b8 100644 --- a/app/frontend/src/javascript/api/clients/api-client.ts +++ b/app/frontend/src/javascript/api/clients/api-client.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance } from 'axios'; +import ParsingLib from '../../lib/parsing'; type Error = { error: string }; @@ -48,7 +49,9 @@ function extractHumanReadableMessage (error: string|Error): string { // iterate through all the keys to build the message for (const key in error) { if (Object.prototype.hasOwnProperty.call(error, key)) { - message += `${key} : `; + if (!ParsingLib.isInteger(key)) { + message += `${key} : `; + } if (error[key] instanceof Array) { // standard rails messages are stored as {field: [error1, error2]} // we rebuild them as "field: error1, error2" diff --git a/app/frontend/src/javascript/api/coupon.ts b/app/frontend/src/javascript/api/coupon.ts new file mode 100644 index 000000000..5f0dd4fd8 --- /dev/null +++ b/app/frontend/src/javascript/api/coupon.ts @@ -0,0 +1,10 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Coupon } from '../models/coupon'; + +export default class CouponAPI { + static async validate (code: string, amount: number, userId?: number): Promise { + const res: AxiosResponse = await apiClient.post('/api/coupons/validate', { code, amount, user_id: userId }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/member.ts b/app/frontend/src/javascript/api/member.ts index 9970bef03..5890f553d 100644 --- a/app/frontend/src/javascript/api/member.ts +++ b/app/frontend/src/javascript/api/member.ts @@ -9,6 +9,16 @@ export default class MemberAPI { return res?.data; } + static async search (name: string): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/members/search/${name}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/members/${id}`); + return res?.data; + } + static async create (user: User): Promise { const data = serialize({ user }); if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) { diff --git a/app/frontend/src/javascript/api/order.ts b/app/frontend/src/javascript/api/order.ts new file mode 100644 index 000000000..f36f5d50d --- /dev/null +++ b/app/frontend/src/javascript/api/order.ts @@ -0,0 +1,26 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Order, OrderIndexFilter, OrderIndex } from '../models/order'; +import ApiLib from '../lib/api'; + +export default class OrderAPI { + static async index (filters?: OrderIndexFilter): Promise { + const res: AxiosResponse = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters, false)}`); + return res?.data; + } + + static async get (id: number | string): Promise { + const res: AxiosResponse = await apiClient.get(`/api/orders/${id}`); + return res?.data; + } + + static async updateState (order: Order, state: string, note?: string): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/orders/${order.id}`, { order: { state, note } }); + return res?.data; + } + + static async withdrawalInstructions (order?: Order): Promise { + const res: AxiosResponse = await apiClient.get(`/api/orders/${order?.id}/withdrawal_instructions`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/product-category.ts b/app/frontend/src/javascript/api/product-category.ts new file mode 100644 index 000000000..2870e35a6 --- /dev/null +++ b/app/frontend/src/javascript/api/product-category.ts @@ -0,0 +1,35 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { ProductCategory } from '../models/product-category'; + +export default class ProductCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/product_categories'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/product_categories/${id}`); + return res?.data; + } + + static async create (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/product_categories', { product_category: productCategory }); + return res?.data; + } + + static async update (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory }); + return res?.data; + } + + static async destroy (productCategoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/product_categories/${productCategoryId}`); + return res?.data; + } + + static async updatePosition (productCategory: ProductCategory, position: number): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}/position`, { position }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts new file mode 100644 index 000000000..a62787a29 --- /dev/null +++ b/app/frontend/src/javascript/api/product.ts @@ -0,0 +1,110 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { serialize } from 'object-to-formdata'; +import { + Product, + ProductIndexFilter, + ProductsIndex, + StockMovementIndex, StockMovementIndexFilter +} from '../models/product'; +import ApiLib from '../lib/api'; +import ProductLib from '../lib/product'; + +export default class ProductAPI { + static async index (filters?: ProductIndexFilter): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters), false)}`); + return res?.data; + } + + static async get (id: number | string): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${id}`); + return res?.data; + } + + static async create (product: Product): Promise { + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); + } + }); + const res: AxiosResponse = await apiClient.post('/api/products', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async update (product: Product): Promise { + const data = serialize({ + product: { + ...product, + product_files_attributes: null, + product_images_attributes: null + } + }); + data.delete('product[product_files_attributes]'); + data.delete('product[product_images_attributes]'); + product.product_files_attributes?.forEach((file, i) => { + if (file?.attachment_files && file?.attachment_files[0]) { + data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]); + } + if (file?.id) { + data.set(`product[product_files_attributes][${i}][id]`, file.id.toString()); + } + if (file?._destroy) { + data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString()); + } + }); + product.product_images_attributes?.forEach((image, i) => { + if (image?.attachment_files && image?.attachment_files[0]) { + data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]); + } + if (image?.id) { + data.set(`product[product_images_attributes][${i}][id]`, image.id.toString()); + } + if (image?._destroy) { + data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); + } + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); + }); + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async clone (product: Product, data: Product): Promise { + const res: AxiosResponse = await apiClient.put(`/api/products/${product.id}/clone`, { + product: data + }); + return res?.data; + } + + static async destroy (productId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/products/${productId}`); + return res?.data; + } + + static async stockMovements (productId: number, filters: StockMovementIndexFilter): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${productId}/stock_movements${ApiLib.filtersToQuery(filters)}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/setting.ts b/app/frontend/src/javascript/api/setting.ts index 5e6e985ea..c0f8832e7 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -1,6 +1,13 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; -import { Setting, SettingBulkResult, SettingError, SettingName, SettingValue } from '../models/setting'; +import { + Setting, + SettingBulkArray, + SettingBulkResult, + SettingError, + SettingName, + SettingValue +} from '../models/setting'; export default class SettingAPI { static async get (name: SettingName): Promise { @@ -8,7 +15,7 @@ export default class SettingAPI { return res?.data?.setting; } - static async query (names: Array): Promise> { + static async query (names: readonly SettingName[]): Promise> { const params = new URLSearchParams(); params.append('names', `['${names.join("','")}']`); @@ -32,7 +39,7 @@ export default class SettingAPI { return res?.data?.isPresent; } - private static toSettingsMap (names: Array, data: Record): Map { + private static toSettingsMap (names: readonly SettingName[], data: Record): Map { const map = new Map(); names.forEach(name => { map.set(name, data[name] || ''); @@ -60,7 +67,7 @@ export default class SettingAPI { return map; } - private static toObjectArray (data: Map): Array> { + private static toObjectArray (data: Map): SettingBulkArray { const array = []; data.forEach((value, key) => { array.push({ diff --git a/app/frontend/src/javascript/components/base/accordion-item.tsx b/app/frontend/src/javascript/components/base/accordion-item.tsx new file mode 100644 index 000000000..63eb6c9e1 --- /dev/null +++ b/app/frontend/src/javascript/components/base/accordion-item.tsx @@ -0,0 +1,30 @@ +import React, { useState, useEffect } from 'react'; +import { CaretDown } from 'phosphor-react'; + +interface AccordionItemProps { + isOpen: boolean, + onChange: (id: number, isOpen: boolean) => void, + id: number, + label: string +} + +/** + * Renders an accordion item + */ +export const AccordionItem: React.FC = ({ isOpen, onChange, id, label, children }) => { + const [state, setState] = useState(isOpen); + + useEffect(() => { + onChange(id, state); + }, [state]); + + return ( +
+
setState(!state)}> + {label} + +
+ {children} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index b5500ff84..f7e57dc5c 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -36,11 +36,9 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * If the default value changes, update the value of the input until there's no content in it. */ useEffect(() => { - if (!inputValue) { - setInputValue(defaultValue); - if (typeof onChange === 'function') { - onChange(defaultValue); - } + setInputValue(defaultValue); + if (typeof onChange === 'function') { + onChange(defaultValue); } }, [defaultValue]); diff --git a/app/frontend/src/javascript/components/base/fab-modal.tsx b/app/frontend/src/javascript/components/base/fab-modal.tsx index 3a091f1dc..0318f3dea 100644 --- a/app/frontend/src/javascript/components/base/fab-modal.tsx +++ b/app/frontend/src/javascript/components/base/fab-modal.tsx @@ -23,6 +23,7 @@ interface FabModalProps { customHeader?: ReactNode, customFooter?: ReactNode, onConfirm?: (event: BaseSyntheticEvent) => void, + onClose?: (event: BaseSyntheticEvent) => void, preventConfirm?: boolean, onCreation?: () => void, onConfirmSendFormId?: string, @@ -31,7 +32,7 @@ interface FabModalProps { /** * This component is a template for a modal dialog that wraps the application style */ -export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => { +export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => { const { t } = useTranslation('shared'); useEffect(() => { @@ -40,12 +41,20 @@ export const FabModal: React.FC = ({ title, isOpen, toggleModal, } }, [isOpen]); + /** + * Callback triggered when the user request to close the modal without confirming. + */ + const handleClose = (event) => { + if (typeof onClose === 'function') onClose(event); + toggleModal(); + }; + return ( - {closeButton && {t('app.shared.fab_modal.close')}} + onRequestClose={handleClose}> + {closeButton && {t('app.shared.fab_modal.close')}}
{!customHeader &&

{ title }

} {customHeader && customHeader} diff --git a/app/frontend/src/javascript/components/base/fab-pagination.tsx b/app/frontend/src/javascript/components/base/fab-pagination.tsx new file mode 100644 index 000000000..89af684ce --- /dev/null +++ b/app/frontend/src/javascript/components/base/fab-pagination.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { CaretDoubleLeft, CaretLeft, CaretRight, CaretDoubleRight } from 'phosphor-react'; + +interface FabPaginationProps { + pageCount: number, + currentPage: number, + selectPage: (page: number) => void +} + +/** + * Renders a pagination navigation + */ +export const FabPagination: React.FC = ({ pageCount, currentPage, selectPage }) => { + return ( + + ); +}; diff --git a/app/frontend/src/javascript/components/base/fab-state-label.tsx b/app/frontend/src/javascript/components/base/fab-state-label.tsx new file mode 100644 index 000000000..25fc0a998 --- /dev/null +++ b/app/frontend/src/javascript/components/base/fab-state-label.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface FabStateLabelProps { + status?: string, + background?: boolean +} + +/** + * Render a label preceded by a bot + */ +export const FabStateLabel: React.FC = ({ status, background, children }) => { + return ( + + {children} + + ); +}; diff --git a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx index 9a1076349..075bb3191 100644 --- a/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/fab-text-editor.tsx @@ -12,11 +12,14 @@ import { MenuBar } from './menu-bar'; import { WarningOctagon } from 'phosphor-react'; interface FabTextEditorProps { - paragraphTools?: boolean, - content?: string, - limit?: number, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, image?: boolean, + content?: string, + limit?: number, onChange?: (content: string) => void, placeholder?: string, error?: string, @@ -30,7 +33,7 @@ export interface FabTextEditorRef { /** * This component is a WYSIWYG text editor */ -export const FabTextEditor: React.ForwardRefRenderFunction = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject) => { +const FabTextEditor: React.ForwardRefRenderFunction = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject) => { const { t } = useTranslation('shared'); const placeholderText = placeholder || t('app.shared.text_editor.fab_text_editor.text_placeholder'); // TODO: Add ctrl+click on link to visit @@ -72,7 +75,11 @@ export const FabTextEditor: React.ForwardRefRenderFunction { - onChange(editor.getHTML()); + if (editor.isEmpty) { + onChange(''); + } else { + onChange(editor.getHTML()); + } } }); @@ -80,17 +87,23 @@ export const FabTextEditor: React.ForwardRefRenderFunction { + if (editor?.getHTML() !== content) { + editor?.commands.setContent(content); + } + }, [content]); + // bind the editor to the ref, once it is ready if (!editor) return null; editorRef.current = editor; return ( -
- +
+ -
+ {limit &&
{editor?.storage.characterCount.characters()} / {limit} -
+
} {error &&
diff --git a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx index 1a580d949..a984c473f 100644 --- a/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx +++ b/app/frontend/src/javascript/components/base/text-editor/menu-bar.tsx @@ -6,7 +6,10 @@ import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, Li interface MenuBarProps { editor?: Editor, - paragraphTools?: boolean, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, image?: boolean, disabled?: boolean, @@ -15,7 +18,7 @@ interface MenuBarProps { /** * This component is the menu bar for the WYSIWYG text editor */ -export const MenuBar: React.FC = ({ editor, paragraphTools, video, image, disabled = false }) => { +export const MenuBar: React.FC = ({ editor, heading, bulletList, blockquote, link, video, image, disabled = false }) => { const { t } = useTranslation('shared'); const [submenu, setSubmenu] = useState(''); @@ -44,6 +47,10 @@ export const MenuBar: React.FC = ({ editor, paragraphTools, video, if (submenu !== type) { setSubmenu(type); if (type === 'link') { + if (editor.view.state.selection.from === editor.view.state.selection.to) { + setSubmenu(''); + return; + } const previousUrl = { href: editor.getAttributes('link').href, target: editor.getAttributes('link').target || '' @@ -72,7 +79,7 @@ export const MenuBar: React.FC = ({ editor, paragraphTools, video, // Support keyboard "Enter" key event to validate const handleEnter = (evt) => { if (evt.keyCode === 13) { - setLink(); + setLink(true); } }; @@ -142,8 +149,7 @@ export const MenuBar: React.FC = ({ editor, paragraphTools, video, return ( <>
- { paragraphTools && - (<> + {heading && + } + {bulletList && + } + {blockquote && - - ) } + { (heading || bulletList || blockquote) && } - + {link && + + } { (video || image) && } { video && (<> diff --git a/app/frontend/src/javascript/components/cart/cart-button.tsx b/app/frontend/src/javascript/components/cart/cart-button.tsx new file mode 100644 index 000000000..b9e2b3029 --- /dev/null +++ b/app/frontend/src/javascript/components/cart/cart-button.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { Order } from '../../models/order'; +import { useCustomEventListener } from 'react-custom-events'; + +declare const Application: IApplication; + +/** + * This component shows my cart button + */ +const CartButton: React.FC = () => { + const { t } = useTranslation('public'); + const [cart, setCart] = useState(); + useCustomEventListener('CartUpdate', (data) => { + setCart(data); + }); + + /** + * Goto cart page + */ + const showCart = () => { + window.location.href = '/#!/store/cart'; + }; + + return ( +
+ + {cart && cart.order_items_attributes.length > 0 && + {cart.order_items_attributes.length} + } +

{t('app.public.cart_button.my_cart')}

+
+ ); +}; + +const CartButtonWrapper: React.FC = () => { + return ( + + + + ); +}; + +Application.Components.component('cartButton', react2angular(CartButtonWrapper)); diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx new file mode 100644 index 000000000..87345ee6c --- /dev/null +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -0,0 +1,383 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabButton } from '../base/fab-button'; +import useCart from '../../hooks/use-cart'; +import FormatLib from '../../lib/format'; +import CartAPI from '../../api/cart'; +import { User } from '../../models/user'; +import { PaymentModal } from '../payment/stripe/payment-modal'; +import { PaymentMethod } from '../../models/payment'; +import { Order, OrderErrors } from '../../models/order'; +import { MemberSelect } from '../user/member-select'; +import { CouponInput } from '../coupon/coupon-input'; +import { Coupon } from '../../models/coupon'; +import noImage from '../../../../images/no_image.png'; +import Switch from 'react-switch'; +import OrderLib from '../../lib/order'; +import { CaretDown, CaretUp } from 'phosphor-react'; +import _ from 'lodash'; +import OrderAPI from '../../api/order'; + +declare const Application: IApplication; + +interface StoreCartProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, + userLogin: () => void, + currentUser?: User +} + +/** + * This component shows user's cart + */ +const StoreCart: React.FC = ({ onSuccess, onError, currentUser, userLogin }) => { + const { t } = useTranslation('public'); + + const { cart, setCart, reloadCart } = useCart(currentUser); + const [cartErrors, setCartErrors] = useState(null); + const [noMemberError, setNoMemberError] = useState(false); + const [paymentModal, setPaymentModal] = useState(false); + const [withdrawalInstructions, setWithdrawalInstructions] = useState(null); + + useEffect(() => { + if (cart) { + checkCart(); + } + if (cart && !withdrawalInstructions) { + OrderAPI.withdrawalInstructions(cart) + .then(setWithdrawalInstructions) + .catch(onError); + } + }, [cart]); + + /** + * Remove the product from cart + */ + const removeProductFromCart = (item) => { + return (e: React.BaseSyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + const errors = getItemErrors(item); + if (errors.length === 1 && errors[0].error === 'not_found') { + reloadCart().catch(onError); + } else { + CartAPI.removeItem(cart, item.orderable_id).then(data => { + setCart(data); + }).catch(onError); + } + }; + }; + + /** + * Change product quantity + */ + const changeProductQuantity = (e: React.BaseSyntheticEvent, item) => { + CartAPI.setQuantity(cart, item.orderable_id, e.target.value) + .then(data => { + setCart(data); + }) + .catch(() => onError(t('app.public.store_cart.stock_limit'))); + }; + + /** + * Increment/decrement product quantity + */ + const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => { + CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1) + .then(data => { + setCart(data); + }) + .catch(() => onError(t('app.public.store_cart.stock_limit'))); + }; + + /** + * Refresh product amount + */ + const refreshItem = (item) => { + return (e: React.BaseSyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + CartAPI.refreshItem(cart, item.orderable_id).then(data => { + setCart(data); + }).catch(onError); + }; + }; + + /** + * Check the current cart's items (available, price, stock, quantity_min) + */ + const checkCart = async (): Promise => { + const errors = await CartAPI.validate(cart); + setCartErrors(errors); + return errors; + }; + + /** + * Checkout cart + */ + const checkout = () => { + if (!currentUser) { + userLogin(); + } else { + if (!cart.user) { + setNoMemberError(true); + onError(t('app.public.store_cart.select_user')); + } else { + setNoMemberError(false); + checkCart().then(errors => { + if (!hasCartErrors(errors)) { + setPaymentModal(true); + } + }); + } + } + }; + + /** + * Check if the carrent cart has any error + */ + const hasCartErrors = (errors: OrderErrors) => { + if (!errors) return false; + for (const item of cart.order_items_attributes) { + const error = _.find(errors.details, (e) => e.item_id === item.id); + if (!error || error?.errors?.length > 0) return true; + } + return false; + }; + + /** + * get givean item's error + */ + const getItemErrors = (item) => { + if (!cartErrors) return []; + const errors = _.find(cartErrors.details, (e) => e.item_id === item.id); + return errors?.errors || [{ error: 'not_found' }]; + }; + + /** + * Open/closes the payment modal + */ + const togglePaymentModal = (): void => { + setPaymentModal(!paymentModal); + }; + + /** + * Handle payment + */ + const handlePaymentSuccess = (data: Order): void => { + if (data.state === 'paid') { + setPaymentModal(false); + window.location.href = '/#!/store'; + onSuccess(t('app.public.store_cart.checkout_success')); + } else { + onError(t('app.public.store_cart.checkout_error')); + } + }; + + /** + * Change cart's customer by admin/manger + */ + const handleChangeMember = (user: User): void => { + // if the selected user is the operator, he cannot offer products to himself + if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) { + Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => { + return CartAPI.setOffer(cart, item.orderable_id, false); + })).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } })); + } else { + setCart({ ...cart, user: { id: user.id, role: user.role } }); + } + }; + + /** + * Check if the current operator has administrative rights or is a normal member + */ + const isPrivileged = (): boolean => { + return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); + }; + + /** + * Check if the current cart is empty ? + */ + const cartIsEmpty = (): boolean => { + return cart && cart.order_items_attributes.length === 0; + }; + + /** + * Toggle product offer + */ + const toggleProductOffer = (item) => { + return (checked: boolean) => { + CartAPI.setOffer(cart, item.orderable_id, checked).then(data => { + setCart(data); + }).catch(e => { + if (e.match(/code 403/)) { + onError(t('app.public.store_cart.errors.unauthorized_offering_product')); + } else { + onError(e); + } + }); + }; + }; + + /** + * Apply coupon to current cart + */ + const applyCoupon = (coupon?: Coupon): void => { + if (coupon !== cart.coupon) { + setCart({ ...cart, coupon }); + } + }; + + /** + * Show item error + */ + const itemError = (item, error) => { + if (error.error === 'is_active' || error.error === 'not_found') { + return

{t('app.public.store_cart.errors.product_not_found')}

; + } + if (error.error === 'stock' && error.value === 0) { + return

{t('app.public.store_cart.errors.out_of_stock')}

; + } + if (error.error === 'stock' && error.value > 0) { + return

{t('app.public.store_cart.errors.stock_limit_QUANTITY', { QUANTITY: error.value })}

; + } + if (error.error === 'quantity_min') { + return

{t('app.public.store_cart.errors.quantity_min_QUANTITY', { QUANTITY: error.value })}

; + } + if (error.error === 'amount') { + return
+

{t('app.public.store_cart.errors.price_changed_PRICE', { PRICE: `${FormatLib.price(error.value)} / ${t('app.public.store_cart.unit')}` })}

+ {t('app.public.store_cart.update_item')} +
; + } + }; + + return ( +
+
+ {cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} + {cart && cart.order_items_attributes.map(item => ( +
0 ? 'error' : ''}`}> +
+ +
+
+ {t('app.public.store_cart.reference_short')} {item.orderable_ref || ''} +

{item.orderable_name}

+ {item.quantity_min > 1 && + {t('app.public.store_cart.minimum_purchase')}{item.quantity_min} + } + {getItemErrors(item).map(e => { + return itemError(item, e); + })} +
+
+
+

{FormatLib.price(item.amount)}

+ / {t('app.public.store_cart.unit')} +
+
+ changeProductQuantity(e, item)} + min={item.quantity_min} + max={item.orderable_external_stock} + value={item.quantity} + /> + + +
+
+ {t('app.public.store_cart.total')} +

{FormatLib.price(OrderLib.itemAmount(item))}

+
+ + + +
+ {isPrivileged() && +
+ +
+ } +
+ ))} +
+ +
+ {cart && !cartIsEmpty() && +
+

{t('app.public.store_cart.pickup')}

+

+

+ } + + {cart && !cartIsEmpty() && +
+ +
+ } +
+ + + + {cart && !cartIsEmpty() && cart.user &&
+ 'dont need update shopping cart'} /> +
} +
+ ); +}; + +const StoreCartWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('storeCart', react2angular(StoreCartWrapper, ['onSuccess', 'onError', 'currentUser', 'userLogin'])); diff --git a/app/frontend/src/javascript/components/coupon/coupon-input.tsx b/app/frontend/src/javascript/components/coupon/coupon-input.tsx new file mode 100644 index 000000000..427f14998 --- /dev/null +++ b/app/frontend/src/javascript/components/coupon/coupon-input.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabInput } from '../base/fab-input'; +import { FabAlert } from '../base/fab-alert'; +import CouponAPI from '../../api/coupon'; +import { Coupon } from '../../models/coupon'; +import { User } from '../../models/user'; +import FormatLib from '../../lib/format'; + +interface CouponInputProps { + amount: number, + user?: User, + onChange?: (coupon?: Coupon) => void +} + +interface Message { + type: 'info' | 'warning' | 'danger', + message: string +} + +/** + * This component renders an input of coupon + */ +export const CouponInput: React.FC = ({ user, amount, onChange }) => { + const { t } = useTranslation('shared'); + const [messages, setMessages] = useState>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [coupon, setCoupon] = useState(); + const [code, setCode] = useState(); + + useEffect(() => { + if (user && code) { + handleChange(code); + } + }, [user?.id]); + + useEffect(() => { + if (code) { + handleChange(code); + } + }, [amount]); + + /** + * callback for validate the code + */ + const handleChange = (value: string) => { + const mgs = []; + setMessages([]); + setError(false); + setCoupon(null); + setCode(value); + if (value) { + setLoading(true); + CouponAPI.validate(value, amount, user?.id).then((res) => { + setCoupon(res); + if (res.type === 'percent_off') { + mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) }); + } else { + mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: FormatLib.currencySymbol() }) }); + } + if (res.validity_per_user === 'once') { + mgs.push({ type: 'warning', message: t('app.shared.coupon_input.coupon_validity_once') }); + } + setMessages(mgs); + setLoading(false); + if (typeof onChange === 'function') { + onChange(res); + } + }).catch((err) => { + const state = err.split(':')[1].trim(); + setError(true); + setCoupon(null); + setLoading(false); + setMessages([{ type: 'danger', message: t(`app.shared.coupon_input.unable_to_apply_the_coupon_because_${state}`) }]); + onChange(null); + }); + } else { + onChange(null); + } + }; + + // input addon + const inputAddOn = () => { + if (error) { + return ; + } else { + if (loading) { + return ; + } + if (coupon) { + return ; + } + } + }; + + return ( +
+ + + {messages.map((m, i) => { + return ( + + {m.message} + + ); + })} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/dashboard/orders/orders-dashboard.tsx b/app/frontend/src/javascript/components/dashboard/orders/orders-dashboard.tsx new file mode 100644 index 000000000..652d05306 --- /dev/null +++ b/app/frontend/src/javascript/components/dashboard/orders/orders-dashboard.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../../base/loader'; +import { IApplication } from '../../../models/application'; +import { StoreListHeader } from '../../store/store-list-header'; +import { OrderItem } from '../../store/order-item'; +import { FabPagination } from '../../base/fab-pagination'; +import OrderAPI from '../../../api/order'; +import { Order, OrderSortOption } from '../../../models/order'; +import { User } from '../../../models/user'; +import { SelectOption } from '../../../models/select'; + +declare const Application: IApplication; + +interface OrdersDashboardProps { + currentUser: User, + onError: (message: string) => void +} + +/** + * This component shows a list of all orders from the store for the current user + */ +export const OrdersDashboard: React.FC = ({ currentUser, onError }) => { + const { t } = useTranslation('public'); + + const [orders, setOrders] = useState>([]); + const [pageCount, setPageCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + OrderAPI.index({ user_id: currentUser.id }).then(res => { + setPageCount(res.total_pages); + setTotalCount(res.total_count); + setOrders(res.data); + }).catch(onError); + }, []); + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array> => { + return [ + { value: 'created_at-desc', label: t('app.public.orders_dashboard.sort.newest') }, + { value: 'created_at-asc', label: t('app.public.orders_dashboard.sort.oldest') } + ]; + }; + /** + * Display option: sorting + */ + const handleSorting = (option: SelectOption) => { + OrderAPI.index({ page: 1, sort: option.value }).then(res => { + setCurrentPage(1); + setOrders(res.data); + setPageCount(res.total_pages); + setTotalCount(res.total_count); + }).catch(onError); + }; + + /** + * Handle orders pagination + */ + const handlePagination = (page: number) => { + if (page !== currentPage) { + OrderAPI.index({ user_id: currentUser.id, page }).then(res => { + setCurrentPage(page); + setOrders(res.data); + setPageCount(res.total_pages); + setTotalCount(res.total_count); + }).catch(onError); + } + }; + + return ( +
+
+

{t('app.public.orders_dashboard.heading')}

+
+ +
+ +
+ {orders.map(order => ( + + ))} +
+ {pageCount > 1 && + + } +
+
+ ); +}; + +const OrdersDashboardWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('ordersDashboard', react2angular(OrdersDashboardWrapper, ['onError', 'currentUser'])); diff --git a/app/frontend/src/javascript/components/events/event-themes.tsx b/app/frontend/src/javascript/components/events/event-themes.tsx index 1c4d4eae2..473c6e887 100644 --- a/app/frontend/src/javascript/components/events/event-themes.tsx +++ b/app/frontend/src/javascript/components/events/event-themes.tsx @@ -7,6 +7,7 @@ import { Event } from '../../models/event'; import { EventTheme } from '../../models/event-theme'; import { IApplication } from '../../models/application'; import EventThemeAPI from '../../api/event-theme'; +import { SelectOption } from '../../models/select'; declare const Application: IApplication; @@ -15,12 +16,6 @@ interface EventThemesProps { onChange: (themes: Array) => void } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * This component shows a select input to edit the themes associated with the event */ @@ -43,7 +38,7 @@ export const EventThemes: React.FC = ({ event, onChange }) => /** * Return the current theme(s) for the given event, formatted to match the react-select format */ - const defaultValues = (): Array => { + const defaultValues = (): Array> => { const res = []; themes.forEach(t => { if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) { @@ -57,7 +52,7 @@ export const EventThemes: React.FC = ({ event, onChange }) => * Callback triggered when the selection has changed. * Convert the react-select specific format to an array of EventTheme, and call the provided callback. */ - const handleChange = (selectedOptions: Array): void => { + const handleChange = (selectedOptions: Array>): void => { const res = []; selectedOptions.forEach(opt => { res.push(themes.find(t => t.id === opt.value)); @@ -68,7 +63,7 @@ export const EventThemes: React.FC = ({ event, onChange }) => /** * Convert all themes to the react-select format */ - const buildOptions = (): Array => { + const buildOptions = (): Array> => { return themes.map(t => { return { value: t.id, label: t.name }; }); diff --git a/app/frontend/src/javascript/components/form/abstract-form-item.tsx b/app/frontend/src/javascript/components/form/abstract-form-item.tsx index afa91a07a..d4e6f49fc 100644 --- a/app/frontend/src/javascript/components/form/abstract-form-item.tsx +++ b/app/frontend/src/javascript/components/form/abstract-form-item.tsx @@ -63,7 +63,7 @@ export const AbstractFormItem = ({ id, label, <> {(label && !inLine) &&

{label}

- {tooltip &&
+ {tooltip &&
{tooltip}
} @@ -71,7 +71,7 @@ export const AbstractFormItem = ({ id, label,
{inLine &&

{label}

- {tooltip &&
+ {tooltip &&
{tooltip}
} diff --git a/app/frontend/src/javascript/components/form/form-checklist.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx new file mode 100644 index 000000000..b5cc8d8a2 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Controller, Path, FieldPathValue } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import { ChecklistOption } from '../../models/select'; + +interface FormChecklistProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: Array, + options: Array>, + onChange?: (values: Array) => void, +} + +/** + * This component is a template for a checklist component to use within React Hook Form + */ +export const FormChecklist = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps) => { + const { t } = useTranslation('shared'); + + /** + * Verify if the provided option is currently ticked + */ + const isChecked = (values: Array, option: ChecklistOption): boolean => { + return !!values?.includes(option.value); + }; + + /** + * Callback triggered when a checkbox is ticked or unticked. + */ + const toggleCheckbox = (option: ChecklistOption, rhfValues: Array = [], rhfCallback: (value: Array) => void) => { + return (event: React.ChangeEvent) => { + let newValues: Array = []; + if (event.target.checked) { + newValues = rhfValues.concat(option.value); + } else { + newValues = rhfValues.filter(v => v !== option.value); + } + rhfCallback(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Mark all options as selected + */ + const selectAll = (rhfCallback: (value: Array) => void) => { + return () => { + const newValues: Array = options.map(o => o.value); + rhfCallback(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Mark all options as non-selected + */ + const unselectAll = (rhfCallback: (value: Array) => void) => { + return () => { + rhfCallback([]); + if (typeof onChange === 'function') { + onChange([]); + } + }; + }; + + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + rules={rules} + render={({ field: { onChange, value } }) => { + return ( + <> +
+ {options.map((option, k) => { + return ( +
+ + +
+ ); + })} +
+
+ {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')} +
+ + ); + }} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx new file mode 100644 index 000000000..a4832a824 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from './form-input'; +import { FormComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import { FilePdf, Trash } from 'phosphor-react'; +import { FileType } from '../../models/file'; +import FileUploadLib from '../../lib/file-upload'; + +interface FormFileUploadProps extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultFile?: FileType, + accept?: string, + onFileChange?: (value: FileType) => void, + onFileRemove?: () => void, +} + +/** + * This component allows to upload file, in forms managed by react-hook-form. + */ +export const FormFileUpload = ({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultFile); + + /** + * Check if file is selected + */ + const hasFile = (): boolean => { + return FileUploadLib.hasFile(file); + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + setFile({ + attachment_name: f.name + }); + setValue( + `${id}._destroy` as Path, + false as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove); + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + /** + * Returns placeholder text + */ + const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse'); + + return ( +
+ {hasFile() && ( + {file.attachment_name} + )} +
+ {file?.id && file?.attachment_url && ( + + + + )} + + {hasFile() && + } className="is-main" /> + } +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx new file mode 100644 index 000000000..10c0db07b --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Path, Controller } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPath, FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from './form-input'; +import { FormComponent, FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; +import noImage from '../../../../images/no_image.png'; +import { Trash } from 'phosphor-react'; +import { ImageType } from '../../models/file'; +import FileUploadLib from '../../lib/file-upload'; + +interface FormImageUploadProps extends FormComponent, FormControlledComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + defaultImage?: ImageType, + accept?: string, + size?: 'small' | 'medium' | 'large', + mainOption?: boolean, + onFileChange?: (value: ImageType) => void, + onFileRemove?: () => void, + onFileIsMain?: (setIsMain: () => void) => void, +} + +/** + * This component allows to upload image, in forms managed by react-hook-form. + */ +export const FormImageUpload = ({ id, label, register, control, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultImage); + const [image, setImage] = useState(defaultImage.attachment_url); + + useEffect(() => { + setFile(defaultImage); + }, [defaultImage]); + + /** + * Check if image is selected + */ + const hasImage = (): boolean => { + return FileUploadLib.hasFile(file); + }; + + /** + * Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled). + */ + function onFileSelected (event: React.ChangeEvent) { + const f = event.target?.files[0]; + if (f) { + const reader = new FileReader(); + reader.onload = (): void => { + setImage(reader.result); + }; + reader.readAsDataURL(f); + setFile({ + ...file, + attachment_name: f.name + }); + setValue( + id as Path, + { + attachment_name: f.name, + _destroy: false + } as UnpackNestedValue>> + ); + if (typeof onFileChange === 'function') { + onFileChange({ attachment_name: f.name }); + } + } + } + + /** + * Callback triggered when the user clicks on the delete button. + */ + function onRemoveFile () { + FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove); + } + + /** + * Returns placeholder text + */ + const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse'); + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ {file?.attachment_name { + currentTarget.onerror = null; + currentTarget.src = noImage; + }} /> +
+
+ {mainOption && + + } + + {hasImage() && } className="is-main" />} +
+
+ ); +}; + +FormImageUpload.defaultProps = { + size: 'medium' +}; diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 7d07b6950..aa022f248 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -18,12 +18,13 @@ interface FormInputProps extends FormComponent) => void, + nullable?: boolean } /** * This component is a template for an input component to use within React Hook Form */ -export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps) => { +export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps) => { /** * Debounced (ie. temporised) version of the 'on change' callback. */ @@ -57,8 +58,8 @@ export const FormInput = ({ id, re , { ...rules, - valueAsNumber: type === 'number', valueAsDate: type === 'date', + setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v), value: defaultValue as FieldPathValue>, onChange: (e) => { handleChange(e); } })} @@ -67,6 +68,7 @@ export const FormInput = ({ id, re disabled={typeof disabled === 'function' ? disabled(id) : disabled} placeholder={placeholder} accept={accept} /> + {(type === 'file' && placeholder) && {placeholder}} {addOn && {addOn}} ); diff --git a/app/frontend/src/javascript/components/form/form-multi-file-upload.tsx b/app/frontend/src/javascript/components/form/form-multi-file-upload.tsx new file mode 100644 index 000000000..3746a9097 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-multi-file-upload.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode } from 'react'; +import { FormFileUpload } from './form-file-upload'; +import { FabButton } from '../base/fab-button'; +import { Plus } from 'phosphor-react'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormComponent, FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { ArrayPath, FieldArray, useFieldArray } from 'react-hook-form'; +import { FileType } from '../../models/file'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; + +interface FormMultiFileUploadProps extends FormComponent, FormControlledComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + addButtonLabel: ReactNode, + accept: string +} + +/** + * This component allows to upload multiple files, in forms managed by react-hook-form. + */ +export const FormMultiFileUpload = ({ id, className, register, control, setValue, formState, addButtonLabel, accept }: FormMultiFileUploadProps) => { + const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath }); + + return ( +
+
+ {fields.map((field: FileType, index) => ( + remove(index)}/> + ))} +
+ append({ _destroy: false } as UnpackNestedValue>>)} + className='is-secondary' + icon={}> + {addButtonLabel} + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-multi-image-upload.tsx b/app/frontend/src/javascript/components/form/form-multi-image-upload.tsx new file mode 100644 index 000000000..b1bca2a3d --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-multi-image-upload.tsx @@ -0,0 +1,102 @@ +import React, { ReactNode } from 'react'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormComponent, FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItemProps } from './abstract-form-item'; +import { UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { ArrayPath, FieldArray, Path, useFieldArray, useWatch } from 'react-hook-form'; +import { FormImageUpload } from './form-image-upload'; +import { FabButton } from '../base/fab-button'; +import { Plus } from 'phosphor-react'; +import { ImageType } from '../../models/file'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; + +interface FormMultiImageUploadProps extends FormComponent, FormControlledComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + addButtonLabel: ReactNode +} + +/** + * This component allows to upload multiple images, in forms managed by react-hook-form. + */ +export const FormMultiImageUpload = ({ id, className, register, control, setValue, formState, addButtonLabel }: FormMultiImageUploadProps) => { + const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath }); + const output = useWatch({ control, name: id as Path }); + + /** + * Add new image, set as main if it is the first + */ + const addImage = () => { + append({ + is_main: output.filter(i => i.is_main).length === 0, + _destroy: false + } as UnpackNestedValue>>); + }; + + /** + * Remove an image and set the first image as the new main image if the provided was main + */ + const handleRemoveImage = (image: ImageType, index: number) => { + return () => { + if (image.is_main && output.length > 1) { + setValue( + `${id}.${index === 0 ? 1 : 0}.is_main` as Path, + true as UnpackNestedValue>> + ); + } + if (typeof image.id === 'string') { + remove(index); + } else { + setValue( + `${id}.${index}._destroy` as Path, + true as UnpackNestedValue>> + ); + } + }; + }; + + /** + * Set the image at the given index as the new main image, and unset the current main image + */ + const handleSetMainImage = (index: number) => { + return (setNewImageValue) => { + const mainImageIndex = output.findIndex(i => i.is_main && i !== index); + if (mainImageIndex > -1) { + setValue( + `${id}.${mainImageIndex}.is_main` as Path, + false as UnpackNestedValue>> + ); + } + setNewImageValue(true); + }; + }; + + return ( +
+
+ {fields.map((field: ImageType, index) => ( + + ))} +
+ }> + {addButtonLabel} + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-rich-text.tsx b/app/frontend/src/javascript/components/form/form-rich-text.tsx index b5c93f986..7cef3a8c3 100644 --- a/app/frontend/src/javascript/components/form/form-rich-text.tsx +++ b/app/frontend/src/javascript/components/form/form-rich-text.tsx @@ -10,15 +10,18 @@ import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; interface FormRichTextProps extends FormControlledComponent, AbstractFormItemProps { valueDefault?: string, limit?: number, - paragraphTools?: boolean, + heading?: boolean, + bulletList?: boolean, + blockquote?: boolean, + link?: boolean, video?: boolean, - image?: boolean, + image?: boolean } /** * This component is a rich-text editor to use with react-hook-form. */ -export const FormRichText = ({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps) => { +export const FormRichText = ({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, heading, bulletList, blockquote, video, image, link }: FormRichTextProps) => { const textEditorRef = React.useRef(); const [isDisabled, setIsDisabled] = React.useState(false); @@ -54,9 +57,12 @@ export const FormRichText = } /> diff --git a/app/frontend/src/javascript/components/form/form-select.tsx b/app/frontend/src/javascript/components/form/form-select.tsx index 82e42be34..208170544 100644 --- a/app/frontend/src/javascript/components/form/form-select.tsx +++ b/app/frontend/src/javascript/components/form/form-select.tsx @@ -7,9 +7,10 @@ import { FieldPath } from 'react-hook-form/dist/types/path'; import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; import { FormControlledComponent } from '../../models/form-component'; import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { SelectOption } from '../../models/select'; -interface FormSelectProps extends FormControlledComponent, AbstractFormItemProps { - options: Array>, +interface FormSelectProps extends FormControlledComponent, AbstractFormItemProps { + options: Array>, valueDefault?: TOptionValue, onChange?: (value: TOptionValue) => void, placeholder?: string, @@ -17,16 +18,10 @@ interface FormSelectProps e creatable?: boolean, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: TOptionValue, label: string }; - /** * This component is a wrapper for react-select to use with react-hook-form */ -export const FormSelect = ({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled = false, onChange, clearable = false, formState, creatable = false }: FormSelectProps) => { +export const FormSelect = ({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled = false, onChange, clearable = false, formState, creatable = false }: FormSelectProps) => { const [isDisabled, setIsDisabled] = React.useState(false); useEffect(() => { diff --git a/app/frontend/src/javascript/components/form/form-switch.tsx b/app/frontend/src/javascript/components/form/form-switch.tsx index 2deda376c..0d6979cec 100644 --- a/app/frontend/src/javascript/components/form/form-switch.tsx +++ b/app/frontend/src/javascript/components/form/form-switch.tsx @@ -41,8 +41,11 @@ export const FormSwitch = ({ id, label, t onChangeCb(val); }} checked={value as boolean || false} - height={19} width={40} + height={19} + uncheckedIcon={false} + checkedIcon={false} + handleDiameter={15} ref={ref} disabled={typeof disabled === 'function' ? disabled(id) : disabled} /> } /> diff --git a/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx new file mode 100644 index 000000000..87e93a7ea --- /dev/null +++ b/app/frontend/src/javascript/components/form/unsaved-form-alert.tsx @@ -0,0 +1,112 @@ +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import { UIRouter } from '@uirouter/angularjs'; +import { FormState } from 'react-hook-form/dist/types/form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FabModal } from '../base/fab-modal'; +import Deferred from '../../lib/deferred'; +import { useTranslation } from 'react-i18next'; + +interface UnsavedFormAlertProps { + uiRouter: UIRouter, + formState: FormState, +} + +/** + * Alert the user about unsaved changes in the given form, before leaving the current page. + * This component is highly dependent of these external libraries: + * - [react-hook-form](https://react-hook-form.com/) + * - [ui-router](https://ui-router.github.io/) + */ +export const UnsavedFormAlert = ({ uiRouter, formState, children }: PropsWithChildren>) => { + const { t } = useTranslation('shared'); + + const [showAlertModal, setShowAlertModal] = useState(false); + const [promise, setPromise] = useState>(null); + const [dirty, setDirty] = useState(formState.isDirty); + + useEffect(() => { + const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful)); + setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0); + }, [formState]); + + /** + * Check if the current form is dirty. If so, show the confirmation modal and return a promise + */ + const alertOnDirtyForm = (isDirty: boolean): Promise|void => { + if (isDirty) { + toggleAlertModal(); + const userChoicePromise = new Deferred(); + setPromise(userChoicePromise); + return userChoicePromise.promise; + } + }; + + // memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty + const alertDirty = useCallback<() => Promise|void>(() => alertOnDirtyForm(dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) + useEffect(() => { + const { transitionService, globals: { current } } = uiRouter; + const deregisters = transitionService.onBefore({ from: current.name }, alertDirty); + return () => { + deregisters(); + }; + }, [alertDirty]); + + /** + * When the user tries to close the current page (tab/window), we alert him about unsaved changes + */ + const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => { + if (isDirty) { + event.preventDefault(); + event.returnValue = ''; + } + }; + + // memoised version of the alertOnExit function, will be updated only when the form becames dirty + const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]); + + // we should place this useEffect after the useCallback declaration (because it's a scoped variable) + useEffect(() => { + window.addEventListener('beforeunload', alertExit); + return () => { + window.removeEventListener('beforeunload', alertExit); + }; + }, [alertExit]); + + /** + * Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?" + */ + const toggleAlertModal = () => { + setShowAlertModal(!showAlertModal); + }; + + /** + * Callback triggered when the user has choosen: continue and exit + */ + const handleConfirmation = () => { + promise.resolve(true); + }; + + /** + * Callback triggered when the user has choosen: cancel and stay + */ + const handleCancel = () => { + promise.resolve(false); + }; + + return ( +
+ {children} + + {t('app.shared.unsaved_form_alert.confirmation_message')} + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/group/change-group.tsx b/app/frontend/src/javascript/components/group/change-group.tsx index 653f65931..4477dfe02 100644 --- a/app/frontend/src/javascript/components/group/change-group.tsx +++ b/app/frontend/src/javascript/components/group/change-group.tsx @@ -12,6 +12,7 @@ import { FormSelect } from '../form/form-select'; import MemberAPI from '../../api/member'; import SettingAPI from '../../api/setting'; import UserLib from '../../lib/user'; +import { SelectOption } from '../../models/select'; declare const Application: IApplication; @@ -23,12 +24,6 @@ interface ChangeGroupProps { className?: string, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * Component to display the group of the provided user, and allow him to change his group. */ @@ -43,7 +38,7 @@ export const ChangeGroup: React.FC = ({ user, onSuccess, onErr const { handleSubmit, control } = useForm(); useEffect(() => { - GroupAPI.index({ disabled: false, admins: user?.role === 'admin' }).then(setGroups).catch(onError); + GroupAPI.index({ disabled: false }).then(setGroups).catch(onError); MemberAPI.current().then(setOperator).catch(onError); SettingAPI.get('user_change_group').then((setting) => { setAllowedUserChangeGoup(setting.value === 'true'); @@ -72,7 +67,7 @@ export const ChangeGroup: React.FC = ({ user, onSuccess, onErr /** * Convert the provided array of items to the react-select format */ - const buildGroupsOptions = (): Array => { + const buildGroupsOptions = (): Array> => { return groups?.map(t => { return { value: t.id, label: t.name }; }); diff --git a/app/frontend/src/javascript/components/machines/machine-card.tsx b/app/frontend/src/javascript/components/machines/machine-card.tsx index 10a360634..5e6135aa7 100644 --- a/app/frontend/src/javascript/components/machines/machine-card.tsx +++ b/app/frontend/src/javascript/components/machines/machine-card.tsx @@ -44,12 +44,12 @@ const MachineCard: React.FC = ({ user, machine, onShowMachine, * Return the machine's picture or a placeholder */ const machinePicture = (): ReactNode => { - if (!machine.machine_image) { + if (!machine.machine_image_attributes) { return
; } return ( -
+
); }; diff --git a/app/frontend/src/javascript/components/machines/machine-form.tsx b/app/frontend/src/javascript/components/machines/machine-form.tsx new file mode 100644 index 000000000..b466f887a --- /dev/null +++ b/app/frontend/src/javascript/components/machines/machine-form.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { Machine } from '../../models/machine'; +import MachineAPI from '../../api/machine'; +import { useTranslation } from 'react-i18next'; +import { FormInput } from '../form/form-input'; +import { FormImageUpload } from '../form/form-image-upload'; +import { IApplication } from '../../models/application'; +import { Loader } from '../base/loader'; +import { react2angular } from 'react2angular'; +import { ErrorBoundary } from '../base/error-boundary'; +import { FormRichText } from '../form/form-rich-text'; +import { FormSwitch } from '../form/form-switch'; + +declare const Application: IApplication; + +interface MachineFormProps { + action: 'create' | 'update', + machine?: Machine, + onError: (message: string) => void, + onSuccess: (message: string) => void, +} + +/** + * Form to edit or create machines + */ +export const MachineForm: React.FC = ({ action, machine, onError, onSuccess }) => { + const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: { ...machine } }); + const output = useWatch({ control }); + const { t } = useTranslation('admin'); + + /** + * Callback triggered when the user validates the machine form: handle create or update + */ + const onSubmit: SubmitHandler = (data: Machine) => { + MachineAPI[action](data).then(() => { + onSuccess(t(`app.admin.machine_form.${action}_success`)); + }).catch(error => { + onError(error); + }); + }; + + return ( +
+ + + + + + + + ); +}; + +const MachineFormWrapper: React.FC = (props) => { + return ( + + + + + + ); +}; + +Application.Components.component('machineForm', react2angular(MachineFormWrapper, ['action', 'machine', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/machines/machines-filters.tsx b/app/frontend/src/javascript/components/machines/machines-filters.tsx index 747050b41..c1218c15f 100644 --- a/app/frontend/src/javascript/components/machines/machines-filters.tsx +++ b/app/frontend/src/javascript/components/machines/machines-filters.tsx @@ -1,17 +1,12 @@ import React from 'react'; import Select from 'react-select'; import { useTranslation } from 'react-i18next'; +import { SelectOption } from '../../models/select'; interface MachinesFiltersProps { onStatusSelected: (enabled: boolean) => void, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: boolean, label: string }; - /** * Allows filtering on machines list */ @@ -23,7 +18,7 @@ export const MachinesFilters: React.FC = ({ onStatusSelect /** * Provides boolean options in the react-select format (yes/no/all) */ - const buildBooleanOptions = (): Array => { + const buildBooleanOptions = (): Array> => { return [ defaultValue, { value: false, label: t('app.public.machines_filters.status_disabled') }, @@ -34,7 +29,7 @@ export const MachinesFilters: React.FC = ({ onStatusSelect /** * Callback triggered when the user selects a machine status in the dropdown list */ - const handleStatusSelected = (option: selectOption): void => { + const handleStatusSelected = (option: SelectOption): void => { onStatusSelected(option.value); }; diff --git a/app/frontend/src/javascript/components/machines/machines-list.tsx b/app/frontend/src/javascript/components/machines/machines-list.tsx index 9f54083cf..0476464cf 100644 --- a/app/frontend/src/javascript/components/machines/machines-list.tsx +++ b/app/frontend/src/javascript/components/machines/machines-list.tsx @@ -7,6 +7,8 @@ import MachineAPI from '../../api/machine'; import { MachineCard } from './machine-card'; import { MachinesFilters } from './machines-filters'; import { User } from '../../models/user'; +import { useTranslation } from 'react-i18next'; +import { FabButton } from '../base/fab-button'; declare const Application: IApplication; @@ -25,6 +27,7 @@ interface MachinesListProps { * This component shows a list of all machines and allows filtering on that list. */ export const MachinesList: React.FC = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => { + const { t } = useTranslation('public'); // shown machines const [machines, setMachines] = useState>(null); // we keep the full list of machines, for filtering @@ -56,10 +59,30 @@ export const MachinesList: React.FC = ({ onError, onSuccess, setMachines(allMachines.filter(m => !!m.disabled === !status)); }; + /** + * Go to store + */ + const linkToStore = (): void => { + window.location.href = '/#!/store'; + }; + + // TODO: Conditionally display the store ad return (
+ {false && +
linkToStore}> +
+

{t('app.public.machines_list.store_ad.title')}

+

{t('app.public.machines_list.store_ad.buy')}

+

{t('app.public.machines_list.store_ad.sell')}

+
+ } className="cta" onClick={linkToStore}> + {t('app.public.machines_list.store_ad.link')} + +
+ } {machines && machines.map(machine => { return = ({ onError, onSuccess, ); }; -const MachinesListWrapper: React.FC = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, canProposePacks }) => { +const MachinesListWrapper: React.FC = (props) => { return ( - + ); }; diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index 96ee200c0..16d52c9f2 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -109,7 +109,7 @@ const PaymentSchedulesTable: React.FC = ({ paymentSc const formatState = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => { let res = t(`app.shared.payment_schedules_table.state_${item.state}${item.state === 'pending' ? '_' + schedule.payment_method : ''}`); if (item.state === PaymentScheduleItemState.Paid) { - const key = `app.shared.schedules_table.method_${item.payment_method}`; + const key = `app.shared.payment_schedules_table.method_${item.payment_method}`; res += ` (${t(key)})`; } // eslint-disable-next-line fabmanager/component-class-named-as-component diff --git a/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx index e051191b2..7e95b80ce 100644 --- a/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { FabModal } from '../base/fab-modal'; import { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule'; import PaymentScheduleAPI from '../../api/payment-schedule'; +import { SelectOption } from '../../models/select'; interface UpdatePaymentMeanModalProps { isOpen: boolean, @@ -13,12 +14,6 @@ interface UpdatePaymentMeanModalProps { paymentSchedule: PaymentSchedule } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: PaymentMethod, label: string }; - /** * Component to allow the member to change his payment mean for the given payment schedule (e.g. from card to transfer) */ @@ -30,7 +25,7 @@ export const UpdatePaymentMeanModal: React.FC = ({ /** * Convert all payment means to the react-select format */ - const buildOptions = (): Array => { + const buildOptions = (): Array> => { return Object.keys(PaymentMethod).filter(pm => PaymentMethod[pm] !== PaymentMethod.Card).map(pm => { return { value: PaymentMethod[pm], label: t(`app.admin.update_payment_mean_modal.method_${pm}`) }; }); @@ -39,7 +34,7 @@ export const UpdatePaymentMeanModal: React.FC = ({ /** * When the payment mean is changed in the select, update the state */ - const handleMeanSelected = (option: selectOption): void => { + const handleMeanSelected = (option: SelectOption): void => { setPaymentMean(option.value); }; diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index 45e80c9f7..69244f6cc 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -17,16 +17,19 @@ import { GoogleTagManager } from '../../models/gtm'; import { ComputePriceResult } from '../../models/price'; import { Wallet } from '../../models/wallet'; import FormatLib from '../../lib/format'; +import { Order } from '../../models/order'; +import { computePriceWithCoupon } from '../../lib/coupon'; export interface GatewayFormProps { onSubmit: () => void, - onSuccess: (result: Invoice|PaymentSchedule) => void, + onSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, customer: User, operator: User, className?: string, paymentSchedule?: PaymentSchedule, cart?: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, formId: string, } @@ -34,9 +37,10 @@ export interface GatewayFormProps { interface AbstractPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart?: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -60,7 +64,7 @@ declare const GTM: GoogleTagManager; * This component must not be called directly but must be extended for each implemented payment gateway. * @see https://reactjs.org/docs/composition-vs-inheritance.html */ -export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { +export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => { // customer's wallet const [wallet, setWallet] = useState(null); // server-computed price with all details @@ -107,16 +111,25 @@ export const AbstractPaymentModal: React.FC = ({ isOp * - Refresh the remaining price */ useEffect(() => { - if (!cart) return; - WalletAPI.getByUser(cart.customer_id).then((wallet) => { - setWallet(wallet); - PriceAPI.compute(cart).then((res) => { - setPrice(res); - setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + if (order && order?.user?.id) { + WalletAPI.getByUser(order.user.id).then((wallet) => { + setWallet(wallet); + const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }; + setPrice(p); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price)); setReady(true); }); - }); - }, [cart]); + } else if (cart && cart.customer_id) { + WalletAPI.getByUser(cart.customer_id).then((wallet) => { + setWallet(wallet); + PriceAPI.compute(cart).then((res) => { + setPrice(res); + setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price)); + setReady(true); + }); + }); + } + }, [cart, order]); /** * Check if there is currently an error to display @@ -156,7 +169,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp /** * After sending the form with success, process the resulting payment method */ - const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise => { + const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise => { setSubmitState(false); GTM.trackPurchase(result.id, result.total); afterSuccess(result); @@ -212,6 +225,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp className={`gateway-form ${formClassName || ''}`} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={schedule}> diff --git a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx index 525dd4d4d..eb6fba8e9 100644 --- a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx @@ -11,15 +11,17 @@ import { Setting } from '../../models/setting'; import { Invoice } from '../../models/invoice'; import SettingAPI from '../../api/setting'; import { useTranslation } from 'react-i18next'; +import { Order } from '../../models/order'; declare const Application: IApplication; interface CardPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface CardPaymentModalProps { * This component open a modal dialog for the configured payment gateway, allowing the user to input his card data * to process an online payment. */ -const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => { +const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => { const { t } = useTranslation('shared'); const [gateway, setGateway] = useState(null); @@ -49,6 +51,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -63,6 +66,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC = (props) => { export { CardPaymentModalWrapper as CardPaymentModal }; -Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer'])); +Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order'])); diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index e8f5f5799..cf612adfd 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -8,22 +8,18 @@ import SettingAPI from '../../../api/setting'; import { CardPaymentModal } from '../card-payment-modal'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { HtmlTranslate } from '../../base/html-translate'; +import CheckoutAPI from '../../../api/checkout'; +import { SelectOption } from '../../../models/select'; const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const; type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number]; -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: scheduleMethod, label: string }; - /** * A form component to ask for confirmation before cashing a payment directly at the FabLab's reception. * This is intended for use by privileged users. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => { +export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => { const { t } = useTranslation('admin'); const [method, setMethod] = useState('check'); @@ -43,14 +39,14 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce /** * Convert all payement methods for schedules to the react-select format */ - const buildMethodOptions = (): Array => { + const buildMethodOptions = (): Array> => { return ALL_SCHEDULE_METHODS.map(i => methodToOption(i)); }; /** * Convert the given payment-method to the react-select format */ - const methodToOption = (value: scheduleMethod): selectOption => { + const methodToOption = (value: scheduleMethod): SelectOption => { if (!value) return { value, label: '' }; return { value, label: t(`app.admin.local_payment_form.method_${value}`) }; @@ -59,7 +55,7 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce /** * Callback triggered when the user selects a payment method for the current payment schedule. */ - const handleUpdateMethod = (option: selectOption) => { + const handleUpdateMethod = (option: SelectOption) => { updateCart(Object.assign({}, cart, { payment_method: option.value })); setMethod(option.value); }; @@ -85,8 +81,14 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce } try { - const document = await LocalPaymentAPI.confirmPayment(cart); - onSuccess(document); + let res; + if (order) { + res = await CheckoutAPI.payment(order); + res = res.order; + } else { + res = await LocalPaymentAPI.confirmPayment(cart); + } + onSuccess(res); } catch (e) { onError(e); } @@ -113,6 +115,9 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce * Get the type of the main item in the cart compile */ const mainItemType = (): string => { + if (order) { + return ''; + } return Object.keys(cart.items[0])[0]; }; diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx index 9d0ee2032..ad04b105b 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx @@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal'; import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; import { IApplication } from '../../../models/application'; +import { Order } from '../../../models/order'; declare const Application: IApplication; interface LocalPaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, @@ -28,7 +30,7 @@ interface LocalPaymentModalProps { /** * This component enables a privileged user to confirm a local payments. */ -const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => { +const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => { const { t } = useTranslation('admin'); /** @@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod /** * Integrates the LocalPaymentForm into the parent AbstractPaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleMod className={className} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={paymentSchedule}> @@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod formClassName="local-payment-form" currentUser={currentUser} cart={cart} + order={order} updateCart={updateCart} customer={customer} afterSuccess={afterSuccess} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index 0c5bf8e86..61904af66 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -11,6 +11,8 @@ import { } from '../../../models/payzen'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { Invoice } from '../../../models/invoice'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; // we use these two additional parameters to update the card, if provided interface PayzenFormProps extends GatewayFormProps { @@ -21,7 +23,7 @@ interface PayzenFormProps extends GatewayFormProps { * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => { +export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => { const PayZenKR = useRef(null); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); @@ -43,7 +45,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE .catch(error => onError(error)); }).catch(error => onError(error)); }); - }, [cart, paymentSchedule, customer]); + }, [cart, paymentSchedule, customer, order]); /** * Ask the API to create the form token. @@ -54,6 +56,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE return await PayzenAPI.updateToken(paymentSchedule?.id); } else if (paymentSchedule) { return await PayzenAPI.chargeCreateToken(cart, customer); + } else if (order) { + const res = await CheckoutAPI.payment(order); + return res.payment as CreateTokenResponse; } else { return await PayzenAPI.chargeCreatePayment(cart, customer); } @@ -87,9 +92,12 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE /** * Confirm the payment, depending on the current type of payment (single shot or recurring) */ - const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { + const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { if (paymentSchedule) { return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart); + } else if (order) { + const res = await CheckoutAPI.confirmPayment(order, event.clientAnswer.orderDetails.orderId); + return res.order; } else { return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart); } @@ -131,7 +139,9 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE try { const { result } = await PayZenKR.current.validateForm(); if (result === null) { - await PayzenAPI.checkCart(cart, customer); + if (!order) { + await PayzenAPI.checkCart(cart, customer); + } await PayZenKR.current.onSubmit(onPaid); await PayZenKR.current.onError(handleError); await PayZenKR.current.submit(); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx index 0b6a70bf2..ea117b12f 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx @@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { PayzenForm } from './payzen-form'; +import { Order } from '../../../models/order'; interface PayzenModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -28,7 +30,7 @@ interface PayzenModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -45,7 +47,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the PayzenForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a operator={operator} formId={formId} cart={cart} + order={order} className={className} paymentSchedule={paymentSchedule}> {children} @@ -70,6 +73,7 @@ export const PayzenModal: React.FC = ({ isOpen, toggleModal, a className="payzen-modal" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx index 0acd1b28d..17c625343 100644 --- a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx @@ -11,13 +11,16 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal'; import { CardPaymentModal } from '../card-payment-modal'; import PriceAPI from '../../../api/price'; import { ComputePriceResult } from '../../../models/price'; +import { Order } from '../../../models/order'; +import { computePriceWithCoupon } from '../../../lib/coupon'; interface PaymentModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, updateCart: (cart: ShoppingCart) => void, operator: User, schedule?: PaymentSchedule, @@ -27,7 +30,7 @@ interface PaymentModalProps { /** * This component is responsible for rendering the payment modal. */ -export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => { +export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => { // the user's wallet const [wallet, setWallet] = useState(null); // the price of the cart @@ -44,10 +47,14 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, // refresh the price when the cart changes useEffect(() => { - PriceAPI.compute(cart).then(price => { - setPrice(price); - }); - }, [cart]); + if (order) { + setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }); + } else { + PriceAPI.compute(cart).then(price => { + setPrice(price); + }); + } + }, [cart, order]); // refresh the remaining price when the cart price was computed and the wallet was retrieved useEffect(() => { @@ -73,6 +80,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} updateCart={updateCart} currentUser={operator} customer={customer} @@ -86,6 +94,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={operator} customer={customer} schedule={schedule} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx index ca48eaa79..d6fccc677 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx @@ -95,7 +95,7 @@ export const StripeCardUpdate: React.FC = ({ onSubmit, on }; return ( -
+ {children} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index 0343e4bb7..826246764 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment'; import StripeAPI from '../../../api/stripe'; import { Invoice } from '../../../models/invoice'; import { PaymentSchedule } from '../../../models/payment-schedule'; +import CheckoutAPI from '../../../api/checkout'; +import { Order } from '../../../models/order'; /** * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => { +export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => { const { t } = useTranslation('shared'); const stripe = useStripe(); @@ -41,9 +43,18 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on } else { try { if (!paymentSchedule) { - // process the normal payment pipeline, including SCA validation - const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); - await handleServerConfirmation(res); + if (order) { + const res = await CheckoutAPI.payment(order, paymentMethod.id); + if (res.payment) { + await handleServerConfirmation(res.payment as PaymentConfirmation); + } else { + await handleServerConfirmation(res.order); + } + } else { + // process the normal payment pipeline, including SCA validation + const res = await StripeAPI.confirmMethod(paymentMethod.id, cart); + await handleServerConfirmation(res); + } } else { const res = await StripeAPI.setupSubscription(paymentMethod.id, cart); await handleServerConfirmation(res, paymentMethod.id); @@ -61,7 +72,7 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on * @param paymentMethodId ID of the payment method, required only when confirming a payment schedule * @see app/controllers/api/stripe_controller.rb#confirm_payment */ - const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => { + const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => { if ('error' in response) { if (response.error.statusText) { onError(response.error.statusText); @@ -78,8 +89,13 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on // The card action has been handled // The PaymentIntent can be confirmed again on the server try { - const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); - await handleServerConfirmation(confirmation); + if (order) { + const confirmation = await CheckoutAPI.confirmPayment(order, result.paymentIntent.id); + await handleServerConfirmation(confirmation.order); + } else { + const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); + await handleServerConfirmation(confirmation); + } } catch (e) { onError(e); } diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx index f4974615f..69fcb46cf 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx @@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png'; import mastercardLogo from '../../../../../images/mastercard.png'; import visaLogo from '../../../../../images/visa.png'; import { Invoice } from '../../../models/invoice'; +import { Order } from '../../../models/order'; interface StripeModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: Invoice|PaymentSchedule) => void, + afterSuccess: (result: Invoice|PaymentSchedule|Order) => void, onError: (message: string) => void, cart: ShoppingCart, + order?: Order, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -29,7 +31,7 @@ interface StripeModalProps { * This component should not be called directly. Prefer using which can handle the configuration * of a different payment gateway. */ -export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => { /** * Return the logos, shown in the modal footer. */ @@ -47,7 +49,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a /** * Integrates the StripeForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleModal, a className={className} formId={formId} cart={cart} + order={order} customer={customer} paymentSchedule={paymentSchedule}> {children} @@ -74,6 +77,7 @@ export const StripeModal: React.FC = ({ isOpen, toggleModal, a formClassName="stripe-form" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/plans/plans-filter.tsx b/app/frontend/src/javascript/components/plans/plans-filter.tsx index 8a280bd62..5abbc7c31 100644 --- a/app/frontend/src/javascript/components/plans/plans-filter.tsx +++ b/app/frontend/src/javascript/components/plans/plans-filter.tsx @@ -5,6 +5,7 @@ import { Group } from '../../models/group'; import { User } from '../../models/user'; import PlanAPI from '../../api/plan'; import { PlansDuration } from '../../models/plan'; +import { SelectOption } from '../../models/select'; interface PlansFilterProps { user?: User, @@ -14,12 +15,6 @@ interface PlansFilterProps { onDurationSelected: (plansIds: Array) => void, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * Allows filtering on plans list */ @@ -38,8 +33,8 @@ export const PlansFilter: React.FC = ({ user, groups, onGroupS /** * Convert all groups to the react-select format */ - const buildGroupOptions = (): Array => { - return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => { + const buildGroupOptions = (): Array> => { + return groups.filter(g => !g.disabled).map(g => { return { value: g.id, label: g.name }; }); }; @@ -47,7 +42,7 @@ export const PlansFilter: React.FC = ({ user, groups, onGroupS /** * Convert all durations to the react-select format */ - const buildDurationOptions = (): Array => { + const buildDurationOptions = (): Array> => { const options = durations.map((d, index) => { return { value: index, label: d.name }; }); @@ -58,14 +53,14 @@ export const PlansFilter: React.FC = ({ user, groups, onGroupS /** * Callback triggered when the user selects a group in the dropdown list */ - const handleGroupSelected = (option: selectOption): void => { + const handleGroupSelected = (option: SelectOption): void => { onGroupSelected(option.value); }; /** * Callback triggered when the user selects a duration in the dropdown list */ - const handleDurationSelected = (option: selectOption): void => { + const handleDurationSelected = (option: SelectOption): void => { onDurationSelected(durations[option.value]?.plans_ids); }; diff --git a/app/frontend/src/javascript/components/plans/plans-list.tsx b/app/frontend/src/javascript/components/plans/plans-list.tsx index 9354da44b..40e7374aa 100644 --- a/app/frontend/src/javascript/components/plans/plans-list.tsx +++ b/app/frontend/src/javascript/components/plans/plans-list.tsx @@ -235,7 +235,7 @@ export const PlansList: React.FC = ({ onError, onPlanSelection, {plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => { return (
- {plansByGroup.size > 0 &&

{ groupName(groupId) }

} + {plansByGroup?.size > 0 &&

{ groupName(groupId) }

} {plansByGroup && renderPlansByCategory(plansByGroup)}
); diff --git a/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx b/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx index 652a543c0..3df72caf9 100644 --- a/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx @@ -41,7 +41,7 @@ export const MachinesPricing: React.FC = ({ onError, onSuc MachineAPI.index({ disabled: false }) .then(data => setMachines(data)) .catch(error => onError(error)); - GroupAPI.index({ disabled: false, admins: false }) + GroupAPI.index({ disabled: false }) .then(data => setGroups(data)) .catch(error => onError(error)); PriceAPI.index({ priceable_type: 'Machine', plan_id: null }) diff --git a/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx b/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx index ba4381c46..43c5b4820 100644 --- a/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useImmer } from 'use-immer'; import { FabInput } from '../../base/fab-input'; import { IFablab } from '../../../models/fablab'; +import { SelectOption } from '../../../models/select'; declare let Fablab: IFablab; @@ -18,12 +19,6 @@ interface PackFormProps { const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const; type interval = typeof ALL_INTERVALS[number]; -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: interval, label: string }; - /** * A form component to create/edit a PrepaidPack. * The form validation must be created elsewhere, using the attribute form={formId}. @@ -36,14 +31,14 @@ export const PackForm: React.FC = ({ formId, onSubmit, pack }) => /** * Convert all validity-intervals to the react-select format */ - const buildOptions = (): Array => { + const buildOptions = (): Array> => { return ALL_INTERVALS.map(i => intervalToOption(i)); }; /** * Convert the given validity-interval to the react-select format */ - const intervalToOption = (value: interval): selectOption => { + const intervalToOption = (value: interval): SelectOption => { if (!value) return { value, label: '' }; return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) }; @@ -87,7 +82,7 @@ export const PackForm: React.FC = ({ formId, onSubmit, pack }) => /** * Callback triggered when the user selects a type of interval for the current pack. */ - const handleUpdateValidityInterval = (option: selectOption) => { + const handleUpdateValidityInterval = (option: SelectOption) => { updatePackData(draft => { draft.validity_interval = option.value as interval; }); diff --git a/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx b/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx index 3bca4d6f6..2772cb9c7 100644 --- a/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx +++ b/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx @@ -38,7 +38,7 @@ export const SpacesPricing: React.FC = ({ onError, onSuccess SpaceAPI.index() .then(data => setSpaces(data)) .catch(error => onError(error)); - GroupAPI.index({ disabled: false, admins: false }) + GroupAPI.index({ disabled: false }) .then(data => setGroups(data)) .catch(error => onError(error)); PriceAPI.index({ priceable_type: 'Space', plan_id: null }) diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx new file mode 100644 index 000000000..cf03ae9bd --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -0,0 +1,85 @@ +import { PencilSimple, Trash } from 'phosphor-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabModal, ModalSize } from '../../base/fab-modal'; +import { ProductCategoryForm } from './product-category-form'; + +interface ManageProductCategoryProps { + action: 'create' | 'update' | 'delete', + productCategories: Array, + productCategory?: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a button. + * When clicked, we show a modal dialog allowing to fill the parameters of a product category. + */ +export const ManageProductCategory: React.FC = ({ productCategories, productCategory, action, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + // is the modal open? + const [isOpen, setIsOpen] = useState(false); + + /** + * Opens/closes the product category modal + */ + const toggleModal = (): void => { + setIsOpen(!isOpen); + }; + + /** + * Close the modal if the form submission was successful + */ + const handleSuccess = (message) => { + setIsOpen(false); + onSuccess(message); + }; + + /** + * Render the appropriate button depending on the action type + */ + const toggleBtn = () => { + switch (action) { + case 'create': + return ( + + {t('app.admin.store.manage_product_category.create')} + + ); + case 'update': + return (} + className="edit-btn" + onClick={toggleModal} />); + case 'delete': + return (} + className="delete-btn" + onClick={toggleModal} />); + } + }; + + return ( +
+ { toggleBtn() } + + { action === 'update' &&

{productCategory.name}

} + +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx new file mode 100644 index 000000000..391c27640 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ManageProductCategory } from './manage-product-category'; +import { ArrowElbowDownRight, ArrowLeft, CaretDown, DotsSixVertical } from 'phosphor-react'; + +interface ProductCategoriesItemProps { + productCategories: Array, + category: ProductCategory, + offset: 'up' | 'down' | null, + collapsed?: boolean, + handleCollapse?: (id: number) => void, + status: 'child' | 'single' | 'parent', + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Renders a draggable category item + */ +export const ProductCategoriesItem: React.FC = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: category.id }); + + const style = { + transition, + transform: CSS.Transform.toString(transform) + }; + + return ( +
+ {((isDragging && offset) || status === 'child') && +
+ {(offset === 'down') && } + {(offset === 'up') && } +
+ } +
+
+ {status === 'parent' &&
+ +
} +

{category.name}

+ +
+
+ {!isDragging && +
+ + +
+ } +
+ +
+
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx new file mode 100644 index 000000000..8b7e6b10a --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -0,0 +1,381 @@ +import React, { useEffect } from 'react'; +import { useImmer } from 'use-immer'; +import { ProductCategory } from '../../../models/product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; +import { ProductCategoriesItem } from './product-categories-item'; + +interface ProductCategoriesTreeProps { + productCategories: Array, + onDnd: (list: Array, activeCategory: ProductCategory, position: number) => void, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a tree list of all Product's Categories + */ +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [activeData, setActiveData] = useImmer(initActiveData); + const [extractedChildren, setExtractedChildren] = useImmer({}); + const [collapsed, setCollapsed] = useImmer([]); + + // Initialize state from props + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + // @dnd-kit config + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + * Collect dragged items' data + * Extract children from list + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + const activeIndex = active.data.current.sortable.index; + const children = getChildren(active.id); + + setActiveData(draft => { + draft.index = activeIndex; + draft.category = getCategory(active.id); + draft.status = getStatus(active.id); + draft.children = children?.length ? children : null; + }); + + setExtractedChildren(draft => { draft[active.id] = children; }); + hideChildren(active.id, activeIndex); + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, active, over }: DragMoveEvent) => { + const activeStatus = getStatus(active.id); + if (activeStatus === 'single') { + if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') { + setActiveData(draft => { + return { ...draft, offset: 'down' }; + }); + } else if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') { + setActiveData(draft => { + return { ...draft, offset: 'up' }; + }); + } else { + setActiveData(draft => { + return { ...draft, offset: null }; + }); + } + } + if (activeStatus === 'child') { + if (Math.ceil(delta.x) > 32 && getStatus(over.id) !== 'child') { + setActiveData(draft => { + return { ...draft, offset: 'down' }; + }); + } else if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') { + setActiveData(draft => { + return { ...draft, offset: 'up' }; + }); + } else { + setActiveData(draft => { + return { ...draft, offset: null }; + }); + } + } + }; + + /** + * On drag End + * Insert children back in list + */ + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + const currentIdsOrder = over?.data.current.sortable.items; + let newIndex = over.data.current.sortable.index; + let droppedItem = getCategory(active.id); + const activeStatus = getStatus(active.id); + const overStatus = getStatus(over.id); + let newPosition = getCategory(over.id).position; + + // [A]:Single dropped over [B]:Single + if (activeStatus === 'single' && overStatus === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex && active.id !== over.id) { + category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; + newPosition = 1; + } else if (activeData.offset === 'down' && sortedId === active.id && (activeData.index > newIndex || active.id === over.id)) { + const adopter = getPreviousAdopter(over.id); + const siblingsLength = getChildren(adopter)?.length | 0; + category = { ...category, parent_id: adopter }; + droppedItem = category; + newPosition = siblingsLength + 1; + } + return category; + }); + } + + // [A]:Child dropped over [B]:Single + if ((activeStatus === 'child') && overStatus === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; + newPosition = 1; + } else if (activeData.offset === 'down' && sortedId === active.id && activeData.index > newIndex) { + category = { ...category, parent_id: getPreviousAdopter(over.id) }; + droppedItem = category; + newPosition = getChildren(getPreviousAdopter(over.id))?.length || 1; + } else if (sortedId === active.id) { + category = { ...category, parent_id: null }; + droppedItem = category; + newPosition = getCategory(over.id).position + 1; + } + return category; + }); + } + + // [A]:Single || [A]:Child dropped over… + if (activeStatus === 'single' || activeStatus === 'child') { + // [B]:Parent + if (overStatus === 'parent') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + if (activeData.index < newIndex) { + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; + newPosition = 1; + } + return category; + }); + } else if (activeData.index > newIndex) { + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id && !activeData.offset) { + category = { ...category, parent_id: null }; + droppedItem = category; + } else if (sortedId === active.id && activeData.offset === 'down') { + category = { ...category, parent_id: getPreviousAdopter(over.id) }; + droppedItem = category; + newPosition = getChildren(getPreviousAdopter(over.id))?.length || 1; + } + return category; + }); + } + } + // [B]:Child + if (overStatus === 'child') { + if (activeData.offset === 'up') { + const lastChildIndex = newOrder.findIndex(c => c.id === getChildren(getCategory(over.id).parent_id).pop().id); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, lastChildIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: null }; + droppedItem = category; + newPosition = getCategory(getCategory(over.id).parent_id).position + 1; + } + return category; + }); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + droppedItem = category; + } + return category; + }); + } + } + } + + // [A]:Parent dropped over… + if (activeStatus === 'parent') { + // [B]:Single + if (overStatus === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + // [B]:Parent + if (overStatus === 'parent') { + if (activeData.index < newIndex) { + newIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // [B]:Child + if (overStatus === 'child') { + const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); + if (activeData.index < newIndex) { + newIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + newPosition = parent.position; + } else { + newIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + newPosition = parent.position; + } + } + // insert children back + newOrder = showChildren(active.id, newOrder, newIndex); + } + + setActiveData(initActiveData); + onDnd(newOrder, droppedItem, newPosition); + }; + + /** + * On drag cancel + * Reset states + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setCategoriesList(productCategories); + setActiveData(initActiveData); + setExtractedChildren({ ...extractedChildren, [active.id]: null }); + }; + + /** + * Get a category by its id + */ + const getCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + + /** + * Get the children categories of a parent category by its id + */ + const getChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return extractedChildren[id]; + }; + + /** + * Get previous category that can have children + */ + const getPreviousAdopter = (overId) => { + const reversedList = [...categoriesList].reverse(); + const dropIndex = reversedList.findIndex(c => c.id === overId); + const adopter = reversedList.find((c, index) => index > dropIndex && !c.parent_id)?.id; + return adopter || null; + }; + + /** + * Get category's status by its id + * child | single | parent + */ + const getStatus = (id) => { + const c = getCategory(id); + return !c.parent_id + ? getChildren(id)?.length + ? 'parent' + : 'single' + : 'child'; + }; + + /** + * Extract children from the list by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = getChildren(parentId); + if (children?.length) { + const shortenList = [...categoriesList]; + shortenList.splice(parentIndex + 1, children.length); + setCategoriesList(shortenList); + } + }; + + /** + * Insert children back in the list by their parent's id + */ + const showChildren = (parentId, currentList, insertIndex) => { + if (extractedChildren[parentId]?.length) { + currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]); + setExtractedChildren({ ...extractedChildren, [parentId]: null }); + } + return currentList; + }; + + /** + * Toggle parent category by hiding/showing its children + */ + const handleCollapse = (id) => { + const i = collapsed.findIndex(el => el === id); + if (i === -1) { + setCollapsed([...collapsed, id]); + } else { + const copy = [...collapsed]; + copy.splice(i, 1); + setCollapsed(copy); + } + }; + + return ( + + +
+ {categoriesList + .map((category) => ( + + ))} +
+
+
+ ); +}; + +interface ActiveData { + index: number, + category: ProductCategory, + status: 'child' | 'single' | 'parent', + children: ProductCategory[], + offset: 'up' | 'down' | null +} +const initActiveData: ActiveData = { + index: null, + category: null, + status: null, + children: [], + offset: null +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx new file mode 100644 index 000000000..4cb5590d5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ProductCategory } from '../../../models/product-category'; +import ProductCategoryAPI from '../../../api/product-category'; +import { ManageProductCategory } from './manage-product-category'; +import { ProductCategoriesTree } from './product-categories-tree'; +import { FabAlert } from '../../base/fab-alert'; +import { HtmlTranslate } from '../../base/html-translate'; +import { IApplication } from '../../../models/application'; +import { Loader } from '../../base/loader'; +import { react2angular } from 'react2angular'; +import ProductLib from '../../../lib/product'; + +declare const Application: IApplication; + +interface ProductCategoriesProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a tree list of all product categories and offer to manager them + * by creating, deleting, modifying and reordering each product categories. + */ +const ProductCategories: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + // List of all products' categories + const [productCategories, setProductCategories] = useState>([]); + + // load the categories list on component mount + useEffect(() => { + refreshCategories(); + }, []); + + /** + * The creation/edition/deletion was successful. + * Show the provided message and refresh the list + */ + const handleSuccess = (message: string): void => { + onSuccess(message); + refreshCategories(); + }; + + /** + * Update state after drop + */ + const handleDnd = (list: ProductCategory[], activeCategory: ProductCategory, position: number) => { + setProductCategories(list); + ProductCategoryAPI + .update(activeCategory) + .then(c => { + ProductCategoryAPI + .updatePosition(c, position) + .then(refreshCategories) + .catch(error => onError(error)); + }) + .catch(error => onError(error)); + }; + + /** + * Refresh the list of categories + */ + const refreshCategories = () => { + ProductCategoryAPI.index().then(data => { + setProductCategories(ProductLib.sortCategories(data)); + }).catch((error) => onError(error)); + }; + + return ( +
+
+

{t('app.admin.store.product_categories.title')}

+
+ +
+
+ + + + +
+ ); +}; + +const ProductCategoriesWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx new file mode 100644 index 000000000..74fd0be03 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import slugify from 'slugify'; +import { FormInput } from '../../form/form-input'; +import { FormSelect } from '../../form/form-select'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import ProductCategoryAPI from '../../../api/product-category'; +import { HtmlTranslate } from '../../base/html-translate'; +import { SelectOption } from '../../../models/select'; + +interface ProductCategoryFormProps { + action: 'create' | 'update' | 'delete', + productCategories: Array, + productCategory?: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Form to create/edit/delete a product category + */ +export const ProductCategoryForm: React.FC = ({ action, productCategories, productCategory, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); + + // filter all first level product categorie + let parents = productCategories.filter(c => !c.parent_id); + if (action === 'update') { + parents = parents.filter(c => c.id !== productCategory.id); + } + + /** + * Convert all parents to the react-select format + */ + const buildOptions = (): Array> => { + const options = parents.map(t => { + return { value: t.id, label: t.name }; + }); + if (action === 'update') { + options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') }); + } + return options; + }; + + // Create slug from category's name + useEffect(() => { + const subscription = watch((value, { name }) => { + if (name === 'name') { + const _slug = slugify(value.name, { lower: true, strict: true }); + setValue('slug', _slug); + } + }); + return () => subscription.unsubscribe(); + }, [watch]); + // Check slug pattern + // Only lowercase alphanumeric groups of characters separated by an hyphen + const slugPattern = /^[a-z\d]+(?:-[a-z\d]+)*$/g; + + // Form submit + const onSubmit: SubmitHandler = (category: ProductCategory) => { + switch (action) { + case 'create': + ProductCategoryAPI.create(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.create.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.create.error') + error); + }); + break; + case 'update': + ProductCategoryAPI.update(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.update.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.update.error') + error); + }); + break; + case 'delete': + ProductCategoryAPI.destroy(category.id).then(() => { + onSuccess(t('app.admin.store.product_category_form.delete.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.delete.error') + error); + }); + break; + } + }; + + return ( +
+ { action === 'delete' + ? <> + + {t('app.admin.store.product_category_form.delete.save')} + + : <> + + + + {t('app.admin.store.product_category_form.save')} + + } + + ); +}; diff --git a/app/frontend/src/javascript/components/store/clone-product-modal.tsx b/app/frontend/src/javascript/components/store/clone-product-modal.tsx new file mode 100644 index 000000000..70c225a96 --- /dev/null +++ b/app/frontend/src/javascript/components/store/clone-product-modal.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { useTranslation } from 'react-i18next'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +interface CloneProductModalProps { + isOpen: boolean, + toggleModal: () => void, + onSuccess: (product: Product) => void, + onError: (message: string) => void, + product: Product, +} + +/** + * Modal dialog to clone a product + */ +export const CloneProductModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, product }) => { + const { t } = useTranslation('admin'); + const { handleSubmit, register, control, formState, reset } = useForm({ + defaultValues: { + name: product.name, + sku: product.sku, + is_active: false + } + }); + + /** + * Call product clone api + */ + const handleClone: SubmitHandler = (data: Product) => { + ProductAPI.clone(product, data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + }; + + return ( + +
+ + + {product.is_active && + + } + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx new file mode 100644 index 000000000..20680df9f --- /dev/null +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; +import { UIRouter } from '@uirouter/angularjs'; + +declare const Application: IApplication; + +interface EditProductProps { + productId: number, + onSuccess: (message: string) => void, + onError: (message: string) => void, + uiRouter: UIRouter +} + +/** + * This component show edit product form + */ +const EditProduct: React.FC = ({ productId, onSuccess, onError, uiRouter }) => { + const { t } = useTranslation('admin'); + + const [product, setProduct] = useState(); + + useEffect(() => { + ProductAPI.get(productId).then(data => { + setProduct(data); + }).catch(onError); + }, []); + + /** + * Success to save product and return to product list + * or + * Success to clone product and return to new product + */ + const saveProductSuccess = (data: Product) => { + if (data.id === product.id) { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + } else { + onSuccess(t('app.admin.store.edit_product.successfully_cloned')); + window.location.href = `/#!/admin/store/products/${data.id}/edit`; + } + }; + + if (product) { + return ( +
+ +
+ ); + } + return null; +}; + +const EditProductWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx b/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx new file mode 100644 index 000000000..6c5fbcd1e --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import _ from 'lodash'; +import { ProductIndexFilter } from '../../../models/product'; +import { X } from 'phosphor-react'; +import { ProductCategory } from '../../../models/product-category'; +import { Machine } from '../../../models/machine'; +import { useTranslation } from 'react-i18next'; + +interface ActiveFiltersTagsProps { + filters: ProductIndexFilter, + displayCategories?: boolean, + onRemoveCategory?: (category: ProductCategory) => void, + onRemoveMachine: (machine: Machine) => void, + onRemoveKeyword: () => void, + onRemoveStock?: () => void, +} + +/** + * Some tags listing the currently actives filters for a product list + */ +export const ActiveFiltersTags: React.FC = ({ filters, displayCategories = true, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => { + const { t } = useTranslation('shared'); + return ( + <> + {displayCategories && filters.categories.map(c => ( +
+

{c.name}

+ +
+ ))} + {filters.machines.map(m => ( +
+

{m.name}

+ +
+ ))} + {filters.keywords[0] &&
+

{t('app.shared.active_filters_tags.keyword', { KEYWORD: filters.keywords[0] })}

+ +
} + {(!_.isNil(filters.stock_to) && (filters.stock_to !== 0 || filters.stock_from !== 0)) &&
+

{t(`app.shared.active_filters_tags.stock_${filters.stock_type}`)} [{filters.stock_from || '…'} ⟶ {filters.stock_to || '…'}]

+ +
} + + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/categories-filter.tsx b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx new file mode 100644 index 000000000..d08dcc33d --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; +import ProductLib from '../../../lib/product'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; + +interface CategoriesFilterProps { + productCategories: Array, + onApplyFilters: (categories: Array) => void, + currentFilters: Array, + openDefault?: boolean, + instantUpdate?: boolean, +} + +/** + * Component to filter the products list by categories + */ +export const CategoriesFilter: React.FC = ({ productCategories, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [selectedCategories, setSelectedCategories] = useState(currentFilters || []); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) { + setSelectedCategories(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when a category filter is selected or unselected. + * This may cause other categories to be selected or unselected accordingly. + */ + const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => { + const list = ProductLib.categoriesSelectionTree(productCategories, selectedCategories, currentCategory, checked ? 'add' : 'remove'); + + setSelectedCategories(list); + if (instantUpdate) { + onApplyFilters(list); + } + }; + + return ( + <> + +
+
+ {productCategories.map(pc => ( + + ))} +
+ onApplyFilters(selectedCategories)} className="is-secondary">{t('app.admin.store.categories_filter.filter_apply')} +
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx new file mode 100644 index 000000000..be7573213 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import _ from 'lodash'; + +interface KeywordFilterProps { + onApplyFilters: (keywork: string) => void, + currentFilters?: string, + openDefault?: boolean, + instantUpdate?: boolean, +} + +/** + * Component to filter the products list by keyword or product reference + */ +export const KeywordFilter: React.FC = ({ onApplyFilters, currentFilters = '', openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [keyword, setKeyword] = useState(currentFilters || ''); + + useEffect(() => { + if (!_.isEqual(currentFilters, keyword)) { + setKeyword(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when the user types anything in the input + */ + const handleKeywordTyping = (evt: React.ChangeEvent) => { + setKeyword(evt.target.value); + + if (instantUpdate) { + onApplyFilters(evt.target.value); + } + }; + + return ( + <> + +
+
+ handleKeywordTyping(event)} value={keyword} /> + onApplyFilters(keyword || undefined)} className="is-secondary">{t('app.admin.store.keyword_filter.filter_apply')} +
+
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/machines-filter.tsx b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx new file mode 100644 index 000000000..a0537ea48 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import { Machine } from '../../../models/machine'; +import MachineAPI from '../../../api/machine'; +import _ from 'lodash'; + +interface MachinesFilterProps { + allMachines?: Array, + onError: (message: string) => void, + onApplyFilters: (categories: Array) => void, + currentFilters: Array, + openDefault?: boolean, + instantUpdate?: boolean +} + +/** + * Component to filter the products list by associated machine + */ +export const MachinesFilter: React.FC = ({ allMachines, onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [machines, setMachines] = useState(allMachines || []); + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [selectedMachines, setSelectedMachines] = useState(currentFilters || []); + + useEffect(() => { + if (_.isEmpty(allMachines)) { + MachineAPI.index({ disabled: false }).then(data => { + setMachines(data); + }).catch(onError); + } + }, []); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, selectedMachines)) { + setSelectedMachines(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when a machine filter is seleced or unselected. + */ + const handleSelectMachine = (currentMachine: Machine, checked: boolean) => { + const list = [...selectedMachines]; + checked + ? list.push(currentMachine) + : list.splice(list.indexOf(currentMachine), 1); + + setSelectedMachines(list); + if (instantUpdate) { + onApplyFilters(list); + } + }; + + return ( + <> + +
+
+ {machines.map(m => ( + + ))} +
+ onApplyFilters(selectedMachines)} className="is-secondary">{t('app.admin.store.machines_filter.filter_apply')} +
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/stock-filter.tsx b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx new file mode 100644 index 000000000..ba2497959 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import { ProductIndexFilter, StockType } from '../../../models/product'; +import { FormSelect } from '../../form/form-select'; +import { FormInput } from '../../form/form-input'; +import { useForm } from 'react-hook-form'; +import _ from 'lodash'; +import { SelectOption } from '../../../models/select'; + +interface StockFilterProps { + onApplyFilters: (filters: ProductIndexFilter) => void, + currentFilters: ProductIndexFilter, + openDefault?: boolean +} + +/** + * Component to filter the products list by stock + */ +export const StockFilter: React.FC = ({ onApplyFilters, currentFilters, openDefault = false }) => { + const { t } = useTranslation('admin'); + + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + + const { register, control, handleSubmit, getValues, reset } = useForm({ defaultValues: { ...currentFilters } }); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, getValues())) { + reset(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when the user clicks on "apply" to apply teh current filters. + */ + const onSubmit = (data: ProductIndexFilter) => { + onApplyFilters(data); + }; + + /** Creates sorting options to the react-select format */ + const buildStockOptions = (): Array> => { + return [ + { value: 'internal', label: t('app.admin.store.stock_filter.stock_internal') }, + { value: 'external', label: t('app.admin.store.stock_filter.stock_external') } + ]; + }; + + return ( + <> + +
+
+ +
+ + +
+ {t('app.admin.store.stock_filter.filter_apply')} +
+
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx new file mode 100644 index 000000000..17b5711ec --- /dev/null +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; +import { UIRouter } from '@uirouter/angularjs'; + +declare const Application: IApplication; + +interface NewProductProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, + uiRouter: UIRouter, +} + +/** + * This component show new product form + */ +const NewProduct: React.FC = ({ onSuccess, onError, uiRouter }) => { + const { t } = useTranslation('admin'); + + const product = { + id: undefined, + name: '', + slug: '', + sku: '', + description: '', + is_active: false, + quantity_min: 1, + stock: { + internal: 0, + external: 0 + }, + low_stock_alert: false, + machine_ids: [], + product_files_attributes: [], + product_images_attributes: [] + }; + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.new_product.successfully_created')); + window.location.href = '/#!/admin/store/products'; + }; + + return ( +
+ +
+ ); +}; + +const NewProductWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/store/order-actions.tsx b/app/frontend/src/javascript/components/store/order-actions.tsx new file mode 100644 index 000000000..a339fff2e --- /dev/null +++ b/app/frontend/src/javascript/components/store/order-actions.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import { FabModal } from '../base/fab-modal'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; +import FabTextEditor from '../base/text-editor/fab-text-editor'; +import { HtmlTranslate } from '../base/html-translate'; +import { SelectOption } from '../../models/select'; + +interface OrderActionsProps { + order: Order, + onSuccess: (order: Order, message: string) => void, + onError: (message: string) => void, +} + +/** + * Actions for an order + */ +export const OrderActions: React.FC = ({ order, onSuccess, onError }) => { + const { t } = useTranslation('shared'); + const [currentAction, setCurrentAction] = useState>(); + const [modalIsOpen, setModalIsOpen] = useState(false); + const [readyNote, setReadyNote] = useState(''); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + /** + * Close the action confirmation modal + */ + const closeModal = (): void => { + setModalIsOpen(false); + setCurrentAction(null); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array> => { + let actions = []; + switch (order.state) { + case 'paid': + actions = actions.concat(['in_progress', 'ready', 'delivered', 'canceled', 'refunded']); + break; + case 'payment_failed': + actions = actions.concat(['canceled']); + break; + case 'in_progress': + actions = actions.concat(['ready', 'delivered', 'canceled', 'refunded']); + break; + case 'ready': + actions = actions.concat(['delivered', 'canceled', 'refunded']); + break; + case 'canceled': + actions = actions.concat(['refunded']); + break; + default: + actions = []; + } + return actions.map(action => { + return { value: action, label: t(`app.shared.store.order_actions.state.${action}`) }; + }); + }; + + /** + * Callback after selecting an action + */ + const handleAction = (action: SelectOption) => { + setCurrentAction(action); + setModalIsOpen(true); + }; + + /** + * Callback after confirm an action + */ + const handleActionConfirmation = () => { + OrderAPI.updateState(order, currentAction.value, readyNote).then(data => { + onSuccess(data, t(`app.shared.store.order_actions.order_${currentAction.value}_success`)); + setCurrentAction(null); + setModalIsOpen(false); + }).catch((e) => { + onError(e); + setCurrentAction(null); + setModalIsOpen(false); + }); + }; + + return ( + <> + {buildOptions().length > 0 && + handleReferenceChanged(event.target.value)}/> + {t('app.admin.store.orders.filter_apply')} +
+
+ + +
+
+ {statusOptions.map(s => ( + + ))} +
+ {t('app.admin.store.orders.filter_apply')} +
+
+ +
+
+ + {t('app.admin.store.orders.filter_apply')} +
+
+
+ +
+
+
+ + +
+ {t('app.admin.store.orders.filter_apply')} +
+
+
+
+ + +
+ +
+ {filters.reference &&
+

{filters.reference}

+ +
} + {filters.states?.map((status, index) => ( +
+

{t(`app.admin.store.orders.state.${status}`)}

+ +
+ ))} + {filters.user_id > 0 &&
+

{user?.name}

+ +
} + {filters.period_from &&
+

{filters.period_from} {'>'} {filters.period_to}

+ +
} +
+ +
+ {orders.map(order => ( + + ))} +
+ {pageCount > 1 && + + } +
+
+ ); +}; + +const OrdersWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('orders', react2angular(OrdersWrapper, ['currentUser', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx new file mode 100644 index 000000000..99dc48f82 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useState } from 'react'; +import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import slugify from 'slugify'; +import _ from 'lodash'; +import { HtmlTranslate } from '../base/html-translate'; +import { Product } from '../../models/product'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { FormChecklist } from '../form/form-checklist'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; +import { FabAlert } from '../base/fab-alert'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import ProductAPI from '../../api/product'; +import { ProductStockForm } from './product-stock-form'; +import { CloneProductModal } from './clone-product-modal'; +import ProductLib from '../../lib/product'; +import { UnsavedFormAlert } from '../form/unsaved-form-alert'; +import { UIRouter } from '@uirouter/angularjs'; +import { SelectOption, ChecklistOption } from '../../models/select'; +import { FormMultiFileUpload } from '../form/form-multi-file-upload'; +import { FormMultiImageUpload } from '../form/form-multi-image-upload'; + +interface ProductFormProps { + product: Product, + title: string, + onSuccess: (product: Product) => void, + onError: (message: string) => void, + uiRouter: UIRouter +} + +/** + * Form component to create or update a product + */ +export const ProductForm: React.FC = ({ product, title, onSuccess, onError, uiRouter }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const output = useWatch({ control }); + const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount)); + const [productCategories, setProductCategories] = useState[]>([]); + const [machines, setMachines] = useState[]>([]); + const [stockTab, setStockTab] = useState(false); + const [openCloneModal, setOpenCloneModal] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(buildSelectOptions(ProductLib.sortCategories(data))); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + /** + * Convert the provided array of items to the react-select format + */ + const buildSelectOptions = (items: Array<{ id?: number, name: string, parent_id?: number }>): Array> => { + return items.map(t => { + return { + value: t.id, + label: t.parent_id + ? {t.name} + : t.name + }; + }); + }; + + /** + * Convert the provided array of items to the checklist format + */ + const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array> => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (event: React.ChangeEvent): void => { + const name = event.target.value; + const slug = slugify(name, { lower: true, strict: true }); + setValue('slug', slug); + }; + + /** + * Callback triggered when the user toggles the visibility of the product in the store. + */ + const handleIsActiveChanged = (value: boolean): void => { + if (value) { + setValue('is_active_price', true); + setIsActivePrice(true); + } + }; + + /** + * Callback triggered when is active price has changed. + */ + const toggleIsActivePrice = (value: boolean) => { + if (!value) { + setValue('amount', null); + setValue('is_active', false); + } + setIsActivePrice(value); + }; + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit: SubmitHandler = (data: Product) => { + saveProduct(data); + }; + + /** + * Call product creation or update api + */ + const saveProduct = (data: Product) => { + setSaving(true); + if (product.id) { + ProductAPI.update(data).then((res) => { + reset(res); + setSaving(false); + onSuccess(res); + }).catch(e => { + setSaving(false); + onError(e); + }); + } else { + ProductAPI.create(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(e => { + setSaving(false); + onError(e); + }); + } + }; + + /** + * Toggle clone product modal + */ + const toggleCloneModal = () => { + setOpenCloneModal(!openCloneModal); + }; + + return ( + <> +
+

{title}

+
+ {product.id && + <> + {t('app.admin.store.product_form.clone')} + + + } + + {!saving && t('app.admin.store.product_form.save')} + {saving && } + +
+
+
+ +
+

setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}

+

setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}

+
+ {stockTab + ? + :
+
+ + +
+
+ + +
+ +
+ +
+
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ +
+ {isActivePrice &&
+ + +
} +
+ +
+ +
+

{t('app.admin.store.product_form.product_images')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.assigning_category')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.assigning_machines')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.product_description')}

+ + + + +
+ +
+ +
+

{t('app.admin.store.product_form.product_files')}

+ + + + +
+ +
+ + {!saving && t('app.admin.store.product_form.save')} + {saving && } + +
+
+ } + + + ); +}; diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx new file mode 100644 index 000000000..298d231c9 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -0,0 +1,94 @@ +import 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'; + +interface ProductItemProps { + product: Product, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a product item in the admin view + */ +export const ProductItem: React.FC = ({ product, onEdit, onDelete }) => { + const { t } = useTranslation('admin'); + + /** + * Get the main image + */ + const thumbnail = () => { + return product.product_images_attributes.find(att => att.is_main); + }; + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + /** + * Returns CSS class from stock status + */ + const stockColor = (product: Product, stockType: string) => { + if (product.stock[stockType] < (product.quantity_min || 1)) { + return 'out-of-stock'; + } + if (product.low_stock_threshold && product.stock[stockType] <= product.low_stock_threshold) { + return 'low'; + } + return ''; + }; + + return ( +
+
+ +

{product.name}

+
+
+ + {product.is_active + ? t('app.admin.store.product_item.visible') + : t('app.admin.store.product_item.hidden') + } + +
+ {t('app.admin.store.product_item.stock.internal')} +

{product.stock.internal}

+
+
+ {t('app.admin.store.product_item.stock.external')} +

{product.stock.external}

+
+ +
+
+
+ + + + + + +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-price.tsx b/app/frontend/src/javascript/components/store/product-price.tsx new file mode 100644 index 000000000..0e868b0fa --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-price.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Product } from '../../models/product'; +import FormatLib from '../../lib/format'; +import { useTranslation } from 'react-i18next'; + +interface ProductPriceProps { + product: Product; + className?: string; +} + +/** + * Render the formatted price for the given product, or "free" if the price is 0 or not set + */ +export const ProductPrice: React.FC = ({ product, className }) => { + const { t } = useTranslation('public'); + + /** + * Return the formatted price data + */ + const renderPrice = (product: Product) => { + if (product.amount === 0) { + return

{t('app.public.product_price.free')}

; + } + if ([null, undefined].includes(product.amount)) { + return
; + } + return <> +

{FormatLib.price(product.amount)}

+ {t('app.public.product_price.per_unit')} + ; + }; + + return ( +
+ {renderPrice(product)} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-stock-form.tsx b/app/frontend/src/javascript/components/store/product-stock-form.tsx new file mode 100644 index 000000000..7e5162b30 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-stock-form.tsx @@ -0,0 +1,297 @@ +import React, { useEffect, useState } from 'react'; +import Select from 'react-select'; +import { PencilSimple, X } 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, + stockMovementAllReasons, StockMovementIndex, StockMovementIndexFilter, + StockMovementReason, + StockType +} from '../../models/product'; +import { HtmlTranslate } from '../base/html-translate'; +import { FormSwitch } from '../form/form-switch'; +import { FormInput } from '../form/form-input'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductStockModal } from './product-stock-modal'; +import { FabStateLabel } from '../base/fab-state-label'; +import ProductAPI from '../../api/product'; +import FormatLib from '../../lib/format'; +import ProductLib from '../../lib/product'; +import { useImmer } from 'use-immer'; +import { FabPagination } from '../base/fab-pagination'; + +interface ProductStockFormProps { + currentFormValues: Product, + register: UseFormRegister, + control: Control, + formState: FormState, + setValue: UseFormSetValue, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +const DEFAULT_LOW_STOCK_THRESHOLD = 30; + +/** + * Form tab to manage a product's stock + */ +export const ProductStockForm = ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps) => { + const { t } = useTranslation('admin'); + + const [activeThreshold, setActiveThreshold] = useState(currentFormValues.low_stock_threshold != null); + // is the update stock modal open? + const [isOpen, setIsOpen] = useState(false); + const [stockMovements, setStockMovements] = useState(null); + const [filters, setFilters] = useImmer({ page: 1 }); + + const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' }); + + useEffect(() => { + if (!currentFormValues?.id) return; + + ProductAPI.stockMovements(currentFormValues.id, filters).then(setStockMovements).catch(onError); + }, [filters]); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + border: 'none', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + type reasonSelectOption = { value: StockMovementReason, label: string }; + /** + * Creates sorting options to the react-select format + */ + const buildReasonsOptions = (): Array => { + return stockMovementAllReasons.map(key => { + return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) }; + }); + }; + + type typeSelectOption = { value: StockType, label: string }; + /** + * Creates sorting options to the react-select format + */ + const buildStocksOptions = (): Array => { + return [ + { value: 'internal', label: t('app.admin.store.product_stock_form.internal') }, + { value: 'external', label: t('app.admin.store.product_stock_form.external') }, + { value: 'all', label: t('app.admin.store.product_stock_form.all') } + ]; + }; + + /** + * On stock movement reason filter change + */ + const eventsOptionsChange = (evt: reasonSelectOption) => { + setFilters(draft => { + return { + ...draft, + reason: evt.value + }; + }); + }; + /** + * On stocks type filter change + */ + const stocksOptionsChange = (evt: typeSelectOption) => { + setFilters(draft => { + return { + ...draft, + stock_type: evt.value + }; + }); + }; + + /** + * Callback triggered when the user wants to swich the current page of stock movements + */ + const handlePagination = (page: number) => { + setFilters(draft => { + return { + ...draft, + page + }; + }); + }; + + /** + * Toggle stock threshold + */ + const toggleStockThreshold = (checked: boolean) => { + setActiveThreshold(checked); + setValue( + 'low_stock_threshold', + (checked ? DEFAULT_LOW_STOCK_THRESHOLD : null) + ); + }; + + /** + * Opens/closes the product stock edition modal + */ + const toggleModal = (): void => { + setIsOpen(!isOpen); + }; + + /** + * Triggered when a new product stock movement was added + */ + const onNewStockMovement = (movement): void => { + append({ ...movement }); + }; + + /** + * Return the data of the update of the stock for the current product + */ + const lastStockUpdate = () => { + if (stockMovements?.data[0]) { + return stockMovements?.data[0].date; + } else { + return currentFormValues?.created_at || new Date(); + } + }; + + return ( +
+

{t('app.admin.store.product_stock_form.stock_up_to_date')}  + {t('app.admin.store.product_stock_form.date_time', { + DATE: FormatLib.date(lastStockUpdate()), + TIME: FormatLib.time((lastStockUpdate())) + })} +

+
+
+

{currentFormValues?.name}

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

{currentFormValues?.stock?.internal}

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

{currentFormValues?.stock?.external}

+
+ } className="is-black">Modifier +
+ + {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')} + +

+ + + +
+ ))} +
} + +
+ +
+
+

{t('app.admin.store.product_stock_form.low_stock_threshold')}

+ +
+ + + + {activeThreshold && <> + {t('app.admin.store.product_stock_form.low_stock')} +
+ + +
+ } +
+
+ +
+

{t('app.admin.store.product_stock_form.events_history')}

+
+
+

{t('app.admin.store.product_stock_form.event_type')}

+ stocksOptionsChange(evt)} + styles={customStyles} + /> +
+
+ {stockMovements?.data?.map(movement =>
+
+

{currentFormValues.name}

+

{FormatLib.date(movement.date)}

+
+ {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))}

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

{movement.remaining_stock}

+
+
+
)} + {stockMovements?.total_pages > 1 && + + } +
+ +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-stock-modal.tsx b/app/frontend/src/javascript/components/store/product-stock-modal.tsx new file mode 100644 index 000000000..dec8cea69 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-stock-modal.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ProductStockMovement, + stockMovementInReasons, + stockMovementOutReasons, + StockMovementReason, + StockType +} from '../../models/product'; +import { FormSelect } from '../form/form-select'; +import { FormInput } from '../form/form-input'; +import { FabButton } from '../base/fab-button'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { useForm } from 'react-hook-form'; +import ProductLib from '../../lib/product'; + +type reasonSelectOption = { value: StockMovementReason, label: string }; +type typeSelectOption = { value: StockType, label: string }; + +interface ProductStockModalProps { + onSuccess: (movement: ProductStockMovement) => void, + isOpen: boolean, + toggleModal: () => void, +} + +/** + * Form to manage a product's stock movement and quantity + */ +export const ProductStockModal: React.FC = ({ onSuccess, isOpen, toggleModal }) => { + const { t } = useTranslation('admin'); + + const [movement, setMovement] = useState<'in' | 'out'>('in'); + + const { handleSubmit, register, control, formState } = useForm(); + + /** + * Toggle between adding or removing product from stock + */ + const toggleMovementType = (evt: React.MouseEvent, type: 'in' | 'out') => { + evt.preventDefault(); + setMovement(type); + }; + + /** + * Callback triggered when the user validates the new stock movement. + * 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: ProductStockMovement) => { + onSuccess(data); + toggleModal(); + })(event); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildEventsOptions = (): Array => { + return (movement === 'in' ? stockMovementInReasons : stockMovementOutReasons).map(key => { + return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) }; + }); + }; + /** + * Creates sorting options to the react-select format + */ + const buildStocksOptions = (): Array => { + return [ + { value: 'internal', label: t('app.admin.store.product_stock_modal.internal') }, + { value: 'external', label: t('app.admin.store.product_stock_modal.external') } + ]; + }; + + return ( + +
+

{t('app.admin.store.product_stock_modal.new_event')}

+
+ + +
+ + + + {t('app.admin.store.product_stock_modal.update_stock')} + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx new file mode 100644 index 000000000..77b8a1375 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -0,0 +1,267 @@ +import React, { useState, useEffect } from 'react'; +import { useImmer } from 'use-immer'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { + initialFilters, initialResources, + Product, + ProductIndexFilter, + ProductResourcesFetching, + ProductsIndex, + ProductSortOption +} from '../../models/product'; +import { ProductCategory } from '../../models/product-category'; +import { FabButton } from '../base/fab-button'; +import { ProductItem } from './product-item'; +import ProductAPI from '../../api/product'; +import { StoreListHeader } from './store-list-header'; +import { FabPagination } from '../base/fab-pagination'; +import { CategoriesFilter } from './filters/categories-filter'; +import { Machine } from '../../models/machine'; +import { MachinesFilter } from './filters/machines-filter'; +import { KeywordFilter } from './filters/keyword-filter'; +import { StockFilter } from './filters/stock-filter'; +import ProductLib from '../../lib/product'; +import { ActiveFiltersTags } from './filters/active-filters-tags'; +import SettingAPI from '../../api/setting'; +import { UIRouter } from '@uirouter/angularjs'; +import { CaretDoubleUp } from 'phosphor-react'; +import { SelectOption } from '../../models/select'; + +declare const Application: IApplication; + +interface ProductsProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, + uiRouter: UIRouter, +} + +/** This component shows the admin view of the store */ +const Products: React.FC = ({ onSuccess, onError, uiRouter }) => { + const { t } = useTranslation('admin'); + + const [productsList, setProductList] = useState>([]); + // this includes the resources fetch from the API (machines, categories) and from the URL (filters) + const [resources, setResources] = useImmer(initialResources); + const [machinesModule, setMachinesModule] = useState(false); + const [pageCount, setPageCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [productsCount, setProductsCount] = useState(0); + const [filtersPanel, setFiltersPanel] = useState(true); + + useEffect(() => { + ProductLib.fetchInitialResources(setResources, onError); + SettingAPI.get('machines_module').then(data => { + setMachinesModule(data.value === 'true'); + }).catch(onError); + }, []); + + useEffect(() => { + if (resources.filters.ready) { + fetchProducts(); + uiRouter.stateService.transitionTo(uiRouter.globals.current, ProductLib.indexFiltersToRouterParams(resources.filters.data)); + } + }, [resources.filters]); + + useEffect(() => { + if (resources.machines.ready && resources.categories.ready) { + setResources(draft => { + return { + ...draft, + filters: { + data: ProductLib.readFiltersFromUrl(uiRouter.globals.params, resources.machines.data, resources.categories.data), + ready: true + } + }; + }); + } + }, [resources.machines, resources.categories]); + + /** + * Handle products pagination + */ + const handlePagination = (page: number) => { + if (page !== currentPage) { + ProductLib.updateFilter(setResources, 'page', page); + } + }; + + /** + * Fetch the products from the API, according to the current filters + */ + const fetchProducts = async (): Promise => { + try { + const data = await ProductAPI.index(resources.filters.data); + setCurrentPage(data.page); + setProductList(data.data); + setPageCount(data.total_pages); + setProductsCount(data.total_count); + return data; + } catch (error) { + onError(t('app.admin.store.products.unexpected_error_occurred') + error); + } + }; + + /** Goto edit product page */ + const editProduct = (product: Product) => { + window.location.href = `/#!/admin/store/products/${product.id}/edit`; + }; + + /** 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); + } + }; + + /** Goto new product page */ + const newProduct = (): void => { + window.location.href = '/#!/admin/store/products/new'; + }; + + /** Filter: toggle non-available products visibility */ + const toggleVisible = (checked: boolean) => { + ProductLib.updateFilter(setResources, 'is_active', checked); + }; + + /** + * Update the list of applied filters with the given categories + */ + const handleCategoriesFilterUpdate = (categories: Array) => { + ProductLib.updateFilter(setResources, 'categories', categories); + }; + + /** + * Remove the provided category from the filters selection + */ + const handleRemoveCategory = (category: ProductCategory) => { + const list = ProductLib.categoriesSelectionTree(resources.categories.data, resources.filters.data.categories, category, 'remove'); + handleCategoriesFilterUpdate(list); + }; + + /** + * Update the list of applied filters with the given machines + */ + const handleMachinesFilterUpdate = (machines: Array) => { + ProductLib.updateFilter(setResources, 'machines', machines); + }; + + /** + * Update the list of applied filters with the given keywords (or reference) + */ + const handleKeywordFilterUpdate = (keywords: Array) => { + ProductLib.updateFilter(setResources, 'keywords', keywords); + }; + + /** Filter: by stock range */ + const handleStockFilterUpdate = (filters: ProductIndexFilter) => { + setResources(draft => { + return { ...draft, filters: { ...draft.filters, data: { ...draft.filters.data, ...filters } } }; + }); + }; + + /** Display option: sorting */ + const handleSorting = (option: SelectOption) => { + ProductLib.updateFilter(setResources, 'sort', option.value); + }; + + /** Clear filters */ + const clearAllFilters = () => { + setResources(draft => { + return { ...draft, filters: { ...draft.filters, data: initialFilters } }; + }); + }; + + /** Creates sorting options to the react-select format */ + const buildSortOptions = (): Array> => { + return [ + { value: 'name-asc', label: t('app.admin.store.products.sort.name_az') }, + { value: 'name-desc', label: t('app.admin.store.products.sort.name_za') }, + { value: 'amount-asc', label: t('app.admin.store.products.sort.price_low') }, + { value: 'amount-desc', label: t('app.admin.store.products.sort.price_high') } + ]; + }; + + return ( +
+
+

{t('app.admin.store.products.all_products')}

+
+ {t('app.admin.store.products.create_a_product')} +
+
+ +
+ +
+ handleMachinesFilterUpdate(resources.filters.data.machines.filter(machine => machine !== m))} + onRemoveKeyword={() => handleKeywordFilterUpdate([])} + onRemoveStock={() => handleStockFilterUpdate({ stock_type: 'internal', stock_to: 0, stock_from: 0 })} /> +
+ +
+ {productsList.map((product) => ( + + ))} +
+ {pageCount > 1 && + + } +
+
+ ); +}; + +const ProductsWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError', 'uiRouter'])); diff --git a/app/frontend/src/javascript/components/store/show-order.tsx b/app/frontend/src/javascript/components/store/show-order.tsx new file mode 100644 index 000000000..9294c81c9 --- /dev/null +++ b/app/frontend/src/javascript/components/store/show-order.tsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IApplication } from '../../models/application'; +import { User } from '../../models/user'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import noImage from '../../../../images/no_image.png'; +import { FabStateLabel } from '../base/fab-state-label'; +import OrderAPI from '../../api/order'; +import { Order } from '../../models/order'; +import FormatLib from '../../lib/format'; +import OrderLib from '../../lib/order'; +import { OrderActions } from './order-actions'; + +declare const Application: IApplication; + +interface ShowOrderProps { + orderId: string, + currentUser?: User, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows an order details + */ +export const ShowOrder: React.FC = ({ orderId, currentUser, onSuccess, onError }) => { + const { t } = useTranslation('shared'); + + const [order, setOrder] = useState(); + const [withdrawalInstructions, setWithdrawalInstructions] = useState(null); + + useEffect(() => { + OrderAPI.get(orderId).then(data => { + setOrder(data); + OrderAPI.withdrawalInstructions(data) + .then(setWithdrawalInstructions) + .catch(onError); + }).catch(onError); + }, []); + + /** + * Check if the current operator has administrative rights or is a normal member + */ + const isPrivileged = (): boolean => { + return (currentUser?.role === 'admin' || currentUser?.role === 'manager'); + }; + + /** + * Returns order's payment info + */ + const paymentInfo = (): string => { + let paymentVerbose = ''; + if (order.payment_method === 'card') { + paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_debit_card'); + } else if (order.payment_method === 'wallet') { + paymentVerbose = t('app.shared.store.show_order.payment.settlement_by_wallet'); + } else { + paymentVerbose = t('app.shared.store.show_order.payment.settlement_done_at_the_reception'); + } + paymentVerbose += ' ' + t('app.shared.store.show_order.payment.on_DATE_at_TIME', { + DATE: FormatLib.date(order.payment_date), + TIME: FormatLib.time(order.payment_date) + }); + if (order.payment_method !== 'wallet') { + paymentVerbose += ' ' + t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.paid_total) }); + } + if (order.wallet_amount) { + if (order.payment_method === 'wallet') { + paymentVerbose += ' ' + t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.wallet_amount) }); + } else { + paymentVerbose += ' ' + t('app.shared.store.show_order.payment.and') + ' ' + t('app.shared.store.show_order.payment.by_wallet') + ' ' + + t('app.shared.store.show_order.payment.for_an_amount_of_AMOUNT', { AMOUNT: FormatLib.price(order.wallet_amount) }); + } + } + return paymentVerbose; + }; + + /** + * Callback after action success + */ + const handleActionSuccess = (data: Order, message: string) => { + setOrder(data); + onSuccess(message); + }; + + /** + * Ruturn item's ordrable url + */ + const itemOrderableUrl = (item) => { + if (isPrivileged()) { + return `/#!/admin/store/products/${item.orderable_id}/edit`; + } + return `/#!/store/p/${item.orderable_slug}`; + }; + + if (!order) { + return null; + } + + return ( +
+
+

[{order.reference}]

+
+ {isPrivileged() && + + } + {order?.invoice_id && ( + + {t('app.shared.store.show_order.see_invoice')} + + )} +
+
+ +
+ +
+ {isPrivileged() && order.user && +
+ {t('app.shared.store.show_order.client')} +

{order.user.name}

+
+ } +
+ {t('app.shared.store.show_order.created_at')} +

{FormatLib.date(order.created_at)}

+
+
+ {t('app.shared.store.show_order.last_update')} +

{FormatLib.date(order.updated_at)}

+
+ + {t(`app.shared.store.show_order.state.${OrderLib.statusText(order)}`)} + +
+
+ +
+ +
+ {order.order_items_attributes.map(item => ( +
+
+ +
+
+ {t('app.shared.store.show_order.reference_short')} {item.orderable_ref || ''} +

{item.orderable_name}

+
+
+
+

{FormatLib.price(item.amount)}

+ / {t('app.shared.store.show_order.unit')} +
+ + {item.quantity} + +
+ {t('app.shared.store.show_order.item_total')} +

{FormatLib.price(OrderLib.itemAmount(item))}

+
+
+
+ ))} +
+
+ +
+
+ + {order.invoice_id &&

{paymentInfo()}

} +
+
+ +

{t('app.shared.store.show_order.products_total')}{FormatLib.price(OrderLib.totalBeforeOfferedAmount(order))}

+ {OrderLib.hasOfferedItem(order) && +

{t('app.shared.store.show_order.gift_total')}-{FormatLib.price(OrderLib.offeredAmount(order))}

+ } + {order.coupon && +

{t('app.shared.store.show_order.coupon')}-{FormatLib.price(OrderLib.couponAmount(order))}

+ } +

{t('app.shared.store.show_order.cart_total')} {FormatLib.price(OrderLib.paidTotal(order))}

+
+
+ +

+

+
+
+ ); +}; + +const ShowOrderWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('showOrder', react2angular(ShowOrderWrapper, ['orderId', 'currentUser', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/store/store-list-header.tsx b/app/frontend/src/javascript/components/store/store-list-header.tsx new file mode 100644 index 000000000..5bd6014a1 --- /dev/null +++ b/app/frontend/src/javascript/components/store/store-list-header.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import Switch from 'react-switch'; +import { SortOption } from '../../models/api'; +import { SelectOption } from '../../models/select'; + +interface StoreListHeaderProps { + productsCount: number, + selectOptions: SelectOption[], + onSelectOptionsChange: (option: SelectOption) => void, + selectValue?: SortOption, + switchLabel?: string, + switchChecked?: boolean, + onSwitch?: (boolean) => void +} + +/** + * Renders an accordion item + */ +export const StoreListHeader: React.FC = ({ productsCount, selectOptions, onSelectOptionsChange, switchLabel, switchChecked, onSwitch, selectValue }) => { + const { t } = useTranslation('admin'); + + // Styles the React-select component + const customStyles = { + control: base => ({ + ...base, + width: '20ch', + border: 'none', + backgroundColor: 'transparent' + }), + indicatorSeparator: () => ({ + display: 'none' + }) + }; + + return ( +
+
+

{t('app.admin.store.store_list_header.result_count')}{productsCount}

+
+
+
+

{t('app.admin.store.store_list_header.sort')}

+ typeCount(evt)} /> + setCount('add')} icon={} className="plus" /> + addToCart()} icon={} + className="main-action-btn"> + {t('app.public.store_product.add_to_cart')} + +
+ } + +
+ ); + } + return null; +}; + +const StoreProductWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('storeProduct', react2angular(StoreProductWrapper, ['productSlug', 'currentUser', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/store-settings.tsx b/app/frontend/src/javascript/components/store/store-settings.tsx new file mode 100644 index 000000000..e0b8d6165 --- /dev/null +++ b/app/frontend/src/javascript/components/store/store-settings.tsx @@ -0,0 +1,89 @@ +import React, { useEffect } from 'react'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { useTranslation } from 'react-i18next'; +import { HtmlTranslate } from '../base/html-translate'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { FabAlert } from '../base/fab-alert'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; +import SettingAPI from '../../api/setting'; +import SettingLib from '../../lib/setting'; +import { SettingName, SettingValue, storeSettings } from '../../models/setting'; +import { FormSwitch } from '../form/form-switch'; + +declare const Application: IApplication; + +interface StoreSettingsProps { + onError: (message: string) => void, + onSuccess: (message: string) => void +} + +/** + * Store settings display and edition + */ +export const StoreSettings: React.FC = ({ onError, onSuccess }) => { + const { t } = useTranslation('admin'); + const { control, handleSubmit, reset } = useForm>(); + + useEffect(() => { + SettingAPI.query(storeSettings) + .then(settings => { + const data = SettingLib.bulkMapToObject(settings); + reset(data); + }) + .catch(onError); + }, []); + + /** + * Callback triggered when the form is submitted: save the settings + */ + const onSubmit: SubmitHandler> = (data) => { + SettingAPI.bulkUpdate(SettingLib.objectToBulkMap(data)).then(() => { + onSuccess(t('app.admin.store_settings.update_success')); + }, reason => { + onError(reason); + }); + }; + + return ( +
+
+

{t('app.admin.store_settings.title')}

+
+
+
+

{t('app.admin.store_settings.withdrawal_instructions')}

+ + + + +
+
+

{t('app.admin.store_settings.store_hidden_title')}

+ + + + +
+ {t('app.admin.store_settings.save')} +
+
+ ); +}; + +const StoreSettingsWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('storeSettings', react2angular(StoreSettingsWrapper, ['onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx new file mode 100644 index 000000000..7177a519b --- /dev/null +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -0,0 +1,337 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabButton } from '../base/fab-button'; +import { + initialFilters, + initialResources, + Product, + ProductResourcesFetching, + ProductsIndex, + ProductSortOption +} from '../../models/product'; +import { ProductCategory } from '../../models/product-category'; +import ProductAPI from '../../api/product'; +import { StoreProductItem } from './store-product-item'; +import useCart from '../../hooks/use-cart'; +import { User } from '../../models/user'; +import { Order } from '../../models/order'; +import { StoreListHeader } from './store-list-header'; +import { FabPagination } from '../base/fab-pagination'; +import { MachinesFilter } from './filters/machines-filter'; +import { useImmer } from 'use-immer'; +import { Machine } from '../../models/machine'; +import { KeywordFilter } from './filters/keyword-filter'; +import { ActiveFiltersTags } from './filters/active-filters-tags'; +import ProductLib from '../../lib/product'; +import { UIRouter } from '@uirouter/angularjs'; +import SettingAPI from '../../api/setting'; +import { SelectOption } from '../../models/select'; + +declare const Application: IApplication; + +const storeInitialFilters = { + ...initialFilters, + is_active: true +}; + +const storeInitialResources = { + ...initialResources, + filters: { + data: storeInitialFilters, + ready: false + } +}; + +interface StoreProps { + onError: (message: string) => void, + onSuccess: (message: string) => void, + currentUser: User, + uiRouter: UIRouter, +} + +/** + * This component shows public store + */ +const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter }) => { + const { t } = useTranslation('public'); + + const { cart, setCart } = useCart(currentUser); + + const [products, setProducts] = useState>([]); + // this includes the resources fetch from the API (machines, categories) and from the URL (filters) + const [resources, setResources] = useImmer(storeInitialResources); + const [machinesModule, setMachinesModule] = useState(false); + const [categoriesTree, setCategoriesTree] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [filtersPanel, setFiltersPanel] = useState(false); + const [pageCount, setPageCount] = useState(0); + const [productsCount, setProductsCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + ProductLib.fetchInitialResources(setResources, onError, formatCategories); + SettingAPI.get('machines_module').then(data => { + setMachinesModule(data.value === 'true'); + }).catch(onError); + }, []); + + useEffect(() => { + if (resources.filters.ready) { + fetchProducts(); + uiRouter.stateService.transitionTo(uiRouter.globals.current, ProductLib.indexFiltersToRouterParams(resources.filters.data)); + } + }, [resources.filters]); + + useEffect(() => { + if (resources.machines.ready && resources.categories.ready) { + setResources(draft => { + return { + ...draft, + filters: { + data: ProductLib.readFiltersFromUrl(uiRouter.globals.params, resources.machines.data, resources.categories.data, storeInitialFilters), + ready: true + } + }; + }); + } + }, [resources.machines, resources.categories]); + + /** + * Create categories tree (parent/children) + */ + const formatCategories = (list: ProductCategory[]) => { + const tree: Array = []; + const parents = list.filter(c => !c.parent_id); + parents.forEach(p => { + tree.push({ + parent: p, + children: list.filter(c => c.parent_id === p.id) + }); + }); + setCategoriesTree(tree); + }; + + /** + * Filter by category: the selected category will always be first + */ + const filterCategory = (category: ProductCategory) => { + ProductLib.updateFilter( + setResources, + 'categories', + category + ? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(resources.categories.data, [], category, 'add')])) + : [] + ); + }; + + /** + * Update the list of applied filters with the given machines + */ + const applyMachineFilters = (machines: Array) => { + ProductLib.updateFilter(setResources, 'machines', machines); + }; + + /** + * Update the list of applied filters with the given keywords (or reference) + */ + const applyKeywordFilter = (keywords: Array) => { + ProductLib.updateFilter(setResources, 'keywords', keywords); + }; + /** + * Clear filters + */ + const clearAllFilters = () => { + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...storeInitialFilters, + categories: draft.filters.data.categories + } + } + }; + }); + }; + + /** + * Creates sorting options to the react-select format + */ + const buildOptions = (): Array> => { + return [ + { value: 'name-asc', label: t('app.public.store.products.sort.name_az') }, + { value: 'name-desc', label: t('app.public.store.products.sort.name_za') }, + { value: 'amount-asc', label: t('app.public.store.products.sort.price_low') }, + { value: 'amount-desc', label: t('app.public.store.products.sort.price_high') } + ]; + }; + /** + * Display option: sorting + */ + const handleSorting = (option: SelectOption) => { + ProductLib.updateFilter(setResources, 'sort', option.value); + }; + + /** + * Filter: toggle non-available products visibility + */ + const toggleVisible = (checked: boolean) => { + ProductLib.updateFilter(setResources, 'is_available', checked); + }; + + /** + * Add product to the cart + */ + const addToCart = (cart: Order) => { + setCart(cart); + onSuccess(t('app.public.store.add_to_cart_success')); + }; + + /** + * Handle products pagination + */ + const handlePagination = (page: number) => { + if (page !== currentPage) { + ProductLib.updateFilter(setResources, 'page', page); + } + }; + + /** + * Fetch the products from the API, according to the current filters + */ + const fetchProducts = async (): Promise => { + try { + const data = await ProductAPI.index(Object.assign({ store: true }, resources.filters.data)); + setCurrentPage(data.page); + setProducts(data.data); + setPageCount(data.total_pages); + setProductsCount(data.total_count); + return data; + } catch (error) { + onError(t('app.public.store.unexpected_error_occurred') + error); + } + }; + + const selectedCategory = resources.filters.data.categories[0]; + const parent = resources.categories.data.find(c => c.id === selectedCategory?.parent_id); + return ( +
+
    +
  • + filterCategory(null)}>{t('app.public.store.products.all_products')} +
  • + {parent && +
  • + filterCategory(parent)}> + {parent.name} + +
  • + } + {selectedCategory && +
  • + filterCategory(selectedCategory)}> + {selectedCategory.name} + +
  • + } +
+ +
+ +
+ applyMachineFilters(resources.filters.data.machines.filter(machine => machine !== m))} + onRemoveKeyword={() => applyKeywordFilter([])} /> +
+
+ {products.map((product) => ( + + ))} +
+ {pageCount > 1 && + + } +
+
+ ); +}; + +const StoreWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser', 'uiRouter'])); + +interface CategoryTree { + parent: ProductCategory, + children: ProductCategory[] +} diff --git a/app/frontend/src/javascript/components/subscriptions/subscribe-modal.tsx b/app/frontend/src/javascript/components/subscriptions/subscribe-modal.tsx index 006e92356..2607d922a 100644 --- a/app/frontend/src/javascript/components/subscriptions/subscribe-modal.tsx +++ b/app/frontend/src/javascript/components/subscriptions/subscribe-modal.tsx @@ -18,6 +18,7 @@ import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-sum import { PaymentSchedule } from '../../models/payment-schedule'; import PriceAPI from '../../api/price'; import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal'; +import { SelectOption } from '../../models/select'; declare const Application: IApplication; @@ -30,12 +31,6 @@ interface SubscribeModalProps { onError: (message: string) => void, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * Modal dialog shown to create a subscription for the given customer */ @@ -91,7 +86,7 @@ export const SubscribeModal: React.FC = ({ isOpen, toggleMo /** * Callback triggered when the user selects a group in the dropdown list */ - const handlePlanSelect = (option: selectOption): void => { + const handlePlanSelect = (option: SelectOption): void => { const plan = allPlans.find(p => p.id === option.value); setSelectedPlan(plan); }; @@ -116,7 +111,7 @@ export const SubscribeModal: React.FC = ({ isOpen, toggleMo /** * Convert all groups to the react-select format */ - const buildOptions = (): Array => { + const buildOptions = (): Array> => { if (!allPlans) return []; return allPlans.filter(p => !p.disabled && p.group_id === customer.group_id).map(p => { diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx index da5538c2c..f940409c7 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx @@ -4,6 +4,7 @@ import Select from 'react-select'; import { FabInput } from '../base/fab-input'; import { ProofOfIdentityType } from '../../models/proof-of-identity-type'; import { Group } from '../../models/group'; +import { SelectOption } from '../../models/select'; interface SupportingDocumentsTypeFormProps { groups: Array, @@ -11,12 +12,6 @@ interface SupportingDocumentsTypeFormProps { onChange: (field: string, value: string | Array) => void, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * Form to set create/edit supporting documents type */ @@ -26,7 +21,7 @@ export const SupportingDocumentsTypeForm: React.FC => { + const buildOptions = (): Array> => { return groups.map(t => { return { value: t.id, label: t.name }; }); @@ -35,7 +30,7 @@ export const SupportingDocumentsTypeForm: React.FC => { + const groupsValues = (): Array> => { const res = []; const groupIds = proofOfIdentityType?.group_ids || []; if (groupIds.length > 0) { @@ -51,7 +46,7 @@ export const SupportingDocumentsTypeForm: React.FC): void => { + const handleGroupsChange = (selectedOptions: Array>): void => { onChange('group_ids', selectedOptions.map(o => o.value)); }; diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx index 71030e138..02c5d23d7 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx @@ -45,7 +45,7 @@ const SupportingDocumentsTypesList: React.FC // get groups useEffect(() => { - GroupAPI.index({ disabled: false, admins: false }).then(data => { + GroupAPI.index({ disabled: false }).then(data => { setGroups(data); ProofOfIdentityTypeAPI.index().then(pData => { setSupportingDocumentsTypes(pData); diff --git a/app/frontend/src/javascript/components/user/change-role-modal.tsx b/app/frontend/src/javascript/components/user/change-role-modal.tsx index 3f844f643..a13ebb05f 100644 --- a/app/frontend/src/javascript/components/user/change-role-modal.tsx +++ b/app/frontend/src/javascript/components/user/change-role-modal.tsx @@ -39,13 +39,12 @@ type selectGroupOption = { value: number, label: string }; */ export const ChangeRoleModal: React.FC = ({ isOpen, toggleModal, user, onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { control, handleSubmit } = useForm({ defaultValues: { groupId: user.group_id } }); + const { control, handleSubmit } = useForm({ defaultValues: { role: user.role, groupId: user.group_id } }); const [groups, setGroups] = useState>([]); - const [selectedRole, setSelectedRole] = useState(user.role); useEffect(() => { - GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError); + GroupAPI.index({ disabled: false }).then(setGroups).catch(onError); }, []); /** @@ -64,10 +63,10 @@ export const ChangeRoleModal: React.FC = ({ isOpen, toggle }; /** - * Callback triggered when the user changes the selected role in the dropdown selection list + * Check if we can change the group of the user */ - const onRoleSelect = (data: UserRole) => { - setSelectedRole(data); + const canChangeGroup = (): boolean => { + return !user.subscription; }; /** @@ -104,15 +103,14 @@ export const ChangeRoleModal: React.FC = ({ isOpen, toggle control={control} id="role" label={t('app.admin.change_role_modal.new_role')} - rules={{ required: true }} - onChange={onRoleSelect} /> - {selectedRole !== 'admin' && - } + rules={{ required: true }} /> + ); diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx new file mode 100644 index 000000000..35b7f9416 --- /dev/null +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; +import AsyncSelect from 'react-select/async'; +import { useTranslation } from 'react-i18next'; +import MemberAPI from '../../api/member'; +import { User } from '../../models/user'; +import { SelectOption } from '../../models/select'; + +interface MemberSelectProps { + defaultUser?: User, + value?: User, + onSelected?: (user: { id: number, name: string }) => void, + noHeader?: boolean, + hasError?: boolean +} + +/** + * This component renders the member select for manager. + */ +export const MemberSelect: React.FC = ({ defaultUser, value, onSelected, noHeader, hasError }) => { + const { t } = useTranslation('public'); + const [option, setOption] = useState>(); + + useEffect(() => { + if (defaultUser) { + setOption({ value: defaultUser.id, label: defaultUser.name }); + } + }, []); + + useEffect(() => { + if (!defaultUser && option) { + onSelected({ id: option.value, name: option.label }); + } + }, [defaultUser]); + + useEffect(() => { + if (value && value?.id !== option?.value) { + setOption({ value: value.id, label: value.name }); + } + if (!value) { + setOption(null); + } + }, [value]); + + /** + * search members by name + */ + const loadMembers = async (inputValue: string): Promise>> => { + if (!inputValue) { + return []; + } + const data = await MemberAPI.search(inputValue); + return data.map(u => { + return { value: u.id, label: u.name }; + }); + }; + + /** + * callback for handle select changed + */ + const onChange = (v: SelectOption) => { + setOption(v); + onSelected({ id: v.value, name: v.label }); + }; + + return ( +
+ {!noHeader && +
+

{t('app.public.member_select.select_a_member')}

+
+ } + +
+ ); +}; + +MemberSelect.defaultProps = { + hasError: false +}; 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 f4aedbb08..f7fbee376 100644 --- a/app/frontend/src/javascript/components/user/user-profile-form.tsx +++ b/app/frontend/src/javascript/components/user/user-profile-form.tsx @@ -30,6 +30,7 @@ import ProfileCustomFieldAPI from '../../api/profile-custom-field'; import { ProfileCustomField } from '../../models/profile-custom-field'; import { SettingName } from '../../models/setting'; import SettingAPI from '../../api/setting'; +import { SelectOption } from '../../models/select'; declare const Application: IApplication; @@ -46,12 +47,6 @@ interface UserProfileFormProps { showTagsInput?: boolean, } -/** - * Option format, expected by react-select - * @see https://github.com/JedWatson/react-select - */ -type selectOption = { value: number, label: string }; - /** * Form component to create or update a user */ @@ -67,7 +62,7 @@ export const UserProfileForm: React.FC = ({ action, size, const [isOrganization, setIsOrganization] = useState(!_isNil(user.invoicing_profile_attributes.organization_attributes)); const [isLocalDatabaseProvider, setIsLocalDatabaseProvider] = useState(false); - const [groups, setGroups] = useState([]); + const [groups, setGroups] = useState[]>([]); const [termsAndConditions, setTermsAndConditions] = useState(null); const [profileCustomFields, setProfileCustomFields] = useState([]); const [requiredFieldsSettings, setRequiredFieldsSettings] = useState>(new Map()); @@ -77,7 +72,7 @@ export const UserProfileForm: React.FC = ({ action, size, setIsLocalDatabaseProvider(data.providable_type === 'DatabaseProvider'); }).catch(error => onError(error)); if (showGroupInput) { - GroupAPI.index({ disabled: false, admins: user.role === 'admin' }).then(data => { + GroupAPI.index({ disabled: false }).then(data => { setGroups(buildOptions(data)); }).catch(error => onError(error)); } @@ -107,7 +102,7 @@ export const UserProfileForm: React.FC = ({ action, size, /** * Convert the provided array of items to the react-select format */ - const buildOptions = (items: Array<{ id?: number, name: string }>): Array => { + const buildOptions = (items: Array<{ id?: number, name: string }>): Array> => { return items.map(t => { return { value: t.id, label: t.name }; }); @@ -116,7 +111,7 @@ export const UserProfileForm: React.FC = ({ action, size, /** * Asynchronously load the full list of enabled trainings to display in the drop-down select field */ - const loadTrainings = (inputValue: string, callback: (options: Array) => void): void => { + const loadTrainings = (inputValue: string, callback: (options: Array>) => void): void => { TrainingAPI.index({ disabled: false }).then(data => { callback(buildOptions(data)); }).catch(error => onError(error)); @@ -125,7 +120,7 @@ export const UserProfileForm: React.FC = ({ action, size, /** * Asynchronously load the full list of tags to display in the drop-down select field */ - const loadTags = (inputValue: string, callback: (options: Array) => void): void => { + const loadTags = (inputValue: string, callback: (options: Array>) => void): void => { TagAPI.index().then(data => { callback(buildOptions(data)); }).catch(error => onError(error)); @@ -155,11 +150,6 @@ export const UserProfileForm: React.FC = ({ action, size, * Check if the given field path should be disabled */ const isDisabled = function (id: string) { - // never allows admins to change their group - if (id === 'group_id' && user.role === 'admin') { - return true; - } - // if the current provider is the local database, then all fields are enabled if (isLocalDatabaseProvider) { return false; @@ -179,7 +169,7 @@ export const UserProfileForm: React.FC = ({ action, size, const userNetworks = new UserLib(user).getUserSocialNetworks(); return ( -
+
{ + ['Machine', 'Space', 'Training', 'Event', 'Subscription', 'Product'].forEach(rateType => { const value = _.isFinite(result.multiVAT[`rate${rateType}`]) ? result.multiVAT[`rate${rateType}`] + '' : ''; Setting.update({ name: `invoice_VAT-rate_${rateType}` }, { value }, function (data) { return growl.success(_t('app.admin.invoices.VAT_rate_successfully_saved')); @@ -1054,6 +1063,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $scope.invoice.multiVAT.rateTraining = settings['invoice_VAT-rate_Training'] ? parseFloat(settings['invoice_VAT-rate_Training']) : ''; $scope.invoice.multiVAT.rateEvent = settings['invoice_VAT-rate_Event'] ? parseFloat(settings['invoice_VAT-rate_Event']) : ''; $scope.invoice.multiVAT.rateSubscription = settings['invoice_VAT-rate_Subscription'] ? parseFloat(settings['invoice_VAT-rate_Subscription']) : ''; + $scope.invoice.multiVAT.rateProduct = settings['invoice_VAT-rate_Product'] ? parseFloat(settings['invoice_VAT-rate_Product']) : ''; $scope.invoice.number.model = settings['invoice_order-nb']; $scope.invoice.code.model = settings['invoice_code-value']; $scope.invoice.code.active = (settings['invoice_code-active'] === 'true'); diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index ab1bdae22..52c03bed6 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -38,7 +38,7 @@ class MembersController { constructor ($scope, $state, Group, Training) { // Retrieve the profiles groups (e.g. students ...) - Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); }); + Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return !g.disabled; }); }); // Retrieve the list of available trainings Training.query().$promise.then(function (data) { @@ -1118,8 +1118,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', ' /** * Controller used in the admin creation page (admin view) */ -Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise', - function ($state, $scope, Admin, growl, _t, settingsPromise) { +Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise', 'groupsPromise', + function ($state, $scope, Admin, growl, _t, settingsPromise, groupsPromise) { // default admin profile let getGender; $scope.admin = { @@ -1145,6 +1145,9 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A // is the address required in _admin_form? $scope.addressRequired = (settingsPromise.address_required === 'true'); + // all available groups + $scope.groups = groupsPromise; + /** * Shows the birthday datepicker */ @@ -1208,7 +1211,7 @@ Application.Controllers.controller('NewManagerController', ['$state', '$scope', }; // list of all groups - $scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); + $scope.groups = groupsPromise.filter(function (g) { return !g.disabled; }); // list of all tags $scope.tags = tagsPromise; diff --git a/app/frontend/src/javascript/controllers/admin/orders.js b/app/frontend/src/javascript/controllers/admin/orders.js new file mode 100644 index 000000000..3c6bc077f --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/orders.js @@ -0,0 +1,52 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminShowOrdersController', ['$rootScope', '$scope', 'CSRF', 'growl', '$state', '$transition$', + function ($rootScope, $scope, CSRF, growl, $state, $transition$) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + $scope.orderId = $transition$.params().id; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back orders list + */ + $scope.backOrdersList = () => { + $state.go('app.admin.store.orders'); + }; + + // currently logged-in user + $scope.currentUser = $rootScope.currentUser; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/admin/plans.js b/app/frontend/src/javascript/controllers/admin/plans.js index 5ac4f62aa..792ea12ac 100644 --- a/app/frontend/src/javascript/controllers/admin/plans.js +++ b/app/frontend/src/javascript/controllers/admin/plans.js @@ -27,7 +27,7 @@ class PlanController { // groups list $scope.groups = groups - .filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }) + .filter(function (g) { return !g.disabled; }) .map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` })); $scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' }); diff --git a/app/frontend/src/javascript/controllers/admin/pricing.js b/app/frontend/src/javascript/controllers/admin/pricing.js index da5afd614..4891e921a 100644 --- a/app/frontend/src/javascript/controllers/admin/pricing.js +++ b/app/frontend/src/javascript/controllers/admin/pricing.js @@ -30,8 +30,8 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state', $scope.enabledPlans = plans.filter(function (p) { return !p.disabled; }); // List of groups (eg. normal, student ...) - $scope.groups = groups.filter(function (g) { return g.slug !== 'admins'; }); - $scope.enabledGroups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); + $scope.groups = groups; + $scope.enabledGroups = groups.filter(function (g) { return !g.disabled; }); // List of all plan-categories $scope.planCategories = planCategories; diff --git a/app/frontend/src/javascript/controllers/admin/statistics.js b/app/frontend/src/javascript/controllers/admin/statistics.js index 6a45242ce..cf84ce794 100644 --- a/app/frontend/src/javascript/controllers/admin/statistics.js +++ b/app/frontend/src/javascript/controllers/admin/statistics.js @@ -187,7 +187,8 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state', return ((tab.es_type_key === 'subscription' && !$rootScope.modules.plans) || (tab.es_type_key === 'training' && !$rootScope.modules.trainings) || (tab.es_type_key === 'space' && !$rootScope.modules.spaces) || - (tab.es_type_key === 'machine' && !$rootScope.modules.machines) + (tab.es_type_key === 'machine' && !$rootScope.modules.machines) || + (tab.es_type_key === 'order' && !$rootScope.modules.store) ); } else { return true; diff --git a/app/frontend/src/javascript/controllers/admin/store.js b/app/frontend/src/javascript/controllers/admin/store.js new file mode 100644 index 000000000..d4500246a --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/store.js @@ -0,0 +1,67 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', '$uiRouter', + function ($scope, CSRF, growl, $state, $uiRouter) { + /* PRIVATE SCOPE */ + // Map of tab state and index + const TABS = { + 'app.admin.store.settings': 0, + 'app.admin.store.products': 1, + 'app.admin.store.categories': 2, + 'app.admin.store.orders': 3 + }; + + /* PUBLIC SCOPE */ + // default tab: products + $scope.tabs = { + active: TABS[$state.current.name] + }; + + // the following item is used by the Products component to save/restore filters in the URL + $scope.uiRouter = $uiRouter; + + /** + * Callback triggered in click tab + */ + $scope.selectTab = () => { + setTimeout(function () { + const currentTab = _.keys(TABS)[$scope.tabs.active]; + if (currentTab !== $state.current.name) { + $state.go(currentTab, { location: true, notify: false, reload: false }); + } + }); + }; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/admin/store_products.js b/app/frontend/src/javascript/controllers/admin/store_products.js new file mode 100644 index 000000000..dc72476a4 --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/store_products.js @@ -0,0 +1,55 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', '$uiRouter', + function ($scope, CSRF, growl, $state, $transition$, $uiRouter) { + /* PUBLIC SCOPE */ + $scope.productId = $transition$.params().id; + + // the following item is used by the UnsavedFormAlert component to detect a page change + $scope.uiRouter = $uiRouter; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = (event) => { + event.preventDefault(); + event.stopPropagation(); + if ($state.prevState === '') { + $state.prevState = 'app.admin.store.products'; + } + window.history.back(); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/application.js b/app/frontend/src/javascript/controllers/application.js index 93d9e6c1e..318765312 100644 --- a/app/frontend/src/javascript/controllers/application.js +++ b/app/frontend/src/javascript/controllers/application.js @@ -1,6 +1,6 @@ -Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', - function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help) { +Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help', '$cookies', + function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help, $cookies) { /* PRIVATE STATIC CONSTANTS */ // User's notifications will get refreshed every 30s @@ -58,6 +58,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco total: 0, unread: 0 }; + $cookies.remove('fablab_cart_token'); return $state.go('app.public.home'); }, function (error) { console.error(`An error occurred logging out: ${error}`); @@ -117,9 +118,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco // retrieve the groups (standard, student ...) Group.query(function (groups) { $scope.groups = groups; - $scope.enabledGroups = groups.filter(function (g) { - return (g.slug !== 'admins') && !g.disabled; - }); + $scope.enabledGroups = groups.filter(g => !g.disabled); }); // retrieve the CGU @@ -352,9 +351,11 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco if (AuthService.isAuthenticated()) { // user is not allowed console.error('[ApplicationController::initialize] user is not allowed'); + return false; } else { // user is not logged in openLoginModal(trans.$to().name, trans.$to().params); + return false; } } }); diff --git a/app/frontend/src/javascript/controllers/cart.js b/app/frontend/src/javascript/controllers/cart.js new file mode 100644 index 000000000..2d5a0005d --- /dev/null +++ b/app/frontend/src/javascript/controllers/cart.js @@ -0,0 +1,66 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('CartController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + + /** + * Open the modal dialog allowing the user to log into the system + */ + $scope.userLogin = function () { + setTimeout(() => { + if (!$scope.isAuthenticated()) { + $scope.login(); + $scope.$apply(); + } + }, 50); + }; + + /** + * Overlap global function to allow the user to navigate to the previous screen + * If no previous $state were recorded, navigate to the project list page + */ + $scope.backPrevLocation = function (event) { + event.preventDefault(); + event.stopPropagation(); + if ($state.prevState === '') { + $state.prevState = 'app.public.store'; + } + window.history.back(); + }; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/home.js b/app/frontend/src/javascript/controllers/home.js index 8a567b736..fad9c211b 100644 --- a/app/frontend/src/javascript/controllers/home.js +++ b/app/frontend/src/javascript/controllers/home.js @@ -106,6 +106,7 @@ Application.Controllers.controller('HomeController', ['$scope', '$transition$', const setupWelcomeTour = function () { // get the tour defined by the ui-tour directive const uitour = uiTourService.getTourByName('welcome'); + if (!uitour) return; // add the steps uitour.createStep({ selector: 'body', diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index 04bcf9714..2783f7c98 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -263,7 +263,21 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state', $scope.method = 'put'; // Retrieve the details for the machine id in the URL, if an error occurs redirect the user to the machines list - $scope.machine = machinePromise; + $scope.machine = cleanMachine(machinePromise); + + /** + * Shows an error message forwarded from a child component + */ + $scope.onError = function (message) { + growl.error(message); + } + + /** + * Shows a success message forwarded from a child react components + */ + $scope.onSuccess = function (message) { + growl.success(message) + } /* PRIVATE SCOPE */ @@ -277,6 +291,13 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state', return new MachinesController($scope, $state); }; + // prepare the machine for the react-hook-form + function cleanMachine (machine) { + delete machine.$promise; + delete machine.$resolved; + return machine; + } + // !!! MUST BE CALLED AT THE END of the controller return initialize(); } @@ -403,7 +424,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran // the moment when the slot selection changed for the last time, used to trigger changes in the cart $scope.selectionTime = null; - // the last clicked event in the calender + // the last clicked event in the calendar $scope.selectedEvent = null; // the application global settings diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index 898e9ee60..ed404f50e 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -14,6 +14,20 @@ * Navigation controller. List the links availables in the left navigation pane and their icon. */ Application.Controllers.controller('MainNavController', ['$scope', 'settingsPromise', function ($scope, settingsPromise) { + /** + * Returns the current state of the public registration setting (allowed/blocked). + */ + $scope.registrationEnabled = function () { + return settingsPromise.public_registrations === 'true'; + }; + + /** + * Check if the store should be hidden to members/visitors + */ + $scope.storeHidden = function () { + return settingsPromise.store_hidden === 'true'; + }; + // Common links (public application) $scope.navLinks = [ { @@ -53,6 +67,13 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm linkIcon: 'tags', class: 'reserve-event-link' }, + $scope.$root.modules.store && { + state: 'app.public.store', + linkText: 'app.public.common.fablab_store', + linkIcon: 'cart-plus', + class: 'store-link', + authorizedRoles: $scope.storeHidden() ? ['admin', 'manager'] : undefined + }, { class: 'menu-spacer' }, { state: 'app.public.projects_list', @@ -100,6 +121,12 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm linkIcon: 'tags', authorizedRoles: ['admin', 'manager'] }, + $scope.$root.modules.store && { + state: 'app.admin.store.products', + linkText: 'app.public.common.manage_the_store', + linkIcon: 'cart-plus', + authorizedRoles: ['admin', 'manager'] + }, { class: 'menu-spacer' }, { state: 'app.admin.members', @@ -148,12 +175,5 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm authorizedRoles: ['admin'] } ].filter(Boolean).concat(Fablab.adminNavLinks); - - /** - * Returns the current state of the public registration setting (allowed/blocked). - */ - $scope.registrationEnabled = function () { - return settingsPromise.public_registrations === 'true'; - }; } ]); diff --git a/app/frontend/src/javascript/controllers/members.js b/app/frontend/src/javascript/controllers/members.js index 461892fb7..0f40d51f1 100644 --- a/app/frontend/src/javascript/controllers/members.js +++ b/app/frontend/src/javascript/controllers/members.js @@ -161,7 +161,7 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco * Check if it is allowed the change the group of the current user */ $scope.isAllowedChangingGroup = function () { - return !$scope.user.subscribed_plan?.name && $scope.user.role !== 'admin'; + return !$scope.user.subscribed_plan?.name; }; /** diff --git a/app/frontend/src/javascript/controllers/orders.js b/app/frontend/src/javascript/controllers/orders.js new file mode 100644 index 000000000..badec5b50 --- /dev/null +++ b/app/frontend/src/javascript/controllers/orders.js @@ -0,0 +1,52 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('ShowOrdersController', ['$rootScope', '$scope', 'CSRF', 'growl', '$state', '$transition$', + function ($rootScope, $scope, CSRF, growl, $state, $transition$) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + $scope.orderId = $transition$.params().id; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back orders list + */ + $scope.backOrdersList = () => { + $state.go('app.logged.dashboard.orders'); + }; + + // currently logged-in user + $scope.currentUser = $rootScope.currentUser; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/products.js b/app/frontend/src/javascript/controllers/products.js new file mode 100644 index 000000000..401a1a75c --- /dev/null +++ b/app/frontend/src/javascript/controllers/products.js @@ -0,0 +1,55 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('ShowProductController', ['$scope', 'CSRF', 'growl', '$transition$', '$state', + function ($scope, CSRF, growl, $transition$, $state) { + /* PRIVATE SCOPE */ + + /* PUBLIC SCOPE */ + $scope.productSlug = $transition$.params().slug; + + /** + * Overlap global function to allow the user to navigate to the previous screen + * If no previous $state were recorded, navigate to the project list page + */ + $scope.backPrevLocation = function (event) { + event.preventDefault(); + event.stopPropagation(); + if ($state.prevState === '') { + $state.prevState = 'app.public.store'; + } + window.history.back(); + }; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/projects.js b/app/frontend/src/javascript/controllers/projects.js index fbc18796c..0cd5dc523 100644 --- a/app/frontend/src/javascript/controllers/projects.js +++ b/app/frontend/src/javascript/controllers/projects.js @@ -231,7 +231,7 @@ class ProjectsController { const asciiName = Diacritics.remove(nameLookup); Member.search( - { query: asciiName, include_admins: 'true' }, + { query: asciiName }, function (users) { $scope.matchingMembers = users; }, function (error) { console.error(error); } ); diff --git a/app/frontend/src/javascript/controllers/store.js b/app/frontend/src/javascript/controllers/store.js new file mode 100644 index 000000000..e5a6fd3dd --- /dev/null +++ b/app/frontend/src/javascript/controllers/store.js @@ -0,0 +1,38 @@ +'use strict'; + +Application.Controllers.controller('StoreController', ['$scope', 'CSRF', 'growl', '$uiRouter', + function ($scope, CSRF, growl, $uiRouter) { + /* PUBLIC SCOPE */ + + // the following item is used by the Store component to store the filters in the URL + $scope.uiRouter = $uiRouter; + + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index d21b37618..4e98469cb 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -291,13 +291,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', }; /** - * Check if the currently logged user has the 'admin' role OR the 'manager' role, but is not taking reseravtion for himself + * Check if the currently logged user has the 'admin' OR 'manager' role, but is not taking reseravtion for himself * @returns {boolean} */ $scope.isAuthorized = function () { - if (AuthService.isAuthorized('admin')) return true; - - if (AuthService.isAuthorized('manager')) { + if (AuthService.isAuthorized(['admin', 'manager'])) { return ($rootScope.currentUser.id !== $scope.user.id); } @@ -823,11 +821,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) { const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) || - (AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) { + ($scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) { return payOnline(items); } else { - if (AuthService.isAuthorized(['admin']) || - (AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) || + if (AuthService.isAuthorized(['admin', 'manager'] && $scope.user.id !== $rootScope.currentUser.id) || (amountToPay === 0 && !hasOtherDeadlines())) { return payOnSite(items); } diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts new file mode 100644 index 000000000..216ea5929 --- /dev/null +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from 'react'; +import { emitCustomEvent } from 'react-custom-events'; +import { Order } from '../models/order'; +import CartAPI from '../api/cart'; +import { getCartToken, setCartToken } from '../lib/cart-token'; +import { User } from '../models/user'; + +export default function useCart (user?: User) { + const [cart, setCart] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function createCart () { + const currentCartToken = getCartToken(); + const data = await CartAPI.create(currentCartToken); + _setCart(data); + setLoading(false); + setCartToken(data.token); + } + setLoading(true); + try { + createCart(); + } catch (e) { + setLoading(false); + setError(e); + } + }, []); + + const reloadCart = async () => { + setLoading(true); + const currentCartToken = getCartToken(); + const data = await CartAPI.create(currentCartToken); + _setCart(data); + setLoading(false); + }; + + useEffect(() => { + if (user && cart && (!cart.statistic_profile_id || !cart.operator_profile_id)) { + reloadCart(); + } + }, [user]); + + const _setCart = (data: Order) => { + setCart(data); + emitCustomEvent('CartUpdate', data); + }; + + return { loading, cart, error, setCart: _setCart, reloadCart }; +} diff --git a/app/frontend/src/javascript/lib/api.ts b/app/frontend/src/javascript/lib/api.ts index a1ea452f5..79a5b8491 100644 --- a/app/frontend/src/javascript/lib/api.ts +++ b/app/frontend/src/javascript/lib/api.ts @@ -1,9 +1,13 @@ +import _ from 'lodash'; import { ApiFilter } from '../models/api'; export default class ApiLib { - static filtersToQuery (filters?: ApiFilter): string { + static filtersToQuery (filters?: ApiFilter, keepNullValues = true): string { if (!filters) return ''; - return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&'); + return '?' + Object.entries(filters) + .filter(filter => keepNullValues || !_.isNil(filter[1])) + .map(filter => `${filter[0]}=${filter[1]}`) + .join('&'); } } diff --git a/app/frontend/src/javascript/lib/cart-token.ts b/app/frontend/src/javascript/lib/cart-token.ts new file mode 100644 index 000000000..9d7973faf --- /dev/null +++ b/app/frontend/src/javascript/lib/cart-token.ts @@ -0,0 +1,23 @@ +import Cookies from 'js-cookie'; + +export const cartCookieName = 'fablab_cart_token'; +export const cartCookieExpire = 7; + +export const getCartToken = () => + Cookies.get(cartCookieName); + +export const setCartToken = (cartToken: string) => { + const cookieOptions = { + expires: cartCookieExpire + }; + + Cookies.set( + cartCookieName, + cartToken, + cookieOptions + ); +}; + +export const removeCartToken = () => { + Cookies.remove(cartCookieName); +}; diff --git a/app/frontend/src/javascript/lib/coupon.ts b/app/frontend/src/javascript/lib/coupon.ts new file mode 100644 index 000000000..1583837e4 --- /dev/null +++ b/app/frontend/src/javascript/lib/coupon.ts @@ -0,0 +1,13 @@ +import { Coupon } from '../models/coupon'; + +export const computePriceWithCoupon = (price: number, coupon?: Coupon): number => { + if (!coupon) { + return price; + } + if (coupon.type === 'percent_off') { + return (Math.round(price * 100) - (Math.round(price * 100) * coupon.percent_off / 100)) / 100; + } else if (coupon.type === 'amount_off' && price > coupon.amount_off) { + return (Math.round(price * 100) - Math.round(coupon.amount_off * 100)) / 100; + } + return price; +}; diff --git a/app/frontend/src/javascript/lib/deferred.ts b/app/frontend/src/javascript/lib/deferred.ts new file mode 100644 index 000000000..811adc07e --- /dev/null +++ b/app/frontend/src/javascript/lib/deferred.ts @@ -0,0 +1,22 @@ +// This is a kind of promise you can resolve from outside the function callback. +// Credits to https://stackoverflow.com/a/71158892/1039377 +export default class Deferred { + public readonly promise: Promise; + private resolveFn!: (value: T | PromiseLike) => void; + private rejectFn!: (reason?: unknown) => void; + + public constructor () { + this.promise = new Promise((resolve, reject) => { + this.resolveFn = resolve; + this.rejectFn = reject; + }); + } + + public reject (reason?: unknown): void { + this.rejectFn(reason); + } + + public resolve (param: T): void { + this.resolveFn(param); + } +} diff --git a/app/frontend/src/javascript/lib/file-upload.ts b/app/frontend/src/javascript/lib/file-upload.ts new file mode 100644 index 000000000..3f7f51d7a --- /dev/null +++ b/app/frontend/src/javascript/lib/file-upload.ts @@ -0,0 +1,29 @@ +import { Path } from 'react-hook-form'; +import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FileType } from '../models/file'; +import { Dispatch, SetStateAction } from 'react'; + +export default class FileUploadLib { + public static onRemoveFile (file: FileType, id: string, setFile: Dispatch>, setValue: UseFormSetValue, onFileRemove: () => void) { + if (file?.id) { + setValue( + `${id}._destroy` as Path, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}.attachment_files` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + public static hasFile (file: FileType): boolean { + return file?.attachment_name && !file?._destroy; + } +} diff --git a/app/frontend/src/javascript/lib/format.ts b/app/frontend/src/javascript/lib/format.ts index 16337d780..3b6b608e6 100644 --- a/app/frontend/src/javascript/lib/format.ts +++ b/app/frontend/src/javascript/lib/format.ts @@ -32,4 +32,11 @@ export default class FormatLib { static price = (price: number): string => { return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price); }; + + /** + * Return currency symbol for currency setting + */ + static currencySymbol = (): string => { + return new Intl.NumberFormat('fr', { style: 'currency', currency: Fablab.intl_currency }).formatToParts()[2].value; + }; } diff --git a/app/frontend/src/javascript/lib/order.ts b/app/frontend/src/javascript/lib/order.ts new file mode 100644 index 000000000..8b4119264 --- /dev/null +++ b/app/frontend/src/javascript/lib/order.ts @@ -0,0 +1,79 @@ +import { computePriceWithCoupon } from './coupon'; +import { Order } from '../models/order'; + +export default class OrderLib { + /** + * Get the order item total + */ + static itemAmount = (item): number => { + return item.quantity * Math.round(item.amount * 100) / 100; + }; + + /** + * return true if order has offered item + */ + static hasOfferedItem = (order: Order): boolean => { + return order.order_items_attributes + .filter(i => i.is_offered).length > 0; + }; + + /** + * Get the offered item total + */ + static offeredAmount = (order: Order): number => { + return order.order_items_attributes + .filter(i => i.is_offered) + .map(i => Math.round(i.amount * 100) * i.quantity) + .reduce((acc, curr) => acc + curr, 0) / 100; + }; + + /** + * Get the total amount before offered amount + */ + static totalBeforeOfferedAmount = (order: Order): number => { + return (Math.round(order.total * 100) + Math.round(this.offeredAmount(order) * 100)) / 100; + }; + + /** + * Get the coupon amount + */ + static couponAmount = (order: Order): number => { + return (Math.round(order.total * 100) - Math.round(computePriceWithCoupon(order.total, order.coupon) * 100)) / 100.00; + }; + + /** + * Get the paid total amount + */ + static paidTotal = (order: Order): number => { + return computePriceWithCoupon(order.total, order.coupon); + }; + + /** + * Returns a className according to the status + */ + static statusColor = (order: Order) => { + switch (order.state) { + case 'cart': + return 'cart'; + case 'paid': + return 'paid'; + case 'payment_failed': + return 'error'; + case 'ready': + return 'ready'; + case 'canceled': + return 'canceled'; + case 'in_progress': + return 'pending'; + default: + return 'normal'; + } + }; + + /** + * Returns a status text according to the status + */ + static statusText = (order: Order) => { + return order.state; + }; +} diff --git a/app/frontend/src/javascript/lib/parsing.ts b/app/frontend/src/javascript/lib/parsing.ts new file mode 100644 index 000000000..7f1c43c10 --- /dev/null +++ b/app/frontend/src/javascript/lib/parsing.ts @@ -0,0 +1,50 @@ +type baseType = string|number|boolean; +type ValueOrArray = T | ValueOrArray[]; +type NestedBaseArray = ValueOrArray; + +export default class ParsingLib { + /** + * Try to parse the given value to get the value with the matching type. + * It supports parsing arrays. + */ + static parse = (value: string|string[]): NestedBaseArray => { + let parsedValue: NestedBaseArray = value; + if (Array.isArray(value)) { + parsedValue = []; + for (const item of value) { + parsedValue.push(ParsingLib.parse(item)); + } + } else { + parsedValue = ParsingLib.simpleParse(value); + } + return parsedValue; + }; + + /** + * Try to parse the given value to get the value with the matching type. + * Arrays are not supported. + */ + static simpleParse = (value: string): baseType => { + let parsedValue: baseType = value; + if (ParsingLib.isBoolean(value)) { + parsedValue = (value === 'true'); + } else if (ParsingLib.isInteger(value)) { + parsedValue = parseInt(value, 10); + } + return parsedValue; + }; + + /** + * Check if the provided string represents an integer + */ + static isInteger = (value: string): boolean => { + return (parseInt(value, 10).toString() === value); + }; + + /** + * Check if the provided string represents a boolean value + */ + static isBoolean = (value: string): boolean => { + return ['true', 'false'].includes(value); + }; +} diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts new file mode 100644 index 000000000..306b146ad --- /dev/null +++ b/app/frontend/src/javascript/lib/product.ts @@ -0,0 +1,202 @@ +import { ProductCategory } from '../models/product-category'; +import { + initialFilters, Product, + ProductIndexFilter, + ProductIndexFilterIds, ProductIndexFilterUrl, ProductResourcesFetching, + stockMovementInReasons, + stockMovementOutReasons, + StockMovementReason +} from '../models/product'; +import { Machine } from '../models/machine'; +import { StateParams } from '@uirouter/angularjs'; +import ParsingLib from './parsing'; +import ProductCategoryAPI from '../api/product-category'; +import MachineAPI from '../api/machine'; +import { Updater } from 'use-immer'; + +export default class ProductLib { + /** + * Map product categories by position + * @param categories unsorted categories, as returned by the API + */ + static sortCategories = (categories: Array): Array => { + const sortedCategories = categories + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = categories + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + return sortedCategories; + }; + + /** + * Return the translation key associated with the given reason + */ + static stockMovementReasonTrKey = (reason: StockMovementReason): string => { + return `app.admin.store.stock_movement_reason.${reason}`; + }; + + static stockStatusTrKey = (product: Product): string => { + if (product.stock.external < (product.quantity_min || 1)) { + return 'app.public.stock_status.out_of_stock'; + } + if (product.low_stock_threshold && product.stock.external <= product.low_stock_threshold) { + return 'app.public.stock_status.limited_stock'; + } + return 'app.public.stock_status.available'; + }; + + /** + * Check if the given stock movement is of type 'in' or 'out' + */ + static stockMovementType = (reason: StockMovementReason): 'in' | 'out' => { + if ((stockMovementInReasons as readonly StockMovementReason[]).includes(reason)) return 'in'; + if ((stockMovementOutReasons as readonly StockMovementReason[]).includes(reason)) return 'out'; + + throw new Error(`Unexpected stock movement reason: ${reason}`); + }; + + /** + * Return the given quantity, prefixed by its addition operator (- or +), if needed + */ + static absoluteStockMovement = (quantity: number, reason: StockMovementReason): string => { + if (ProductLib.stockMovementType(reason) === 'in') { + return `+${quantity}`; + } else { + if (quantity < 0) return quantity.toString(); + return `-${quantity}`; + } + }; + + /** + * Add or remove the given category from the given list; + * This may cause parent/children categories to be selected or unselected accordingly. + */ + static categoriesSelectionTree = (allCategories: Array, currentSelection: Array, category: ProductCategory, operation: 'add'|'remove'): Array => { + let list = [...currentSelection]; + const children = allCategories + .filter(el => el.parent_id === category.id); + + if (operation === 'add') { + list.push(category); + if (children.length) { + // if a parent category is selected, we automatically select all its children + list = [...Array.from(new Set([...list, ...children]))]; + } + } else { + list.splice(list.indexOf(category), 1); + const parent = allCategories.find(p => p.id === category.parent_id); + if (category.parent_id && list.includes(parent)) { + // if a child category is unselected, we unselect its parent + list.splice(list.indexOf(parent), 1); + } + if (children.length) { + // if a parent category is unselected, we unselect all its children + children.forEach(child => { + list.splice(list.indexOf(child), 1); + }); + } + } + return list; + }; + + /** + * Extract the IDS from the filters to pass them to the API + */ + static indexFiltersToIds = (filters: ProductIndexFilter): ProductIndexFilterIds => { + return { + ...filters, + categories: filters.categories?.map(c => c.id), + machines: filters.machines?.map(m => m.id) + }; + }; + + /** + * Prepare the filtering data from the filters to pass them to the router URL + */ + static indexFiltersToRouterParams = (filters: ProductIndexFilter): ProductIndexFilterUrl => { + let categoryTypeUrl = null; + let category = null; + if (filters.categories.length > 0) { + categoryTypeUrl = filters.categories[0].parent_id === null ? 'c' : 'sc'; + category = filters.categories.map(c => c.slug)[0]; + } + return { + ...filters, + machines: filters.machines?.map(m => m.slug), + categories: filters.categories?.map(c => c.slug), + category, + categoryTypeUrl + }; + }; + + /** + * Parse the provided URL and return a ready-to-use filter object + */ + static readFiltersFromUrl = (params: StateParams, machines: Array, categories: Array, defaultFilters = initialFilters): ProductIndexFilter => { + const res: ProductIndexFilter = { ...defaultFilters }; + for (const key in params) { + if (['#', 'categoryTypeUrl'].includes(key) || !Object.prototype.hasOwnProperty.call(params, key)) continue; + + const value = ParsingLib.parse(params[key]) || defaultFilters[key]; + switch (key) { + case 'category': { + const category = categories?.find(c => c.slug === value); + const subCategories = category ? categories?.filter(c => c.parent_id === category.id) : []; + res.categories = category ? [category, ...subCategories] : []; + break; + } + case 'categories': + res.categories = [...categories?.filter(c => (value as Array)?.includes(c.slug))]; + break; + case 'machines': + res.machines = machines?.filter(m => (value as Array)?.includes(m.slug)); + break; + default: + res[key] = value; + } + } + return res; + }; + + /** + * Fetch the initial ressources needed to initialise the store and its filters (categories and machines) + */ + static fetchInitialResources = (setResources: Updater, onError: (message: string) => void, onProductCategoryFetched?: (data: Array) => void) => { + ProductCategoryAPI.index().then(data => { + setResources(draft => { + return { ...draft, categories: { data: ProductLib.sortCategories(data), ready: true } }; + }); + if (typeof onProductCategoryFetched === 'function') onProductCategoryFetched(ProductLib.sortCategories(data)); + }).catch(error => { + onError(error); + }); + MachineAPI.index({ disabled: false }).then(data => { + setResources(draft => { + return { ...draft, machines: { data, ready: true } }; + }); + }).catch(onError); + }; + + /** + * Update the given filter in memory with the new provided value + */ + static updateFilter = (setResources: Updater, key: keyof ProductIndexFilter, value: unknown): void => { + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + [key]: value + } + } + }; + }); + }; +} diff --git a/app/frontend/src/javascript/lib/setting.ts b/app/frontend/src/javascript/lib/setting.ts new file mode 100644 index 000000000..327112c9a --- /dev/null +++ b/app/frontend/src/javascript/lib/setting.ts @@ -0,0 +1,26 @@ +import { SettingName, SettingValue } from '../models/setting'; +import ParsingLib from './parsing'; + +export default class SettingLib { + /** + * Convert the provided data to a map, as expected by BulkUpdate + */ + static objectToBulkMap = (data: Record): Map => { + const res = new Map(); + for (const key in data) { + res.set(key as SettingName, `${data[key]}`); + } + return res; + }; + + /** + * Convert the provided map to a simple javascript object, usable by react-hook-form + */ + static bulkMapToObject = (data: Map): Record => { + const res = {} as Record; + data.forEach((value, key) => { + res[key] = ParsingLib.simpleParse(value); + }); + return res; + }; +} diff --git a/app/frontend/src/javascript/lib/user.ts b/app/frontend/src/javascript/lib/user.ts index eaa54b5ea..a7fd30f69 100644 --- a/app/frontend/src/javascript/lib/user.ts +++ b/app/frontend/src/javascript/lib/user.ts @@ -13,9 +13,7 @@ export default class UserLib { * Check if the current user has privileged access for resources concerning the provided customer */ isPrivileged = (customer: User): boolean => { - if (this.user?.role === 'admin') return true; - - if (this.user?.role === 'manager') { + if (this.user?.role === 'admin' || this.user?.role === 'manager') { return (this.user?.id !== customer.id); } diff --git a/app/frontend/src/javascript/models/api.ts b/app/frontend/src/javascript/models/api.ts index b1fbafd1d..ef11decc3 100644 --- a/app/frontend/src/javascript/models/api.ts +++ b/app/frontend/src/javascript/models/api.ts @@ -1,3 +1,17 @@ // ApiFilter should be extended by an interface listing all the filters allowed for a given API -// eslint-disable-next-line @typescript-eslint/ban-types -export type ApiFilter = {}; +export type ApiFilter = Record; + +export interface PaginatedIndex { + page: number, + total_pages: number, + page_size: number, + total_count: number, + data: Array +} + +export type SortOption = `${string}-${'asc' | 'desc'}` | ''; + +export interface ApiResource { + data: T, + ready: boolean +} diff --git a/app/frontend/src/javascript/models/coupon.ts b/app/frontend/src/javascript/models/coupon.ts new file mode 100644 index 000000000..ad3ee624a --- /dev/null +++ b/app/frontend/src/javascript/models/coupon.ts @@ -0,0 +1,8 @@ +export interface Coupon { + id: number, + code: string, + type: string, + amount_off: number, + percent_off: number, + validity_per_user: string +} diff --git a/app/frontend/src/javascript/models/file.ts b/app/frontend/src/javascript/models/file.ts new file mode 100644 index 000000000..66b6d870e --- /dev/null +++ b/app/frontend/src/javascript/models/file.ts @@ -0,0 +1,10 @@ +export interface FileType { + id?: number|string, + attachment_name?: string, + attachment_url?: string, + _destroy?: boolean +} + +export interface ImageType extends FileType { + is_main?: boolean +} diff --git a/app/frontend/src/javascript/models/group.ts b/app/frontend/src/javascript/models/group.ts index bf3eda384..26d19f43e 100644 --- a/app/frontend/src/javascript/models/group.ts +++ b/app/frontend/src/javascript/models/group.ts @@ -1,8 +1,7 @@ import { ApiFilter } from './api'; export interface GroupIndexFilter extends ApiFilter { - disabled?: boolean, - admins?: boolean, + disabled?: boolean } export interface Group { diff --git a/app/frontend/src/javascript/models/machine.ts b/app/frontend/src/javascript/models/machine.ts index a4c5b7694..43b1a143a 100644 --- a/app/frontend/src/javascript/models/machine.ts +++ b/app/frontend/src/javascript/models/machine.ts @@ -1,23 +1,20 @@ import { Reservation } from './reservation'; import { ApiFilter } from './api'; +import { FileType } from './file'; export interface MachineIndexFilter extends ApiFilter { disabled: boolean, } export interface Machine { - id: number, + id?: number, name: string, description?: string, spec?: string, disabled: boolean, slug: string, - machine_image: string, - machine_files_attributes?: Array<{ - id: number, - attachment: string, - attachment_url: string - }>, + machine_image_attributes: FileType, + machine_files_attributes?: Array, trainings?: Array<{ id: number, name: string, diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts new file mode 100644 index 000000000..08d9d53bf --- /dev/null +++ b/app/frontend/src/javascript/models/order.ts @@ -0,0 +1,77 @@ +import { TDateISO } from '../typings/date-iso'; +import { PaymentConfirmation } from './payment'; +import { CreateTokenResponse } from './payzen'; +import { UserRole } from './user'; +import { Coupon } from './coupon'; +import { ApiFilter, PaginatedIndex } from './api'; + +export interface Order { + id: number, + token: string, + statistic_profile_id?: number, + user?: { + id: number, + role: UserRole + name?: string, + }, + operator_profile_id?: number, + reference?: string, + state?: string, + total?: number, + coupon?: Coupon, + created_at?: TDateISO, + updated_at?: TDateISO, + invoice_id?: number, + payment_method?: string, + payment_date?: TDateISO, + wallet_amount?: number, + paid_total?: number, + order_items_attributes: Array<{ + id: number, + orderable_type: string, + orderable_id: number, + orderable_name: string, + orderable_slug: string, + orderable_ref?: string, + orderable_main_image_url?: string, + orderable_external_stock: number, + quantity: number, + quantity_min: number, + amount: number, + is_offered: boolean + }>, +} + +export interface OrderPayment { + order: Order, + payment?: PaymentConfirmation|CreateTokenResponse +} + +export type OrderIndex = PaginatedIndex; + +export type OrderSortOption = 'created_at-asc' | 'created_at-desc' | ''; + +export interface OrderIndexFilter extends ApiFilter { + reference?: string, + user_id?: number, + user?: { + id: number, + name?: string, + }, + page?: number, + sort?: OrderSortOption + states?: Array, + period_from?: string, + period_to?: string +} + +export interface OrderErrors { + order_id: number, + details: Array<{ + item_id: number, + errors: Array<{ + error: string, + value: string|number + }> + }> +} diff --git a/app/frontend/src/javascript/models/product-category.ts b/app/frontend/src/javascript/models/product-category.ts new file mode 100644 index 000000000..7fcd5bbc6 --- /dev/null +++ b/app/frontend/src/javascript/models/product-category.ts @@ -0,0 +1,8 @@ +export interface ProductCategory { + id: number, + name: string, + slug: string, + parent_id?: number, + position: number, + products_count: number +} diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts new file mode 100644 index 000000000..f391823ae --- /dev/null +++ b/app/frontend/src/javascript/models/product.ts @@ -0,0 +1,135 @@ +import { TDateISO } from '../typings/date-iso'; +import { ApiFilter, ApiResource, PaginatedIndex } from './api'; +import { ProductCategory } from './product-category'; +import { Machine } from './machine'; + +export type ProductSortOption = 'name-asc' | 'name-desc' | 'amount-asc' | 'amount-desc' | ''; + +export interface ProductIndexFilter { + is_active?: boolean, + is_available?: boolean, + page?: number, + categories?: ProductCategory[], + machines?: Machine[], + keywords?: string[], + stock_type?: 'internal' | 'external', + stock_from?: number, + stock_to?: number, + sort?: ProductSortOption +} + +export interface ProductIndexFilterIds extends Omit, 'machines'>, ApiFilter { + categories?: Array, + machines?: Array, +} + +export interface ProductIndexFilterUrl extends Omit, 'machines'> { + categoryTypeUrl?: 'c' | 'sc', + category?: string, + machines?: Array, + categories?: Array, +} + +export interface ProductResourcesFetching { + machines: ApiResource>, + categories: ApiResource>, + filters: ApiResource +} + +export const initialFilters: ProductIndexFilter = { + categories: [], + keywords: [], + machines: [], + is_active: false, + is_available: false, + stock_type: 'external', + stock_from: 0, + stock_to: 0, + page: 1, + sort: '' +}; + +export const initialResources: ProductResourcesFetching = { + machines: { + data: [], + ready: false + }, + categories: { + data: [], + ready: false + }, + filters: { + data: initialFilters, + ready: false + } +}; + +export type StockType = 'internal' | 'external' | 'all'; + +export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'other_in'] as const; +export const stockMovementOutReasons = ['sold', 'missing', 'damaged', 'other_out'] as const; +export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovementOutReasons] as const; + +export type StockMovementReason = typeof stockMovementAllReasons[number]; + +export interface Stock { + internal?: number, + external?: number, +} + +export type ProductsIndex = PaginatedIndex; + +export interface ProductStockMovement { + id?: number, + product_id?: number, + quantity?: number, + reason?: StockMovementReason, + stock_type?: StockType, + remaining_stock?: number, + date?: TDateISO +} + +export type StockMovementIndex = PaginatedIndex; + +export interface StockMovementIndexFilter extends ApiFilter { + reason?: StockMovementReason, + stock_type?: StockType, + page?: number, +} + +export interface Product { + id?: number, + name?: string, + slug?: string, + sku?: string, + description?: string, + is_active?: boolean, + product_category_id?: number, + is_active_price?: boolean, + amount?: number, + quantity_min?: number, + stock?: Stock, + low_stock_alert?: boolean, + low_stock_threshold?: number, + machine_ids?: number[], + created_at?: TDateISO, + product_files_attributes?: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string, + _destroy?: boolean + }>, + product_images_attributes?: Array<{ + id?: number, + attachment?: File, + attachment_files?: FileList, + attachment_name?: string, + attachment_url?: string, + thumb_attachment_url?: string, + _destroy?: boolean, + is_main?: boolean + }>, + product_stock_movements_attributes?: Array, +} diff --git a/app/frontend/src/javascript/models/select.ts b/app/frontend/src/javascript/models/select.ts new file mode 100644 index 000000000..e3dab0e1b --- /dev/null +++ b/app/frontend/src/javascript/models/select.ts @@ -0,0 +1,10 @@ +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +export type SelectOption = { value: TOptionValue, label: TOptionLabel } + +/** + * Checklist Option format + */ +export type ChecklistOption = { value: TOptionValue, label: string }; diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 144c53631..1f81a30e0 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -7,20 +7,20 @@ export const homePageSettings = [ 'home_content', 'home_css', 'upcoming_events_shown' -]; +] as const; export const privacyPolicySettings = [ 'privacy_draft', 'privacy_body', 'privacy_dpo' -]; +] as const; export const aboutPageSettings = [ 'about_title', 'about_body', 'about_contacts', 'link_name' -]; +] as const; export const socialNetworksSettings = [ 'facebook', @@ -36,7 +36,7 @@ export const socialNetworksSettings = [ 'pinterest', 'lastfm', 'flickr' -]; +] as const; export const messagesSettings = [ 'machine_explications_alert', @@ -45,7 +45,7 @@ export const messagesSettings = [ 'subscription_explications_alert', 'event_explications_alert', 'space_explications_alert' -]; +] as const; export const invoicesSettings = [ 'invoice_logo', @@ -60,11 +60,12 @@ export const invoicesSettings = [ 'invoice_VAT-rate_Space', 'invoice_VAT-rate_Event', 'invoice_VAT-rate_Subscription', + 'invoice_VAT-rate_Product', 'invoice_text', 'invoice_legals', 'invoice_prefix', 'payment_schedule_prefix' -]; +] as const; export const bookingSettings = [ 'booking_window_start', @@ -81,17 +82,17 @@ export const bookingSettings = [ 'book_overlapping_slots', 'slot_duration', 'overlapping_categories' -]; +] as const; export const themeSettings = [ 'main_color', 'secondary_color' -]; +] as const; export const titleSettings = [ 'fablab_name', 'name_genre' -]; +] as const; export const accountingSettings = [ 'accounting_journal_code', @@ -114,8 +115,10 @@ export const accountingSettings = [ 'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', - 'accounting_Space_label' -]; + 'accounting_Space_label', + 'accounting_Product_code', + 'accounting_Product_label' +] as const; export const modulesSettings = [ 'spaces_module', @@ -126,14 +129,15 @@ export const modulesSettings = [ 'machines_module', 'online_payment_module', 'public_agenda_module', - 'invoicing_module' -]; + 'invoicing_module', + 'store_module' +] as const; export const stripeSettings = [ 'stripe_public_key', 'stripe_secret_key', 'stripe_currency' -]; +] as const; export const payzenSettings = [ 'payzen_username', @@ -142,13 +146,13 @@ export const payzenSettings = [ 'payzen_public_key', 'payzen_hmac', 'payzen_currency' -]; +] as const; export const openLabSettings = [ 'openlab_app_id', 'openlab_app_secret', 'openlab_default' -]; +] as const; export const accountSettings = [ 'phone_required', @@ -157,13 +161,13 @@ export const accountSettings = [ 'user_change_group', 'user_validation_required', 'user_validation_required_list' -]; +] as const; export const analyticsSettings = [ 'tracking_id', 'facebook_app_id', 'twitter_analytics' -]; +] as const; export const fabHubSettings = [ 'hub_last_version', @@ -171,43 +175,48 @@ export const fabHubSettings = [ 'fab_analytics', 'origin', 'uuid' -]; +] as const; export const projectsSettings = [ 'allowed_cad_extensions', 'allowed_cad_mime_types', 'disqus_shortname' -]; +] as const; export const prepaidPacksSettings = [ 'renew_pack_threshold', 'pack_only_for_subscription' -]; +] as const; export const registrationSettings = [ 'public_registrations', 'recaptcha_site_key', 'recaptcha_secret_key' -]; +] as const; export const adminSettings = [ 'feature_tour_display', 'show_username_in_admin_list' -]; +] as const; export const pricingSettings = [ 'extended_prices_in_same_day' -]; +] as const; export const poymentSettings = [ 'payment_gateway' -]; +] as const; export const displaySettings = [ 'machines_sort_by', 'events_in_calendar', 'email_from' -]; +] as const; + +export const storeSettings = [ + 'store_withdrawal_instructions', + 'store_hidden' +] as const; export const allSettings = [ ...homePageSettings, @@ -233,7 +242,8 @@ export const allSettings = [ ...adminSettings, ...pricingSettings, ...poymentSettings, - ...displaySettings + ...displaySettings, + ...storeSettings ] as const; export type SettingName = typeof allSettings[number]; @@ -260,3 +270,5 @@ export interface SettingBulkResult { error?: string, localized?: string, } + +export type SettingBulkArray = Array<{ name: SettingName, value: SettingValue }>; diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 1ca1d2752..2e8593899 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -27,8 +27,8 @@ angular.module('application.router', ['ui.router']) logoFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-file' }).$promise; }], logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }], sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }], - modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module', 'spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module']" }).$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations']" }).$promise; }] + modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module', 'spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module', 'store_module']" }).$promise; }], + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden']" }).$promise; }] }, onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) { // Retrieve Anti-CSRF tokens from cookies @@ -41,6 +41,7 @@ angular.module('application.router', ['ui.router']) spaces: (modulesPromise.spaces_module === 'true'), plans: (modulesPromise.plans_module === 'true'), trainings: (modulesPromise.trainings_module === 'true'), + store: (modulesPromise.store_module === 'true'), invoicing: (modulesPromise.invoicing_module === 'true'), wallet: (modulesPromise.wallet_module === 'true'), publicAgenda: (modulesPromise.public_agenda_module === 'true'), @@ -227,6 +228,24 @@ angular.module('application.router', ['ui.router']) } } }) + .state('app.logged.dashboard.orders', { + url: '/orders', + views: { + 'main@': { + templateUrl: '/dashboard/orders.html', + controller: 'DashboardController' + } + } + }) + .state('app.logged.dashboard.order_show', { + url: '/orders/:id', + views: { + 'main@': { + templateUrl: '/orders/show.html', + controller: 'ShowOrdersController' + } + } + }) .state('app.logged.dashboard.wallet', { url: '/wallet', abstract: !Fablab.walletModule, @@ -328,6 +347,7 @@ angular.module('application.router', ['ui.router']) // machines .state('app.public.machines_list', { url: '/machines', + abstract: !Fablab.machinesModule, views: { 'main@': { templateUrl: '/machines/index.html', @@ -341,6 +361,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.admin.machines_new', { url: '/machines/new', + abstract: !Fablab.machinesModule, views: { 'main@': { templateUrl: '/machines/new.html', @@ -350,6 +371,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.public.machines_show', { url: '/machines/:id', + abstract: !Fablab.machinesModule, views: { 'main@': { templateUrl: '/machines/show.html', @@ -362,6 +384,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.logged.machines_reserve', { url: '/machines/:id/reserve', + abstract: !Fablab.machinesModule, views: { 'main@': { templateUrl: '/machines/reserve.html', @@ -383,6 +406,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.admin.machines_edit', { url: '/machines/:id/edit', + abstract: !Fablab.machinesModule, views: { 'main@': { templateUrl: '/machines/edit.html', @@ -600,6 +624,53 @@ angular.module('application.router', ['ui.router']) } }) + // store + .state('app.public.store', { + url: '/store/:categoryTypeUrl/:category?{machines:string}{keywords:string}{is_active:string}{is_available:string}{page:string}{sort:string}', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/store/index.html', + controller: 'StoreController' + } + }, + params: { + categoryTypeUrl: { raw: true, type: 'path', value: null, squash: true }, + category: { type: 'path', raw: true, value: null, squash: true }, + machines: { array: true, dynamic: true, type: 'query', raw: true }, + keywords: { dynamic: true, type: 'query' }, + is_active: { dynamic: true, type: 'query', value: 'true', squash: true }, + is_available: { dynamic: true, type: 'query', value: 'false', squash: true }, + page: { dynamic: true, type: 'query', value: '1', squash: true }, + sort: { dynamic: true, type: 'query' }, + authorizedRoles: { dynamic: true, raw: true } + } + }) + + // show product + .state('app.public.product_show', { + url: '/store/p/:slug', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/products/show.html', + controller: 'ShowProductController' + } + } + }) + + // cart + .state('app.public.store_cart', { + url: '/store/cart', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/cart/index.html', + controller: 'CartController' + } + } + }) + // --- namespace /admin/... --- // calendar .state('app.admin.calendar', { @@ -871,6 +942,18 @@ angular.module('application.router', ['ui.router']) } }) + // show order + .state('app.admin.order_show', { + url: '/admin/store/orders/:id', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/orders/show.html', + controller: 'AdminShowOrdersController' + } + } + }) + // invoices .state('app.admin.invoices', { url: '/admin/invoices', @@ -884,13 +967,13 @@ angular.module('application.router', ['ui.router']) settings: ['Setting', function (Setting) { return Setting.query({ names: "['invoice_legals', 'invoice_text', 'invoice_VAT-rate', 'invoice_VAT-rate_Machine', 'invoice_VAT-rate_Training', 'invoice_VAT-rate_Space', " + - "'invoice_VAT-rate_Event', 'invoice_VAT-rate_Subscription', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', " + + "'invoice_VAT-rate_Event', 'invoice_VAT-rate_Subscription', 'invoice_VAT-rate_Product', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', " + "'invoice_code-active', 'invoice_reference', 'invoice_logo', 'accounting_journal_code', 'accounting_card_client_code', " + "'accounting_card_client_label', 'accounting_wallet_client_code', 'accounting_wallet_client_label', 'invoicing_module', " + "'accounting_other_client_code', 'accounting_other_client_label', 'accounting_wallet_code', 'accounting_wallet_label', " + "'accounting_VAT_code', 'accounting_VAT_label', 'accounting_subscription_code', 'accounting_subscription_label', " + "'accounting_Machine_code', 'accounting_Machine_label', 'accounting_Training_code', 'accounting_Training_label', " + - "'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label', " + + "'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label', 'accounting_Product_code', 'accounting_Product_label', " + "'payment_gateway', 'accounting_Error_code', 'accounting_Error_label', 'payment_schedule_prefix', " + "'feature_tour_display', 'online_payment_module', 'stripe_public_key', 'stripe_currency', 'invoice_prefix', " + "'accounting_Pack_code', 'accounting_Pack_label']" @@ -1001,7 +1084,8 @@ angular.module('application.router', ['ui.router']) } }, resolve: { - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }] + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }], + groupsPromise: ['Group', function (Group) { return Group.query({ disabled: false }).$promise; }] } }) .state('app.admin.managers_new', { @@ -1093,7 +1177,8 @@ angular.module('application.router', ['ui.router']) "'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," + "'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'public_registrations'," + "'extended_prices_in_same_day', 'recaptcha_site_key', 'recaptcha_secret_key', 'user_validation_required', " + - "'user_validation_required_list', 'machines_module', 'user_change_group', 'show_username_in_admin_list']" + "'user_validation_required_list', 'machines_module', 'user_change_group', 'show_username_in_admin_list', " + + "'store_module']" }).$promise; }], privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }], @@ -1104,6 +1189,91 @@ angular.module('application.router', ['ui.router']) } }) + .state('app.admin.store', { + abstract: true, + url: '/admin/store' + }) + + .state('app.admin.store.settings', { + url: '/settings', + abstract: !Fablab.storeModule, + data: { + authorizedRoles: ['admin'] + }, + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + + .state('app.admin.store.products', { + url: '/products?{categories:string}{machines:string}{keywords:string}{stock_type:string}{stock_from:string}{stock_to:string}{is_active:string}{page:string}{sort:string}', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + }, + params: { + categories: { array: true, dynamic: true, type: 'query', raw: true }, + machines: { array: true, dynamic: true, type: 'query', raw: true }, + keywords: { dynamic: true, type: 'query' }, + stock_type: { dynamic: true, type: 'query', value: 'internal', squash: true }, + stock_from: { dynamic: true, type: 'query', value: '0', squash: true }, + stock_to: { dynamic: true, type: 'query', value: '0', squash: true }, + is_active: { dynamic: true, type: 'query', value: 'false', squash: true }, + page: { dynamic: true, type: 'query', value: '1', squash: true }, + sort: { dynamic: true, type: 'query' } + } + }) + + .state('app.admin.store.products_new', { + url: '/products/new', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/store/product_new.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.products_edit', { + url: '/products/:id/edit', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/store/product_edit.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.categories', { + url: '/categories', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + + .state('app.admin.store.orders', { + url: '/orders', + abstract: !Fablab.storeModule, + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } + }) + // OpenAPI Clients .state('app.admin.open_api_clients', { url: '/open_api_clients', diff --git a/app/frontend/src/stylesheets/app.nav.scss b/app/frontend/src/stylesheets/app.nav.scss index 1ddef5cfd..519645f96 100644 --- a/app/frontend/src/stylesheets/app.nav.scss +++ b/app/frontend/src/stylesheets/app.nav.scss @@ -527,6 +527,19 @@ } } +.nav { + &.nav-tabs { + display: flex; + & > li { + flex: 1; + width: auto; + display: block; + float: none; + a { height: 100%; } + } + } +} + // overrides bootstrap .nav-justified > li, .nav-tabs.nav-justified > li { cursor: pointer; diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index e7d0a4efe..19126124e 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -25,21 +25,32 @@ @import "modules/base/fab-input"; @import "modules/base/fab-modal"; @import "modules/base/fab-output-copy"; +@import "modules/base/fab-pagination"; @import "modules/base/fab-panel"; @import "modules/base/fab-popover"; +@import "modules/base/fab-state-label"; @import "modules/base/fab-text-editor"; +@import "modules/base/fab-tooltip"; @import "modules/base/labelled-input"; @import "modules/calendar/calendar"; +@import "modules/cart/cart-button"; +@import "modules/cart/store-cart"; @import "modules/dashboard/reservations/credits-panel"; @import "modules/dashboard/reservations/reservations-dashboard"; @import "modules/dashboard/reservations/reservations-panel"; @import "modules/events/event"; @import "modules/form/abstract-form-item"; @import "modules/form/form-input"; +@import "modules/form/form-multi-file-upload"; +@import "modules/form/form-multi-image-upload"; @import "modules/form/form-rich-text"; @import "modules/form/form-select"; @import "modules/form/form-switch"; +@import "modules/form/form-checklist"; +@import "modules/form/form-file-upload"; +@import "modules/form/form-image-upload"; @import "modules/group/change-group"; +@import "modules/layout/header-page"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; @import "modules/machines/machines-list"; @@ -86,6 +97,24 @@ @import "modules/settings/check-list-setting"; @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; +@import "modules/store/_utilities"; +@import "modules/store/order-actions.scss"; +@import "modules/store/order-item"; +@import "modules/store/orders-dashboard"; +@import "modules/store/orders"; +@import "modules/store/product-categories"; +@import "modules/store/product-category-form"; +@import "modules/store/product-form"; +@import "modules/store/product-stock-form"; +@import "modules/store/product-stock-modal"; +@import "modules/store/products-grid"; +@import "modules/store/products-list"; +@import "modules/store/products"; +@import "modules/store/store-filters"; +@import "modules/store/store-list-header"; +@import "modules/store/store-list"; +@import "modules/store/store-settings"; +@import "modules/store/store"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; @@ -96,6 +125,7 @@ @import "modules/user/avatar"; @import "modules/user/avatar-input"; @import "modules/user/gender-input"; +@import "modules/user/member-select"; @import "modules/user/user-profile-form"; @import "modules/user/user-validation"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5f70a2d3a..1f5cbea32 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -1,23 +1,23 @@ .fab-button { - color: black; - background-color: #fbfbfb; - display: inline-block; + height: 38px; margin-bottom: 0; - font-weight: normal; - text-align: center; - white-space: nowrap; - vertical-align: middle; - touch-action: manipulation; - cursor: pointer; - background-image: none; - border: 1px solid #c9c9c9; padding: 6px 12px; + display: inline-flex; + align-items: center; + border: 1px solid #c9c9c9; + border-radius: 4px; + background-color: #fbfbfb; + background-image: none; font-size: 16px; line-height: 1.5; - border-radius: 4px; - user-select: none; + text-align: center; + font-weight: normal; text-decoration: none; - height: 38px; + color: black; + white-space: nowrap; + touch-action: manipulation; + cursor: pointer; + user-select: none; &:hover { background-color: #f2f2f2; @@ -45,5 +45,34 @@ &--icon { margin-right: 0.5em; + display: flex; + } + &--icon-only { + display: flex; + } + + // color variants + @mixin colorVariant($color, $textColor) { + border-color: $color; + background-color: $color; + color: $textColor; + &:hover { + border-color: $color; + background-color: $color; + color: $textColor; + opacity: 0.75; + } + } + &.is-info { + @include colorVariant(var(--information), var(--gray-soft-lightest)); + } + &.is-secondary { + @include colorVariant(var(--secondary), var(--gray-hard-darkest)); + } + &.is-black { + @include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest)); + } + &.is-main { + @include colorVariant(var(--main), var(--gray-soft-lightest)); } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index 4e5d538b3..890c98c3b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -81,6 +81,12 @@ position: relative; padding: 15px; + .subtitle { + margin-bottom: 3.2rem; + @include title-base; + color: var(--gray-hard-darkest); + } + form { display: flex; flex-direction: column; diff --git a/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss b/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss index 24c3a9941..30eebc19c 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-output-copy.scss @@ -1,7 +1,7 @@ .fab-output-copy { .form-item-field { & > input { - background-color: var(--gray-soft); + background-color: var(--gray-soft-dark); border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/app/frontend/src/stylesheets/modules/base/fab-pagination.scss b/app/frontend/src/stylesheets/modules/base/fab-pagination.scss new file mode 100644 index 000000000..e102cfb77 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/base/fab-pagination.scss @@ -0,0 +1,25 @@ +.fab-pagination { + display: grid; + grid-auto-flow: column; + grid-template-columns: repeat(9, min-content); + justify-content: center; + gap: 1.6rem; + button { + min-width: 4rem; + height: 4rem; + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + border-radius: var(--border-radius-sm); + @include text-lg(500); + &:hover:not(.is-active) { + background-color: var(--gray-soft); + } + } + .is-active { + background-color: var(--main); + color: var(--main-text-color); + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/base/fab-state-label.scss b/app/frontend/src/stylesheets/modules/base/fab-state-label.scss new file mode 100644 index 000000000..73c630be2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/base/fab-state-label.scss @@ -0,0 +1,28 @@ +.fab-state-label { + // --status-color needs to be defined in the component's CSS + --status-color: var(--gray-hard-darkest); + display: flex; + align-items: center; + @include text-sm; + line-height: 1.714; + color: var(--status-color); + + &.bg { + width: fit-content; + padding: 0.4rem 0.8rem; + justify-content: center; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + color: var(--gray-hard-darkest); + } + + &::before { + flex-shrink: 0; + content: ""; + margin-right: 0.8rem; + width: 1rem; + height: 1rem; + background-color: var(--status-color); + border-radius: 50%; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss b/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss new file mode 100644 index 000000000..91c9ea07b --- /dev/null +++ b/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss @@ -0,0 +1,29 @@ +.fab-tooltip { + position: relative; + cursor: help; + + .trigger i { display: block; } + .content { + position: absolute; + top: 0; + right: 0; + display: none; + width: max-content; + max-width: min(75vw, 65ch); + padding: 1rem; + background-color: var(--information-lightest); + color: var(--information); + border: 1px solid var(--information); + border-radius: var(--border-radius); + font-size: 14px; + font-weight: normal; + line-height: 1.2em; + z-index: 1; + & > span { display: block; } + a { + color: var(--gray-hard); + text-decoration: underline; + } + } + &:hover .content { display: block; } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/cart/cart-button.scss b/app/frontend/src/stylesheets/modules/cart/cart-button.scss new file mode 100644 index 000000000..2f8c491ac --- /dev/null +++ b/app/frontend/src/stylesheets/modules/cart/cart-button.scss @@ -0,0 +1,41 @@ +.cart-button { + position: relative; + width: 100%; + height: 100%; + padding: 0.8rem 0.6rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + background-color: var(--secondary); + + &:hover { + cursor: pointer; + } + + span { + position: absolute; + top: 1rem; + right: 1rem; + min-width: 2rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--secondary-text-color); + border-radius: 10rem; + color: var(--secondary); + @include text-sm(600); + } + i { + margin-bottom: 0.8rem; + font-size: 2.6rem; + columns: var(--secondary-text-color); + } + p { + margin: 0; + @include text-sm; + text-align: center; + color: var(--secondary-text-color); + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss new file mode 100644 index 000000000..f53369009 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/cart/store-cart.scss @@ -0,0 +1,271 @@ +.store-cart { + width: 100%; + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + display: grid; + gap: 3.2rem; + align-items: flex-start; + + &-list { + display: grid; + gap: 1.6rem; + + &-item { + padding: 0.8rem; + display: grid; + gap: 1.6rem; + grid-template-columns: min-content 1fr; + align-items: center; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + + .picture { + grid-area: 1 / 1 / 2 / 2; + width: 10rem !important; + @include imageRatio(76%); + border-radius: var(--border-radius); + } + .ref { + grid-area: 1 / 2 / 2 / 3; + display: flex; + flex-direction: column; + span { + @include text-sm; + color: var(--gray-hard-lightest); + text-transform: uppercase; + } + p { + max-width: 60ch; + margin: 0; + @include text-base(600); + } + .min,.error p { + margin-top: 0.8rem; + @include text-sm; + color: var(--alert); + text-transform: none; + } + .error .refresh-btn { + @extend .fab-button, .is-black; + height: auto; + margin-top: 0.4rem; + padding: 0.4rem 0.8rem; + @include text-sm; + } + } + .actions { + grid-area: 2 / 1 / 3 / 3; + align-self: stretch; + padding: 0.8rem; + display: grid; + grid-template-columns: min-content min-content; + justify-content: space-evenly; + justify-items: flex-end; + align-items: center; + gap: 2.4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + } + .offer { + grid-area: 3 / 1 / 4 / 3; + justify-self: flex-end; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + label { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0; + @include text-base; + cursor: pointer; + span { margin-right: 0.8rem; } + } + } + .quantity { + padding: 0.8rem 1.6rem 0.8rem 1.2rem; + display: grid; + grid-auto-flow: column; + gap: 0 0.8rem; + background-color: var(--gray-soft); + border-radius: var(--border-radius-sm); + input[type="number"] { + grid-area: 1 / 1 / 3 / 2; + min-width: 4ch; + background-color: transparent; + border: none; + text-align: right; + @include text-base(400); + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; + } + input[type=number]::-webkit-inner-spin-button, + input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + } + button { + padding: 0; + display: flex; + background-color: transparent; + border: none; + } + } + .price, + .total { + min-width: 10rem; + p { + margin: 0; + display: flex; + @include title-base; + } + span { @include text-sm; } + } + .count { + padding: 0.8rem 1.6rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--gray-soft); + border-radius: var(--border-radius-sm); + } + .total { + span { + @include text-sm; + color: var(--main); + text-transform: uppercase; + } + } + &.error { + border-color: var(--alert); + } + } + } + .group { + display: grid; + grid-template-columns: 1fr; + gap: 2.4rem; + } + &-info, + &-coupon { + padding: 2.4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + h3, label { + margin: 0 0 1.6rem; + @include text-base(500); + color: var(--gray-hard-darkest) !important; + } + .fab-input .input-wrapper { + width: 100%; + .fab-input--input { + border-radius: var(--border-radius); + } + } + } + &-info { + p { @include text-sm; } + } + + aside { + & > div { + margin-bottom: 3.2rem; + padding: 1.6rem; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + h3, + .member-select-title { + margin: 0 0 2.4rem; + padding-bottom: 1.2rem; + border-bottom: 1px solid var(--gray-hard); + @include title-base; + color: var(--gray-hard-dark) !important; + } + } + .checkout { + .list { + margin: 0.8rem 0 2.4rem; + padding: 2.4rem 0; + border-top: 1px solid var(--main); + border-bottom: 1px solid var(--main); + p { + display: flex; + justify-content: space-between; + align-items: center; + span { @include title-base; } + } + .gift { color: var(--gray-hard-dark); } + } + .total { + display: flex; + justify-content: space-between; + align-items: flex-start; + @include text-base(600); + span { @include title-lg; } + } + + &-btn { + width: 100%; + height: auto; + padding: 1.6rem 0.8rem; + background-color: var(--main); + border: none; + color: var(--gray-soft-lightest); + justify-content: center; + text-transform: uppercase; + &:hover { + color: var(--gray-soft-lightest); + opacity: 0.75; + cursor: pointer; + } + } + } + } + + @media (min-width: 640px) { + .actions { + grid-auto-flow: column; + grid-template-columns: 1fr min-content 1fr min-content; + justify-content: stretch; + justify-items: flex-end; + align-items: center; + } + } + + @media (min-width: 1024px) { + &-list-item { + .ref { grid-area: 1 / 2 / 2 / 3; } + .actions { grid-area: 2 / 1 / 3 / 3; } + } + .group { grid-template-columns: repeat(2, 1fr); } + } + @media (min-width: 1200px) { + &-list-item { + grid-auto-flow: row; + grid-template-columns: min-content 1fr 1fr; + justify-content: space-between; + align-items: center; + .picture { grid-area: 1 / 1 / 2 / 2; } + .ref { grid-area: 1 / 2 / 2 / 3; } + .actions { grid-area: 1 / 3 / 2 / 4; } + .offer { + grid-area: 2 / 1 / 3 / 4; + justify-self: flex-end; + } + } + } + @media (min-width: 1440px) { + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-rows: minmax(0, min-content); + + &-list { grid-area: 1 / 1 / 2 / 10; } + .group { grid-area: 2 / 1 / 3 / 10; } + aside { grid-area: 1 / 10 / 3 / 13; } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 59ac7a110..775fb4a2f 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -15,36 +15,6 @@ cursor: pointer; &::first-letter { text-transform: uppercase; } } - - .item-tooltip { - position: relative; - cursor: help; - - .trigger i { display: block; } - .content { - position: absolute; - top: 0; - right: 0; - display: none; - width: max-content; - max-width: min(75vw, 65ch); - padding: 1rem; - background-color: var(--information-lightest); - color: var(--information); - border: 1px solid var(--information); - border-radius: 8px; - font-size: 14px; - font-weight: normal; - line-height: 1.2em; - z-index: 1; - & > span { display: block; } - a { - color: var(--gray-hard); - text-decoration: underline; - } - } - &:hover .content { display: block; } - } } &.is-hidden { display: none; @@ -52,7 +22,7 @@ &.is-required &-header p::after { content: "*"; margin-left: 0.5ch; - color: var(--error); + color: var(--alert); } &-field { @@ -64,6 +34,7 @@ border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); transition: border-color ease-in-out 0.15s; + font-weight: 400; .icon, .addon { @@ -151,19 +122,19 @@ } } &.is-incorrect &-field { - border-color: var(--error); + border-color: var(--alert); .icon { - color: var(--error); - border-color: var(--error); - background-color: var(--error-lightest); + color: var(--alert); + border-color: var(--alert); + background-color: var(--alert-lightest); } } &.is-warned &-field { - border-color: var(--warning); + border-color: var(--notification); .icon { - color: var(--warning); - border-color: var(--warning); - background-color: var(--warning-lightest); + color: var(--notification); + border-color: var(--notification); + background-color: var(--notification-lightest); } } &.is-disabled &-field input, @@ -173,10 +144,21 @@ &-error { margin-top: 0.4rem; - color: var(--error); + color: var(--alert); } &-warning { margin-top: 0.4rem; - color: var(--warning); + color: var(--notification); + } + + input[type='file'] { + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + .file-placeholder { + border: none; } } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss new file mode 100644 index 000000000..20721b0c5 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -0,0 +1,28 @@ +.form-checklist { + .form-item-field { + display: flex; + flex-direction: column; + border: none; + } + + .checklist { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.6rem 3.2rem; + + .checklist-item input { + margin-right: 1em; + } + } + + .actions { + align-self: flex-end; + margin: 2.4rem 0; + display: flex; + justify-content: flex-end; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss new file mode 100644 index 000000000..773f763d8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -0,0 +1,25 @@ +.form-file-upload { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + .actions { + margin-left: auto; + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } + a { + display: flex; + } + + .image-file-input { + margin-bottom: 0; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss new file mode 100644 index 000000000..370eb1ba8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -0,0 +1,67 @@ +@mixin base { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); +} +.form-image-upload { + &--small, + &--medium, + &--large { + @include base; + } + + &.with-label { + margin-top: 2.6rem; + position: relative; + margin-bottom: 1.6rem; + } + + .image { + flex-shrink: 0; + display: flex; + object-fit: cover; + border-radius: var(--border-radius-sm); + overflow: hidden; + &--small { + width: 8rem; + height: 8rem; + } + &--medium { + width: 20rem; + height: 20rem; + } + &--large { + width: 40rem; + height: 40rem; + } + + img { + width: 100%; + object-fit: contain; + } + } + + .actions { + display: flex; + align-items: center; + & > *:not(:first-child) { + margin-left: 1rem; + } + + input[type="radio"] { margin-left: 0.5rem; } + + .image-file-input { + margin-bottom: 0; + } + .form-item-header { + position: absolute; + top: -1.5em; + left: 0; + margin-bottom: 0.8rem; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-multi-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-multi-file-upload.scss new file mode 100644 index 000000000..3da636f4d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-multi-file-upload.scss @@ -0,0 +1,11 @@ +.form-multi-file-upload { + display: flex; + flex-direction: column; + .list { + margin-bottom: 2.4rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); + gap: 2.4rem; + } + button { margin-left: auto; } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-multi-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-multi-image-upload.scss new file mode 100644 index 000000000..e4b1da5f4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-multi-image-upload.scss @@ -0,0 +1,11 @@ +.form-multi-image-upload { + display: flex; + flex-direction: column; + .list { + margin-bottom: 2.4rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); + gap: 2.4rem; + } + button { margin-left: auto; } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-switch.scss b/app/frontend/src/stylesheets/modules/form/form-switch.scss index c08e3dc34..c191c8c32 100644 --- a/app/frontend/src/stylesheets/modules/form/form-switch.scss +++ b/app/frontend/src/stylesheets/modules/form/form-switch.scss @@ -6,7 +6,7 @@ margin-left: 1.5rem; } - .item-tooltip .content { + .fab-tooltip .content { max-width: min(75vw, 30ch); } } diff --git a/app/frontend/src/stylesheets/modules/layout/header-page.scss b/app/frontend/src/stylesheets/modules/layout/header-page.scss new file mode 100644 index 000000000..ccace1adf --- /dev/null +++ b/app/frontend/src/stylesheets/modules/layout/header-page.scss @@ -0,0 +1,37 @@ +.header-page { + width: 100%; + min-height: 9rem; + display: grid; + grid-template-columns: min-content 1fr min-content; + background-color: var(--gray-soft-lightest); + border-bottom: 1px solid var(--gray-soft-dark); + + .back { + width: 9rem; + border-right: 1px solid var(--gray-soft-dark); + a { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + color: var(--gray-hard-darkest) !important; + &:hover { + cursor: pointer; + background-color: var(--secondary); + } + } + } + + .center { + padding: 3.2rem; + h1 { + margin: 0; + } + } + + .right { + min-width: 9rem; + border-left: 1px solid var(--gray-soft-dark); + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/machines/machine-card.scss b/app/frontend/src/stylesheets/modules/machines/machine-card.scss index cb26def75..42be73755 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-card.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-card.scss @@ -1,11 +1,9 @@ .machine-card { background-color: #fff; border: 1px solid #ddd; - border-radius: 6px; - margin: 0 15px 30px; - width: 30%; - min-width: 263px; + border-radius: var(--border-radius); position: relative; + overflow: hidden; &.loading::before { content: ''; @@ -37,18 +35,6 @@ 100% { transform: rotate(360deg);} } - @media screen and (max-width: 1219px) { - width: 45%; - min-width: 195px; - margin: 0 auto 30px; - } - - @media screen and (max-width: 674px) { - width: 95%; - max-width: 400px; - margin: 0 auto 30px; - } - .machine-picture { height: 250px; background-size: cover; @@ -63,18 +49,8 @@ border-top-right-radius: 5px; position: relative; - &.no-picture::before { - position: absolute; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - content: '\f03e'; - font-family: 'Font Awesome 5 Free' !important; - font-weight: 900; - font-size: 80px; - color: #ebebeb; + &.no-picture { + background-image: url('../../../../images/default-image.png'); } } diff --git a/app/frontend/src/stylesheets/modules/machines/machines-list.scss b/app/frontend/src/stylesheets/modules/machines/machines-list.scss index 35b5d9c7c..3d93c80ca 100644 --- a/app/frontend/src/stylesheets/modules/machines/machines-list.scss +++ b/app/frontend/src/stylesheets/modules/machines/machines-list.scss @@ -1,6 +1,44 @@ -.machines-list { +.machines-list { .all-machines { - display: flex; - flex-wrap: wrap; + max-width: 1600px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 3.2rem; + + .store-ad { + display: flex; + flex-direction: column; + background-color: var(--main); + border-radius: var(--border-radius); + overflow: hidden; + color: var(--main-text-color); + .content { + flex: 1; + padding: 3.2rem; + display: flex; + flex-direction: column; + h3 { + margin: 0 0 2.4rem; + @include title-lg; + color: var(--main-text-color) !important; + } + p { margin: 0; } + .sell { + margin-top: auto; + @include text-lg(500); + } + } + .cta { + margin-top: auto; + width: 100%; + height: 5.4rem; + justify-content: center; + border: none; + border-radius: 0; + background-color: var(--gray-hard-darkest); + color: var(--main-text-color); + } + } } } diff --git a/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss b/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss index 507e62268..6cb8cf6a3 100644 --- a/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss +++ b/app/frontend/src/stylesheets/modules/plan-categories/manage-plan-category.scss @@ -3,8 +3,12 @@ margin-right: 5px; .create-button { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss index bf8d86c96..04f032429 100644 --- a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss @@ -10,9 +10,13 @@ vertical-align: middle; } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-left: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss index ec368ce3d..75b1c5f3f 100644 --- a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss @@ -1,8 +1,12 @@ .user-validation-setting { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-top: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss index 4404ae008..44005e290 100644 --- a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss +++ b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss @@ -1,7 +1,11 @@ .fab-socials { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss new file mode 100644 index 000000000..3799f785f --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -0,0 +1,96 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} + +@mixin grid-col($col-count) { + width: 100%; + display: grid; + grid-template-columns: repeat($col-count, minmax(0, 1fr)); +} + +.back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + color: var(--gray-soft-lightest); + background-color: var(--gray-hard-lightest); + cursor: pointer; + } +} + +.main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { + background-color: var(--main); + color: var(--gray-soft-lightest); + opacity: 0.75; + } +} + +@mixin header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } +} + +// Custom scrollbar +.u-scrollbar { + &::-webkit-scrollbar-track + { + border-radius: 6px; + background-color: #d9d9d9; + } + + &::-webkit-scrollbar + { + width: 12px; + background-color: #ffffff; + } + + &::-webkit-scrollbar-thumb + { + border-radius: 6px; + background-color: #2d2d2d; + border: 2px solid #d9d9d9 + } +} + +.u-leading-space { + display: flex; + padding-left: 2em; +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/order-actions.scss b/app/frontend/src/stylesheets/modules/store/order-actions.scss new file mode 100644 index 000000000..aed833f02 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/order-actions.scss @@ -0,0 +1,6 @@ +.order-actions-confirmation-modal { + .fab-text-editor, + span > p { + margin-top: 1rem; + } +} diff --git a/app/frontend/src/stylesheets/modules/store/order-item.scss b/app/frontend/src/stylesheets/modules/store/order-item.scss new file mode 100644 index 000000000..ca571e3f6 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/order-item.scss @@ -0,0 +1,99 @@ +.order-item { + width: 100%; + display: grid; + grid-template-rows: repeat(3, min-content); + grid-template-columns: 1fr 1fr; + gap: 1.6rem 2.4rem; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + p { margin: 0; } + .ref { + grid-area: 1 / 1 / 2 / 2; + @include text-base(600); + } + .fab-state-label { + --status-color: var(--success); + &.cart { --status-color: var(--information); } + &.paid { --status-color: var(--success); } + &.ready { --status-color: var(--success); } + &.error { --status-color: var(--alert); } + &.canceled { --status-color: var(--alert-light); } + &.pending { --status-color: var(--information); } + &.normal { --status-color: var(--success); } + grid-area: 1 / 2 / 2 / 3; + } + .client { + grid-area: 2 / 1 / 3 / 2; + display: flex; + flex-direction: column; + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-sm; } + } + .date { + grid-area: 2 / 2 / 3 / 3; + & > span { + @include text-xs; + color: var(--gray-hard-light); + } + p { + display: flex; + @include text-sm; + .fab-tooltip { + margin-left: 1rem; + color: var(--information); + } + } + } + .price { + grid-area: 3 / 1 / 4 / 2; + display: flex; + flex-direction: column; + justify-self: flex-end; + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-base(600); } + } + button { + grid-area: 3 / 2 / 4 / 3; + justify-self: flex-end; + width: 4rem; + padding: 0; + display: flex; + justify-content: center; + } + + @media (min-width: 640px) { + grid-template-rows: repeat(2, min-content); + grid-template-columns: 2fr 1fr 10ch; + .ref { grid-area: 1 / 1 / 2 / 2; } + .fab-state-label { grid-area: 1 / 2 / 2 / 3; } + .client { grid-area: 2 / 1 / 3 / 2; } + .date { grid-area: 2 / 2 / 3 / 3; } + .price { grid-area: 1 / 3 / 3 / 4; } + button { grid-area: 1 / 4 / 3 / 5; } + } + + @media (min-width: 1440px) { + grid-auto-flow: column; + grid-template-rows: auto; + grid-template-columns: 1fr minmax(max-content, 1fr) 2fr 12ch 12ch; + gap: 2.4rem; + + .ref, + .fab-state-label, + .client, + .date, + .price, + button { grid-area: auto; } + .fab-state-label { justify-self: center; } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss b/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss new file mode 100644 index 000000000..231ca7390 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/orders-dashboard.scss @@ -0,0 +1,17 @@ +.orders-dashboard { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + header { + @include header(); + padding-bottom: 0; + grid-column: 2 / -2; + } + .store-list { + grid-column: 2 / -2; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/orders.scss b/app/frontend/src/stylesheets/modules/store/orders.scss new file mode 100644 index 000000000..f95edc7c3 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/orders.scss @@ -0,0 +1,149 @@ +.orders, +.show-order { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + + header { + @include header(); + padding-bottom: 0; + grid-column: 1 / -1; + } + + &-list { + padding-bottom: 6rem; + & > *:not(:first-child) { + margin-top: 1.6rem; + } + } +} + +.orders { + display: grid; + grid-template-rows: min-content 8rem 1fr; + align-items: flex-start; + + & > header { margin-bottom: 2.4rem; } + + .store-filters { + grid-area: 2 / 1 / 4 / 2; + background-color: var(--gray-soft-lightest); + z-index: 1; + } + .store-list { grid-area: 3 / 1 / 4 / 2; } + + @media (min-width: 1200px) { + @include grid-col(12); + gap: 2.4rem 3.2rem; + align-items: flex-start; + + & > header { margin-bottom: 0; } + + .store-filters { + position: static; + grid-area: 2 / 1 / 3 / 4; + } + .store-list { grid-area: 2 / 4 / 3 / -1; } + } +} + +.show-order { + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + &-nav { + max-width: 1600px; + margin: 0 auto; + @include grid-col(12); + gap: 3.2rem; + justify-items: flex-start; + & > * { + grid-column: 2 / -2; + } + } + header { grid-column: 2 / -2; } + .client-info, + .cart { + grid-column: 2 / -2; + label { + margin-bottom: 1.6rem; + @include title-base; + } + .content { + display: flex; + align-items: center; + & > *:not(:last-child) { + margin-right: 2.4rem; + padding-right: 2.4rem; + border-right: 1px solid var(--gray-hard-dark); + } + } + p { + margin: 0; + line-height: 1.18; + } + .group { + display: flex; + flex-direction: column; + span { + @include text-xs; + color: var(--gray-hard-light); + } + } + .actions { + grid-template-columns: auto; + grid-auto-flow: column; + } + } + + .subgrid { + grid-column: 2 / -2; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2.4rem; + align-items: flex-start; + + .payment-info, + .amount, + .withdrawal-instructions { + padding: 2.4rem; + border: 1px solid var(--gray-soft); + border-radius: var(--border-radius); + label { + margin: 0 0 2.4rem; + padding: 0 0 0.8rem; + width: 100%; + border-bottom: 1px solid var(--gray-hard); + @include title-base; + } + } + .amount { + p { + display: flex; + justify-content: space-between; + align-items: center; + span { @include title-base; } + } + .gift { color: var(--gray-hard-dark); } + .total { + padding: 1.6rem 0 0; + align-items: flex-start; + border-top: 1px solid var(--main); + @include text-base(600); + span { @include title-lg; } + } + } + } + + .fab-state-label { + --status-color: var(--success); + &.cart { --status-color: var(--secondary-dark); } + &.paid { --status-color: var(--success-light); } + &.ready { --status-color: var(--success); } + &.error { --status-color: var(--alert); } + &.canceled { --status-color: var(--alert-light); } + &.pending { --status-color: var(--information); } + &.normal { --status-color: var(--success); } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss new file mode 100644 index 000000000..698e17c8e --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -0,0 +1,103 @@ +.product-categories { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 0 3.2rem; + + header { + @include header(); + grid-column: 2 / -2; + } + .fab-alert { + grid-column: 2 / -2; + } + + &-tree { + grid-column: 2 / -2; + & > *:not(:first-child) { + margin-top: 1.6rem; + } + } + &-item { + display: flex; + pointer-events: all; + + &.is-collapsed { + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; + pointer-events: none; + } + .offset { + width: 4.8rem; + } + + .wrap { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &.is-child { margin-left: 4.8rem; } + .itemInfo { + display: flex; + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--gray-hard-dark); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn { background: var(--gray-hard-darkest); } + .delete-btn { background: var(--main); } + } + } + } + + .collapse-handle { + width: 4rem; + margin: 0 0 0 -1rem; + button { + @include btn; + background: none; + border-radius: 0; + transition: transform 250ms ease-in-out; + &.rotate { + transform: rotateZ(-180deg); + } + } + } + .drag-handle button { + @include btn; + cursor: grab; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-category-form.scss b/app/frontend/src/stylesheets/modules/store/product-category-form.scss new file mode 100644 index 000000000..661152e00 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-category-form.scss @@ -0,0 +1,5 @@ +.product-category-form { + span { + margin-bottom: 2.4rem; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss new file mode 100644 index 000000000..ecf9df56f --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -0,0 +1,68 @@ +.product-form { + grid-column: 2 / -2; + + .tabs { + display: flex; + justify-content: space-between; + p { + flex: 1; + margin-bottom: 4rem; + padding: 0.8rem; + text-align: center; + color: var(--main); + border-bottom: 1px solid var(--gray-soft-dark); + &.is-active { + 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; } + } + } + + h4 { + margin: 0 0 2.4rem; + @include title-base; + } + hr { + margin: 4.8rem 0; + } + + .subgrid { + @include grid-col(10); + gap: 3.2rem; + align-items: flex-end; + } + .span-3 { grid-column: span 3; } + .span-7 { grid-column: span 7; } + + & > div { + grid-column: 2 / -2; + } + + .flex { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .header-switch { + display: flex; + flex-direction: row; + gap: 3.2rem; + justify-content: space-between; + align-items: center; + label { flex: 0 1 fit-content; } + } + .price-data-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 3.2rem; + align-items: flex-end; + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss new file mode 100644 index 000000000..e13f05245 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss @@ -0,0 +1,99 @@ +.product-stock-form { + h4 span { @include text-sm; } + + .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 { + h4 { margin: 0; } + } + .store-list-header { + & > *:not(:first-child) { + &::before { + content: ""; + margin: 0 2rem; + width: 1px; + height: 2rem; + background-color: var(--gray-hard-darkest); + } + } + .sort-events { + margin-left: auto; + display: flex; + align-items: center; + } + .sort-stocks { + display: flex; + align-items: center; + } + } + + .threshold-data-content { + margin-top: 1.6rem; + padding: 1.6rem; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 3.2rem; + border: 1px solid var(--gray-soft); + border-radius: var(--border-radius); + label { flex: 0 1 fit-content; } + + } + .fab-state-label { + --status-color: var(--alert-light); + } + + .stock-item { + width: 100%; + display: flex; + gap: 4.8rem; + justify-items: flex-start; + align-items: center; + padding: 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + & > * { flex: 1 1 45%; } + button { flex: 0;} + + 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); } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss b/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss new file mode 100644 index 000000000..a61ad683e --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-stock-modal.scss @@ -0,0 +1,30 @@ +.product-stock-modal { + .movement { + 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/store/products-grid.scss b/app/frontend/src/stylesheets/modules/store/products-grid.scss new file mode 100644 index 000000000..30992490e --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-grid.scss @@ -0,0 +1,68 @@ +.products-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 3.2rem; + + .store-product-item { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + + padding: 1.6rem 2.4rem; + display: grid; + grid-template-areas: "image image" + "name name" + "min min" + "price btn" + "stock btn"; + grid-template-columns: auto min-content; + grid-template-rows: repeat(2, min-content) auto repeat(2, min-content); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + cursor: pointer; + + .picture { + grid-area: image; + @include imageRatio(50%); + border-radius: var(--border-radius); + img { object-fit: contain; } + } + .name { + margin: 1.6rem 0 0.8rem; + grid-area: name; + align-self: flex-start; + @include text-base(600); + } + .min { + grid-area: min; + @include text-sm; + color: var(--alert); + } + .price { + grid-area: price; + display: flex; + align-items: baseline; + p { + margin: 0; + @include title-base; + } + span { + margin-left: 0.8rem; + @include text-sm; + word-break: break-all; + } + } + .fab-state-label { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + grid-area: stock; + } + button { + grid-area: btn; + align-self: flex-end; + margin-left: 1rem; + i { margin-right: 0.8rem; } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/products-list.scss b/app/frontend/src/stylesheets/modules/store/products-list.scss new file mode 100644 index 000000000..4a4411bd1 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products-list.scss @@ -0,0 +1,97 @@ +.products-list { + & > *:not(:first-child) { + margin-top: 1.6rem; + } + .product-item { + --status-color: var(--gray-hard-darkest); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { + --status-color: var(--alert); + .stock { color: var(--alert) !important; } + } + + width: 100%; + display: grid; + justify-content: space-between; + grid-template-columns: 1fr min-content; + gap: 1.6rem; + align-items: center; + padding: 1.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + .itemInfo { + grid-area: 1 / 1 / 2 / 2; + display: flex; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: contain; + border-radius: var(--border-radius); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + .details { + grid-area: 2 / 1 / 3 / 3; + display: grid; + grid-template-columns: repeat(4, minmax(min-content, 12ch)); + justify-items: center; + align-items: center; + gap: 1.6rem; + p { + margin: 0; + @include text-base(600); + } + + .fab-state-label.is-active { + --status-color: var(--success); + } + .stock { + display: flex; + flex-direction: column; + color: var(--gray-hard-darkest); + span { @include text-xs; } + &.low { color: var(--alert-light); } + &.out-of-stock { color: var(--alert); } + } + .price { + justify-self: flex-end; + } + } + .actions { + grid-area: 1 / 2 / 2 / 3; + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--main) } + } + } + + @media (min-width: 1024px) { + grid-template-columns: 1fr auto min-content; + .itemInfo, + .details, + .actions { grid-area: auto; } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss new file mode 100644 index 000000000..f9a081626 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -0,0 +1,63 @@ +.products, +.new-product, +.edit-product { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + + header { + @include header(); + padding-bottom: 0; + grid-column: 1 / -1; + } +} + +.products { + display: grid; + grid-template-rows: min-content 8rem 1fr; + align-items: flex-start; + + & > header { margin-bottom: 2.4rem; } + + .store-filters { + grid-area: 2 / 1 / 4 / 2; + background-color: var(--gray-soft-lightest); + z-index: 1; + } + .store-list { grid-area: 3 / 1 / 4 / 2; } + + @media (min-width: 1200px) { + @include grid-col(12); + grid-template-rows: min-content 1fr; + gap: 2.4rem 3.2rem; + align-items: flex-start; + + & > header { margin-bottom: 0; } + + .store-filters { + position: static; + grid-area: 2 / 1 / 3 / 4; + } + .store-list { grid-area: 2 / 4 / 3 / -1; } + } +} + +.new-product, +.edit-product { + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + + &-nav { + max-width: 1600px; + margin: 0 auto; + @include grid-col(12); + gap: 3.2rem; + justify-items: flex-start; + & > * { + grid-column: 2 / -2; + } + } + + header { grid-column: 2 / -2; } +} diff --git a/app/frontend/src/stylesheets/modules/store/store-filters.scss b/app/frontend/src/stylesheets/modules/store/store-filters.scss new file mode 100644 index 000000000..94d68ce07 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-filters.scss @@ -0,0 +1,197 @@ +.store-filters { + margin: 0 -30px; + padding: 0 30px; + box-shadow: 0 10px 10px 0 rgb(39 32 32 / 12%); + .grp { + max-height: 0; + overflow: hidden; + transition: all 500ms ease-in-out; + } + + &.collapsed { + header .grpBtn svg { transform: rotateZ(-180deg); } + header .grpBtn button { display: flex; } + .grp { + max-height: 100vh; + padding-bottom: 2.4rem; + } + } + + header { + @include header(); + margin: 0; + padding: 0 0 2.4rem 0; + + .grpBtn { + display: flex; + align-items: center; + button { display: none; } + svg { + cursor: pointer; + transition: transform 250ms ease-in-out; + } + } + } + + .categories { + margin-bottom: 1.6rem; + h3 { @include text-base(600); } + .list { + max-height: 30vh; + overflow: auto; + } + p { + display: flex; + align-items: baseline; + cursor: pointer; + span { + margin-left: 0.8rem; + @include text-xs; + color: var(--gray-hard-dark); + } + } + .parent { + & > p { + margin-bottom: 2.4rem; + @include text-base(500); + color: var(--gray-hard); + } + &.is-active > p { + @include text-base(600); + color: var(--primary); + .children { + max-height: 1000px; + } + } + &.is-active .children { + max-height: 1000px; + margin: -0.8rem 0 1.6rem; + transition: max-height 500ms ease-in-out; + } + } + .children { + max-height: 0; + overflow: hidden; + p { + margin-bottom: 1.6rem; + @include text-base(400); + color: var(--gray-hard-light); + &.is-active { + background-color: var(--gray-soft-light); + } + } + } + } + + .filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + } + + .accordion { + &-item:not(:last-of-type) { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--gray-soft-darkest); + } + &-item { + position: relative; + padding-bottom: 1.6rem; + &.collapsed { + header svg { transform: rotateZ(180deg); } + .content { + max-height: 0; + overflow: hidden; + * { opacity: 0; } + } + } + + header { + width: 100%; + padding: 0; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + @include text-base(600); + cursor: pointer; + svg { transition: transform 250ms ease-in-out; } + } + .content { + max-height: 24rem; + padding-top: 1.6rem; + display: flex; + flex-direction: column; + align-items: stretch; + transition: max-height 500ms ease-in-out; + * { transition: opacity 250ms ease-in-out 300ms; } + + .group { + display: flex; + flex-direction: column; + opacity: 1; + &.u-scrollbar { + overflow: hidden auto; + label { + margin: 0 0.8rem 0 0; + padding: 0.6rem; + &:hover { background-color: var(--gray-soft-light); } + } + } + + & > label { + display: flex; + align-items: center; + cursor: pointer; + input[type=checkbox] { margin: 0 0.8rem 0 0; } + p { + margin: 0; + @include text-base; + } + &.offset { margin-left: 1.6rem; } + } + .form-item-field { width: 100%; } + } + + input[type="text"] { + width: 100%; + width: 100%; + min-height: 4rem; + padding: 0 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius-sm); + @include text-base; + } + + button { + opacity: 100; + margin-top: 0.8rem; + justify-content: center; + } + } + } + } + + .range { + display: flex; + justify-content: center; + align-items: center; + gap: 1.6rem; + label { + margin: 0; + flex-direction: column; + } + } + + @media (min-width: 1200px) { + margin: 0; + padding: 0 0 2.4rem; + box-shadow: none; + .filters-toggle { display: none; } + .grp { max-height: 100vh; } + header .grpBtn button { display: flex; } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/store-list-header.scss b/app/frontend/src/stylesheets/modules/store/store-list-header.scss new file mode 100644 index 000000000..a4bcee06c --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-list-header.scss @@ -0,0 +1,64 @@ +.store-list-header { + padding: 0.8rem 2.4rem; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + + .count { + margin-right: 2.4rem; + display: flex; + align-items: center; + p { + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + + .display { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + + @media (min-width: 1024px) { + width: auto; + & > *:not(:first-child) { + &::before { + content: ""; + margin: 0 2rem; + width: 1px; + height: 2rem; + background-color: var(--gray-hard-darkest); + } + } + } + + .sort { + display: flex; + align-items: center; + p { margin-right: 0.8rem; } + } + + .visibility { + display: flex; + align-items: center; + label { + margin: 0; + display: flex; + align-items: center; + font-weight: 400; + cursor: pointer; + span { + margin-right: 1rem; + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/store-list.scss b/app/frontend/src/stylesheets/modules/store/store-list.scss new file mode 100644 index 000000000..c4177d458 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-list.scss @@ -0,0 +1,31 @@ +.store-list { + display: grid; + grid-template-columns: 1fr; + gap: 2.4rem 0; + + .features { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--secondary-light); + border-radius: 100px; + color: var(--gray-hard-dark); + overflow: hidden; + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + margin-left: 0.8rem; + display: flex; + align-items: center; + background: none; + border: none; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/store-settings.scss b/app/frontend/src/stylesheets/modules/store/store-settings.scss new file mode 100644 index 000000000..a6a8e31ba --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store-settings.scss @@ -0,0 +1,37 @@ +.store-settings { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + @include grid-col(12); + gap: 3.2rem; + align-items: flex-start; + header { + @include header(); + padding-bottom: 0; + grid-column: 2 / -2; + } + form { + grid-column: 2 / -2; + @include grid-col(2); + gap: 2.4rem 3.2rem; + + .setting-section { grid-column: 1 / -1; } + @media (min-width: 1024px) { + .setting-section { grid-column: span 1; } + } + + .section-title { @include title-base; } + .save-btn { + grid-column: 1 / -1; + justify-self: flex-start; + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { + background-color: var(--main); + color: var(--gray-soft-lightest); + opacity: 0.75; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/store/store.scss b/app/frontend/src/stylesheets/modules/store/store.scss new file mode 100644 index 000000000..7db3049ab --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/store.scss @@ -0,0 +1,262 @@ +.store { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; + display: grid; + grid-template-rows: min-content 8rem 1fr; + align-items: flex-start; + + .breadcrumbs { + display: none; + padding: 0.8rem 1.6rem; + list-style: none; + border-radius: var(--border-radius-sm); + background-color: var(--gray-soft-light); + li:not(:last-of-type)::after { + margin: 0 2.4rem; + content: "\f054"; + font-family: 'Font Awesome 5 Free'; + font-size: 1.2rem; + font-weight: 900; + color: var(--gray-hard-darkest); + } + li:last-of-type:not(:first-of-type) span { + color: var(--gray-hard-dark); + } + span { + color: var(--gray-hard-light); + cursor: pointer; + } + + } + + &-filters { + grid-area: 2 / 1 / 4 / 2; + background-color: var(--gray-soft-lightest); + z-index: 1; + } + &-list { grid-area: 3 / 1 / 4 / 2; } + + @media (min-width: 768px) { + .breadcrumbs { display: flex; } + } + + @media (min-width: 1200px) { + @include grid-col(12); + grid-template-rows: min-content 1fr; + gap: 2.4rem 3.2rem; + align-items: flex-start; + .breadcrumbs { grid-column: 1 / -1; } + &-filters { + position: static; + grid-area: 2 / 1 / 3 / 4; + } + &-list { grid-area: 2 / 4 / 3 / -1; } + } +} + +.store-product { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + + max-width: 1600px; + margin: 0 auto; + padding: 4rem 0 6rem; + display: grid; + grid-template-columns: 1fr; + justify-items: flex-start; + align-items: flex-start; + gap: 0 3.2rem; + + .ref { + grid-row: 1 / 2; + @include text-sm; + color: var(--gray-hard-lightest); + text-transform: uppercase; + } + .name { + grid-row: 2 / 3; + margin: 0.8rem 0 3.2rem; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + .gallery { + grid-row: 3 / 4; + width: 100%; + max-width: 50rem; + .picture{ + @include imageRatio; + border-radius: var(--border-radius-sm); + border: 1px solid var(--gray-soft-darkest); + img { + object-fit: contain; + cursor: pointer; + } + } + .thumbnails { + margin-top: 1.6rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.8rem; + .is-active { + border-color: transparent; + box-shadow: 0 0 0 2px var(--gray-hard-darkest); + } + } + } + .description { + grid-row: 5 / 6; + &-text { + padding-bottom: 4rem; + overflow: hidden; + @include editor; + transition: max-height 0.5s ease-in-out; + h3 { + @include text-base(600); + } + p { + @include text-sm; + color: var(--gray-hard-lightest); + } + } + &-toggle { + position: relative; + width: 100%; + height: 6rem; + display: flex; + justify-content: center; + align-items: flex-end; + background: linear-gradient(0deg, white 0%, transparent 100%); + border: none; + transform: translateY(-4rem); + &::before { + position: absolute; + bottom: 1.2rem; + left: 0; + content: ''; + width: 100%; + height: 1px; + background-color: var(--gray-hard-dark); + z-index: -1; + } + span { + padding: 0 1.6rem; + color: var(--gray-hard-dark); + background-color: var(--gray-soft-lightest); + } + } + &-document { + padding: 2.4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius-sm); + p { @include text-sm(500); } + .list { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1.6rem; + a { + display: flex; + align-items: center; + svg { margin-right: 0.8rem; } + } + } + } + } + aside { + justify-self: stretch; + margin: 2.4rem 0; + grid-row: 4 / 5; + top: 4rem; + padding: 4rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius-sm); + + .fab-state-label { + --status-color: var(--success); + &.low { --status-color: var(--alert-light); } + &.out-of-stock { --status-color: var(--alert); } + } + + .price { + p { + margin: 0; + display: flex; + @include title-xl; + sup { + margin: 0.8rem 0 0 0.8rem; + @include title-sm; + } + } + span { + @include text-sm; + } + } + .to-cart { + margin-top: 1.6rem; + padding-top: 3.2rem; + display: grid; + grid-template-areas: "min min min" + "minus input plus" + "btn btn btn"; + grid-template-columns: min-content 1fr min-content; + justify-content: center; + gap: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + .min { + grid-area: min; + display: flex; + justify-content: center; + @include text-sm; + color: var(--alert); + } + .minus { + grid-area: minus; + color: var(--gray-hard-darkest); + } + .plus { + grid-area: plus; + color: var(--gray-hard-darkest); + } + input { + grid-area: input; + text-align: center; + } + .main-action-btn { + grid-area: btn; + justify-content: center; + } + } + } + + @media (min-width: 1024px) { + .ref { grid-area: 1 / 1 / 2 / 3; } + .name { grid-area: 2 / 1 / 3 / 3; } + .gallery { grid-area: 3 / 1 / 4 / 2; } + .description { + margin-top: 2.4rem; + grid-area: 4 / 1 / 5 / 3; + } + aside { + margin: 0; + grid-area: 3 / 2 / 4 / 3; + } + } + + @media (min-width: 1200px) { + @include grid-col(12); + grid-template-rows: repeat(2, min-content) 1fr; + align-items: flex-start; + .ref { grid-area: 1 / 1 / 2 / 9; } + .name { grid-area: 2 / 1 / 3 / 9; } + .gallery { grid-area: 3 / 1 / 4 / 4; } + .description { grid-area: 3 / 4 / 4 / 9; } + aside { + grid-area: 1 / 9 / 4 / -1; + position: sticky; + } + } + @media (min-width: 1600px) { + aside { grid-area: 1 / 10 / 4 / -1; } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss index 3572dd43d..bae4cfa58 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-files.scss @@ -12,7 +12,7 @@ .file-item { &.has-error { - color: var(--error); + color: var(--alert); } label { @@ -113,11 +113,15 @@ } } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); + float: right; margin-bottom: 15px; margin-top: 15px; - float: right; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss index a886005e0..e762d10d1 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-validation.scss @@ -16,7 +16,7 @@ } .missing-file { - color: var(--error); + color: var(--alert); } } diff --git a/app/frontend/src/stylesheets/modules/user/avatar-input.scss b/app/frontend/src/stylesheets/modules/user/avatar-input.scss index 47242dae1..66be087b2 100644 --- a/app/frontend/src/stylesheets/modules/user/avatar-input.scss +++ b/app/frontend/src/stylesheets/modules/user/avatar-input.scss @@ -23,7 +23,7 @@ } } .delete-avatar { - background-color: var(--error); + background-color: var(--alert); color: white; } } diff --git a/app/frontend/src/stylesheets/modules/user/member-select.scss b/app/frontend/src/stylesheets/modules/user/member-select.scss new file mode 100644 index 000000000..469198a39 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/user/member-select.scss @@ -0,0 +1,26 @@ +.member-select { + &.error { + .select-input > div:first-of-type { + border-color: var(--alert); + transform: perspective(1px) translateZ(0); + box-shadow: 0 0 1px rgba(0, 0, 0, 0); + animation-name: buzz-out; + animation-duration: 0.75s; + animation-timing-function: linear; + animation-iteration-count: 1; + } + } +} + +@keyframes buzz-out { + 10% { transform: translateX(3px) rotate(2deg); } + 20% { transform: translateX(-3px) rotate(-2deg); } + 30% { transform: translateX(3px) rotate(2deg); } + 40% { transform: translateX(-3px) rotate(-2deg); } + 50% { transform: translateX(2px) rotate(1deg); } + 60% { transform: translateX(-2px) rotate(-1deg); } + 70% { transform: translateX(2px) rotate(1deg); } + 80% { transform: translateX(-2px) rotate(-1deg); } + 90% { transform: translateX(1px) rotate(0); } + 100% { transform: translateX(-1px) rotate(0); } +} diff --git a/app/frontend/src/stylesheets/variables/colors.scss b/app/frontend/src/stylesheets/variables/colors.scss index 50ba2ad5f..fff231c5c 100644 --- a/app/frontend/src/stylesheets/variables/colors.scss +++ b/app/frontend/src/stylesheets/variables/colors.scss @@ -28,11 +28,11 @@ --success-dark: #229051; --success-darkest: #155239; - --error-lightest: #FDF1F1; - --error-light: #EA8585; - --error: #DA3030; - --error-dark: #9F1D1D; - --error-darkest: #611818; + --alert-lightest: #FDF1F1; + --alert-light: #EA8585; + --alert: #DA3030; + --alert-dark: #9F1D1D; + --alert-darkest: #611818; --information-lightest: #EFF6FF; --information-light: #93C5FD; @@ -40,11 +40,11 @@ --information-dark: #1E3A8A; --information-darkest: #122354; - --warning-lightest: #FFFCF4; - --warning-light: #FAE29F; - --warning: #D6AE47; - --warning-dark: #8C6D1F; - --warning-darkest: #5C4813; + --notification-lightest: #FFFCF4; + --notification-light: #FAE29F; + --notification: #D6AE47; + --notification-dark: #8C6D1F; + --notification-darkest: #5C4813; --main-text-color: black; --secondary-text-color: black; diff --git a/app/frontend/src/stylesheets/variables/decoration.scss b/app/frontend/src/stylesheets/variables/decoration.scss index 29a009f81..e1b2f447b 100644 --- a/app/frontend/src/stylesheets/variables/decoration.scss +++ b/app/frontend/src/stylesheets/variables/decoration.scss @@ -1,4 +1,20 @@ :root { --border-radius: 8px; + --border-radius-sm: 4px; --shadow: 0 0 10px rgba(39, 32, 32, 0.25); +} + +@mixin imageRatio($ratio: 100%) { + position: relative; + width: 100%; + height: 0; + padding-bottom: $ratio; + overflow: hidden; + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } } \ No newline at end of file diff --git a/app/frontend/src/stylesheets/variables/typography.scss b/app/frontend/src/stylesheets/variables/typography.scss index 156b8144b..49eea5746 100644 --- a/app/frontend/src/stylesheets/variables/typography.scss +++ b/app/frontend/src/stylesheets/variables/typography.scss @@ -56,6 +56,12 @@ font-size: 1.4rem; line-height: normal; } +@mixin text-xs($weight: normal) { + font-family: var(--font-text); + font-weight: $weight; + font-size: 1.1rem; + line-height: 1.18; +} // Text Editor @mixin editor { @@ -65,7 +71,7 @@ h3 { @include text-lg(600); margin: 0 0 1rem; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } ul { padding-inline-start: 2.2rem; diff --git a/app/frontend/templates/admin/calendar/icalendar.html b/app/frontend/templates/admin/calendar/icalendar.html index ab91191d3..edd60ecfe 100644 --- a/app/frontend/templates/admin/calendar/icalendar.html +++ b/app/frontend/templates/admin/calendar/icalendar.html @@ -38,7 +38,7 @@ - {{calendar.name}} plop + {{calendar.name}} {{calendar.url}} {{ calendar.text_hidden ? '' : 'app.admin.icalendar.example' }} diff --git a/app/frontend/templates/admin/groups/index.html b/app/frontend/templates/admin/groups/index.html index 6cb85780e..2bb4fa8aa 100644 --- a/app/frontend/templates/admin/groups/index.html +++ b/app/frontend/templates/admin/groups/index.html @@ -37,7 +37,7 @@ -
+
diff --git a/app/frontend/templates/admin/invoices/codes.html b/app/frontend/templates/admin/invoices/codes.html index debcc75c5..c974997f2 100644 --- a/app/frontend/templates/admin/invoices/codes.html +++ b/app/frontend/templates/admin/invoices/codes.html @@ -116,6 +116,16 @@
+
+
+ + +
+
+ + +
+
diff --git a/app/frontend/templates/admin/invoices/settings/editMultiVAT.html b/app/frontend/templates/admin/invoices/settings/editMultiVAT.html index d14f26bd8..a22690209 100644 --- a/app/frontend/templates/admin/invoices/settings/editMultiVAT.html +++ b/app/frontend/templates/admin/invoices/settings/editMultiVAT.html @@ -49,6 +49,14 @@
+
+ +
+ % + + +
+
- +
diff --git a/app/frontend/templates/admin/settings/general.html b/app/frontend/templates/admin/settings/general.html index ffe2be512..36b6971a8 100644 --- a/app/frontend/templates/admin/settings/general.html +++ b/app/frontend/templates/admin/settings/general.html @@ -452,6 +452,15 @@ on-error="onError" class-name="'m-l'">
+
+

{{ 'app.admin.settings.store' }}

+

+ +

{{ 'app.admin.settings.invoicing' }}

diff --git a/app/frontend/templates/admin/settings/privacy.html b/app/frontend/templates/admin/settings/privacy.html index 9513115ce..da577abcc 100644 --- a/app/frontend/templates/admin/settings/privacy.html +++ b/app/frontend/templates/admin/settings/privacy.html @@ -67,7 +67,7 @@ + placeholder="G-XXXXXX-X">
diff --git a/app/frontend/templates/admin/store/categories.html b/app/frontend/templates/admin/store/categories.html new file mode 100644 index 000000000..636dbdc36 --- /dev/null +++ b/app/frontend/templates/admin/store/categories.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html new file mode 100644 index 000000000..ecf45b336 --- /dev/null +++ b/app/frontend/templates/admin/store/index.html @@ -0,0 +1,44 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ + + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ +
+
diff --git a/app/frontend/templates/admin/store/orders.html b/app/frontend/templates/admin/store/orders.html new file mode 100644 index 000000000..fed347a30 --- /dev/null +++ b/app/frontend/templates/admin/store/orders.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html new file mode 100644 index 000000000..c2e7f5834 --- /dev/null +++ b/app/frontend/templates/admin/store/product_edit.html @@ -0,0 +1,19 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+ + +
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html new file mode 100644 index 000000000..24904f5a0 --- /dev/null +++ b/app/frontend/templates/admin/store/product_new.html @@ -0,0 +1,19 @@ +
+
+ +
+ +
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+ + +
diff --git a/app/frontend/templates/admin/store/products.html b/app/frontend/templates/admin/store/products.html new file mode 100644 index 000000000..63b089744 --- /dev/null +++ b/app/frontend/templates/admin/store/products.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/store/settings.html b/app/frontend/templates/admin/store/settings.html new file mode 100644 index 000000000..35dfdb99f --- /dev/null +++ b/app/frontend/templates/admin/store/settings.html @@ -0,0 +1 @@ + diff --git a/app/frontend/templates/admin/trainings/index.html b/app/frontend/templates/admin/trainings/index.html index 48cae7e1e..a809c83eb 100644 --- a/app/frontend/templates/admin/trainings/index.html +++ b/app/frontend/templates/admin/trainings/index.html @@ -5,13 +5,13 @@
-
+

{{ 'app.admin.trainings.trainings_monitoring' }}

-
+
{{ 'app.admin.trainings.plan_session' }} diff --git a/app/frontend/templates/cart/index.html b/app/frontend/templates/cart/index.html new file mode 100644 index 000000000..ff038fd9d --- /dev/null +++ b/app/frontend/templates/cart/index.html @@ -0,0 +1,13 @@ +
+
+ +
+ +
+

{{ 'app.public.cart.my_cart' }}

+
+
+ +
+ +
diff --git a/app/frontend/templates/dashboard/nav.html b/app/frontend/templates/dashboard/nav.html index 9f40297d0..9dedf71ae 100644 --- a/app/frontend/templates/dashboard/nav.html +++ b/app/frontend/templates/dashboard/nav.html @@ -19,6 +19,7 @@
  • {{ 'app.public.common.my_events' }}
  • {{ 'app.public.common.my_invoices' }}
  • {{ 'app.public.common.my_payment_schedules' }}
  • +
  • {{ 'app.public.common.my_orders' }}
  • {{ 'app.public.common.my_wallet' }}
  • diff --git a/app/frontend/templates/dashboard/orders.html b/app/frontend/templates/dashboard/orders.html new file mode 100644 index 000000000..c4137b54a --- /dev/null +++ b/app/frontend/templates/dashboard/orders.html @@ -0,0 +1,9 @@ +
    +
    +
    + +
    +
    + + +
    diff --git a/app/frontend/templates/machines/edit.html b/app/frontend/templates/machines/edit.html index 0ded43ba9..cbc1ca1fe 100644 --- a/app/frontend/templates/machines/edit.html +++ b/app/frontend/templates/machines/edit.html @@ -24,6 +24,11 @@
    + + + + +
    diff --git a/app/frontend/templates/orders/show.html b/app/frontend/templates/orders/show.html new file mode 100644 index 000000000..8c985d2b5 --- /dev/null +++ b/app/frontend/templates/orders/show.html @@ -0,0 +1,17 @@ + diff --git a/app/frontend/templates/products/show.html b/app/frontend/templates/products/show.html new file mode 100644 index 000000000..54f73efbe --- /dev/null +++ b/app/frontend/templates/products/show.html @@ -0,0 +1,17 @@ +
    +
    + +
    + +
    +

    {{ 'app.public.store.fablab_store' }}

    +
    + +
    + +
    +
    + +
    + +
    diff --git a/app/frontend/templates/shared/_admin_form.html b/app/frontend/templates/shared/_admin_form.html index 458a79a50..808db2c5c 100644 --- a/app/frontend/templates/shared/_admin_form.html +++ b/app/frontend/templates/shared/_admin_form.html @@ -119,6 +119,19 @@ ng-required="phoneRequired">
    + +
    +
    + + +
    +
    diff --git a/app/frontend/templates/shared/header.html.erb b/app/frontend/templates/shared/header.html.erb index d43802728..8f7d37845 100644 --- a/app/frontend/templates/shared/header.html.erb +++ b/app/frontend/templates/shared/header.html.erb @@ -48,6 +48,7 @@
  • {{ 'app.public.common.my_events' }}
  • {{ 'app.public.common.my_invoices' }}
  • {{ 'app.public.common.my_payment_schedules' }}
  • +
  • {{ 'app.public.common.my_orders' }}
  • {{ 'app.public.common.my_wallet' }}
  • {{ 'app.public.common.help' }}
  • diff --git a/app/frontend/templates/shared/leftnav.html b/app/frontend/templates/shared/leftnav.html index 27b4f80d8..799a5bf80 100644 --- a/app/frontend/templates/shared/leftnav.html +++ b/app/frontend/templates/shared/leftnav.html @@ -67,7 +67,7 @@ {{ linkName }} -
  • +
  • {{navLink.linkText | translate}} diff --git a/app/frontend/templates/store/index.html b/app/frontend/templates/store/index.html new file mode 100644 index 000000000..fb2ce5966 --- /dev/null +++ b/app/frontend/templates/store/index.html @@ -0,0 +1,17 @@ +
    +
    + +
    + +
    +

    {{ 'app.public.store.fablab_store' }}

    +
    + +
    + +
    +
    + +
    + +
    diff --git a/app/models/accounting_period.rb b/app/models/accounting_period.rb index 0926bd8de..39746142f 100644 --- a/app/models/accounting_period.rb +++ b/app/models/accounting_period.rb @@ -19,7 +19,7 @@ class AccountingPeriod < ApplicationRecord validates_with PeriodOverlapValidator validates_with PeriodIntegrityValidator - belongs_to :user, class_name: 'User', foreign_key: 'closed_by' + belongs_to :user, class_name: 'User', foreign_key: 'closed_by', inverse_of: :accounting_periods def delete false diff --git a/app/models/cart_item/machine_reservation.rb b/app/models/cart_item/machine_reservation.rb index ef692fe13..e456b37bd 100644 --- a/app/models/cart_item/machine_reservation.rb +++ b/app/models/cart_item/machine_reservation.rb @@ -47,6 +47,6 @@ class CartItem::MachineReservation < CartItem::Reservation return 0 if @plan.nil? machine_credit = @plan.machine_credits.find { |credit| credit.creditable_id == @reservable.id } - credits_hours(machine_credit, @new_subscription) + credits_hours(machine_credit, new_plan_being_bought: @new_subscription) end end diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 5bb11af3d..2c86364ee 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -23,7 +23,7 @@ class CartItem::Reservation < CartItem::BaseItem amount = 0 hours_available = credits - grouped_slots.values.each do |slots| + grouped_slots.each_value do |slots| prices = applicable_prices(slots) slots.each_with_index do |slot, index| amount += get_slot_price_from_prices(prices, slot, is_privileged, @@ -100,7 +100,7 @@ class CartItem::Reservation < CartItem::BaseItem slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? } price = prices[:prices].first if price.nil? - hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR + hourly_rate = ((Rational(price[:price].amount.to_f) / Rational(price[:price].duration)) * Rational(MINUTES_PER_HOUR)).to_f # apply the base price to the real slot duration real_price = get_slot_price(hourly_rate, slot, is_privileged, options) @@ -130,7 +130,7 @@ class CartItem::Reservation < CartItem::BaseItem slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE # apply the base price to the real slot duration real_price = if options[:is_division] - (slot_rate / MINUTES_PER_HOUR) * slot_minutes + ((Rational(slot_rate) / Rational(MINUTES_PER_HOUR)) * Rational(slot_minutes)).to_f else slot_rate end @@ -138,7 +138,7 @@ class CartItem::Reservation < CartItem::BaseItem if real_price.positive? && options[:prepaid][:minutes]&.positive? consumed = slot_minutes consumed = options[:prepaid][:minutes] if slot_minutes > options[:prepaid][:minutes] - real_price = (slot_minutes - consumed) * (slot_rate / MINUTES_PER_HOUR) + real_price = (Rational(slot_minutes - consumed) * (Rational(slot_rate) / Rational(MINUTES_PER_HOUR))).to_f options[:prepaid][:minutes] -= consumed end @@ -158,7 +158,9 @@ class CartItem::Reservation < CartItem::BaseItem # and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12). # All these prices are returned to be applied to the reservation. def applicable_prices(slots) - total_duration = slots.map { |slot| (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+) + total_duration = slots.map do |slot| + (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE + end.reduce(:+) rates = { prices: [] } remaining_duration = total_duration @@ -182,7 +184,7 @@ class CartItem::Reservation < CartItem::BaseItem ## # Compute the number of remaining hours in the users current credits (for machine or space) ## - def credits_hours(credits, new_plan_being_bought = false) + def credits_hours(credits, new_plan_being_bought: false) return 0 unless credits hours_available = credits.hours diff --git a/app/models/cart_item/space_reservation.rb b/app/models/cart_item/space_reservation.rb index 36a0c36cd..e8463b8da 100644 --- a/app/models/cart_item/space_reservation.rb +++ b/app/models/cart_item/space_reservation.rb @@ -41,6 +41,6 @@ class CartItem::SpaceReservation < CartItem::Reservation return 0 if @plan.nil? space_credit = @plan.space_credits.find { |credit| credit.creditable_id == @reservable.id } - credits_hours(space_credit, @new_subscription) + credits_hours(space_credit, new_plan_being_bought: @new_subscription) end end diff --git a/app/models/concerns/single_sign_on_concern.rb b/app/models/concerns/single_sign_on_concern.rb index fb5bffadc..d4642b7cb 100644 --- a/app/models/concerns/single_sign_on_concern.rb +++ b/app/models/concerns/single_sign_on_concern.rb @@ -13,27 +13,8 @@ module SingleSignOnConcern ## Retrieve the requested data in the User and user's Profile tables ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' def get_data_from_sso_mapping(sso_mapping) - parsed = /^(user|profile)\.(.+)$/.match(sso_mapping) - if parsed[1] == 'user' - self[parsed[2].to_sym] - elsif parsed[1] == 'profile' - case sso_mapping - when 'profile.avatar' - profile.user_avatar.remote_attachment_url - when 'profile.address' - invoicing_profile.address&.address - when 'profile.organization_name' - invoicing_profile.organization&.name - when 'profile.organization_address' - invoicing_profile.organization&.address&.address - when 'profile.gender' - statistic_profile.gender - when 'profile.birthday' - statistic_profile.birthday - else - profile[parsed[2].to_sym] - end - end + service = UserSetterService.new(self) + service.read_attribute(sso_mapping) end ## Set some data on the current user, according to the sso_key given @@ -42,36 +23,8 @@ module SingleSignOnConcern def set_data_from_sso_mapping(sso_mapping, data) return if data.nil? || data.blank? - if sso_mapping.to_s.start_with? 'user.' - self[sso_mapping[5..-1].to_sym] = data - elsif sso_mapping.to_s.start_with? 'profile.' - case sso_mapping.to_s - when 'profile.avatar' - profile.user_avatar ||= UserAvatar.new - profile.user_avatar.remote_attachment_url = data - when 'profile.address' - self.invoicing_profile ||= InvoicingProfile.new - self.invoicing_profile.address ||= Address.new - self.invoicing_profile.address.address = data - when 'profile.organization_name' - self.invoicing_profile ||= InvoicingProfile.new - self.invoicing_profile.organization ||= Organization.new - self.invoicing_profile.organization.name = data - when 'profile.organization_address' - self.invoicing_profile ||= InvoicingProfile.new - self.invoicing_profile.organization ||= Organization.new - self.invoicing_profile.organization.address ||= Address.new - self.invoicing_profile.organization.address.address = data - when 'profile.gender' - self.statistic_profile ||= StatisticProfile.new - self.statistic_profile.gender = data - when 'profile.birthday' - self.statistic_profile ||= StatisticProfile.new - self.statistic_profile.birthday = data - else - profile[sso_mapping[8..-1].to_sym] = data - end - end + service = UserSetterService.new(self) + service.assign_attibute(sso_mapping, data) return if mapped_from_sso&.include?(sso_mapping) @@ -80,7 +33,7 @@ module SingleSignOnConcern ## used to allow the migration of existing users between authentication providers def generate_auth_migration_token - update_attributes(auth_token: Devise.friendly_token) + update(auth_token: Devise.friendly_token) end ## link the current user to the given provider (omniauth attributes hash) @@ -93,7 +46,7 @@ module SingleSignOnConcern raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user" end - update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil) + update(provider: auth.provider, uid: auth.uid, auth_token: nil) end ## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity @@ -118,13 +71,16 @@ module SingleSignOnConcern # update the user's profile to set the data managed by the SSO auth_provider = AuthProvider.from_strategy_name(sso_user.provider) - logger.debug "found auth_provider=#{auth_provider.name}" - auth_provider.sso_fields.each do |field| + logger.debug "found auth_provider=#{auth_provider&.name}" + auth_provider&.sso_fields&.each do |field| value = sso_user.get_data_from_sso_mapping(field) logger.debug "mapping sso field #{field} with value=#{value}" - # we do not merge the email field if its end with the special value '-duplicate' as this means - # that the user is currently merging with the account that have the same email than the sso - set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && sso_user.admin?) + # We do not merge the email field if its end with the special value '-duplicate' as this means + # that the user is currently merging with the account that have the same email than the sso. + # Moreover, if the user is an administrator, we must keep him in his group + unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && admin?) + set_data_from_sso_mapping(field, value) + end end # run the account transfer in an SQL transaction to ensure data integrity diff --git a/app/models/coupon.rb b/app/models/coupon.rb index ec7ef910d..25e563565 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -4,6 +4,7 @@ class Coupon < ApplicationRecord has_many :invoices has_many :payment_schedule + has_many :orders after_create :create_gateway_coupon before_destroy :delete_gateway_coupon @@ -83,7 +84,7 @@ class Coupon < ApplicationRecord def users # compact to user removed - invoices.map(&:user).compact + invoices.map(&:user).compact.uniq(&:id) end def users_ids diff --git a/app/models/group.rb b/app/models/group.rb index cd54e6312..278434ed4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -2,17 +2,15 @@ # Group is way to bind users with prices. Different prices can be defined for each plan/reservable, for each group class Group < ApplicationRecord - has_many :plans - has_many :users - has_many :statistic_profiles + has_many :plans, dependent: :destroy + has_many :users, dependent: :nullify + has_many :statistic_profiles, dependent: :nullify has_many :trainings_pricings, dependent: :destroy - has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy - has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy + has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy, inverse_of: :group + has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy, inverse_of: :group has_many :proof_of_identity_types_groups, dependent: :destroy has_many :proof_of_identity_types, through: :proof_of_identity_types_groups - scope :all_except_admins, -> { where.not(slug: 'admins') } - extend FriendlyId friendly_id :name, use: :slugged @@ -41,26 +39,26 @@ class Group < ApplicationRecord end def create_trainings_pricings - Training.all.each do |training| + Training.find_each do |training| TrainingsPricing.create(group: self, training: training, amount: 0) end end def create_machines_prices - Machine.all.each do |machine| + Machine.find_each do |machine| Price.create(priceable: machine, group: self, amount: 0) end end def create_spaces_prices - Space.all.each do |space| + Space.find_each do |space| Price.create(priceable: space, group: self, amount: 0) end end def create_statistic_subtype user_index = StatisticIndex.find_by(es_type_key: 'user') - StatisticSubType.create!( statistic_types: user_index.statistic_types, key: slug, label: name) + StatisticSubType.create!(statistic_types: user_index.statistic_types, key: slug, label: name) end def update_statistic_subtype @@ -74,7 +72,7 @@ class Group < ApplicationRecord def disable_plans plans.each do |plan| - plan.update_attributes(disabled: disabled) + plan.update(disabled: disabled) end end end diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 450a6b823..888a5ec26 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -13,15 +13,17 @@ class Invoice < PaymentDocument belongs_to :wallet_transaction belongs_to :coupon - has_one :avoir, class_name: 'Invoice', foreign_key: :invoice_id, dependent: :destroy - has_one :payment_schedule_item - has_one :payment_gateway_object, as: :item - belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile' + has_one :avoir, class_name: 'Invoice', dependent: :destroy, inverse_of: :avoir + has_one :payment_schedule_item, dependent: :nullify + has_one :payment_gateway_object, as: :item, dependent: :destroy + belongs_to :operator_profile, class_name: 'InvoicingProfile' + + delegate :user, to: :invoicing_profile before_create :add_environment after_create :update_reference, :chain_record - after_commit :generate_and_send_invoice, on: [:create], if: :persisted? after_update :log_changes + after_commit :generate_and_send_invoice, on: [:create], if: :persisted? validates_with ClosedPeriodValidator @@ -44,10 +46,6 @@ class Invoice < PaymentDocument "#{prefix}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf" end - def user - invoicing_profile.user - end - def order_number PaymentDocumentService.generate_order_number(self) end @@ -75,7 +73,7 @@ class Invoice < PaymentDocument invoice_items.each do |ii| paid_items += 1 unless ii.amount.zero? next unless attrs[:invoice_items_ids].include? ii.id # list of items to refund (partial refunds) - raise Exception if ii.invoice_item # cannot refund an item that was already refunded + raise StandardError if ii.invoice_item # cannot refund an item that was already refunded refund_items += 1 unless ii.amount.zero? avoir_ii = avoir.invoice_items.build(ii.dup.attributes) @@ -84,17 +82,7 @@ class Invoice < PaymentDocument avoir.total += avoir_ii.amount end # handle coupon - unless avoir.coupon_id.nil? - discount = avoir.total - if avoir.coupon.type == 'percent_off' - discount = avoir.total * avoir.coupon.percent_off / 100.0 - elsif avoir.coupon.type == 'amount_off' - discount = (avoir.coupon.amount_off / paid_items) * refund_items - else - raise InvalidCouponError - end - avoir.total -= discount - end + avoir.total = CouponService.apply_on_refund(avoir.total, avoir.coupon, paid_items, refund_items) avoir end @@ -145,6 +133,10 @@ class Invoice < PaymentDocument invoice_items.where(main: true).first end + def other_items + invoice_items.where(main: [nil, false]) + end + # get amount total paid def amount_paid total - (wallet_amount || 0) diff --git a/app/models/invoice_item.rb b/app/models/invoice_item.rb index 24db35a24..526f0e3be 100644 --- a/app/models/invoice_item.rb +++ b/app/models/invoice_item.rb @@ -4,8 +4,8 @@ class InvoiceItem < Footprintable belongs_to :invoice - has_one :invoice_item # associates invoice_items of an invoice to invoice_items of an Avoir - has_one :payment_gateway_object, as: :item + has_one :invoice_item, dependent: :destroy # associates invoice_items of an invoice to invoice_items of an Avoir + has_one :payment_gateway_object, as: :item, dependent: :destroy belongs_to :object, polymorphic: true @@ -15,7 +15,8 @@ class InvoiceItem < Footprintable def amount_after_coupon # deduct coupon discount coupon_service = CouponService.new - coupon_service.ventilate(invoice.total, amount, invoice.coupon) + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + coupon_service.ventilate(total_without_coupon, amount, invoice.coupon) end # return the item amount, coupon discount deducted, if any, and VAT excluded, if applicable @@ -23,7 +24,7 @@ class InvoiceItem < Footprintable # deduct VAT vat_service = VatHistoryService.new vat_rate = vat_service.invoice_item_vat(self) - Rational(amount_after_coupon / (vat_rate / 100.00 + 1)).round.to_f + Rational(amount_after_coupon / ((vat_rate / 100.00) + 1)).round.to_f end # return the VAT amount for this item @@ -31,7 +32,7 @@ class InvoiceItem < Footprintable amount_after_coupon - net_amount end - # return invoice item type (Machine/Training/Space/Event/Subscription) used to determine the VAT rate + # return invoice item type (Machine/Training/Space/Event/Subscription/OrderItem) used to determine the VAT rate def invoice_item_type if object_type == Reservation.name object.try(:reservable_type) || '' @@ -39,6 +40,8 @@ class InvoiceItem < Footprintable Subscription.name elsif object_type == StatisticProfilePrepaidPack.name object.prepaid_pack.priceable_type + elsif object_type == OrderItem.name + Product.name else '' end diff --git a/app/models/machine.rb b/app/models/machine.rb index e7d0eadde..661f37256 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -10,12 +10,14 @@ class Machine < ApplicationRecord has_many :machine_files, as: :viewable, dependent: :destroy accepts_nested_attributes_for :machine_files, allow_destroy: true, reject_if: :all_blank - has_and_belongs_to_many :projects, join_table: 'projects_machines' + has_many :projects_machines, dependent: :destroy + has_many :projects, through: :projects_machines has_many :machines_availabilities, dependent: :destroy has_many :availabilities, through: :machines_availabilities - has_and_belongs_to_many :trainings, join_table: 'trainings_machines' + has_many :trainings_machines, dependent: :destroy + has_many :trainings, through: :trainings_machines validates :name, presence: true, length: { maximum: 50 } validates :description, presence: true @@ -27,8 +29,10 @@ class Machine < ApplicationRecord has_many :credits, as: :creditable, dependent: :destroy has_many :plans, through: :credits - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :destroy + has_many :machines_products, dependent: :destroy + has_many :products, through: :machines_products after_create :create_statistic_subtype after_create :create_machine_prices @@ -65,11 +69,11 @@ class Machine < ApplicationRecord end def create_machine_prices - Group.all.each do |group| + Group.find_each do |group| Price.create(priceable: self, group: group, amount: 0) end - Plan.all.includes(:group).each do |plan| + Plan.includes(:group).find_each do |plan| Price.create(group: plan.group, plan: plan, priceable: self, amount: 0) end end diff --git a/app/models/machines_product.rb b/app/models/machines_product.rb new file mode 100644 index 000000000..893144834 --- /dev/null +++ b/app/models/machines_product.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# MachinesAvailability is the relation table between a Machine and a Product. +class MachinesProduct < ApplicationRecord + belongs_to :machine + belongs_to :product +end diff --git a/app/models/notification_type.rb b/app/models/notification_type.rb index 834482649..f20314c5f 100644 --- a/app/models/notification_type.rb +++ b/app/models/notification_type.rb @@ -69,6 +69,10 @@ class NotificationType notify_user_is_invalidated notify_user_proof_of_identity_refusal notify_admin_user_proof_of_identity_refusal + notify_user_order_is_ready + notify_user_order_is_canceled + notify_user_order_is_refunded + notify_admin_low_stock_threshold ] # deprecated: # - notify_member_subscribed_plan_is_changed diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 000000000..713e53f42 --- /dev/null +++ b/app/models/order.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Order is a model for the user hold information of order +class Order < PaymentDocument + belongs_to :statistic_profile + belongs_to :operator_profile, class_name: 'InvoicingProfile' + belongs_to :coupon + belongs_to :invoice + has_many :order_items, dependent: :destroy + has_one :payment_gateway_object, as: :item, dependent: :destroy + has_many :order_activities, dependent: :destroy + + ALL_STATES = %w[cart paid payment_failed refunded in_progress ready canceled delivered].freeze + enum state: ALL_STATES.zip(ALL_STATES).to_h + + validates :token, :state, presence: true + + before_create :add_environment + after_create :update_reference + + delegate :user, to: :statistic_profile + + def generate_reference(_date = DateTime.current) + self.reference = PaymentDocumentService.generate_order_number(self) + end + + def footprint_children + order_items + end + + def paid_by_card? + !payment_gateway_object.nil? && payment_method == 'card' + end + + def self.columns_out_of_footprint + %w[payment_method] + end +end diff --git a/app/models/order_activity.rb b/app/models/order_activity.rb new file mode 100644 index 000000000..1e2fcd513 --- /dev/null +++ b/app/models/order_activity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# OrderActivity is a model for hold activity of order +class OrderActivity < ApplicationRecord + belongs_to :order + + TYPES = %w[paid payment_failed refunded in_progress ready canceled delivered note].freeze + enum activity_type: TYPES.zip(TYPES).to_h + + validates :activity_type, presence: true +end diff --git a/app/models/order_item.rb b/app/models/order_item.rb new file mode 100644 index 000000000..f6e76ec5c --- /dev/null +++ b/app/models/order_item.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# A single line inside an Order. Can be an article of Order +class OrderItem < ApplicationRecord + belongs_to :order + belongs_to :orderable, polymorphic: true + + validates :orderable, :order_id, :amount, presence: true +end diff --git a/app/models/payment_gateway_object.rb b/app/models/payment_gateway_object.rb index c5b976ac1..fa3ef7f58 100644 --- a/app/models/payment_gateway_object.rb +++ b/app/models/payment_gateway_object.rb @@ -15,6 +15,7 @@ class PaymentGatewayObject < ApplicationRecord belongs_to :machine, foreign_type: 'Machine', foreign_key: 'item_id' belongs_to :space, foreign_type: 'Space', foreign_key: 'item_id' belongs_to :training, foreign_type: 'Training', foreign_key: 'item_id' + belongs_to :order, foreign_type: 'Order', foreign_key: 'item_id' belongs_to :payment_gateway_object # some objects may require a reference to another object for remote recovery diff --git a/app/models/payment_schedule.rb b/app/models/payment_schedule.rb index 11646e8ff..c6b0c7587 100644 --- a/app/models/payment_schedule.rb +++ b/app/models/payment_schedule.rb @@ -9,7 +9,7 @@ class PaymentSchedule < PaymentDocument belongs_to :coupon belongs_to :invoicing_profile belongs_to :statistic_profile - belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile' + belongs_to :operator_profile, class_name: 'InvoicingProfile' has_many :payment_schedule_items has_many :payment_gateway_objects, as: :item @@ -61,9 +61,7 @@ class PaymentSchedule < PaymentDocument payment_schedule_objects.find_by(main: true) end - def user - invoicing_profile.user - end + delegate :user, to: :invoicing_profile # for debug & used by rake task "fablab:maintenance:regenerate_schedules" def regenerate_pdf diff --git a/app/models/payment_schedule_item.rb b/app/models/payment_schedule_item.rb index cf6d716d0..cbf1bc256 100644 --- a/app/models/payment_schedule_item.rb +++ b/app/models/payment_schedule_item.rb @@ -4,7 +4,7 @@ class PaymentScheduleItem < Footprintable belongs_to :payment_schedule belongs_to :invoice - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :destroy after_create :chain_record diff --git a/app/models/plan.rb b/app/models/plan.rb index 5a0150dd0..8e1c69d65 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -7,13 +7,13 @@ class Plan < ApplicationRecord belongs_to :plan_category has_many :credits, dependent: :destroy - has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit' - has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit' - has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit' - has_many :subscriptions + has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan + has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan + has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan + has_many :subscriptions, dependent: :nullify has_one :plan_file, as: :viewable, dependent: :destroy has_many :prices, dependent: :destroy - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :destroy extend FriendlyId friendly_id :base_name, use: :slugged @@ -37,7 +37,7 @@ class Plan < ApplicationRecord def self.create_for_all_groups(plan_params) plans = [] - Group.all_except_admins.each do |group| + Group.find_each do |group| plan = if plan_params[:type] == 'PartnerPlan' PartnerPlan.new(plan_params.except(:group_id, :type)) else @@ -59,14 +59,14 @@ class Plan < ApplicationRecord end def create_machines_prices - Machine.all.each do |machine| + Machine.all.find_each do |machine| default_price = Price.find_by(priceable: machine, plan: nil, group_id: group_id)&.amount || 0 Price.create(priceable: machine, plan: self, group_id: group_id, amount: default_price) end end def create_spaces_prices - Space.all.each do |space| + Space.all.find_each do |space| default_price = Price.find_by(priceable: space, plan: nil, group_id: group_id)&.amount || 0 Price.create(priceable: space, plan: self, group_id: group_id, amount: default_price) end @@ -123,12 +123,12 @@ class Plan < ApplicationRecord StatisticTypeSubType.create!(statistic_type: stat_type, statistic_sub_type: stat_subtype) else Rails.logger.error 'Unable to create the statistics association for the new plan. ' \ - 'Possible causes: the type or the subtype were not created successfully.' + 'Possible causes: the type or the subtype were not created successfully.' end end def set_name - update_columns(name: human_readable_name) + update_columns(name: human_readable_name) # rubocop:disable Rails/SkipsModelValidations end def update_gateway_product diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 000000000..d90c02f0e --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Product is a model for the merchandise. +# It holds data of products in the store +class Product < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + + belongs_to :product_category + + has_many :machines_products, dependent: :delete_all + has_many :machines, through: :machines_products + + has_many :product_files, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank + + has_many :product_images, as: :viewable, dependent: :destroy + accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: ->(i) { i[:attachment].blank? } + + has_many :product_stock_movements, dependent: :destroy + accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + + validates :name, :slug, presence: true + validates :slug, uniqueness: { message: I18n.t('.errors.messages.slug_already_used') } + validates :amount, numericality: { greater_than_or_equal_to: 0, allow_nil: true } + validates :amount, exclusion: { in: [nil], message: I18n.t('.errors.messages.undefined_in_store') }, if: -> { is_active } + + scope :active, -> { where(is_active: true) } + + def main_image + product_images.find_by(is_main: true) + end +end diff --git a/app/models/product_category.rb b/app/models/product_category.rb new file mode 100644 index 000000000..57474e5ed --- /dev/null +++ b/app/models/product_category.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Category is a first-level filter, used to categorize Products. +# It is mandatory to choose a Category when creating a Product. +class ProductCategory < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + + validates :name, :slug, presence: true + validates :slug, uniqueness: true + + belongs_to :parent, class_name: 'ProductCategory' + has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify + + has_many :products, dependent: :nullify + + acts_as_list scope: :parent, top_of_list: 1 +end diff --git a/app/models/product_file.rb b/app/models/product_file.rb new file mode 100644 index 000000000..fd23a9b3f --- /dev/null +++ b/app/models/product_file.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductFile is a file stored on the file system, associated with a Product. +class ProductFile < Asset + mount_uploader :attachment, ProductFileUploader +end diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 000000000..9a5da4a85 --- /dev/null +++ b/app/models/product_image.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# ProductImage is an image stored on the file system, associated with a Product. +class ProductImage < Asset + mount_uploader :attachment, ProductImageUploader +end diff --git a/app/models/product_stock_movement.rb b/app/models/product_stock_movement.rb new file mode 100644 index 000000000..40089dc19 --- /dev/null +++ b/app/models/product_stock_movement.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# A ProductStockMovement records a movement of a product's stock. +# Eg. 10 units of item X are added to the stock +class ProductStockMovement < ApplicationRecord + belongs_to :product + + ALL_STOCK_TYPES = %w[internal external].freeze + enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h + + INCOMING_REASONS = %w[inward_stock returned cancelled inventory_fix other_in].freeze + OUTGOING_REASONS = %w[sold missing damaged other_out].freeze + ALL_REASONS = [].concat(INCOMING_REASONS).concat(OUTGOING_REASONS).freeze + enum reason: ALL_REASONS.zip(ALL_REASONS).to_h + + validates :stock_type, presence: true + validates :stock_type, inclusion: { in: ALL_STOCK_TYPES } + + validates :reason, presence: true + validates :reason, inclusion: { in: ALL_REASONS } +end diff --git a/app/models/projects_machine.rb b/app/models/projects_machine.rb new file mode 100644 index 000000000..5211d9644 --- /dev/null +++ b/app/models/projects_machine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# ProjectsMachine is the relation table between a Machine and a Project. +class ProjectsMachine < ApplicationRecord + belongs_to :machine + belongs_to :project +end diff --git a/app/models/reservation.rb b/app/models/reservation.rb index 04aeba681..0bc743eed 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -15,22 +15,23 @@ class Reservation < ApplicationRecord accepts_nested_attributes_for :slots_reservations, allow_destroy: true belongs_to :reservable, polymorphic: true - has_many :tickets + has_many :tickets, dependent: :destroy accepts_nested_attributes_for :tickets, allow_destroy: false has_many :invoice_items, as: :object, dependent: :destroy has_one :payment_schedule_object, as: :object, dependent: :destroy - validates_presence_of :reservable_id, :reservable_type + 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) } validate :slots_not_locked + after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' } after_commit :notify_member_create_reservation, on: :create after_commit :notify_admin_member_create_reservation, on: :create after_commit :extend_subscription, on: :create - after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' } + delegate :user, to: :statistic_profile # @param canceled if true, count the number of seats for this reservation, including canceled seats def total_booked_seats(canceled: false) @@ -50,10 +51,6 @@ class Reservation < ApplicationRecord total end - def user - statistic_profile.user - end - def update_event_nb_free_places return unless reservable_type == 'Event' @@ -83,11 +80,11 @@ class Reservation < ApplicationRecord daily_slots[1..].each do |slot| found = false result[date].each do |group_start, group_slots| - if slot[:start_at] === group_slots.last[:end_at] - result[date][group_start].push(slot) - found = true - break - end + next unless slot[:start_at] == group_slots.last[:end_at] + + result[date][group_start].push(slot) + found = true + break end result[date][slot[:start_at]] = [slot] unless found end diff --git a/app/models/setting.rb b/app/models/setting.rb index a8bf421bf..29e6ab7c4 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true # Setting is a configuration element of the platform. Only administrators are allowed to modify Settings -# For some settings, changing them will involve some callback actions (like rebuilding the stylesheets if the theme color Setting is changed). +# For some settings, changing them will involve some callback actions (like rebuilding the stylesheets +# if the theme color Setting has changed). # A full history of the previous values is kept in database with the date and the author of the change # after_update callback is handled by SettingService class Setting < ApplicationRecord - has_many :history_values + has_many :history_values, dependent: :destroy # The following list contains all the settings that can be customized from the Fab-manager's UI. # A few of them that are system settings, that should not be updated manually (uuid, origin...). validates :name, inclusion: @@ -33,6 +34,7 @@ class Setting < ApplicationRecord invoice_VAT-rate_Space invoice_VAT-rate_Event invoice_VAT-rate_Subscription + invoice_VAT-rate_Product invoice_text invoice_legals booking_window_start @@ -74,6 +76,8 @@ class Setting < ApplicationRecord accounting_Event_label accounting_Space_code accounting_Space_label + accounting_Product_code + accounting_Product_label hub_last_version hub_public_key fab_analytics @@ -148,7 +152,10 @@ class Setting < ApplicationRecord user_change_group user_validation_required user_validation_required_list - show_username_in_admin_list] } + show_username_in_admin_list + store_module + store_withdrawal_instructions + store_hidden] } # WARNING: when adding a new key, you may also want to add it in: # - config/locales/en.yml#settings # - app/frontend/src/javascript/models/setting.ts#SettingName @@ -185,6 +192,7 @@ class Setting < ApplicationRecord previous_value&.created_at end + # @deprecated, prefer Setting.set() instead def value=(val) admin = User.admins.first save && history_values.create(invoicing_profile: admin.invoicing_profile, value: val) @@ -196,7 +204,7 @@ class Setting < ApplicationRecord # @return {String|Boolean} ## def self.get(name) - res = find_by(name: name)&.value + res = find_by('LOWER(name) = ? ', name.downcase)&.value # handle boolean values return true if res == 'true' @@ -220,6 +228,6 @@ class Setting < ApplicationRecord # Check if the given setting was set ## def self.set?(name) - find_by(name: name)&.value.nil? ? false : true + !find_by(name: name)&.value.nil? end end diff --git a/app/models/stats/account.rb b/app/models/stats/account.rb index 85f6f5671..5292e501f 100644 --- a/app/models/stats/account.rb +++ b/app/models/stats/account.rb @@ -1,6 +1,7 @@ -module Stats - class Account - include Elasticsearch::Persistence::Model - include StatConcern - end +# frozen_string_literal: true + +# This is a statistical data saved in ElasticSearch, about an account creation +class Stats::Account + include Elasticsearch::Persistence::Model + include StatConcern end diff --git a/app/models/stats/event.rb b/app/models/stats/event.rb index 9ad73a6a7..9de4440b2 100644 --- a/app/models/stats/event.rb +++ b/app/models/stats/event.rb @@ -1,12 +1,13 @@ -module Stats - class Event - include Elasticsearch::Persistence::Model - include StatConcern - include StatReservationConcern +# frozen_string_literal: true - attribute :eventId, Integer - attribute :eventDate, String - attribute :ageRange, String - attribute :eventTheme, String - end +# This is a statistical data saved in ElasticSearch, about an event reservation +class Stats::Event + include Elasticsearch::Persistence::Model + include StatConcern + include StatReservationConcern + + attribute :eventId, Integer + attribute :eventDate, String + attribute :ageRange, String + attribute :eventTheme, String end diff --git a/app/models/stats/order.rb b/app/models/stats/order.rb new file mode 100644 index 000000000..9e41e1af7 --- /dev/null +++ b/app/models/stats/order.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# This is a statistical data saved in ElasticSearch, about a store's order +class Stats::Order + include Elasticsearch::Persistence::Model + include StatConcern + + attribute :orderId, Integer + attribute :state, String + attribute :products, Array + attribute :categories, Array + attribute :ca, Float +end diff --git a/app/models/stats/project.rb b/app/models/stats/project.rb index d1ecf9fad..d97668a96 100644 --- a/app/models/stats/project.rb +++ b/app/models/stats/project.rb @@ -1,14 +1,15 @@ -module Stats - class Project - include Elasticsearch::Persistence::Model - include StatConcern +# frozen_string_literal: true - attribute :projectId, Integer - attribute :name, String - attribute :licence, Hash - attribute :themes, Array - attribute :components, Array - attribute :machines, Array - attribute :users, Integer - end +# This is a statistical data saved in ElasticSearch, about a project publication +class Stats::Project + include Elasticsearch::Persistence::Model + include StatConcern + + attribute :projectId, Integer + attribute :name, String + attribute :licence, Hash + attribute :themes, Array + attribute :components, Array + attribute :machines, Array + attribute :users, Integer end diff --git a/app/models/stats/space.rb b/app/models/stats/space.rb index fda08fa18..62b1e6aa7 100644 --- a/app/models/stats/space.rb +++ b/app/models/stats/space.rb @@ -1,9 +1,10 @@ -module Stats - class Space - include Elasticsearch::Persistence::Model - include StatConcern - include StatReservationConcern +# frozen_string_literal: true - attribute :spaceId, Integer - end +# This is a statistical data saved in ElasticSearch, about a space reservation +class Stats::Space + include Elasticsearch::Persistence::Model + include StatConcern + include StatReservationConcern + + attribute :spaceId, Integer end diff --git a/app/models/stats/subscription.rb b/app/models/stats/subscription.rb index 7fef5fd2f..2d60429c3 100644 --- a/app/models/stats/subscription.rb +++ b/app/models/stats/subscription.rb @@ -1,12 +1,13 @@ -module Stats - class Subscription - include Elasticsearch::Persistence::Model - include StatConcern +# frozen_string_literal: true - attribute :ca, Float - attribute :planId, Integer - attribute :subscriptionId, Integer - attribute :invoiceItemId, Integer - attribute :groupName, String - end +# This is a statistical data saved in ElasticSearch, about a subscription to a plan +class Stats::Subscription + include Elasticsearch::Persistence::Model + include StatConcern + + attribute :ca, Float + attribute :planId, Integer + attribute :subscriptionId, Integer + attribute :invoiceItemId, Integer + attribute :groupName, String end diff --git a/app/models/stats/training.rb b/app/models/stats/training.rb index 79854fecb..3c877e9f8 100644 --- a/app/models/stats/training.rb +++ b/app/models/stats/training.rb @@ -1,10 +1,11 @@ -module Stats - class Training - include Elasticsearch::Persistence::Model - include StatConcern - include StatReservationConcern +# frozen_string_literal: true - attribute :trainingId, Integer - attribute :trainingDate, String - end +# This is a statistical data saved in ElasticSearch, about a training reservation +class Stats::Training + include Elasticsearch::Persistence::Model + include StatConcern + include StatReservationConcern + + attribute :trainingId, Integer + attribute :trainingDate, String end diff --git a/app/models/stats/user.rb b/app/models/stats/user.rb index 0720844d0..55c9a42a8 100644 --- a/app/models/stats/user.rb +++ b/app/models/stats/user.rb @@ -1,6 +1,7 @@ -module Stats - class User - include Elasticsearch::Persistence::Model - include StatConcern - end +# frozen_string_literal: true + +# This is a statistical data saved in ElasticSearch, about revenue generated per user +class Stats::User + include Elasticsearch::Persistence::Model + include StatConcern end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 5c0e40dd1..febf5323e 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -8,11 +8,11 @@ class Subscription < ApplicationRecord belongs_to :statistic_profile has_one :payment_schedule_object, as: :object, dependent: :destroy - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :destroy has_many :invoice_items, as: :object, dependent: :destroy has_many :offer_days, dependent: :destroy - validates_presence_of :plan_id + validates :plan_id, presence: true validates_with SubscriptionGroupValidator # creation @@ -21,18 +21,20 @@ class Subscription < ApplicationRecord after_save :notify_admin_subscribed_plan after_save :notify_partner_subscribed_plan, if: :of_partner_plan? + delegate :user, to: :statistic_profile + def generate_and_save_invoice(operator_profile_id) generate_invoice(operator_profile_id).save end def expire(time) - if !expired? - update_columns(expiration_date: time, canceled_at: time) + if expired? + false + else + update_columns(expiration_date: time, canceled_at: time) # rubocop:disable Rails/SkipsModelValidations notify_admin_subscription_canceled notify_member_subscription_canceled true - else - false end end @@ -47,10 +49,6 @@ class Subscription < ApplicationRecord expiration_date end - def user - statistic_profile.user - end - def original_payment_schedule payment_schedule_object&.payment_schedule end diff --git a/app/models/trainings_machine.rb b/app/models/trainings_machine.rb new file mode 100644 index 000000000..feb4b0469 --- /dev/null +++ b/app/models/trainings_machine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# TrainingsMachine is the relation table between a Machine and a Training. +class TrainingsMachine < ApplicationRecord + belongs_to :machine + belongs_to :training +end diff --git a/app/models/user.rb b/app/models/user.rb index 94721c2e9..7a0d8ff09 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -43,9 +43,9 @@ class User < ApplicationRecord has_many :exports, dependent: :destroy has_many :imports, dependent: :nullify - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :nullify - has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify + has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify, inverse_of: :user has_many :proof_of_identity_files, dependent: :destroy has_many :proof_of_identity_refusals, dependent: :destroy @@ -56,14 +56,15 @@ class User < ApplicationRecord end before_create :assign_default_role - after_commit :create_gateway_customer, on: [:create] - after_commit :notify_admin_when_user_is_created, on: :create after_create :init_dependencies after_update :update_invoicing_profile, if: :invoicing_data_was_modified? after_update :update_statistic_profile, if: :statistic_data_was_modified? before_destroy :remove_orphan_drafts + after_commit :create_gateway_customer, on: [:create] + after_commit :notify_admin_when_user_is_created, on: :create attr_accessor :cgu + delegate :first_name, to: :profile delegate :last_name, to: :profile delegate :subscriptions, to: :statistic_profile @@ -116,11 +117,11 @@ class User < ApplicationRecord end def self.online_payers - User.with_any_role(:manager, :member) + User.with_any_role(:admin, :manager, :member) end def self.adminsys - return unless Rails.application.secrets.adminsys_email.present? + return if Rails.application.secrets.adminsys_email.blank? User.find_by('lower(email) = ?', Rails.application.secrets.adminsys_email&.downcase) end @@ -225,7 +226,7 @@ class User < ApplicationRecord def update_statistic_profile raise NoProfileError if statistic_profile.nil? || statistic_profile.id.nil? - statistic_profile.update_attributes( + statistic_profile.update( group_id: group_id, role_id: roles.first.id ) @@ -255,7 +256,7 @@ class User < ApplicationRecord def remove_orphan_drafts orphans = my_projects .joins('LEFT JOIN project_users ON projects.id = project_users.project_id') - .where('project_users.project_id IS NULL') + .where(project_users: { project_id: nil }) .where(state: 'draft') orphans.map(&:destroy!) end @@ -342,7 +343,7 @@ class User < ApplicationRecord def update_invoicing_profile raise NoProfileError if invoicing_profile.nil? - invoicing_profile.update_attributes( + invoicing_profile.update( email: email, first_name: first_name, last_name: last_name @@ -351,7 +352,7 @@ class User < ApplicationRecord def password_complexity return if password.blank? || SecurePassword.is_secured?(password) - + errors.add I18n.t("app.public.common.password_is_too_weak"), I18n.t("app.public.common.password_is_too_weak_explanations") end end diff --git a/app/models/wallet.rb b/app/models/wallet.rb index 3ff32dffa..e4c76a018 100644 --- a/app/models/wallet.rb +++ b/app/models/wallet.rb @@ -11,6 +11,8 @@ class Wallet < ApplicationRecord validates :invoicing_profile, presence: true + delegate :user, to: :invoicing_profile + def credit(amount) if amount.is_a?(Numeric) && amount >= 0 self.amount += amount @@ -26,8 +28,4 @@ class Wallet < ApplicationRecord end false end - - def user - invoicing_profile.user - end end diff --git a/app/models/wallet_transaction.rb b/app/models/wallet_transaction.rb index 87311ce6b..e95d08b0a 100644 --- a/app/models/wallet_transaction.rb +++ b/app/models/wallet_transaction.rb @@ -8,17 +8,15 @@ class WalletTransaction < ApplicationRecord belongs_to :wallet belongs_to :reservation # what was paid with the wallet - has_one :invoice - has_one :payment_schedule + has_one :invoice, dependent: :nullify + has_one :payment_schedule, dependent: :nullify # how the wallet was credited has_one :invoice_item, as: :object, dependent: :destroy - validates_inclusion_of :transaction_type, in: %w[credit debit] + validates :transaction_type, inclusion: { in: %w[credit debit] } validates :invoicing_profile, :wallet, presence: true - def user - invoicing_profile.user - end + delegate :user, to: :invoicing_profile def original_invoice invoice_item.invoice diff --git a/app/pdfs/pdf/invoice.rb b/app/pdfs/pdf/invoice.rb index e2962d57e..bef9bf4cb 100644 --- a/app/pdfs/pdf/invoice.rb +++ b/app/pdfs/pdf/invoice.rb @@ -41,15 +41,10 @@ class PDF::Invoice < Prawn::Document else text I18n.t('invoices.invoice_reference', REF: invoice.reference), leading: 3 end - if Setting.get('invoice_code-active') - text I18n.t('invoices.code', CODE: Setting.get('invoice_code-value')), leading: 3 - end + text I18n.t('invoices.code', CODE: Setting.get('invoice_code-value')), leading: 3 if Setting.get('invoice_code-active') if invoice.main_item.object_type != WalletTransaction.name - if invoice.is_a?(Avoir) - text I18n.t('invoices.order_number', NUMBER: invoice.invoice.order_number), leading: 3 - else - text I18n.t('invoices.order_number', NUMBER: invoice.order_number), leading: 3 - end + order_number = invoice.main_item.object_type == OrderItem.name ? invoice.main_item.object.order.reference : invoice.order_number + text I18n.t('invoices.order_number', NUMBER: order_number), leading: 3 end if invoice.is_a?(Avoir) text I18n.t('invoices.refund_invoice_issued_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date)) @@ -58,10 +53,10 @@ class PDF::Invoice < Prawn::Document end # user/organization's information - if invoice&.invoicing_profile&.organization + if invoice.invoicing_profile.organization name = invoice.invoicing_profile.organization.name full_name = "#{name} (#{invoice.invoicing_profile.full_name})" - others = invoice&.invoicing_profile&.user_profile_custom_fields&.joins(:profile_custom_field) + others = invoice.invoicing_profile.user_profile_custom_fields&.joins(:profile_custom_field) &.where('profile_custom_fields.actived' => true) &.order('profile_custom_fields.id ASC') &.select { |f| f.value.present? } @@ -107,7 +102,7 @@ class PDF::Invoice < Prawn::Document next unless item.object_type == Subscription.name subscription = item.object - cancellation = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : '' + cancellation = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : '' object = "\n- #{object}\n- #{cancellation + subscription_verbose(subscription, name)}" break end @@ -119,11 +114,13 @@ class PDF::Invoice < Prawn::Document object = I18n.t('invoices.error_invoice') when 'StatisticProfilePrepaidPack' object = I18n.t('invoices.prepaid_pack') + when 'OrderItem' + object = I18n.t('invoices.order') else Rails.logger.error "specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown" end end - text I18n.t('invoices.object') + ' ' + object + text "#{I18n.t('invoices.object')} #{object}" # details table of the invoice's elements move_down 20 @@ -136,10 +133,9 @@ class PDF::Invoice < Prawn::Document total_vat = 0 # going through invoice_items invoice.invoice_items.each do |item| - price = item.amount.to_i / 100.00 - details = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : '' + details = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : '' if item.object_type == Subscription.name subscription = item.object @@ -148,9 +144,10 @@ class PDF::Invoice < Prawn::Document START: I18n.l(invoice.main_item.object.start_at.to_date), END: I18n.l(invoice.main_item.object.end_at.to_date)) else - subscription_end_at = if subscription_expiration_date.is_a?(Time) + subscription_end_at = case subscription_expiration_date + when Time subscription_expiration_date - elsif subscription_expiration_date.is_a?(String) + when String DateTime.parse(subscription_expiration_date) else subscription.expiration_date @@ -162,7 +159,6 @@ class PDF::Invoice < Prawn::Document END: I18n.l(subscription_end_at.to_date)) end - elsif item.object_type == Reservation.name case invoice.main_item.object.try(:reservable_type) ### Machine reservation @@ -178,12 +174,12 @@ class PDF::Invoice < Prawn::Document details += I18n.t('invoices.event_reservation_DESCRIPTION', DESCRIPTION: item.description) # details of the number of tickets if invoice.main_item.object.nb_reserve_places.positive? - details += "\n " + I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places) + details += "\n #{I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)}" end invoice.main_item.object.tickets.each do |t| - details += "\n " + I18n.t('invoices.other_rate_ticket', - count: t.booked, - NAME: t.event_price_category.price_category.name) + details += "\n #{I18n.t('invoices.other_rate_ticket', + count: t.booked, + NAME: t.event_price_category.price_category.name)}" end else details += item.description @@ -201,22 +197,9 @@ class PDF::Invoice < Prawn::Document ## subtract the coupon, if any unless invoice.coupon_id.nil? cp = invoice.coupon - discount = 0 - if cp.type == 'percent_off' - discount = total_calc * cp.percent_off / 100.00 - elsif cp.type == 'amount_off' - # refunds of invoices with cash coupons: we need to ventilate coupons on paid items - if invoice.is_a?(Avoir) - paid_items = invoice.invoice.invoice_items.select { |ii| ii.amount.positive? }.length - refund_items = invoice.invoice_items.select { |ii| ii.amount.positive? }.length - - discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.00 - else - discount = cp.amount_off / 100.00 - end - else - raise InvalidCouponError - end + coupon_service = CouponService.new + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + discount = (total_without_coupon - invoice.total) / 100.00 total_calc -= discount @@ -243,7 +226,8 @@ class PDF::Invoice < Prawn::Document else data += [[I18n.t('invoices.total_including_all_taxes'), number_to_currency(total)]] vat_rate_group.each do |_type, rate| - data += [[I18n.t('invoices.including_VAT_RATE', RATE: rate[:vat_rate], AMOUNT: number_to_currency(rate[:amount] / 100.00)), number_to_currency(rate[:total_vat] / 100.00)]] + data += [[I18n.t('invoices.including_VAT_RATE', RATE: rate[:vat_rate], AMOUNT: number_to_currency(rate[:amount] / 100.00)), + number_to_currency(rate[:total_vat] / 100.00)]] end data += [[I18n.t('invoices.including_total_excluding_taxes'), number_to_currency(total_ht / 100.00)]] data += [[I18n.t('invoices.including_amount_payed_on_ordering'), number_to_currency(total)]] @@ -290,7 +274,7 @@ class PDF::Invoice < Prawn::Document # payment details move_down 20 if invoice.is_a?(Avoir) - payment_verbose = I18n.t('invoices.refund_on_DATE', DATE:I18n.l(invoice.avoir_date.to_date)) + ' ' + payment_verbose = "#{I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date))} " case invoice.payment_method when 'stripe' payment_verbose += I18n.t('invoices.by_card_online_payment') @@ -307,7 +291,7 @@ class PDF::Invoice < Prawn::Document else Rails.logger.error "specified refunding method (#{payment_verbose}) is unknown" end - payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total)) + payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}" else # subtract the wallet amount for this invoice from the total if invoice.wallet_amount @@ -327,18 +311,18 @@ class PDF::Invoice < Prawn::Document # if the invoice was 100% payed with the wallet ... payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount - payment_verbose += ' ' + I18n.t('invoices.on_DATE_at_TIME', - DATE: I18n.l(invoice.created_at.to_date), - TIME: I18n.l(invoice.created_at, format: :hour_minute)) + payment_verbose += " #{I18n.t('invoices.on_DATE_at_TIME', + DATE: I18n.l(invoice.created_at.to_date), + TIME: I18n.l(invoice.created_at, format: :hour_minute))}" if total.positive? || !invoice.wallet_amount - payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total)) + payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}" end if invoice.wallet_amount payment_verbose += if total.positive? - ' ' + I18n.t('invoices.and') + ' ' + I18n.t('invoices.by_wallet') + ' ' + - I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount)) + " #{I18n.t('invoices.and')} #{I18n.t('invoices.by_wallet')} " \ + "#{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}" else - ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount)) + " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}" end end end @@ -351,7 +335,6 @@ class PDF::Invoice < Prawn::Document text line, style: :bold, inline_format: true end - # address and legals information move_down 40 txt = parse_html(Setting.get('invoice_legals')) @@ -365,7 +348,7 @@ class PDF::Invoice < Prawn::Document transparent(0.1) do rotate(45, origin: [0, 0]) do - image "#{Rails.root}/app/pdfs/data/watermark-#{I18n.default_locale}.png", at: [90, 150] + image Rails.root.join("app/pdfs/data/watermark-#{I18n.default_locale}.png"), at: [90, 150] end end end @@ -374,16 +357,16 @@ class PDF::Invoice < Prawn::Document def reservation_dates_verbose(slot) if slot.start_at.to_date == slot.end_at.to_date - '- ' + I18n.t('invoices.on_DATE_from_START_to_END', - DATE: I18n.l(slot.start_at.to_date), - START: I18n.l(slot.start_at, format: :hour_minute), - END: I18n.l(slot.end_at, format: :hour_minute)) + "\n" + "- #{I18n.t('invoices.on_DATE_from_START_to_END', + DATE: I18n.l(slot.start_at.to_date), + START: I18n.l(slot.start_at, format: :hour_minute), + END: I18n.l(slot.end_at, format: :hour_minute))}\n" else - '- ' + I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME', - STARTDATE: I18n.l(slot.start_at.to_date), - ENDDATE: I18n.l(slot.start_at.to_date), - STARTTIME: I18n.l(slot.start_at, format: :hour_minute), - ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) + "\n" + "- #{I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME', + STARTDATE: I18n.l(slot.start_at.to_date), + ENDDATE: I18n.l(slot.start_at.to_date), + STARTTIME: I18n.l(slot.start_at, format: :hour_minute), + ENDTIME: I18n.l(slot.end_at, format: :hour_minute))}\n" end end diff --git a/app/policies/cart_context.rb b/app/policies/cart_context.rb new file mode 100644 index 000000000..f962eb492 --- /dev/null +++ b/app/policies/cart_context.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Pundit Additional context for authorizing a product offering +class CartContext + attr_reader :customer_id, :is_offered + + def initialize(customer_id, is_offered) + @customer_id = customer_id + @is_offered = is_offered + end + + def policy_class + CartPolicy + end +end diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb new file mode 100644 index 000000000..b63dcbcac --- /dev/null +++ b/app/policies/cart_policy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Check the access policies for API::CartController +class CartPolicy < ApplicationPolicy + def create? + !Setting.get('store_hidden') || user&.privileged? + end + + %w[add_item remove_item set_quantity refresh_item validate].each do |action| + define_method "#{action}?" do + return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user + + record.statistic_profile_id.nil? && record.operator_profile_id.nil? + end + end + + def set_offer? + !record.is_offered || (user.privileged? && record.customer_id != user.id) + end +end diff --git a/app/policies/checkout_policy.rb b/app/policies/checkout_policy.rb new file mode 100644 index 000000000..045361caf --- /dev/null +++ b/app/policies/checkout_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Check the access policies for API::CheckoutController +class CheckoutPolicy < ApplicationPolicy + %w[payment confirm_payment].each do |action| + define_method "#{action}?" do + return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) + end + end +end diff --git a/app/policies/local_payment_policy.rb b/app/policies/local_payment_policy.rb index 3b0971eab..e981fd8ac 100644 --- a/app/policies/local_payment_policy.rb +++ b/app/policies/local_payment_policy.rb @@ -6,6 +6,6 @@ class LocalPaymentPolicy < ApplicationPolicy # only admins and managers can offer free extensions of a subscription has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension } - user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days) + ((user.admin? || user.manager?) && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days) end end diff --git a/app/policies/order_policy.rb b/app/policies/order_policy.rb new file mode 100644 index 000000000..79edab981 --- /dev/null +++ b/app/policies/order_policy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Check the access policies for API::OrdersController +class OrderPolicy < ApplicationPolicy + def show? + user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end + + def withdrawal_instructions? + user&.privileged? || (record&.statistic_profile_id == user&.statistic_profile&.id) + end +end diff --git a/app/policies/product_category_policy.rb b/app/policies/product_category_policy.rb new file mode 100644 index 000000000..5f6a0fb74 --- /dev/null +++ b/app/policies/product_category_policy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductCategoriesController +class ProductCategoryPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end + + def position? + user.privileged? + end +end diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb new file mode 100644 index 000000000..179c4a722 --- /dev/null +++ b/app/policies/product_policy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductsController +class ProductPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def clone? + user.privileged? + end + + def destroy? + user.privileged? + end + + def stock_movements? + user.privileged? + end +end diff --git a/app/policies/setting_policy.rb b/app/policies/setting_policy.rb index 3c279459d..86f76b6ee 100644 --- a/app/policies/setting_policy.rb +++ b/app/policies/setting_policy.rb @@ -42,7 +42,7 @@ class SettingPolicy < ApplicationPolicy payment_gateway payzen_endpoint payzen_public_key public_agenda_module renew_pack_threshold statistics_module pack_only_for_subscription overlapping_categories public_registrations facebook twitter viadeo linkedin instagram youtube vimeo dailymotion github echosciences pinterest lastfm flickr machines_module user_change_group - user_validation_required user_validation_required_list] + user_validation_required user_validation_required_list store_module store_withdrawal_instructions store_hidden] end ## @@ -50,7 +50,7 @@ class SettingPolicy < ApplicationPolicy # This blacklist is automatically generated from the public_whitelist above. ## def self.public_blacklist - Setting.validators.detect { |v| v.class == ActiveModel::Validations::InclusionValidator && v.attributes.include?(:name) } + Setting.validators.detect { |v| v.instance_of?(ActiveModel::Validations::InclusionValidator) && v.attributes.include?(:name) } .options[:in] - SettingPolicy.public_whitelist end end diff --git a/app/policies/statistic_policy.rb b/app/policies/statistic_policy.rb index 47814de04..852786a89 100644 --- a/app/policies/statistic_policy.rb +++ b/app/policies/statistic_policy.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + +# Check the access policies for API::StatisticsController class StatisticPolicy < ApplicationPolicy - %w(index account event machine project subscription training user space scroll export_subscription export_machine - export_training export_event export_account export_project export_space export_global).each do |action| + %w[index account event machine project subscription training user space order scroll export_subscription export_machine + export_training export_event export_account export_project export_space export_order export_global].each do |action| define_method "#{action}?" do user.admin? end diff --git a/app/services/accounting_export_service.rb b/app/services/accounting_export_service.rb index 0a523083c..bdc1033f8 100644 --- a/app/services/accounting_export_service.rb +++ b/app/services/accounting_export_service.rb @@ -61,26 +61,21 @@ class AccountingExportService # Generate the "subscription" and "reservation" rows associated with the provided invoice def items_rows(invoice) - rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : '' - case invoice.main_item.object_type - when 'Reservation' - items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' } + rows = '' + { + subscription: 'Subscription', reservation: 'Reservation', wallet: 'WalletTransaction', + pack: 'StatisticProfilePrepaidPack', product: 'OrderItem', error: 'Error' + }.each do |type, object_type| + items = invoice.invoice_items.filter { |ii| ii.object_type == object_type } items.each do |item| - rows << "#{reservation_row(invoice, item)}\n" + rows << "#{row( + invoice, + account(invoice, type), + account(invoice, type, type: :label), + item.net_amount / 100.00, + line_label: label(invoice) + )}\n" end - when 'WalletTransaction' - rows << "#{wallet_row(invoice)}\n" - when 'StatisticProfilePrepaidPack' - rows << "#{pack_row(invoice)}\n" - when 'Error' - items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' } - items.each do |item| - rows << "#{error_row(invoice, item)}\n" - end - when 'Subscription' - # do nothing, subscription was already handled by subscription_row - else - Rails.logger.warn { "Unknown main object type #{invoice.main_item.object_type}" } end rows end @@ -91,8 +86,8 @@ class AccountingExportService invoice.payment_means.each do |details| rows << row( invoice, - account(invoice, :projets, means: details[:means]), - account(invoice, :projets, means: details[:means], type: :label), + account(invoice, :client, means: details[:means]), + account(invoice, :client, means: details[:means], type: :label), details[:amount] / 100.00, line_label: label(invoice), debit_method: :debit_client, @@ -103,51 +98,6 @@ class AccountingExportService rows end - # Generate the "reservation" row, which contains the credit to the reservation account, all taxes excluded - def reservation_row(invoice, item) - row( - invoice, - account(invoice, :reservation), - account(invoice, :reservation, type: :label), - item.net_amount / 100.00, - line_label: label(invoice) - ) - end - - # Generate the "subscription" row, which contains the credit to the subscription account, all taxes excluded - def subscription_row(invoice) - subscription_item = invoice.invoice_items.select { |ii| ii.object_type == 'Subscription' }.first - row( - invoice, - account(invoice, :subscription), - account(invoice, :subscription, type: :label), - subscription_item.net_amount / 100.00, - line_label: label(invoice) - ) - end - - # Generate the "wallet" row, which contains the credit to the wallet account, all taxes excluded - # This applies to wallet crediting, when an Avoir is generated at this time - def wallet_row(invoice) - row( - invoice, - account(invoice, :wallet), - account(invoice, :wallet, type: :label), - invoice.invoice_items.first.net_amount / 100.00, - line_label: label(invoice) - ) - end - - def pack_row(invoice) - row( - invoice, - account(invoice, :pack), - account(invoice, :pack, type: :label), - invoice.invoice_items.first.net_amount / 100.00, - line_label: label(invoice) - ) - end - # Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only def vat_row(invoice) total = invoice.invoice_items.map(&:net_amount).sum @@ -163,16 +113,6 @@ class AccountingExportService ) end - def error_row(invoice, item) - row( - invoice, - account(invoice, :error), - account(invoice, :error, type: :label), - item.net_amount / 100.00, - line_label: label(invoice) - ) - end - # Generate a row of the export, filling the configured columns with the provided values def row(invoice, account_code, account_label, amount, line_label: '', debit_method: :debit, credit_method: :credit) row = '' @@ -207,38 +147,12 @@ class AccountingExportService # Get the account code (or label) for the given invoice and the specified line type (client, vat, subscription or reservation) def account(invoice, account, type: :code, means: :other) case account - when :projets + when :client Setting.get("accounting_#{means}_client_#{type}") - when :vat - Setting.get("accounting_VAT_#{type}") - when :subscription - if invoice.subscription_invoice? - Setting.get("accounting_subscription_#{type}") - else - Rails.logger.debug { "WARN: Invoice #{invoice.id} has no subscription" } - end when :reservation - if invoice.main_item.object_type == 'Reservation' - Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}") - else - Rails.logger.debug { "WARN: Invoice #{invoice.id} has no reservation" } - end - when :wallet - if invoice.main_item.object_type == 'WalletTransaction' - Setting.get("accounting_wallet_#{type}") - else - Rails.logger.debug { "WARN: Invoice #{invoice.id} is not a wallet credit" } - end - when :pack - if invoice.main_item.object_type == 'StatisticProfilePrepaidPack' - Setting.get("accounting_Pack_#{type}") - else - Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" } - end - when :error - Setting.get("accounting_Error_#{type}") + Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}") if invoice.main_item.object_type == 'Reservation' else - Rails.logger.debug { "Unsupported account #{account}" } + Setting.get("accounting_#{account}_#{type}") end || '' end @@ -275,8 +189,11 @@ class AccountingExportService reference = invoice.reference items = invoice.subscription_invoice? ? [I18n.t('accounting_export.subscription')] : [] - items.push I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation") if invoice.main_item.object_type == 'Reservation' + if invoice.main_item.object_type == 'Reservation' + items.push I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation") + end items.push I18n.t('accounting_export.wallet') if invoice.main_item.object_type == 'WalletTransaction' + items.push I18n.t('accounting_export.shop_order') if invoice.main_item.object_type == 'OrderItem' summary = items.join(' + ') res = "#{reference}, #{summary}" diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb new file mode 100644 index 000000000..f4e1c16d2 --- /dev/null +++ b/app/services/cart/add_item_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Provides methods for add order item to cart +class Cart::AddItemService + def call(order, orderable, quantity = 1) + return order if quantity.to_i.zero? + + raise Cart::InactiveProductError unless orderable.is_active + + order.created_at = DateTime.current if order.order_items.length.zero? + + item = order.order_items.find_by(orderable: orderable) + quantity = orderable.quantity_min > quantity.to_i && item.nil? ? orderable.quantity_min : quantity.to_i + + if item.nil? + item = order.order_items.new(quantity: quantity, orderable: orderable, amount: orderable.amount || 0) + else + item.quantity += quantity + end + raise Cart::OutStockError if item.quantity > orderable.stock['external'] + + ActiveRecord::Base.transaction do + item.save + Cart::UpdateTotalService.new.call(order) + order.save + end + order.reload + end +end diff --git a/app/services/cart/check_cart_service.rb b/app/services/cart/check_cart_service.rb new file mode 100644 index 000000000..e576c5b79 --- /dev/null +++ b/app/services/cart/check_cart_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Provides methods for check cart's items (available, price, stock, quantity_min) +class Cart::CheckCartService + def call(order) + res = { order_id: order.id, details: [] } + order.order_items.each do |item| + errors = [] + errors.push({ error: 'is_active', value: false }) unless item.orderable.is_active + if item.quantity > item.orderable.stock['external'] || item.orderable.stock['external'] < item.orderable.quantity_min + value = item.orderable.stock['external'] < item.orderable.quantity_min ? 0 : item.orderable.stock['external'] + errors.push({ error: 'stock', value: value }) + end + orderable_amount = item.orderable.amount || 0 + errors.push({ error: 'amount', value: orderable_amount / 100.0 }) if item.amount != orderable_amount + errors.push({ error: 'quantity_min', value: item.orderable.quantity_min }) if item.quantity < item.orderable.quantity_min + res[:details].push({ item_id: item.id, errors: errors }) + end + res + end +end diff --git a/app/services/cart/find_or_create_service.rb b/app/services/cart/find_or_create_service.rb new file mode 100644 index 000000000..e882ca725 --- /dev/null +++ b/app/services/cart/find_or_create_service.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# Provides methods for find or create a cart +class Cart::FindOrCreateService + def initialize(user) + @user = user + end + + def call(order_token) + @order = Order.find_by(token: order_token, state: 'cart') + check_order_authorization + set_last_cart_if_user_login if @order.nil? + + if @order + if @order.order_items.count.zero? && @user && ((@user.member? && @order.statistic_profile_id.nil?) || (@user.privileged? && @order.operator_profile_id.nil?)) + set_last_order_if_anonymous_order_s_items_is_empty_after_user_login + end + clean_old_cart if @user + @order.update(statistic_profile_id: @user.statistic_profile.id) if @order.statistic_profile_id.nil? && @user&.member? + @order.update(operator_profile_id: @user.invoicing_profile.id) if @order.operator_profile_id.nil? && @user&.privileged? + Cart::UpdateTotalService.new.call(@order) + return @order + end + + token = GenerateTokenService.new.call(Order) + order_param = { + token: token, + state: 'cart', + total: 0 + } + if @user + order_param[:statistic_profile_id] = @user.statistic_profile.id if @user.member? + + order_param[:operator_profile_id] = @user.invoicing_profile.id if @user.privileged? + end + Order.create!(order_param) + end + + # This function check current order that + # 1. belongs current user + # 2. has belonged an user but this user dont login + # 3. created date > last paid order of user + # if not, set current order = nil + def check_order_authorization + if @order && @user && ((@user.member? && @order.statistic_profile_id.present? && @order.statistic_profile_id != @user.statistic_profile.id) || + (@user.privileged? && @order.operator_profile_id.present? && @order.operator_profile_id != @user.invoicing_profile.id)) + @order = nil + end + @order = nil if @order && !@user && (@order.statistic_profile_id.present? || @order.operator_profile_id.present?) + if @order && @order.statistic_profile_id.present? && Order.where(statistic_profile_id: @order.statistic_profile_id, + state: 'paid').where('created_at > ?', @order.created_at).last.present? + @order = nil + end + if @order && @order.operator_profile_id.present? && Order.where(operator_profile_id: @order.operator_profile_id, + state: 'paid').where('created_at > ?', @order.created_at).last.present? + @order = nil + end + end + + # set user last cart of user when login + def set_last_cart_if_user_login + if @user&.member? + last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'paid').last + @order = if last_paid_order + Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').last + end + end + if @user&.privileged? + last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'paid').last + @order = if last_paid_order + Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').last + end + end + end + + # set last order if current cart is anoymous and user is login + def set_last_order_if_anonymous_order_s_items_is_empty_after_user_login + last_unpaid_order = nil + if @user&.member? + last_paid_order = Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'paid').last + last_unpaid_order = if last_paid_order + Order.where(statistic_profile_id: @user.statistic_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart').last + end + end + if @user&.privileged? + last_paid_order = Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'paid').last + last_unpaid_order = if last_paid_order + Order.where(operator_profile_id: @user.invoicing_profile.id, + state: 'cart').where('created_at > ?', last_paid_order.created_at).last + else + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart').last + end + end + if last_unpaid_order && last_unpaid_order.id != @order.id + @order.destroy + @order = last_unpaid_order + end + end + + # delete all old cart if last cart of user isnt empty + # keep every user only one cart + def clean_old_cart + if @user&.member? + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart') + .where.not(id: @order.id) + .destroy_all + end + if @user&.privileged? + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart') + .where.not(id: @order.id) + .destroy_all + end + end + + # delete all empty cart if last cart of user isnt empty + def clean_empty_cart + if @user&.member? + Order.where(statistic_profile_id: @user.statistic_profile.id, state: 'cart') + .where('(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) = 0') + .destroy_all + end + if @user&.privileged? + Order.where(operator_profile_id: @user.invoicing_profile.id, state: 'cart') + .where('(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) = 0') + .destroy_all + end + end +end diff --git a/app/services/cart/refresh_item_service.rb b/app/services/cart/refresh_item_service.rb new file mode 100644 index 000000000..143df6e12 --- /dev/null +++ b/app/services/cart/refresh_item_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Provides methods for refresh amount of order item +class Cart::RefreshItemService + def call(order, orderable) + raise Cart::InactiveProductError unless orderable.is_active + + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + item.amount = orderable.amount || 0 + ActiveRecord::Base.transaction do + item.save + Cart::UpdateTotalService.new.call(order) + order.save + end + order.reload + end +end diff --git a/app/services/cart/remove_item_service.rb b/app/services/cart/remove_item_service.rb new file mode 100644 index 000000000..01d8d01fe --- /dev/null +++ b/app/services/cart/remove_item_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Provides methods for remove order item to cart +class Cart::RemoveItemService + def call(order, orderable) + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + ActiveRecord::Base.transaction do + item.destroy! + Cart::UpdateTotalService.new.call(order) + order.save + end + order.reload + end +end diff --git a/app/services/cart/set_offer_service.rb b/app/services/cart/set_offer_service.rb new file mode 100644 index 000000000..d9bd11b1a --- /dev/null +++ b/app/services/cart/set_offer_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for set offer to item in cart +class Cart::SetOfferService + def call(order, orderable, is_offered) + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + item.is_offered = is_offered + ActiveRecord::Base.transaction do + item.save + Cart::UpdateTotalService.new.call(order) + order.save + end + order.reload + end +end diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb new file mode 100644 index 000000000..545ff99b3 --- /dev/null +++ b/app/services/cart/set_quantity_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Provides methods for update quantity of order item +class Cart::SetQuantityService + def call(order, orderable, quantity = nil) + return order if quantity.to_i.zero? + + quantity = orderable.quantity_min > quantity.to_i ? orderable.quantity_min : quantity.to_i + + raise Cart::OutStockError if quantity.to_i > orderable.stock['external'] + + item = order.order_items.find_by(orderable: orderable) + + raise ActiveRecord::RecordNotFound if item.nil? + + ActiveRecord::Base.transaction do + item.update(quantity: quantity.to_i) + Cart::UpdateTotalService.new.call(order) + order.save + end + order.reload + end +end diff --git a/app/services/cart/update_total_service.rb b/app/services/cart/update_total_service.rb new file mode 100644 index 000000000..24dec77e2 --- /dev/null +++ b/app/services/cart/update_total_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Provides methods for update total of cart +class Cart::UpdateTotalService + def call(order) + total = 0 + order.order_items.each do |item| + total += (item.amount * item.quantity) unless item.is_offered + end + order.total = total + order.save + order + end +end diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb new file mode 100644 index 000000000..f84535f3b --- /dev/null +++ b/app/services/checkout/payment_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Provides methods to pay the cart +class Checkout::PaymentService + require 'pay_zen/helper' + require 'stripe/helper' + include Payments::PaymentConcern + + def payment(order, operator, coupon_code, payment_id = '') + raise Cart::InactiveProductError unless Orders::OrderService.all_products_is_active?(order) + + raise Cart::OutStockError unless Orders::OrderService.in_stock?(order, 'external') + + raise Cart::QuantityMinError unless Orders::OrderService.greater_than_quantity_min?(order) + + raise Cart::ItemAmountError unless Orders::OrderService.item_amount_not_equal?(order) + + CouponService.new.validate(coupon_code, order.statistic_profile.user.id) + + amount = debit_amount(order) + if (operator.privileged? && operator != order.statistic_profile.user) || amount.zero? + Payments::LocalService.new.payment(order, coupon_code) + elsif Stripe::Helper.enabled? && payment_id.present? + Payments::StripeService.new.payment(order, coupon_code, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.payment(order, coupon_code) + else + raise Error('Bad gateway or online payment is disabled') + end + end + + def confirm_payment(order, operator, coupon_code, payment_id = '') + return unless operator.member? + + if Stripe::Helper.enabled? + Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id) + elsif PayZen::Helper.enabled? + Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id) + else + raise Error('Bad gateway or online payment is disabled') + end + end +end diff --git a/app/services/coupon_service.rb b/app/services/coupon_service.rb index e849c1d57..15766d265 100644 --- a/app/services/coupon_service.rb +++ b/app/services/coupon_service.rb @@ -26,17 +26,35 @@ class CouponService return price if coupon_object.nil? if coupon_object.status(user_id, total) == 'active' - if coupon_object.type == 'percent_off' - price -= (price * coupon_object.percent_off / 100.00).truncate - elsif coupon_object.type == 'amount_off' + case coupon_object.type + when 'percent_off' + price -= (Rational(price * coupon_object.percent_off) / Rational(100.0)).to_f.ceil + when 'amount_off' # do not apply cash coupon unless it has a lower amount that the total price price -= coupon_object.amount_off if coupon_object.amount_off <= price + else + raise InvalidCouponError("unsupported coupon type #{coupon_object.type}") end end price end + # Apply the provided coupon to the given amount, considering that this applies to a refund invoice (Avoir), + # potentially partial + def self.apply_on_refund(amount, coupon, paid_items = 1, refund_items = 1) + return amount if coupon.nil? + + case coupon.type + when 'percent_off' + amount - (Rational(amount * coupon.percent_off) / Rational(100.0)).to_f.ceil + when 'amount_off' + amount - (Rational(coupon.amount_off / paid_items) * Rational(refund_items)).to_f.ceil + else + raise InvalidCouponError + end + end + ## # Find the coupon associated with the given code and check it is valid for the given user # @param code {String} the literal code of the coupon @@ -61,14 +79,15 @@ class CouponService def ventilate(total, amount, coupon) price = amount if !coupon.nil? && total != 0 - if coupon.type == 'percent_off' - price = amount - (amount * coupon.percent_off / 100.00) - elsif coupon.type == 'amount_off' - ratio = (coupon.amount_off / 100.00) / total - discount = (amount * ratio.abs) * 100 - price = amount - discount + case coupon.type + when 'percent_off' + price = amount - (Rational(amount * coupon.percent_off) / Rational(100.00)).to_f.round + when 'amount_off' + ratio = Rational(amount) / Rational(total) + discount = (coupon.amount_off * ratio.abs) + price = (amount - discount).to_f.round else - raise InvalidCouponError + raise InvalidCouponError("unsupported coupon type #{coupon.type}") end end price diff --git a/app/services/event_service.rb b/app/services/event_service.rb index c27c6b47d..4e5eba4b1 100644 --- a/app/services/event_service.rb +++ b/app/services/event_service.rb @@ -31,14 +31,15 @@ class EventService def date_range(starting, ending, all_day) start_date = Time.zone.parse(starting[:date]) end_date = Time.zone.parse(ending[:date]) - start_time = Time.parse(starting[:time]) if starting[:time] - end_time = Time.parse(ending[:time]) if ending[:time] - if all_day + start_time = starting[:time] ? Time.zone.parse(starting[:time]) : nil + end_time = ending[:time] ? Time.zone.parse(ending[:time]) : nil + if all_day || start_time.nil? || end_time.nil? start_at = DateTime.new(start_date.year, start_date.month, start_date.day, 0, 0, 0, start_date.zone) end_at = DateTime.new(end_date.year, end_date.month, end_date.day, 23, 59, 59, end_date.zone) else - start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time&.hour, start_time&.min, start_time&.sec, start_date.zone) - end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time&.hour, end_time&.min, end_time&.sec, end_date.zone) + start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time.hour, start_time.min, start_time.sec, + start_date.zone) + end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time.hour, end_time.min, end_time.sec, end_date.zone) end { start_at: start_at, end_at: end_at } end @@ -59,16 +60,13 @@ class EventService ) .references(:availabilities, :events) when 'all' - Event.where( - 'recurrence_id = ?', - event.recurrence_id - ) + Event.where(recurrence_id: event.recurrence_id) else [] end events.each do |e| - # here we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success) + # we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success) results.push status: !!e.safe_destroy, event: e # rubocop:disable Style/DoubleNegation end results @@ -89,99 +87,41 @@ class EventService .references(:availabilities, :events) when 'all' Event.includes(:availability, :event_price_categories, :event_files) - .where( - 'recurrence_id = ?', - event.recurrence_id - ) + .where(recurrence_id: event.recurrence_id) else [] end - update_events(event, events, event_params) + update_occurrences(event, events, event_params) end private - def update_events(event, events, event_params) + def update_occurrences(base_event, occurrences, event_params) results = { events: [], slots: [] } - events.each do |e| - next unless e.id != event.id + original_slots_ids = base_event.availability.slots.map(&:id) - start_at = event_params['availability_attributes']['start_at'] - end_at = event_params['availability_attributes']['end_at'] - event_price_categories_attributes = event_params['event_price_categories_attributes'] - event_files_attributes = event_params['event_files_attributes'] - e_params = event_params.merge( - availability_id: e.availability_id, - availability_attributes: { - id: e.availability_id, - start_at: e.availability.start_at.change(hour: start_at.hour, min: start_at.min), - end_at: e.availability.end_at.change(hour: end_at.hour, min: end_at.min), - available_type: e.availability.available_type - } - ) - epc_attributes = [] - event_price_categories_attributes&.each do |epca| - epc = e.event_price_categories.find_by(price_category_id: epca['price_category_id']) - if epc - epc_attributes.push( - id: epc.id, - price_category_id: epc.price_category_id, - amount: epca['amount'], - _destroy: epca['_destroy'] - ) - elsif epca['id'].present? - event_price = event.event_price_categories.find(epca['id']) - epc_attributes.push( - price_category_id: epca['price_category_id'], - amount: event_price.amount, - _destroy: '' - ) - end - end - unless epc_attributes.empty? - e_params = e_params.merge( - event_price_categories_attributes: epc_attributes - ) - end + occurrences.each do |occurrence| + next unless occurrence.id != base_event.id - ef_attributes = [] - event_files_attributes&.each do |efa| - if efa['id'].present? - event_file = event.event_files.find(efa['id']) - ef = e.event_files.find_by(attachment: event_file.attachment.file.filename) - if ef - ef_attributes.push( - id: ef.id, - attachment: efa['attachment'], - _destroy: efa['_destroy'] - ) - end - else - ef_attributes.push(efa) - end - end - e_params = e_params.merge( - event_files_attributes: ef_attributes - ) - original_slots_ids = event.availability.slots.map(&:id) + e_params = occurrence_params(base_event, occurrence, event_params) begin - results[:events].push status: !!e.update(e_params.permit!), event: e # rubocop:disable Style/DoubleNegation - rescue StandardError => err - results[:events].push status: false, event: e, error: err.try(:record).try(:class).try(:name), message: err.message + results[:events].push status: !!occurrence.update(e_params.permit!), event: occurrence # rubocop:disable Style/DoubleNegation + rescue StandardError => e + results[:events].push status: false, event: occurrence, error: e.try(:record).try(:class).try(:name), message: e.message end - results[:slots].concat(update_slots(e.availability_id, original_slots_ids)) + results[:slots].concat(update_slots(occurrence.availability_id, original_slots_ids)) end - original_slots_ids = event.availability.slots.map(&:id) + begin - event_params[:availability_attributes][:id] = event.availability_id - results[:events].push status: !!event.update(event_params), event: event # rubocop:disable Style/DoubleNegation - rescue StandardError => err - results[:events].push status: false, event: event, error: err.try(:record).try(:class).try(:name), message: err.message + event_params[:availability_attributes][:id] = base_event.availability_id + results[:events].push status: !!base_event.update(event_params), event: base_event # rubocop:disable Style/DoubleNegation + rescue StandardError => e + results[:events].push status: false, event: base_event, error: e.try(:record).try(:class).try(:name), message: e.message end - results[:slots].concat(update_slots(event.availability_id, original_slots_ids)) + results[:slots].concat(update_slots(base_event.availability_id, original_slots_ids)) results end @@ -190,13 +130,81 @@ class EventService avail = Availability.find(availability_id) Slot.where(id: original_slots_ids).each do |slot| results.push( - status: !!slot.update_attributes(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation + status: !!slot.update(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation slot: slot ) - rescue StandardError => err - results.push status: false, slot: s, error: err.try(:record).try(:class).try(:name), message: err.message + rescue StandardError => e + results.push status: false, slot: s, error: e.try(:record).try(:class).try(:name), message: e.message end results end + + def occurrence_params(base_event, occurrence, event_params) + start_at = event_params['availability_attributes']['start_at'] + end_at = event_params['availability_attributes']['end_at'] + e_params = event_params.merge( + availability_id: occurrence.availability_id, + availability_attributes: { + id: occurrence.availability_id, + start_at: occurrence.availability.start_at.change(hour: start_at.hour, min: start_at.min), + end_at: occurrence.availability.end_at.change(hour: end_at.hour, min: end_at.min), + available_type: occurrence.availability.available_type + } + ) + epc_attributes = price_categories_attributes(base_event, occurrence, event_params) + unless epc_attributes.empty? + e_params = e_params.merge( + event_price_categories_attributes: epc_attributes + ) + end + + ef_attributes = file_attributes(base_event, occurrence, event_params) + e_params.merge( + event_files_attributes: ef_attributes + ) + end + + def price_categories_attributes(base_event, occurrence, event_params) + epc_attributes = [] + event_params['event_price_categories_attributes']&.each do |epca| + epc = occurrence.event_price_categories.find_by(price_category_id: epca['price_category_id']) + if epc + epc_attributes.push( + id: epc.id, + price_category_id: epc.price_category_id, + amount: epca['amount'], + _destroy: epca['_destroy'] + ) + elsif epca['id'].present? + event_price = base_event.event_price_categories.find(epca['id']) + epc_attributes.push( + price_category_id: epca['price_category_id'], + amount: event_price.amount, + _destroy: '' + ) + end + end + epc_attributes + end + + def file_attributes(base_event, occurrence, event_params) + ef_attributes = [] + event_params['event_files_attributes']&.each do |efa| + if efa['id'].present? + event_file = base_event.event_files.find(efa['id']) + ef = occurrence.event_files.find_by(attachment: event_file.attachment.file.filename) + if ef + ef_attributes.push( + id: ef.id, + attachment: efa['attachment'], + _destroy: efa['_destroy'] + ) + end + else + ef_attributes.push(efa) + end + end + ef_attributes + end end end diff --git a/app/services/export_service.rb b/app/services/export_service.rb index 1e4bd3ebe..0ffea8daa 100644 --- a/app/services/export_service.rb +++ b/app/services/export_service.rb @@ -12,6 +12,8 @@ class ExportService last_export_reservations when 'users/subscription' last_export_subscriptions + when %r{statistics/.*} + last_export_statistics(type.split('/')[1]) else raise TypeError "unknown export type: #{type}" end @@ -44,5 +46,10 @@ class ExportService .where('created_at > ?', last_update) .last end + + def last_export_statistics(type) + Export.where(category: 'statistics', export_type: type, query: params[:body], key: params[:type_key]) + .last + end end end diff --git a/app/services/generate_token_service.rb b/app/services/generate_token_service.rb new file mode 100644 index 000000000..d0ed74972 --- /dev/null +++ b/app/services/generate_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Generate a unique token +class GenerateTokenService + def call(model_class = Order) + loop do + token = "#{random_token}#{unique_ending}" + break token unless model_class.exists?(token: token) + end + end + + private + + def random_token + SecureRandom.urlsafe_base64(nil, false) + end + + def unique_ending + (Time.now.to_f * 1000).to_i + end +end diff --git a/app/services/group_service.rb b/app/services/group_service.rb index 3b0e6e482..c330edca8 100644 --- a/app/services/group_service.rb +++ b/app/services/group_service.rb @@ -2,20 +2,14 @@ # Provides methods for Groups class GroupService - def self.list(operator, filters = {}) - groups = if operator&.admin? - Group.where(nil) - else - Group.where.not(slug: 'admins') - end + def self.list(filters = {}) + groups = Group.where(nil) if filters[:disabled].present? state = filters[:disabled] == 'false' ? [nil, false] : true groups = groups.where(disabled: state) end - groups = groups.where.not(slug: 'admins') if filters[:admins] == 'false' - groups end end diff --git a/app/services/invoices_service.rb b/app/services/invoices_service.rb index b70688bfc..78dd1bcae 100644 --- a/app/services/invoices_service.rb +++ b/app/services/invoices_service.rb @@ -15,7 +15,6 @@ class InvoicesService .page(page) .per(size) - if filters[:number].size.positive? invoices = invoices.where( 'invoices.reference LIKE :search', @@ -74,7 +73,7 @@ class InvoicesService method = if payment_method payment_method else - operator&.admin? || (operator&.manager? && operator != user) ? nil : 'card' + (operator&.admin? || operator&.manager?) && operator != user ? nil : 'card' end invoice = Invoice.new( @@ -96,7 +95,7 @@ class InvoicesService # Generate an array of {InvoiceItem} with the provided elements, price included. # @param invoice {Invoice} the parent invoice # @param payment_details {Hash} as generated by ShoppingCart.total - # @param objects {Array} + # @param objects {Array} ## def self.generate_invoice_items(invoice, payment_details, objects) objects.each_with_index do |object, index| @@ -108,6 +107,8 @@ class InvoicesService InvoicesService.generate_reservation_item(invoice, object, payment_details, index.zero?) elsif object.is_a?(StatisticProfilePrepaidPack) InvoicesService.generate_prepaid_pack_item(invoice, object, payment_details, index.zero?) + elsif object.is_a?(OrderItem) + InvoicesService.generate_order_item(invoice, object, payment_details, index.zero?) else InvoicesService.generate_generic_item(invoice, object, payment_details, index.zero?) end @@ -123,16 +124,16 @@ class InvoicesService reservation.slots_reservations.map(&:slot).each do |slot| description = "#{reservation.reservable.name}\n" - description += if slot.start_at.to_date != slot.end_at.to_date + description += if slot.start_at.to_date == slot.end_at.to_date + "#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \ + " - #{I18n.l slot.end_at, format: :hour_minute}" + else I18n.t('events.from_STARTDATE_to_ENDDATE', STARTDATE: I18n.l(slot.start_at.to_date, format: :long), ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' + I18n.t('events.from_STARTTIME_to_ENDTIME', STARTTIME: I18n.l(slot.start_at, format: :hour_minute), ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) - else - "#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \ - " - #{I18n.l slot.end_at, format: :hour_minute}" end price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } @@ -196,6 +197,21 @@ class InvoicesService ) end + ## + # Generate an InvoiceItem for given OrderItem and sva it in invoice.invoice_items + # This method must be called whith an order + ## + def self.generate_order_item(invoice, item, _payment_details, main = false) + raise TypeError unless item + + invoice.invoice_items.push InvoiceItem.new( + amount: item.is_offered ? 0 : item.amount * item.quantity, + description: "#{item.orderable.name} x #{item.quantity}", + object: item, + main: main + ) + end + def self.generate_generic_item(invoice, item, payment_details, main = false) invoice.invoice_items.push InvoiceItem.new( amount: payment_details[:elements][item.class.name.to_sym], @@ -205,7 +221,6 @@ class InvoicesService ) end - ## # Set the total price to the reservation's invoice, summing its whole items. # Additionally a coupon may be applied to this invoice to make a discount on the total price diff --git a/app/services/members/list_service.rb b/app/services/members/list_service.rb index 955ac0ccd..bbf6c5c9f 100644 --- a/app/services/members/list_service.rb +++ b/app/services/members/list_service.rb @@ -28,7 +28,7 @@ class Members::ListService if params[:search].size.positive? @query = @query.where('users.username ILIKE :search OR ' \ 'profiles.first_name ILIKE :search OR ' \ - 'profiles.last_name ILIKE :search OR ' \ + 'profiles.last_name ILIKE :search OR ' \ 'profiles.phone ILIKE :search OR ' \ 'email ILIKE :search OR ' \ 'groups.name ILIKE :search OR ' \ @@ -41,20 +41,21 @@ class Members::ListService @query end - def search(current_user, query, subscription, include_admins = 'false') + def search(current_user, query, subscription) members = User.includes(:profile, :statistic_profile) .joins(:profile, :statistic_profile, :roles, 'LEFT JOIN "subscriptions" ON "subscriptions"."statistic_profile_id" = "statistic_profiles"."id" AND ' \ '"subscriptions"."created_at" = ( ' \ - 'SELECT max("created_at") ' \ - 'FROM "subscriptions" ' \ - 'WHERE "statistic_profile_id" = "statistic_profiles"."id")') + 'SELECT max("created_at") ' \ + 'FROM "subscriptions" ' \ + 'WHERE "statistic_profile_id" = "statistic_profiles"."id")') .where("users.is_active = 'true'") .limit(50) - query.downcase.split(' ').each do |word| - members = members.where('lower(f_unaccent(profiles.first_name)) ~ :search OR ' \ + query.downcase.split.each do |word| + members = members.where('lower(f_unaccent(users.username)) ~ :search OR ' \ + 'lower(f_unaccent(profiles.first_name)) ~ :search OR ' \ 'lower(f_unaccent(profiles.last_name)) ~ :search', search: word) end @@ -65,13 +66,11 @@ class Members::ListService members = members.where("users.is_allow_contact = 'true'") elsif subscription == 'true' # only admins have the ability to filter by subscription - members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Date.today.to_s) + members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Time.zone.today.to_s) elsif subscription == 'false' - members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Date.today.to_s) + members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Time.zone.today.to_s) end - members = members.where("roles.name = 'member' OR roles.name = 'manager'") if include_admins == 'false' || include_admins.blank? - members.to_a.filter(&:valid?) end diff --git a/app/services/members/members_service.rb b/app/services/members/members_service.rb index 7e7bf8807..ba21f312e 100644 --- a/app/services/members/members_service.rb +++ b/app/services/members/members_service.rb @@ -15,12 +15,6 @@ class Members::MembersService return false end - if admin_group_change?(params) - # an admin cannot change his group - @member.errors.add(:group_id, I18n.t('members.admins_cant_change_group')) - return false - end - group_changed = user_group_change?(params) ex_group = @member.group @@ -130,9 +124,7 @@ class Members::MembersService @member.remove_role ex_role @member.add_role new_role - # if the new role is 'admin', then change the group to the admins group, otherwise to change to the provided group - group_id = new_role == 'admin' ? Group.find_by(slug: 'admins').id : new_group_id - @member.update(group_id: group_id) + @member.update(group_id: new_group_id) # notify NotificationCenter.call type: 'notify_user_role_update', @@ -176,10 +168,6 @@ class Members::MembersService params[:group_id] && @member.group_id != params[:group_id].to_i && !@member.subscribed_plan.nil? end - def admin_group_change?(params) - params[:group_id] && params[:group_id].to_i != Group.find_by(slug: 'admins').id && @member.admin? - end - def user_group_change?(params) @member.group_id && params[:group_id] && @member.group_id != params[:group_id].to_i end diff --git a/app/services/orders/order_canceled_service.rb b/app/services/orders/order_canceled_service.rb new file mode 100644 index 000000000..b9a99b1ed --- /dev/null +++ b/app/services/orders/order_canceled_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for cancel an order +class Orders::OrderCanceledService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart canceled refunded delivered].include?(order.state) + + order.state = 'canceled' + ActiveRecord::Base.transaction do + activity = order.order_activities.create(activity_type: 'canceled', operator_profile_id: current_user.invoicing_profile.id) + order.save + NotificationCenter.call type: 'notify_user_order_is_canceled', + receiver: order.statistic_profile.user, + attached_object: activity + end + order.reload + end +end diff --git a/app/services/orders/order_delivered_service.rb b/app/services/orders/order_delivered_service.rb new file mode 100644 index 000000000..56c0a0bb7 --- /dev/null +++ b/app/services/orders/order_delivered_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Provides methods for set order to delivered state +class Orders::OrderDeliveredService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart payment_failed canceled refunded delivered].include?(order.state) + + order.state = 'delivered' + order.order_activities.push(OrderActivity.new(activity_type: 'delivered', operator_profile_id: current_user.invoicing_profile.id)) + order.save + order.reload + end +end diff --git a/app/services/orders/order_ready_service.rb b/app/services/orders/order_ready_service.rb new file mode 100644 index 000000000..a95a40088 --- /dev/null +++ b/app/services/orders/order_ready_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for set order to ready state +class Orders::OrderReadyService + def call(order, current_user, note = '') + raise ::UpdateOrderStateError if %w[cart payment_failed ready canceled refunded delivered].include?(order.state) + + order.state = 'ready' + ActiveRecord::Base.transaction do + activity = order.order_activities.create(activity_type: 'ready', operator_profile_id: current_user.invoicing_profile.id, note: note) + order.save + NotificationCenter.call type: 'notify_user_order_is_ready', + receiver: order.statistic_profile.user, + attached_object: activity + end + order.reload + end +end diff --git a/app/services/orders/order_refunded_service.rb b/app/services/orders/order_refunded_service.rb new file mode 100644 index 000000000..0935ee1c0 --- /dev/null +++ b/app/services/orders/order_refunded_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Provides methods for refund an order +class Orders::OrderRefundedService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart payment_error refunded delivered].include?(order.state) + + order.state = 'refunded' + ActiveRecord::Base.transaction do + activity = order.order_activities.create(activity_type: 'refunded', operator_profile_id: current_user.invoicing_profile.id) + order.save + NotificationCenter.call type: 'notify_user_order_is_refunded', + receiver: order.statistic_profile.user, + attached_object: activity + end + order.reload + end +end diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb new file mode 100644 index 000000000..693cbca6f --- /dev/null +++ b/app/services/orders/order_service.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# Provides methods for Order +class Orders::OrderService + class << self + include ApplicationHelper + + ORDERS_PER_PAGE = 20 + + def list(filters, current_user) + orders = Order.includes(statistic_profile: [user: [:profile]]).where(nil) + orders = filter_by_user(orders, filters, current_user) + orders = filter_by_reference(orders, filters, current_user) + orders = filter_by_state(orders, filters) + orders = filter_by_period(orders, filters) + + orders = orders.where.not(state: 'cart') if current_user.member? + orders = orders_ordering(orders, filters) + total_count = orders.count + orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE) + { + data: orders, + page: filters[:page]&.to_i || 1, + total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages, + page_size: ORDERS_PER_PAGE, + total_count: total_count + } + end + + def update_state(order, current_user, state, note = nil) + case state + when 'in_progress' + ::Orders::SetInProgressService.new.call(order, current_user) + when 'ready' + ::Orders::OrderReadyService.new.call(order, current_user, note) + when 'canceled' + ::Orders::OrderCanceledService.new.call(order, current_user) + when 'delivered' + ::Orders::OrderDeliveredService.new.call(order, current_user) + when 'refunded' + ::Orders::OrderRefundedService.new.call(order, current_user) + else + nil + end + + # update in elasticsearch (statistics) + stat_order = Stats::Order.search(query: { term: { orderId: order.id } }) + stat_order.map { |s| s.update(state: state) } + + order + end + + def in_stock?(order, stock_type = 'external') + order.order_items.each do |item| + return false if item.orderable.stock[stock_type] < item.quantity || item.orderable.stock[stock_type] < item.orderable.quantity_min + end + true + end + + def greater_than_quantity_min?(order) + order.order_items.each do |item| + return false if item.quantity < item.orderable.quantity_min + end + true + end + + def item_amount_not_equal?(order) + order.order_items.each do |item| + orderable_amount = item.orderable.amount || 0 + return false if item.amount != orderable_amount + end + true + end + + def all_products_is_active?(order) + order.order_items.each do |item| + return false unless item.orderable.is_active + end + true + end + + def withdrawal_instructions(order) + res = order&.order_activities&.find_by(activity_type: 'ready')&.note.presence || + Setting.get('store_withdrawal_instructions').presence || + _t('order.please_contact_FABLAB', FABLAB: Setting.get('fablab_name').presence || 'empty') + + ActionController::Base.helpers.sanitize(res, tags: %w[p ul li h3 u em strong a], attributes: %w[target rel href]) + end + + private + + def filter_by_user(orders, filters, current_user) + if filters[:user_id] + statistic_profile_id = current_user.statistic_profile.id + if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged? + user = User.find(filters[:user_id]) + statistic_profile_id = user.statistic_profile.id + end + orders = orders.where(statistic_profile_id: statistic_profile_id) + elsif current_user.member? + orders = orders.where(statistic_profile_id: current_user.statistic_profile.id) + else + orders = orders.where.not(statistic_profile_id: nil) + end + orders + end + + def filter_by_reference(orders, filters, current_user) + return orders unless filters[:reference].present? && current_user.privileged? + + orders.where(reference: filters[:reference]) + end + + def filter_by_state(orders, filters) + return orders if filters[:states].blank? + + state = filters[:states].split(',') + orders.where(state: state) unless state.empty? + end + + def filter_by_period(orders, filters) + return orders unless filters[:period_from].present? && filters[:period_to].present? + + orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day) + end + + def orders_ordering(orders, filters) + key, order = filters[:sort]&.split('-') + key ||= 'created_at' + order ||= 'desc' + + orders.order(key => order) + end + end +end diff --git a/app/services/orders/set_in_progress_service.rb b/app/services/orders/set_in_progress_service.rb new file mode 100644 index 000000000..9aa6c8fb4 --- /dev/null +++ b/app/services/orders/set_in_progress_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Provides methods for set in progress state to order +class Orders::SetInProgressService + def call(order, current_user) + raise ::UpdateOrderStateError if %w[cart payment_failed in_progress canceled refunded delivered].include?(order.state) + + order.state = 'in_progress' + order.order_activities.push(OrderActivity.new(activity_type: 'in_progress', operator_profile_id: current_user.invoicing_profile.id)) + order.save + order.reload + end +end diff --git a/app/services/payment_document_service.rb b/app/services/payment_document_service.rb index 1590eff3c..4d099ed84 100644 --- a/app/services/payment_document_service.rb +++ b/app/services/payment_document_service.rb @@ -43,16 +43,16 @@ class PaymentDocumentService reference end - def generate_order_number(invoice) + def generate_order_number(document) pattern = Setting.get('invoice_order-nb') # global document number (nn..nn) reference = pattern.gsub(/n+(?![^\[]*\])/) do |match| - pad_and_truncate(number_of_invoices('global'), match.to_s.length) + pad_and_truncate(number_of_invoices(document.is_a?(Order) ? 'order' : 'global'), match.to_s.length) end - reference = replace_invoice_number_pattern(reference, invoice.created_at) - replace_date_pattern(reference, invoice.created_at) + reference = replace_invoice_number_pattern(reference, document.created_at) + replace_date_pattern(reference, document.created_at) end private @@ -83,13 +83,14 @@ class PaymentDocumentService when 'year' start = date.beginning_of_year else - return get_max_id(Invoice) + get_max_id(PaymentSchedule) + return get_max_id(Invoice) + get_max_id(PaymentSchedule) + get_max_id(Order) end ending = date - return Invoice.count + PaymentSchedule.count unless defined? start + return Invoice.count + PaymentSchedule.count + Order.count unless defined? start Invoice.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + - PaymentSchedule.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + PaymentSchedule.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length + + Order.where('created_at >= :start_date AND created_at <= :end_date', start_date: start, end_date: ending).length end ## @@ -101,21 +102,21 @@ class PaymentDocumentService copy = reference.dup # full year (YYYY) - copy.gsub!(/YYYY(?![^\[]*\])/, date.strftime('%Y')) + copy.gsub!(/(?![^\[]*\])YYYY(?![^\[]*\])/, date.strftime('%Y')) # year without century (YY) - copy.gsub!(/YY(?![^\[]*\])/, date.strftime('%y')) + copy.gsub!(/(?![^\[]*\])YY(?![^\[]*\])/, date.strftime('%y')) # abbreviated month name (MMM) - copy.gsub!(/MMM(?![^\[]*\])/, date.strftime('%^b')) + copy.gsub!(/(?![^\[]*\])MMM(?![^\[]*\])/, date.strftime('%^b')) # month of the year, zero-padded (MM) - copy.gsub!(/MM(?![^\[]*\])/, date.strftime('%m')) + copy.gsub!(/(?![^\[]*\])MM(?![^\[]*\])/, date.strftime('%m')) # month of the year, non zero-padded (M) - copy.gsub!(/M(?![^\[]*\])/, date.strftime('%-m')) + copy.gsub!(/(?![^\[]*\])M(?![^\[]*\])/, date.strftime('%-m')) # day of the month, zero-padded (DD) - copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%d')) + copy.gsub!(/(?![^\[]*\])DD(?![^\[]*\])/, date.strftime('%d')) # day of the month, non zero-padded (DD) - copy.gsub!(/DD(?![^\[]*\])/, date.strftime('%-d')) + copy.gsub!(/(?![^\[]*\])DD(?![^\[]*\])/, date.strftime('%-d')) copy end diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 934e6e040..ac8cf90c4 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -17,35 +17,14 @@ class PaymentScheduleService ps = PaymentSchedule.new(total: price + other_items, coupon: coupon) deadlines = plan.duration / 1.month per_month = (price / deadlines).truncate - adjustment = if per_month * deadlines + other_items.truncate != ps.total - ps.total - (per_month * deadlines + other_items.truncate) - else + adjustment = if (per_month * deadlines) + other_items.truncate == ps.total 0 + else + ps.total - ((per_month * deadlines) + other_items.truncate) end items = [] (0..deadlines - 1).each do |i| - date = (start_at || DateTime.current) + i.months - details = { recurring: per_month } - amount = if i.zero? - details[:adjustment] = adjustment.truncate - details[:other_items] = other_items.truncate - per_month + adjustment.truncate + other_items.truncate - else - per_month - end - if coupon - cs = CouponService.new - if (coupon.validity_per_user == 'once' && i.zero?) || coupon.validity_per_user == 'forever' - details[:without_coupon] = amount - amount = cs.apply(amount, coupon) - end - end - items.push PaymentScheduleItem.new( - amount: amount, - due_date: date, - payment_schedule: ps, - details: details - ) + items.push compute_deadline(i, ps, per_month, adjustment, other_items, coupon: coupon, schedule_start_at: start_at) end ps.start_at = start_at ps.total = items.map(&:amount).reduce(:+) @@ -54,9 +33,35 @@ class PaymentScheduleService { payment_schedule: ps, items: items } end + def compute_deadline(deadline_index, payment_schedule, price_per_month, adjustment_price, other_items_price, + coupon: nil, schedule_start_at: nil) + date = (schedule_start_at || DateTime.current) + deadline_index.months + details = { recurring: price_per_month } + amount = if deadline_index.zero? + details[:adjustment] = adjustment_price.truncate + details[:other_items] = other_items_price.truncate + price_per_month + adjustment_price.truncate + other_items_price.truncate + else + price_per_month + end + if coupon + cs = CouponService.new + if (coupon.validity_per_user == 'once' && deadline_index.zero?) || coupon.validity_per_user == 'forever' + details[:without_coupon] = amount + amount = cs.apply(amount, coupon) + end + end + PaymentScheduleItem.new( + amount: amount, + due_date: date, + payment_schedule: payment_schedule, + details: details + ) + end + def create(objects, total, customer, coupon: nil, operator: nil, payment_method: nil, payment_id: nil, payment_type: nil) - subscription = objects.find { |item| item.class == Subscription } + subscription = objects.find { |item| item.instance_of?(Subscription) } schedule = compute(subscription.plan, total, customer, coupon: coupon, start_at: subscription.start_at) ps = schedule[:payment_schedule] @@ -80,7 +85,7 @@ class PaymentScheduleService def build_objects(objects) res = [] res.push(PaymentScheduleObject.new(object: objects[0], main: true)) - objects[1..-1].each do |object| + objects[1..].each do |object| res.push(PaymentScheduleObject.new(object: object)) end res @@ -117,7 +122,7 @@ class PaymentScheduleService # save the results invoice.save - payment_schedule_item.update_attributes(invoice_id: invoice.id) + payment_schedule_item.update(invoice_id: invoice.id) end ## @@ -133,7 +138,6 @@ class PaymentScheduleService .page(page) .per(size) - unless filters[:reference].nil? ps = ps.where( 'payment_schedules.reference LIKE :search', @@ -168,7 +172,7 @@ class PaymentScheduleService payment_schedule.ordered_items.each do |item| next if item.state == 'paid' - item.update_attributes(state: 'canceled') + item.update(state: 'canceled') end # cancel subscription subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription @@ -192,7 +196,7 @@ class PaymentScheduleService ## def reset_erroneous_payment_schedule_items(payment_schedule) results = payment_schedule.payment_schedule_items.where(state: %w[error gateway_canceled]).map do |item| - item.update_attributes(state: item.due_date < DateTime.current ? 'pending' : 'new') + item.update(state: item.due_date < DateTime.current ? 'pending' : 'new') end results.reduce(true) { |acc, item| acc && item } end @@ -208,7 +212,10 @@ class PaymentScheduleService } # the subscription and reservation items - subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription + subscription = payment_schedule_item.payment_schedule + .payment_schedule_objects + .find { |pso| pso.object_type == Subscription.name } + .subscription if payment_schedule_item.payment_schedule.main_object.object_type == Reservation.name details[:reservation] = payment_schedule_item.details['other_items'] reservation = payment_schedule_item.payment_schedule.main_object.reservation @@ -227,7 +234,10 @@ class PaymentScheduleService ## def complete_next_invoice(payment_schedule_item, invoice) # the subscription item - subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription + subscription = payment_schedule_item.payment_schedule + .payment_schedule_objects + .find { |pso| pso.object_type == Subscription.name } + .subscription # sub-price for the subscription details = { subscription: payment_schedule_item.details['recurring'] } @@ -244,7 +254,7 @@ class PaymentScheduleService return unless subscription - generate_subscription_item(invoice, subscription, payment_details, reservation.nil?) + generate_subscription_item(invoice, subscription, payment_details, main: reservation.nil?) end ## @@ -271,7 +281,7 @@ class PaymentScheduleService # Generate an InvoiceItem for the given subscription and save it in invoice.invoice_items. # This method must be called only with a valid subscription ## - def generate_subscription_item(invoice, subscription, payment_details, main = true) + def generate_subscription_item(invoice, subscription, payment_details, main: true) raise TypeError unless subscription invoice.invoice_items.push InvoiceItem.new( @@ -291,11 +301,9 @@ class PaymentScheduleService total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) - unless coupon.nil? - if (coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever' - total = CouponService.new.apply(total, coupon, user.id) - invoice.coupon_id = coupon.id - end + if !coupon.nil? && ((coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever') + total = CouponService.new.apply(total, coupon, user.id) + invoice.coupon_id = coupon.id end invoice.total = total diff --git a/app/services/payments/local_service.rb b/app/services/payments/local_service.rb new file mode 100644 index 000000000..43bd07d08 --- /dev/null +++ b/app/services/payments/local_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Local +class Payments::LocalService + include Payments::PaymentConcern + + def payment(order, coupon_code) + o = payment_success(order, coupon_code, 'local') + { order: o } + end +end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb new file mode 100644 index 000000000..6bc54d567 --- /dev/null +++ b/app/services/payments/payment_concern.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Concern for Payment +module Payments::PaymentConcern + private + + def get_wallet_debit(user, total_amount) + wallet_amount = (user.wallet.amount * 100).to_i + wallet_amount >= total_amount ? total_amount : wallet_amount + end + + def debit_amount(order, coupon_code = nil) + total = CouponService.new.apply(order.total, coupon_code, order.statistic_profile.user.id) + wallet_debit = get_wallet_debit(order.statistic_profile.user, total) + total - wallet_debit + end + + def payment_success(order, coupon_code, payment_method = '', payment_id = nil, payment_type = nil) + ActiveRecord::Base.transaction do + order.paid_total = debit_amount(order, coupon_code) + coupon = Coupon.find_by(code: coupon_code) + order.coupon_id = coupon.id if coupon + WalletService.debit_user_wallet(order, order.statistic_profile.user) + order.operator_profile_id = order.statistic_profile.user.invoicing_profile.id if order.operator_profile.nil? + order.payment_method = if order.total == order.wallet_amount + 'wallet' + else + payment_method + end + order.state = 'paid' + order.created_at = DateTime.current + if payment_id && payment_type + order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type) + end + order.order_activities.create(activity_type: 'paid', operator_profile_id: order.operator_profile_id) + order.order_items.each do |item| + ProductService.update_stock(item.orderable, + [{ stock_type: 'external', reason: 'sold', quantity: item.quantity, order_item_id: item.id }]).save + end + create_invoice(order, coupon, payment_id, payment_type) if order.save + order.reload + end + end + + def create_invoice(order, coupon, payment_id, payment_type) + invoice = InvoicesService.create( + { total: order.total, coupon: coupon }, + order.operator_profile_id, + order.order_items, + order.statistic_profile.user, + payment_id: payment_id, + payment_type: payment_type, + payment_method: order.payment_method + ) + invoice.wallet_amount = order.wallet_amount + invoice.wallet_transaction_id = order.wallet_transaction_id + invoice.save + order.update(invoice_id: invoice.id) + end +end diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb new file mode 100644 index 000000000..09902962b --- /dev/null +++ b/app/services/payments/payzen_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by PayZen +class Payments::PayzenService + require 'pay_zen/helper' + require 'pay_zen/order' + require 'pay_zen/charge' + require 'pay_zen/service' + include Payments::PaymentConcern + + def payment(order, coupon_code) + amount = debit_amount(order, coupon_code) + + raise Cart::ZeroPriceError if amount.zero? + + id = PayZen::Helper.generate_ref(order, order.statistic_profile.user.id) + + client = PayZen::Charge.new + result = client.create_payment(amount: PayZen::Service.new.payzen_amount(amount), + order_id: id, + customer: PayZen::Helper.generate_customer(order.statistic_profile.user.id, + order.statistic_profile.user.id, order)) + { order: order, payment: { formToken: result['answer']['formToken'], orderId: id } } + end + + def confirm_payment(order, coupon_code, payment_id) + client = PayZen::Order.new + payzen_order = client.get(payment_id, operation_type: 'DEBIT') + + if payzen_order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' } + o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order') + { order: o } + else + order.update(state: 'payment_failed') + { order: order, payment: { error: { statusText: payzen_order['answer'] } } } + end + end +end diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb new file mode 100644 index 000000000..479077cd6 --- /dev/null +++ b/app/services/payments/stripe_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Provides methods for pay cart by Stripe +class Payments::StripeService + require 'stripe/service' + include Payments::PaymentConcern + + def payment(order, coupon_code, payment_id) + amount = debit_amount(order, coupon_code) + + raise Cart::ZeroPriceError if amount.zero? + + # Create the PaymentIntent + intent = Stripe::PaymentIntent.create( + { + payment_method: payment_id, + amount: Stripe::Service.new.stripe_amount(amount), + currency: Setting.get('stripe_currency'), + confirmation_method: 'manual', + confirm: true, + customer: order.statistic_profile.user.payment_gateway_object.gateway_object_id + }, { api_key: Setting.get('stripe_secret_key') } + ) + + if intent&.status == 'succeeded' + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) + return { order: o } + end + + if intent&.status == 'requires_action' && intent&.next_action&.type == 'use_stripe_sdk' + { order: order, payment: { requires_action: true, payment_intent_client_secret: intent.client_secret, + type: 'payment' } } + end + end + + def confirm_payment(order, coupon_code, payment_id) + intent = Stripe::PaymentIntent.confirm(payment_id, {}, { api_key: Setting.get('stripe_secret_key') }) + if intent&.status == 'succeeded' + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) + { order: o } + else + order.update(state: 'payment_failed') + { order: order, payment: { error: { statusText: 'payment failed' } } } + end + end +end diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb new file mode 100644 index 000000000..b7e8c984d --- /dev/null +++ b/app/services/product_category_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Provides methods for ProductCategory +class ProductCategoryService + def self.list + ProductCategory.left_outer_joins(:products) + .select('product_categories.*, count(products.*) filter (where is_active is true) as products_count') + .group('product_categories.id') + end + + def self.destroy(product_category) + ActiveRecord::Base.transaction do + sub_categories = ProductCategory.where(parent_id: product_category.id) + # remove product_category and sub-categories related id in product + Product.where(product_category_id: sub_categories.map(&:id).push(product_category.id)).update(product_category_id: nil) + # remove all sub-categories + sub_categories.destroy_all + product_category.destroy + end + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb new file mode 100644 index 000000000..f42d67527 --- /dev/null +++ b/app/services/product_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +# Provides methods for Product +class ProductService + class << self + PRODUCTS_PER_PAGE = 12 + MOVEMENTS_PER_PAGE = 10 + + def list(filters, operator) + products = Product.includes(:product_images) + products = filter_by_active(products, filters) + products = filter_by_categories(products, filters) + products = filter_by_machines(products, filters) + products = filter_by_keyword_or_reference(products, filters) + products = filter_by_stock(products, filters, operator) + products = products_ordering(products, filters) + + total_count = products.count + products = products.page(filters[:page] || 1).per(PRODUCTS_PER_PAGE) + { + data: products, + page: filters[:page]&.to_i || 1, + total_pages: products.page(1).per(PRODUCTS_PER_PAGE).total_pages, + page_size: PRODUCTS_PER_PAGE, + total_count: total_count + } + end + + # amount params multiplied by hundred + def amount_multiplied_by_hundred(amount) + if amount.present? + v = amount.to_f + + return v * 100 + end + nil + end + + # @param product Product + # @param stock_movements [{stock_type: string, reason: string, quantity: number|string, order_item_id: number|nil}] + def update_stock(product, stock_movements = nil) + remaining_stock = { internal: product.stock['internal'], external: product.stock['external'] } + product.product_stock_movements_attributes = stock_movements&.map do |movement| + quantity = ProductStockMovement::OUTGOING_REASONS.include?(movement[:reason]) ? -movement[:quantity].to_i : movement[:quantity].to_i + remaining_stock[movement[:stock_type].to_sym] += quantity + { + stock_type: movement[:stock_type], reason: movement[:reason], quantity: quantity, + remaining_stock: remaining_stock[movement[:stock_type].to_sym], date: DateTime.current, order_item_id: movement[:order_item_id] + } + end || {} + product.stock = remaining_stock + notify_on_low_stock(product) if product.low_stock_alert + product + end + + def create(product_params, stock_movement_params = []) + product = Product.new(product_params) + product.amount = amount_multiplied_by_hundred(product_params[:amount]) + update_stock(product, stock_movement_params) + product + end + + def update(product, product_params, stock_movement_params = []) + product_params[:amount] = amount_multiplied_by_hundred(product_params[:amount]) + product.attributes = product_params + update_stock(product, stock_movement_params) + product + end + + def clone(product, product_params) + new_product = product.dup + new_product.name = product_params[:name] + new_product.sku = product_params[:sku] + new_product.is_active = product_params[:is_active] + new_product.stock['internal'] = 0 + new_product.stock['external'] = 0 + new_product.machine_ids = product.machine_ids + new_product.machine_ids = product.machine_ids + product.product_images.each do |image| + pi = new_product.product_images.build + pi.is_main = image.is_main + pi.attachment = File.open(image.attachment.file.file) + end + product.product_files.each do |file| + pf = new_product.product_files.build + pf.attachment = File.open(file.attachment.file.file) + end + new_product + end + + def destroy(product) + used_in_order = OrderItem.joins(:order).where.not('orders.state' => 'cart') + .exists?(orderable: product) + raise CannotDeleteProductError, I18n.t('errors.messages.product_in_use') if used_in_order + + ActiveRecord::Base.transaction do + orders_with_product = Order.joins(:order_items).where(state: 'cart').where('order_items.orderable': product) + orders_with_product.each do |order| + ::Cart::RemoveItemService.new.call(order, product) + end + + product.destroy + end + end + + def stock_movements(filters) + movements = ProductStockMovement.where(product_id: filters[:id]).order(date: :desc) + movements = filter_by_stock_type(movements, filters) + movements = filter_by_reason(movements, filters) + + total_count = movements.count + movements = movements.page(filters[:page] || 1).per(MOVEMENTS_PER_PAGE) + { + data: movements, + page: filters[:page]&.to_i || 1, + total_pages: movements.page(1).per(MOVEMENTS_PER_PAGE).total_pages, + page_size: MOVEMENTS_PER_PAGE, + total_count: total_count + } + end + + private + + def filter_by_active(products, filters) + return products if filters[:is_active].blank? + + state = filters[:is_active] == 'false' ? [nil, false, true] : true + products.where(is_active: state) + end + + def filter_by_categories(products, filters) + return products if filters[:categories].blank? + + products.where(product_category_id: filters[:categories].split(',')) + end + + def filter_by_machines(products, filters) + return products if filters[:machines].blank? + + products.includes(:machines_products).where('machines_products.machine_id': filters[:machines].split(',')) + end + + def filter_by_keyword_or_reference(products, filters) + return products if filters[:keywords].blank? + + products.where('sku = :sku OR lower(f_unaccent(name)) ILIKE :query OR lower(f_unaccent(description)) ILIKE :query', + { sku: (filters[:keywords]), query: "%#{I18n.transliterate(filters[:keywords])}%" }) + end + + def filter_by_stock(products, filters, operator) + return products if filters[:stock_type] == 'internal' && !operator&.privileged? + + products = if filters[:stock_from].to_i.positive? + products.where('(stock ->> ?)::int >= ?', filters[:stock_type], filters[:stock_from]) + elsif filters[:store] == 'true' && filters[:is_available] == 'true' + products.where('(stock ->> ?)::int >= quantity_min', filters[:stock_type]) + else + products + end + products = products.where('(stock ->> ?)::int <= ?', filters[:stock_type], filters[:stock_to]) if filters[:stock_to].to_i != 0 + + products + end + + def products_ordering(products, filters) + key, order = filters[:sort]&.split('-') + key ||= 'created_at' + order ||= 'desc' + + if key == 'amount' + products.order("COALESCE(amount, 0) #{order.upcase}") + else + products.order(key => order) + end + end + + def filter_by_stock_type(movements, filters) + return movements if filters[:stock_type].blank? || filters[:stock_type] == 'all' + + movements.where(stock_type: filters[:stock_type]) + end + + def filter_by_reason(movements, filters) + return movements if filters[:reason].blank? + + movements.where(reason: filters[:reason]) + end + + def notify_on_low_stock(product) + return product unless product.low_stock_threshold + + if (product.stock['internal'] <= product.low_stock_threshold) || + (product.stock['external'] <= product.low_stock_threshold) + NotificationCenter.call type: 'notify_admin_low_stock_threshold', + receiver: User.admins_and_managers, + attached_object: product + end + product + end + end +end diff --git a/app/services/setting_service.rb b/app/services/setting_service.rb index 2a74fd1b9..e8b1ba092 100644 --- a/app/services/setting_service.rb +++ b/app/services/setting_service.rb @@ -4,31 +4,73 @@ # Due to the way the controller updates the settings, we cannot safely use ActiveRecord's callbacks (eg. after_update, after_commit...) # so this service provides a wrapper around these operations. class SettingService - def self.before_update(setting) - return false if Rails.application.secrets.locked_settings.include? setting.name + class << self + def before_update(setting) + return false if Rails.application.secrets.locked_settings.include? setting.name - true - end + true + end - def self.after_update(setting) - # update the stylesheet - Stylesheet.theme&.rebuild! if %w[main_color secondary_color].include? setting.name - Stylesheet.home_page&.rebuild! if setting.name == 'home_css' + def after_update(setting) + update_theme_stylesheet(setting) + update_home_stylesheet(setting) + notify_privacy_update(setting) + sync_stripe_objects(setting) + build_stats(setting) + export_projects_to_openlab(setting) + validate_admins(setting) + end + + private + + # rebuild the theme stylesheet + def update_theme_stylesheet(setting) + return unless %w[main_color secondary_color].include? setting.name + + Stylesheet.theme&.rebuild! + end + + # rebuild the home page stylesheet + def update_home_stylesheet(setting) + return unless setting.name == 'home_css' + + Stylesheet.home_page&.rebuild! + end # notify about a change in privacy policy - NotifyPrivacyUpdateWorker.perform_async(setting.id) if setting.name == 'privacy_body' + def notify_privacy_update(setting) + return unless setting.name == 'privacy_body' + + NotifyPrivacyUpdateWorker.perform_async(setting.id) + end # sync all objects on stripe - SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) if setting.name == 'stripe_secret_key' + def sync_stripe_objects(setting) + return unless setting.name == 'stripe_secret_key' - # generate statistics - PeriodStatisticsWorker.perform_async(setting.previous_update) if setting.name == 'statistics_module' && setting.value == 'true' + SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) + end + + # generate the statistics since the last update + def build_stats(setting) + return unless setting.name == 'statistics_module' && setting.value == 'true' + + PeriodStatisticsWorker.perform_async(setting.previous_update) + end # export projects to openlab - if %w[openlab_app_id openlab_app_secret].include? setting.name - if Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present? - Project.all.each { |pr| pr.openlab_create } - end + def export_projects_to_openlab(setting) + return unless %w[openlab_app_id openlab_app_secret].include?(setting.name) && + Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present? + + Project.all.each(&:openlab_create) + end + + # automatically validate the admins + def validate_admins(setting) + return unless setting.name == 'user_validation_required' && setting.value == 'true' + + User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? } end end end diff --git a/app/services/statistics/builder_service.rb b/app/services/statistics/builder_service.rb index 53d7a49a5..60fcc57d2 100644 --- a/app/services/statistics/builder_service.rb +++ b/app/services/statistics/builder_service.rb @@ -11,6 +11,7 @@ class Statistics::BuilderService Statistics::Builders::ReservationsBuilderService.build(options) Statistics::Builders::MembersBuilderService.build(options) Statistics::Builders::ProjectsBuilderService.build(options) + Statistics::Builders::StoreOrdersBuilderService.build(options) end private diff --git a/app/services/statistics/builders/reservations_builder_service.rb b/app/services/statistics/builders/reservations_builder_service.rb index 082f4dc38..57a1d7a3a 100644 --- a/app/services/statistics/builders/reservations_builder_service.rb +++ b/app/services/statistics/builders/reservations_builder_service.rb @@ -7,39 +7,30 @@ class Statistics::Builders::ReservationsBuilderService class << self def build(options = default_options) # machine/space/training list - %w[machine space training].each do |category| + %w[machine space training event].each do |category| Statistics::FetcherService.send("reservations_#{category}_list", options).each do |r| %w[booking hour].each do |type| - stat = Stats::Machine.new({ date: format_date(r[:date]), - type: type, - subType: r["#{category}_type".to_sym], - ca: r[:ca], - machineId: r["#{category}_id".to_sym], - name: r["#{category}_name".to_sym], - reservationId: r[:reservation_id] }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? 1 : r[:nb_hours]) + stat = "Stats::#{category.capitalize}" + .constantize + .new({ date: format_date(r[:date]), + type: type, + subType: r["#{category}_type".to_sym], + ca: r[:ca], + name: r["#{category}_name".to_sym], + reservationId: r[:reservation_id] }.merge(user_info_stat(r))) + stat[:stat] = (type == 'booking' ? 1 : r[:nb_hours]) + stat["#{category}Id".to_sym] = r["#{category}_id".to_sym] + + if category == 'event' + stat[:eventDate] = r[:event_date] + stat[:eventTheme] = r[:event_theme] + stat[:ageRange] = r[:age_range] + end + stat.save end end end - - # event list - Statistics::FetcherService.reservations_event_list(options).each do |r| - %w[booking hour].each do |type| - stat = Stats::Event.new({ date: format_date(r[:date]), - type: type, - subType: r[:event_type], - ca: r[:ca], - eventId: r[:event_id], - name: r[:event_name], - eventDate: r[:event_date], - reservationId: r[:reservation_id], - eventTheme: r[:event_theme], - ageRange: r[:age_range] }.merge(user_info_stat(r))) - stat.stat = (type == 'booking' ? r[:nb_places] : r[:nb_hours]) - stat.save - end - end end end end diff --git a/app/services/statistics/builders/store_orders_builder_service.rb b/app/services/statistics/builders/store_orders_builder_service.rb new file mode 100644 index 000000000..640418591 --- /dev/null +++ b/app/services/statistics/builders/store_orders_builder_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Generate statistics indicators about store's orders +class Statistics::Builders::StoreOrdersBuilderService + include Statistics::Concerns::HelpersConcern + include Statistics::Concerns::StoreOrdersConcern + + class << self + def build(options = default_options) + states = { + 'paid-processed': %w[paid in_progress ready delivered], + aborted: %w[payment_failed refunded canceled] + } + # orders list + states.each do |sub_type, order_states| + Statistics::FetcherService.store_orders_list(order_states, options).each do |o| + Stats::Order.create({ date: format_date(o[:date]), + type: 'store', + subType: sub_type, + ca: o[:ca], + products: o[:order_products], + categories: o[:order_categories], + orderId: o[:order_id], + state: o[:order_state], + stat: 1 }.merge(user_info_stat(o))) + end + end + end + end +end diff --git a/app/services/statistics/cleaner_service.rb b/app/services/statistics/cleaner_service.rb index 671700869..8ed6b50e1 100644 --- a/app/services/statistics/cleaner_service.rb +++ b/app/services/statistics/cleaner_service.rb @@ -7,7 +7,7 @@ class Statistics::CleanerService class << self def clean_stat(options = default_options) client = Elasticsearch::Model.client - %w[Account Event Machine Project Subscription Training User Space].each do |o| + %w[Account Event Machine Project Subscription Training User Space Order].each do |o| model = "Stats::#{o}".constantize client.delete_by_query( index: model.index_name, diff --git a/app/services/statistics/concerns/store_orders_concern.rb b/app/services/statistics/concerns/store_orders_concern.rb new file mode 100644 index 000000000..422c74502 --- /dev/null +++ b/app/services/statistics/concerns/store_orders_concern.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Provides methods to consolidate data from Store Orders to use in statistics +module Statistics::Concerns::StoreOrdersConcern + extend ActiveSupport::Concern + + class_methods do + def get_order_products(order) + order.order_items.where(orderable_type: 'Product').map do |item| + { id: item.orderable_id, name: item.orderable.name } + end + end + + def get_order_categories(order) + order.order_items + .where(orderable_type: 'Product') + .map(&:orderable) + .map(&:product_category) + .compact + .map { |cat| { id: cat.id, name: cat.name } } + .uniq + end + + def store_order_info(order) + { + order_id: order.id, + order_state: order.state, + order_products: get_order_products(order), + order_categories: get_order_categories(order) + } + end + end +end diff --git a/app/services/statistics/fetcher_service.rb b/app/services/statistics/fetcher_service.rb index 0569e9ecc..9838a343b 100644 --- a/app/services/statistics/fetcher_service.rb +++ b/app/services/statistics/fetcher_service.rb @@ -6,6 +6,7 @@ class Statistics::FetcherService include Statistics::Concerns::HelpersConcern include Statistics::Concerns::ComputeConcern include Statistics::Concerns::ProjectsConcern + include Statistics::Concerns::StoreOrdersConcern class << self def subscriptions_list(options = default_options) @@ -182,6 +183,22 @@ class Statistics::FetcherService result end + def store_orders_list(states, options = default_options) + result = [] + Order.includes(order_items: [:orderable]) + .joins(:order_items, :order_activities) + .where(order_items: { orderable_type: 'Product' }) + .where(orders: { state: states }) + .where('order_activities.created_at >= :start_date AND order_activities.created_at <= :end_date', options) + .group('orders.id') + .each do |o| + result.push({ date: o.created_at.to_date, ca: calcul_ca(o.invoice) } + .merge(user_info(o.statistic_profile)) + .merge(store_order_info(o))) + end + result + end + private def add_ca(profile, new_ca, users_list) diff --git a/app/services/statistics/query_service.rb b/app/services/statistics/query_service.rb new file mode 100644 index 000000000..34b817f74 --- /dev/null +++ b/app/services/statistics/query_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Query the elasticsearch database of statistics and format the result +class Statistics::QueryService + class << self + def query(statistic_index, request) + # remove additional parameters + statistic_type = request.query_parameters.delete('stat-type') + custom_query = request.query_parameters.delete('custom-query') + start_date = request.query_parameters.delete('start-date') + end_date = request.query_parameters.delete('end-date') + + # run main query in elasticSearch + query = MultiJson.load(request.body.read) + model = "Stats::#{statistic_index.capitalize}".constantize + results = model.search(query, request.query_parameters.symbolize_keys).response + + # run additional custom aggregations, if any + CustomAggregationService.new.call(statistic_index, statistic_type, start_date, end_date, custom_query, results) + + results + end + + def export(statistic_index, params) + export = ExportService.last_export("statistics/#{statistic_index}") + if export.nil? || !FileTest.exist?(export.file) + Export.new(category: 'statistics', + export_type: statistic_index, + user: current_user, + query: params[:body], + key: params[:type_key]) + else + File.root.join(export.file) + end + end + end +end diff --git a/app/services/user_getter_service.rb b/app/services/user_getter_service.rb new file mode 100644 index 000000000..4634dd753 --- /dev/null +++ b/app/services/user_getter_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# helpers to read data from a user +class UserGetterService + def initialize(user) + @user = user + end + + def read_attribute(attribute) + parsed = /^(user|profile)\.(.+)$/.match(attribute) + case parsed[1] + when 'user' + @user[parsed[2].to_sym] + when 'profile' + case attribute + when 'profile.avatar' + @user.profile.user_avatar.remote_attachment_url + when 'profile.address' + @user.invoicing_profile.address&.address + when 'profile.organization_name' + @user.invoicing_profile.organization&.name + when 'profile.organization_address' + @user.invoicing_profile.organization&.address&.address + when 'profile.gender' + @user.statistic_profile.gender + when 'profile.birthday' + @user.statistic_profile.birthday + else + @user.profile[parsed[2].to_sym] + end + else + nil + end + end +end diff --git a/app/services/user_service.rb b/app/services/user_service.rb index d117193fb..912f654cf 100644 --- a/app/services/user_service.rb +++ b/app/services/user_service.rb @@ -2,67 +2,66 @@ # helpers for managing users with special roles class UserService - def self.create_partner(params) - generated_password = SecurePassword.generate - group_id = Group.first.id - user = User.new( - email: params[:email], - username: "#{params[:first_name]}#{params[:last_name]}".parameterize, - password: generated_password, - password_confirmation: generated_password, - group_id: group_id - ) - user.build_profile( - first_name: params[:first_name], - last_name: params[:last_name], - phone: '0000000000' - ) - user.build_statistic_profile( - gender: true, - birthday: DateTime.current - ) + class << self + def create_partner(params) + generated_password = SecurePassword.generate + group_id = Group.first.id + user = User.new( + email: params[:email], + username: "#{params[:first_name]}#{params[:last_name]}".parameterize, + password: generated_password, + password_confirmation: generated_password, + group_id: group_id + ) + user.build_profile( + first_name: params[:first_name], + last_name: params[:last_name], + phone: '0000000000' + ) + user.build_statistic_profile( + gender: true, + birthday: DateTime.current + ) - saved = user.save - if saved - user.remove_role :member - user.add_role :partner + saved = user.save + if saved + user.remove_role :member + user.add_role :partner + end + { saved: saved, user: user } end - { saved: saved, user: user } - end - def self.create_admin(params) - generated_password = SecurePassword.generate - admin = User.new(params.merge(password: generated_password)) - admin.send :set_slug + def create_admin(params) + generated_password = SecurePassword.generate + admin = User.new(params.merge(password: generated_password, validated_at: DateTime.current)) + admin.send :set_slug - # we associate the admin group to prevent linking any other 'normal' group (which won't be deletable afterwards) - admin.group = Group.find_by(slug: 'admins') + # if the authentication is made through an SSO, generate a migration token + admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name - # if the authentication is made through an SSO, generate a migration token - admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name - - saved = admin.save - if saved - admin.send_confirmation_instructions - admin.add_role(:admin) - admin.remove_role(:member) - UsersMailer.notify_user_account_created(admin, generated_password).deliver_later + saved = admin.save + if saved + admin.send_confirmation_instructions + admin.add_role(:admin) + admin.remove_role(:member) + UsersMailer.notify_user_account_created(admin, generated_password).deliver_later + end + { saved: saved, user: admin } end - { saved: saved, user: admin } - end - def self.create_manager(params) - generated_password = SecurePassword.generate - manager = User.new(params.merge(password: generated_password)) - manager.send :set_slug + def create_manager(params) + generated_password = SecurePassword.generate + manager = User.new(params.merge(password: generated_password)) + manager.send :set_slug - saved = manager.save - if saved - manager.send_confirmation_instructions - manager.add_role(:manager) - manager.remove_role(:member) - UsersMailer.notify_user_account_created(manager, generated_password).deliver_later + saved = manager.save + if saved + manager.send_confirmation_instructions + manager.add_role(:manager) + manager.remove_role(:member) + UsersMailer.notify_user_account_created(manager, generated_password).deliver_later + end + { saved: saved, user: manager } end - { saved: saved, user: manager } end end diff --git a/app/services/user_setter_service.rb b/app/services/user_setter_service.rb new file mode 100644 index 000000000..be7ea1c50 --- /dev/null +++ b/app/services/user_setter_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# helpers to assign data to a user +class UserSetterService + def initialize(user) + @user = user + end + + def assign_avatar(data) + @user.profile.user_avatar ||= UserAvatar.new + @user.profile.user_avatar.remote_attachment_url = data + end + + def assign_address(data) + @user.invoicing_profile ||= InvoicingProfile.new + @user.invoicing_profile.address ||= Address.new + @user.invoicing_profile.address.address = data + end + + def assign_organization_name(data) + @user.invoicing_profile ||= InvoicingProfile.new + @user.invoicing_profile.organization ||= Organization.new + @user.invoicing_profile.organization.name = data + end + + def assign_organization_address(data) + @user.invoicing_profile ||= InvoicingProfile.new + @user.invoicing_profile.organization ||= Organization.new + @user.invoicing_profile.organization.address ||= Address.new + @user.invoicing_profile.organization.address.address = data + end + + def assign_gender(data) + @user.statistic_profile ||= StatisticProfile.new + @user.statistic_profile.gender = data + end + + def assign_birthday(data) + @user.statistic_profile ||= StatisticProfile.new + @user.statistic_profile.birthday = data + end + + def assign_profile_attribute(attribute, data) + @user.profile[attribute[8..].to_sym] = data + end + + def assign_user_attribute(attribute, data) + @user[attribute[5..].to_sym] = data + end + + def assign_attibute(attribute, data) + if attribute.to_s.start_with? 'user.' + assign_user_attribute(attribute, data) + elsif attribute.to_s.start_with? 'profile.' + case attribute.to_s + when 'profile.avatar' + assign_avatar(data) + when 'profile.address' + assign_address(data) + when 'profile.organization_name' + assign_organization_name(data) + when 'profile.organization_address' + assign_organization_address(data) + when 'profile.gender' + assign_gender(data) + when 'profile.birthday' + assign_birthday(data) + else + assign_profile_attribute(attribute, data) + end + end + end +end diff --git a/app/services/vat_history_service.rb b/app/services/vat_history_service.rb index 479c96dcc..034e40f68 100644 --- a/app/services/vat_history_service.rb +++ b/app/services/vat_history_service.rb @@ -37,7 +37,9 @@ class VatHistoryService private - def vat_history(vat_rate_type) + # This method is really complex and cannot be simplified using the current data model + # As a futur improvement, we should save the VAT rate for each invoice_item in the DB + def vat_history(vat_rate_type) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity chronology = [] end_date = DateTime.current Setting.find_by(name: 'invoice_VAT-active').history_values.order(created_at: 'DESC').each do |v| @@ -92,7 +94,7 @@ class VatHistoryService # when the VAT rate was enabled, set the date it was enabled and the rate range = chronology.find { |p| rate.created_at.to_i.between?(p[:start].to_i, p[:end].to_i) } date = range[:enabled] ? rate.created_at : range[:end] - date_rates.push(date: date, rate: rate.value.to_i) unless date_rates.find { |d| d[:date] == date } + date_rates.push(date: date, rate: rate.value.to_f) unless date_rates.find { |d| d[:date].to_i == date.to_i } end chronology.reverse_each do |period| # when the VAT rate was disabled, set the date it was disabled and rate=0 diff --git a/app/services/wallet_service.rb b/app/services/wallet_service.rb index 9966aa11a..6dc4dbcdd 100644 --- a/app/services/wallet_service.rb +++ b/app/services/wallet_service.rb @@ -9,33 +9,33 @@ class WalletService ## credit an amount to wallet, if credit success then return a wallet transaction and notify to admin def credit(amount) + transaction = nil ActiveRecord::Base.transaction do - if @wallet.credit(amount) + if @wallet&.credit(amount) transaction = WalletTransaction.new( - invoicing_profile: @user.invoicing_profile, + invoicing_profile: @user&.invoicing_profile, wallet: @wallet, transaction_type: 'credit', amount: amount ) - if transaction.save - NotificationCenter.call type: 'notify_user_wallet_is_credited', - receiver: @wallet.user, - attached_object: transaction - NotificationCenter.call type: 'notify_admin_user_wallet_is_credited', - receiver: User.admins_and_managers, - attached_object: transaction - return transaction - end + raise ActiveRecord::Rollback unless transaction.save + + NotificationCenter.call type: 'notify_user_wallet_is_credited', + receiver: @wallet&.user, + attached_object: transaction + NotificationCenter.call type: 'notify_admin_user_wallet_is_credited', + receiver: User.admins_and_managers, + attached_object: transaction end - raise ActiveRecord::Rollback end - false + transaction end ## debit an amount to wallet, if debit success then return a wallet transaction def debit(amount) + transaction = nil ActiveRecord::Base.transaction do - if @wallet.debit(amount) + if @wallet&.debit(amount) transaction = WalletTransaction.new( invoicing_profile: @user&.invoicing_profile, wallet: @wallet, @@ -43,11 +43,10 @@ class WalletService amount: amount ) - return transaction if transaction.save + raise ActiveRecord::Rollback unless transaction.save end - raise ActiveRecord::Rollback end - false + transaction end ## create a refund invoice associated with the given wallet transaction @@ -92,7 +91,7 @@ class WalletService end ## - # Subtract the amount of the payment document (Invoice|PaymentSchedule) from the customer's wallet + # Subtract the amount of the payment document (Invoice|PaymentSchedule|Order) from the customer's wallet # @param transaction, if false: the wallet is not debited, the transaction is only simulated on the payment document ## def self.debit_user_wallet(payment, user, transaction: true) @@ -110,5 +109,4 @@ class WalletService payment.set_wallet_transaction(wallet_amount, nil) end end - end diff --git a/app/uploaders/product_file_uploader.rb b/app/uploaders/product_file_uploader.rb new file mode 100644 index 000000000..1cee5d75c --- /dev/null +++ b/app/uploaders/product_file_uploader.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# CarrierWave uploader for file of product +# This file defines the parameters for these uploads. +class ProductFileUploader < CarrierWave::Uploader::Base + # Include RMagick or MiniMagick support: + # include CarrierWave::RMagick + # include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + # storage :fog + + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Provide a default URL as a default if there hasn't been a file uploaded: + # def default_url + # # For Rails 3.1+ asset pipeline compatibility: + # # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_')) + # + # "/images/fallback/" + [version_name, "default.png"].compact.join('_') + # end + + # Process files as they are uploaded: + # process :scale => [200, 300] + # + # def scale(width, height) + # # do something + # end + + # Create different versions of your uploaded files: + # version :thumb do + # process :resize_to_fit => [50, 50] + # end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[pdf] + end + + def content_type_whitelist + ['application/pdf'] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + if original_filename + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + end +end diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb new file mode 100644 index 000000000..caaa61799 --- /dev/null +++ b/app/uploaders/product_image_uploader.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# CarrierWave uploader for image of product +# This file defines the parameters for these uploads. +class ProductImageUploader < CarrierWave::Uploader::Base + include CarrierWave::MiniMagick + include UploadHelper + + # Choose what kind of storage to use for this uploader: + storage :file + after :remove, :delete_empty_dirs + + # Override the directory where uploaded files will be stored. + # This is a sensible default for uploaders that are meant to be mounted: + def store_dir + "#{base_store_dir}/#{model.id}" + end + + def base_store_dir + "uploads/#{model.class.to_s.underscore}" + end + + # Create different versions of your uploaded files: + version :large do + process resize_to_fit: [1000, 700] + end + + version :medium do + process resize_to_fit: [700, 400] + end + + version :small do + process resize_to_fit: [400, 250] + end + + version :thumb do + process resize_to_fit: [100, 100] + end + + # Add a white list of extensions which are allowed to be uploaded. + # For images you might use something like this: + def extension_whitelist + %w[jpg jpeg gif png webp] + end + + def content_type_whitelist + [%r{image/}] + end + + # Override the filename of the uploaded files: + # Avoid using model.id or version_name here, see uploader/store.rb for details. + def filename + return unless original_filename + + original_filename.split('.').map do |s| + ActiveSupport::Inflector.transliterate(s).to_s + end.join('.') + end + + # return an array like [width, height] + def dimensions + ::MiniMagick::Image.open(file.file)[:dimensions] + end +end diff --git a/app/views/api/machines/_machine.json.jbuilder b/app/views/api/machines/_machine.json.jbuilder new file mode 100644 index 000000000..22e41d7ee --- /dev/null +++ b/app/views/api/machines/_machine.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +json.extract! machine, :id, :name, :slug, :disabled + +if machine.machine_image + json.machine_image_attributes do + json.id machine.machine_image.id + json.attachment_name machine.machine_image.attachment_identifier + json.attachment_url machine.machine_image.attachment.url + end +end diff --git a/app/views/api/machines/index.json.jbuilder b/app/views/api/machines/index.json.jbuilder index b9e8f5b76..e86436e0b 100644 --- a/app/views/api/machines/index.json.jbuilder +++ b/app/views/api/machines/index.json.jbuilder @@ -1,7 +1,5 @@ # frozen_string_literal: true json.array!(@machines) do |machine| - json.extract! machine, :id, :name, :slug, :disabled - - json.machine_image machine.machine_image.attachment.medium.url if machine.machine_image + json.partial! 'api/machines/machine', machine: machine end diff --git a/app/views/api/machines/show.json.jbuilder b/app/views/api/machines/show.json.jbuilder index e0f615d71..099a136f3 100644 --- a/app/views/api/machines/show.json.jbuilder +++ b/app/views/api/machines/show.json.jbuilder @@ -1,10 +1,11 @@ # frozen_string_literal: true -json.extract! @machine, :id, :name, :description, :spec, :disabled, :slug -json.machine_image @machine.machine_image.attachment.large.url if @machine.machine_image +json.partial! 'api/machines/machine', machine: @machine +json.extract! @machine, :description, :spec + json.machine_files_attributes @machine.machine_files do |f| json.id f.id - json.attachment f.attachment_identifier + json.attachment_name f.attachment_identifier json.attachment_url f.attachment_url end json.trainings @machine.trainings.each, :id, :name, :disabled diff --git a/app/views/api/notifications/_notify_admin_low_stock_threshold.json.jbuilder b/app/views/api/notifications/_notify_admin_low_stock_threshold.json.jbuilder new file mode 100644 index 000000000..71e3fa7ff --- /dev/null +++ b/app/views/api/notifications/_notify_admin_low_stock_threshold.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.low_stock', PRODUCT: t(".#{notification.attached_object.name}")) + + link_to(t('.view_product'), "#!/admin/store/products/#{notification.attached_object.id}/edit") diff --git a/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder b/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder new file mode 100644 index 000000000..a004ebc14 --- /dev/null +++ b/app/views/api/notifications/_notify_user_order_is_canceled.json.jbuilder @@ -0,0 +1,2 @@ +json.title notification.notification_type +json.description t('.order_canceled', REFERENCE: notification.attached_object.order.reference) diff --git a/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder b/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder new file mode 100644 index 000000000..c44ed960e --- /dev/null +++ b/app/views/api/notifications/_notify_user_order_is_ready.json.jbuilder @@ -0,0 +1,2 @@ +json.title notification.notification_type +json.description t('.order_ready', REFERENCE: notification.attached_object.order.reference) diff --git a/app/views/api/notifications/_notify_user_order_is_refunded.json.jbuilder b/app/views/api/notifications/_notify_user_order_is_refunded.json.jbuilder new file mode 100644 index 000000000..832b8604b --- /dev/null +++ b/app/views/api/notifications/_notify_user_order_is_refunded.json.jbuilder @@ -0,0 +1,2 @@ +json.title notification.notification_type +json.description t('.order_refunded', REFERENCE: notification.attached_object.order.reference) diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder new file mode 100644 index 000000000..3bb146813 --- /dev/null +++ b/app/views/api/orders/_order.json.jbuilder @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +json.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at, :updated_at, :invoice_id, + :payment_method +json.total order.total / 100.0 if order.total.present? +json.payment_date order.invoice.created_at if order.invoice_id.present? +json.wallet_amount order.wallet_amount / 100.0 if order.wallet_amount.present? +json.paid_total order.paid_total / 100.0 if order.paid_total.present? +if order.coupon_id + json.coupon do + json.extract! order.coupon, :id, :code, :type, :percent_off, :validity_per_user + json.amount_off order.coupon.amount_off / 100.00 unless order.coupon.amount_off.nil? + end +end +if order&.statistic_profile&.user + json.user do + json.id order.statistic_profile.user.id + json.role order.statistic_profile.user.roles.first.name + json.name order.statistic_profile.user.profile.full_name + end +end + +json.order_items_attributes order.order_items.order(created_at: :asc) do |item| + json.id item.id + json.orderable_type item.orderable_type + json.orderable_id item.orderable_id + json.orderable_name item.orderable.name + json.orderable_ref item.orderable.sku + json.orderable_slug item.orderable.slug + json.orderable_main_image_url item.orderable.main_image&.attachment_url + json.orderable_external_stock item.orderable.stock['external'] + json.quantity item.quantity + json.quantity_min item.orderable.quantity_min + json.amount item.amount / 100.0 + json.is_offered item.is_offered +end diff --git a/app/views/api/orders/index.json.jbuilder b/app/views/api/orders/index.json.jbuilder new file mode 100644 index 000000000..5f1912cfb --- /dev/null +++ b/app/views/api/orders/index.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.extract! @result, :page, :total_pages, :page_size, :total_count +json.data @result[:data] do |order| + json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at + json.total order.total / 100.0 if order.total.present? + json.paid_total order.paid_total / 100.0 if order.paid_total.present? + if order&.statistic_profile&.user + json.user do + json.id order.statistic_profile.user.id + json.role order.statistic_profile.user.roles.first.name + json.name order.statistic_profile.user.profile.full_name + end + end +end diff --git a/app/views/api/orders/show.json.jbuilder b/app/views/api/orders/show.json.jbuilder new file mode 100644 index 000000000..d3b6da6f5 --- /dev/null +++ b/app/views/api/orders/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/orders/order', order: @order diff --git a/app/views/api/orders/update.json.jbuilder b/app/views/api/orders/update.json.jbuilder new file mode 100644 index 000000000..d3b6da6f5 --- /dev/null +++ b/app/views/api/orders/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/orders/order', order: @order diff --git a/app/views/api/product_categories/_product_category.json.jbuilder b/app/views/api/product_categories/_product_category.json.jbuilder new file mode 100644 index 000000000..641a4d027 --- /dev/null +++ b/app/views/api/product_categories/_product_category.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.extract! product_category, :id, :name, :slug, :parent_id, :position +json.products_count product_category.try(:products_count) diff --git a/app/views/api/product_categories/create.json.jbuilder b/app/views/api/product_categories/create.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/product_categories/index.json.jbuilder b/app/views/api/product_categories/index.json.jbuilder new file mode 100644 index 000000000..b97ceff09 --- /dev/null +++ b/app/views/api/product_categories/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array! @product_categories do |product_category| + json.partial! 'api/product_categories/product_category', product_category: product_category +end diff --git a/app/views/api/product_categories/show.json.jbuilder b/app/views/api/product_categories/show.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/product_categories/update.json.jbuilder b/app/views/api/product_categories/update.json.jbuilder new file mode 100644 index 000000000..061c999fe --- /dev/null +++ b/app/views/api/product_categories/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/product_categories/product_category', product_category: @product_category diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder new file mode 100644 index 000000000..f5f143349 --- /dev/null +++ b/app/views/api/products/_product.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, + :low_stock_threshold, :machine_ids, :created_at +json.description sanitize(product.description) +json.amount product.amount / 100.0 if product.amount.present? +json.product_files_attributes product.product_files do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url +end +json.product_images_attributes product.product_images do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url + json.thumb_attachment_url f.attachment.thumb.url + json.is_main f.is_main +end diff --git a/app/views/api/products/_stock_movement.json.jbuilder b/app/views/api/products/_stock_movement.json.jbuilder new file mode 100644 index 000000000..976c2a983 --- /dev/null +++ b/app/views/api/products/_stock_movement.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! stock_movement, :id, :quantity, :reason, :stock_type, :remaining_stock, :date diff --git a/app/views/api/products/clone.json.jbuilder b/app/views/api/products/clone.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/clone.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/create.json.jbuilder b/app/views/api/products/create.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder new file mode 100644 index 000000000..02905d56b --- /dev/null +++ b/app/views/api/products/index.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.extract! @products, :page, :total_pages, :page_size, :total_count +json.data @products[:data] do |product| + json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids, + :low_stock_threshold + json.amount product.amount / 100.0 if product.amount.present? + json.product_images_attributes product.product_images do |f| + json.id f.id + json.attachment_name f.attachment_identifier + json.attachment_url f.attachment_url + json.thumb_attachment_url f.attachment.thumb.url + json.is_main f.is_main + end +end diff --git a/app/views/api/products/show.json.jbuilder b/app/views/api/products/show.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/stock_movements.json.jbuilder b/app/views/api/products/stock_movements.json.jbuilder new file mode 100644 index 000000000..f1ac82c6d --- /dev/null +++ b/app/views/api/products/stock_movements.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.extract! @movements, :page, :total_pages, :page_size, :total_count +json.data @movements[:data] do |movement| + json.partial! 'api/products/stock_movement', stock_movement: movement + json.extract! movement, :product_id +end diff --git a/app/views/api/products/update.json.jbuilder b/app/views/api/products/update.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/application/index.html.erb b/app/views/application/index.html.erb index d9554eef0..12c5422fd 100644 --- a/app/views/application/index.html.erb +++ b/app/views/application/index.html.erb @@ -36,6 +36,7 @@ Fablab.plansModule = ('<%= Setting.get('plans_module') %>' === 'true'); Fablab.spacesModule = ('<%= Setting.get('spaces_module') %>' === 'true'); Fablab.trainingsModule = ('<%= Setting.get('trainings_module') %>' === 'true'); + Fablab.storeModule = ('<%= Setting.get('store_module') %>' === 'true'); Fablab.walletModule = ('<%= Setting.get('wallet_module') %>' === 'true'); Fablab.publicAgendaModule = ('<%= Setting.get('public_agenda_module') %>' === 'true'); Fablab.statisticsModule = ('<%= Setting.get('statistics_module') %>' === 'true'); @@ -43,6 +44,7 @@ Fablab.trackingId = "<%= Setting.get('tracking_id') %>"; Fablab.adminSysId = parseInt("<%= User.adminsys&.id %>", 10); Fablab.activeProviderType = "<%= AuthProvider.active&.providable_type %>"; + Fablab.storeHidden = ('<%= Setting.get('store_hidden') %>' === 'true'); // i18n stuff Fablab.locale = "<%= Rails.application.secrets.app_locale %>"; diff --git a/app/views/notifications_mailer/notify_admin_low_stock_threshold.html.erb b/app/views/notifications_mailer/notify_admin_low_stock_threshold.html.erb new file mode 100644 index 000000000..0c1bae992 --- /dev/null +++ b/app/views/notifications_mailer/notify_admin_low_stock_threshold.html.erb @@ -0,0 +1,11 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

    + <%= t('.body.low_stock', { PRODUCT: @attached_object.name }) %> +

    +

    + <%= t('.body.stocks_state_html', { INTERNAL: @attached_object.stock['internal'], EXTERNAL: @attached_object.stock['external'] }) %> +

    +

    + <%=link_to( t('.body.manage_stock'), "#{root_url}#!/admin/store/products/#{@attached_object.id}/edit", target: "_blank" )%> +

    diff --git a/app/views/notifications_mailer/notify_user_order_is_canceled.erb b/app/views/notifications_mailer/notify_user_order_is_canceled.erb new file mode 100644 index 000000000..19c08f7da --- /dev/null +++ b/app/views/notifications_mailer/notify_user_order_is_canceled.erb @@ -0,0 +1,5 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

    + <%= t('.body.notify_user_order_is_canceled', REFERENCE: @attached_object.order.reference) %> +

    diff --git a/app/views/notifications_mailer/notify_user_order_is_ready.erb b/app/views/notifications_mailer/notify_user_order_is_ready.erb new file mode 100644 index 000000000..6782f340e --- /dev/null +++ b/app/views/notifications_mailer/notify_user_order_is_ready.erb @@ -0,0 +1,8 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

    +<%= t('.body.notify_user_order_is_ready', REFERENCE: @attached_object.order.reference) %> +

    +

    + <%= ::Orders::OrderService.withdrawal_instructions(@attached_object.order) %> +

    diff --git a/app/views/notifications_mailer/notify_user_order_is_refunded.erb b/app/views/notifications_mailer/notify_user_order_is_refunded.erb new file mode 100644 index 000000000..1f4f74271 --- /dev/null +++ b/app/views/notifications_mailer/notify_user_order_is_refunded.erb @@ -0,0 +1,5 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

    + <%= t('.body.notify_user_order_is_refunded', REFERENCE: @attached_object.order.reference) %> +

    diff --git a/app/workers/statistics_export_worker.rb b/app/workers/statistics_export_worker.rb index f54a26207..724c13878 100644 --- a/app/workers/statistics_export_worker.rb +++ b/app/workers/statistics_export_worker.rb @@ -1,27 +1,29 @@ +# frozen_string_literal: true + +# asynchronously export the statistics to an excel file and send the result by email class StatisticsExportWorker include Sidekiq::Worker def perform(export_id) export = Export.find(export_id) - unless export.user.admin? - raise SecurityError, 'Not allowed to export' - end + raise SecurityError, 'Not allowed to export' unless export.user.admin? - unless export.category == 'statistics' - raise KeyError, 'Wrong worker called' - end + raise KeyError, 'Wrong worker called' unless export.category == 'statistics' service = StatisticsExportService.new method_name = "export_#{export.export_type}" - if %w(account event machine project subscription training space global).include?(export.export_type) and service.respond_to?(method_name) - service.public_send(method_name, export) - - NotificationCenter.call type: :notify_admin_export_complete, - receiver: export.user, - attached_object: export + unless %w[account event machine project subscription training space global].include?(export.export_type) && + service.respond_to?(method_name) + return end + service.public_send(method_name, export) + + NotificationCenter.call type: :notify_admin_export_complete, + receiver: export.user, + attached_object: export + end end diff --git a/config/initializers/active_record_base.rb b/config/initializers/active_record_base.rb index 4bb708204..fce2d59f6 100644 --- a/config/initializers/active_record_base.rb +++ b/config/initializers/active_record_base.rb @@ -2,18 +2,16 @@ ActiveRecord::Base.class_eval do def dump_fixture - fixture_file = "#{Rails.root}/test/fixtures/#{self.class.table_name}.yml" + fixture_file = Rails.root.join("test/fixtures/#{self.class.table_name}.yml") File.open(fixture_file, 'a') do |f| - f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }. - to_yaml.sub!(/---\s?/, "\n")) + f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }.to_yaml.sub!(/---\s?/, "\n")) end end def self.dump_fixtures - fixture_file = "#{Rails.root}/test/fixtures/#{table_name}.yml" + fixture_file = Rails.root.join("test/fixtures/#{table_name}.yml") mode = (File.exist?(fixture_file) ? 'a' : 'w') File.open(fixture_file, mode) do |f| - if attribute_names.include?('id') all.each do |instance| f.puts({ "#{table_name.singularize}_#{instance.id}" => instance.attributes }.to_yaml.sub!(/---\s?/, "\n")) diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb deleted file mode 100644 index 89d2efab2..000000000 --- a/config/initializers/application_controller_renderer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# ActiveSupport::Reloader.to_prepare do -# ApplicationController.renderer.defaults.merge!( -# http_host: 'example.org', -# https: false -# ) -# end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb deleted file mode 100644 index 913a6fe6b..000000000 --- a/config/initializers/assets.rb +++ /dev/null @@ -1,39 +0,0 @@ -# # frozen_string_literal: true -# -# # Be sure to restart your server when you modify this file. -# -# # Version of your assets, change this if you want to expire all your assets. -# Rails.application.config.assets.version = '1.0' -# -# # allow use rails helpers in angular templates -# Rails.application.config.assets.configure do |env| -# env.context_class.class_eval do -# include ActionView::Helpers -# include Rails.application.routes.url_helpers -# end -# end -# -# # Add additional assets to the asset load path. -# # Rails.application.config.assets.paths << Emoji.images_path -# # Add Yarn node_modules folder to the asset load path. -# Rails.application.config.assets.paths << Rails.root.join('node_modules') -# -# # Precompile additional assets. -# # application.js, application.css, and all non-JS/CSS in the app/assets -# # folder are already added. -# # Rails.application.config.assets.precompile += %w( admin.js admin.css ) -# -# Rails.application.config.assets.precompile += %w[ -# fontawesome-webfont.eot -# fontawesome-webfont.woff -# fontawesome-webfont.svg -# fontawesome-webfont.ttf -# ] -# Rails.application.config.assets.precompile += %w[app.printer.css] -# -# Rails.application.config.assets.precompile += %w[ -# angular-i18n/angular-locale_*.js -# moment/locale/*.js -# summernote/lang/*.js -# fullcalendar/dist/lang/*.js -# ] diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb deleted file mode 100644 index 59385cdf3..000000000 --- a/config/initializers/backtrace_silencers.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. -# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } - -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. -# Rails.backtrace_cleaner.remove_silencers! diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index a92544780..e5bd6c449 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -8,16 +8,6 @@ Rails.application.config.content_security_policy do |policy| # # If you are using webpack-dev-server then specify webpack-dev-server host policy.connect_src :self, :https, :wss, 'http://localhost:3035', 'ws://localhost:3035' if Rails.env.development? - -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https - -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" end # If you are using UJS then enable automatic nonce generation diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb index caa04057c..43656468e 100644 --- a/config/initializers/friendly_id.rb +++ b/config/initializers/friendly_id.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # FriendlyId Global Configuration # # Use this to set up shared configuration options for your entire application. @@ -16,8 +18,7 @@ FriendlyId.defaults do |config| # undesirable to allow as slugs. Edit this list as needed for your app. config.use :reserved - config.reserved_words = %w(new edit index session login logout users - stylesheets assets javascripts images) + config.reserved_words = %w[new edit index session login logout users stylesheets assets javascripts images] # ## Friendly Finders # diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index a86375742..6b832c4b0 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -2,14 +2,15 @@ # Be sure to restart your server when you modify this file. -redis_host = ENV['REDIS_HOST'] || 'localhost' +redis_host = ENV.fetch('REDIS_HOST', 'localhost') Rails.application.config.session_store :redis_session_store, - redis: { - expire_after: 14.days, # cookie expiration - ttl: 14.days, # Redis expiration, defaults to 'expire_after' - key_prefix: 'fabmanager:session:', - url: "redis://#{redis_host}:6379", - }, + redis: { + expire_after: 14.days, # cookie expiration + ttl: 14.days, # Redis expiration, defaults to 'expire_after' + key_prefix: 'fabmanager:session:', + url: "redis://#{redis_host}:6379" + }, key: '_Fab-manager_session', - secure: (Rails.env.production? || Rails.env.staging?) && !Rails.application.secrets.allow_insecure_http + secure: (Rails.env.production? || Rails.env.staging?) && + !Rails.application.secrets.allow_insecure_http diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 4dca7c99d..517b06e02 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -5,7 +5,7 @@ require 'sidekiq-scheduler' require 'sidekiq/middleware/i18n' require 'sidekiq/server_locale' -redis_host = ENV['REDIS_HOST'] || 'localhost' +redis_host = ENV.fetch('REDIS_HOST', 'localhost') redis_url = "redis://#{redis_host}:6379" Sidekiq.configure_server do |config| diff --git a/config/locales/app.admin.de.yml b/config/locales/app.admin.de.yml index b82e840c0..0491d24f2 100644 --- a/config/locales/app.admin.de.yml +++ b/config/locales/app.admin.de.yml @@ -1,6 +1,18 @@ de: app: admin: + machine_form: + name: "Name" + illustration: "Visual" + add_an_illustration: "Add a visual" + description: "Description" + technical_specifications: "Technical specifications" + attached_files_pdf: "Attached files (pdf)" + attach_a_file: "Attach a file" + add_an_attachment: "Add an attachment" + disable_machine: "Disable machine" + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + validate_your_machine: "Validate your machine" #add a new machine machines_new: declare_a_new_machine: "Neue Maschine angeben" @@ -599,6 +611,7 @@ de: VAT_rate_training: "Schulungsreservierung" VAT_rate_event: "Veranstaltungsreservierung" VAT_rate_subscription: "Abonnement" + VAT_rate_product: "Products (store)" changed_at: "Geändert am" changed_by: "Von" deleted_user: "Gelöschter Nutzer" @@ -714,6 +727,10 @@ de: general_pack_code: "Accounting code for prepaid-packs" accounting_Pack_label: "Prepaid-pack label" general_pack_label: "Account label for prepaid-packs" + accounting_Product_code: "Product code (Store)" + general_product_code: "Accounting code for products (Store)" + accounting_Product_label: "Product label (Store)" + general_product_label: "Account label for products (Store)" accounting_Error_code: "Fehlercode" general_error_code: "Rechnungscode für fehlerhafte Rechnungen" accounting_Error_label: "Fehlerbezeichnung" @@ -754,7 +771,8 @@ de: payzen_keys_form: payzen_keys_info_html: "

    Um Online-Zahlungen zu erhalten, müssen Sie die PayZen Identifikatoren und Schlüssel konfigurieren.

    Holen Sie sie aus Ihrem Händler-Backend.

    " client_keys: "Kundenschlüssel" - payzen_keys: "PayZen-Schlüssel" + payzen_public_key: "Client public key" + api_keys: "API keys" payzen_username: "Benutzername" payzen_password: "Passwort" payzen_endpoint: "REST API Server Name" @@ -782,8 +800,13 @@ de: stripe_currency: "Stripe-Währung" gateway_configuration_error: "Fehler beim Konfigurieren des Zahlungs-Gateways: " payzen_settings: + payzen_keys: "PayZen keys" edit_keys: "Schlüssel bearbeiten" payzen_public_key: "Öffentlicher Schlüssel des Kunden" + payzen_username: "Username" + payzen_password: "Password" + payzen_endpoint: "REST API server name" + payzen_hmac: "HMAC-SHA-256 key" currency: "Währung" payzen_currency: "PayZen Währung" currency_info_html: "Bitte geben Sie unten die Währung an, die für Online-Bezahlung verwendet wird. Sie sollten einen ISO-Code mit drei Buchstaben aus der Liste PayZen unterstützter Währungen eingeben." @@ -974,15 +997,20 @@ de: to_complete: "To complete" refuse_documents: "Refusing the documents" refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail." - #edit a member - members_edit: - change_role: "Rolle ändern" - warning_role_change: "

    Warnung: das Ändern einer Benutzerrolle ist nicht harmlos. Aktuell ist es nicht möglich, einem Benutzer wieder eine weniger privilegierte Rolle zuzuzuweisen.

    • Mitglieder können nur für sich selbst reservieren und mit Kreditkarte oder Guthabenkonto bezahlen.
    • Manager können für sich selbst Reservierungen buchen und per Kreditkarte oder Guthabenkonto bezahlen sowie auch für andere Mitglieder und Manager, indem sie beim Checkout die Zahlungen einbuchen.
    • Administratoren können nur Reservierungen für Mitglieder und Manager buchen, indem sie indem sie beim Checkout die Zahlungen einbuchen. Außerdem können sie alle Einstellungen der Anwendung ändern.
    " + change_role_modal: + change_role: "Change role" + warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators as managers, they can book reservations for themselves and for others. Moreover, they can change every settings of the application.
    " + new_role: "New role" admin: "Administrator" manager: "Manager" - member: "Mitglied" - role_changed: "Rolle erfolgreich von {OLD} auf {NEW} geändert." - error_while_changing_role: "Beim Ändern der Rolle ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut." + member: "Member" + new_group: "New group" + new_group_help: "Users with a running subscription cannot be changed from their current group." + confirm: "Change role" + role_changed: "Role successfully changed from {OLD} to {NEW}." + error_while_changing_role: "An error occurred while changing the role. Please try again later." + #edit a member + members_edit: subscription: "Abonnement" duration: "Dauer:" expires_at: "Läuft ab am:" @@ -1032,6 +1060,7 @@ de: validate_member_error: "An error occurred: impossible to validate from this member." invalidate_member_error: "An error occurred: impossible to invalidate from this member." supporting_documents: "Supporting documents" + change_role: "Change role" #extend a subscription for free free_extend_modal: extend_subscription: "Abonnement verlängern" @@ -1242,6 +1271,7 @@ de: export_is_running_you_ll_be_notified_when_its_ready: "Export wird ausgeführt. Sie werden nach Fertigstellung benachrichtigt." create_plans_to_start: "Beginnen Sie mit dem Erstellen neuer Abonnement-Pläne." click_here: "Klicken Sie hier, um die erste zu erstellen." + average_cart: "Average cart:" #statistics graphs stats_graphs: statistics: "Statistiken" @@ -1422,6 +1452,10 @@ de: trainings_info_html: "

    Trainings are fully integrated Fab-manager's agenda. If enabled, your members will be able to book and pay trainings.

    Trainings provides a way to prevent members to book some machines, if they do have not taken the prerequisite course.

    " enable_trainings: "Schulungen aktivieren" trainings_module: "Schulungs-Modul" + store: "Store" + store_info_html: "You can enable the store module that provides an easy way to sell various products and consumables to your members. This module also allows you to manage stocks and track orders." + enable_store: "Enable the store" + store_module: "store module" invoicing: "Rechnungsstellung" invoicing_info_html: "

    Sie können das Rechnungsmodul komplett deaktivieren.

    Das ist nützlich, wenn Sie über Ihr eigenes Rechnungssystem verfügen und nicht wollen, dass Fab-Manager Rechnungen generiert und an Mitglieder sendet.

    Warnung: Auch wenn Sie das Rechnungsmodul deaktivieren, müssen Sie die Mehrwertsteuer konfigurieren, um Fehler in Rechnungslegung und Preisen zu vermeiden. Die Konfiguration erfolgt in der Sektion « Rechnungen > Einstellungen ».

    " enable_invoicing: "Rechnungsstellung aktivieren" @@ -1893,3 +1927,204 @@ de: doc: title: "Dokumentation" content: "Klicken Sie hier, um die API Online-Dokumentation aufzurufen." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store categories" + the_orders: "Orders" + back_to_list: "Back to list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete {CATEGORY}?
    If it has sub-categories, they will also be deleted." + save: "Delete" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" + products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + sort: "Sort:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + successfully_cloned: "The product has been duplicated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Product reference (SKU)" + slug: "URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your workshop to your product. This product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be presented in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product. They will be presented in the product sheet, in a separate block. You can only upload PDF documents." + add_product_file: "Add a document" + product_images: "Visuals of the product" + product_images_info: "Advice
    We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." + add_product_image: "Add a visual" + save: "Save" + clone: "Duplicate" + product_stock_form: + stock_up_to_date: "Stock up to date" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stock operations" + save_reminder: "Don't forget to save your operations" + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" + event_type: "Events:" + reason: "Reason" + stocks: "Stock:" + internal: "Private stock" + external: "Public stock" + all: "All types" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" + product_stock_modal: + modal_title: "Manage stock" + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + reason_type: "Reason" + stocks: "Stock:" + quantity: "Quantity" + stock_movement_reason: + inward_stock: "Inward stock" + returned: "Returned by client" + cancelled: "Canceled by client" + inventory_fix: "Inventory fix" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + other_in: "Other (in)" + other_out: "Other (out)" + clone_product_modal: + clone_product: "Duplicate the product" + clone: "Duplicate" + name: "Name" + sku: "Product reference (SKU)" + is_show_in_store: "Available in the store" + active_price_info: "Is this product visible by the members on the store?" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + sort: + newest: "Newest first" + oldest: "Oldest first" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + store_hidden_title: "Store publicly available" + store_hidden_info: "You can hide the store to the eyes of the members and the visitors." + store_hidden: "Hide the store" + save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index ab7c05d53..7419041eb 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1,6 +1,18 @@ en: app: admin: + machine_form: + name: "Name" + illustration: "Visual" + add_an_illustration: "Add a visual" + description: "Description" + technical_specifications: "Technical specifications" + attached_files_pdf: "Attached files (pdf)" + attach_a_file: "Attach a file" + add_an_attachment: "Add an attachment" + disable_machine: "Disable machine" + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + validate_your_machine: "Validate your machine" #add a new machine machines_new: declare_a_new_machine: "Declare a new machine" @@ -599,6 +611,7 @@ en: VAT_rate_training: "Training reservation" VAT_rate_event: "Event reservation" VAT_rate_subscription: "Subscription" + VAT_rate_product: "Products (store)" changed_at: "Changed at" changed_by: "By" deleted_user: "Deleted user" @@ -714,6 +727,10 @@ en: general_pack_code: "Accounting code for prepaid-packs" accounting_Pack_label: "Prepaid-pack label" general_pack_label: "Account label for prepaid-packs" + accounting_Product_code: "Product code (Store)" + general_product_code: "Accounting code for products (Store)" + accounting_Product_label: "Product label (Store)" + general_product_label: "Account label for products (Store)" accounting_Error_code: "Errors code" general_error_code: "Accounting code for erroneous invoices" accounting_Error_label: "Errors label" @@ -754,7 +771,8 @@ en: payzen_keys_form: payzen_keys_info_html: "

    To be able to collect online payments, you must configure the PayZen identifiers and keys.

    Retrieve them from your merchant back office.

    " client_keys: "Client key" - payzen_keys: "PayZen keys" + payzen_public_key: "Client public key" + api_keys: "API keys" payzen_username: "Username" payzen_password: "Password" payzen_endpoint: "REST API server name" @@ -782,8 +800,13 @@ en: stripe_currency: "Stripe currency" gateway_configuration_error: "An error occurred while configuring the payment gateway: " payzen_settings: + payzen_keys: "PayZen keys" edit_keys: "Edit keys" payzen_public_key: "Client public key" + payzen_username: "Username" + payzen_password: "Password" + payzen_endpoint: "REST API server name" + payzen_hmac: "HMAC-SHA-256 key" currency: "Currency" payzen_currency: "PayZen currency" currency_info_html: "Please specify below the currency used for online payment. You should provide a three-letter ISO code, from the list of PayZen supported currencies." @@ -976,13 +999,13 @@ en: refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail." change_role_modal: change_role: "Change role" - warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.
    " + warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators as managers, they can book reservations for themselves and for others. Moreover, they can change every settings of the application.
    " new_role: "New role" admin: "Administrator" manager: "Manager" member: "Member" new_group: "New group" - new_group_help: "Members and managers must be placed in a group." + new_group_help: "Users with a running subscription cannot be changed from their current group." confirm: "Change role" role_changed: "Role successfully changed from {OLD} to {NEW}." error_while_changing_role: "An error occurred while changing the role. Please try again later." @@ -1037,6 +1060,7 @@ en: validate_member_error: "An error occurred: impossible to validate from this member." invalidate_member_error: "An error occurred: impossible to invalidate from this member." supporting_documents: "Supporting documents" + change_role: "Change role" # extend a subscription for free free_extend_modal: extend_subscription: "Extend the subscription" @@ -1247,6 +1271,7 @@ en: export_is_running_you_ll_be_notified_when_its_ready: "Export is running. You'll be notified when it's ready." create_plans_to_start: "Start by creating new subscription plans." click_here: "Click here to create your first one." + average_cart: "Average cart:" #statistics graphs stats_graphs: statistics: "Statistics" @@ -1427,6 +1452,10 @@ en: trainings_info_html: "

    Trainings are fully integrated Fab-manager's agenda. If enabled, your members will be able to book and pay trainings.

    Trainings provides a way to prevent members to book some machines, if they do have not taken the prerequisite course.

    " enable_trainings: "Enable the trainings" trainings_module: "trainings module" + store: "Store" + store_info_html: "You can enable the store module that provides an easy way to sell various products and consumables to your members. This module also allows you to manage stocks and track orders." + enable_store: "Enable the store" + store_module: "store module" invoicing: "Invoicing" invoicing_info_html: "

    You can fully disable the invoicing module.

    This is useful if you have your own invoicing system, and you don't want Fab-manager generates and sends invoices to the members.

    Warning: even if you disable the invoicing module, you must to configure the VAT to prevent errors in accounting and prices. Do it from the « Invoices > Invoicing settings » section.

    " enable_invoicing: "Enable invoicing" @@ -1898,3 +1927,204 @@ en: doc: title: "Documentation" content: "Click here to access the API online documentation." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store categories" + the_orders: "Orders" + back_to_list: "Back to list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete {CATEGORY}?
    If it has sub-categories, they will also be deleted." + save: "Delete" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" + products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + sort: "Sort:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + successfully_cloned: "The product has been duplicated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Product reference (SKU)" + slug: "URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your workshop to your product. This product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be presented in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product. They will be presented in the product sheet, in a separate block. You can only upload PDF documents." + add_product_file: "Add a document" + product_images: "Visuals of the product" + product_images_info: "Advice
    We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." + add_product_image: "Add a visual" + save: "Save" + clone: "Duplicate" + product_stock_form: + stock_up_to_date: "Stock up to date" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stock operations" + save_reminder: "Don't forget to save your operations" + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" + event_type: "Events:" + reason: "Reason" + stocks: "Stock:" + internal: "Private stock" + external: "Public stock" + all: "All types" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" + product_stock_modal: + modal_title: "Manage stock" + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + reason_type: "Reason" + stocks: "Stock:" + quantity: "Quantity" + stock_movement_reason: + inward_stock: "Inward stock" + returned: "Returned by client" + cancelled: "Canceled by client" + inventory_fix: "Inventory fix" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + other_in: "Other (in)" + other_out: "Other (out)" + clone_product_modal: + clone_product: "Duplicate the product" + clone: "Duplicate" + name: "Name" + sku: "Product reference (SKU)" + is_show_in_store: "Available in the store" + active_price_info: "Is this product visible by the members on the store?" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + sort: + newest: "Newest first" + oldest: "Oldest first" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + store_hidden_title: "Store publicly available" + store_hidden_info: "You can hide the store to the eyes of the members and the visitors." + store_hidden: "Hide the store" + save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index 79202a048..46ff635b9 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -1,6 +1,18 @@ es: app: admin: + machine_form: + name: "Name" + illustration: "Visual" + add_an_illustration: "Add a visual" + description: "Description" + technical_specifications: "Technical specifications" + attached_files_pdf: "Attached files (pdf)" + attach_a_file: "Attach a file" + add_an_attachment: "Add an attachment" + disable_machine: "Disable machine" + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + validate_your_machine: "Validate your machine" #add a new machine machines_new: declare_a_new_machine: "Declara una nueva máquina" @@ -599,6 +611,7 @@ es: VAT_rate_training: "Training reservation" VAT_rate_event: "Event reservation" VAT_rate_subscription: "Subscription" + VAT_rate_product: "Products (store)" changed_at: "Cambiado en" changed_by: "Por" deleted_user: "Usario eliminado" @@ -714,6 +727,10 @@ es: general_pack_code: "Accounting code for prepaid-packs" accounting_Pack_label: "Prepaid-pack label" general_pack_label: "Account label for prepaid-packs" + accounting_Product_code: "Product code (Store)" + general_product_code: "Accounting code for products (Store)" + accounting_Product_label: "Product label (Store)" + general_product_label: "Account label for products (Store)" accounting_Error_code: "Errors code" general_error_code: "Accounting code for erroneous invoices" accounting_Error_label: "Errors label" @@ -754,7 +771,8 @@ es: payzen_keys_form: payzen_keys_info_html: "

    To be able to collect online payments, you must configure the PayZen identifiers and keys.

    Retrieve them from your merchant back office.

    " client_keys: "Client key" - payzen_keys: "PayZen keys" + payzen_public_key: "Client public key" + api_keys: "API keys" payzen_username: "Username" payzen_password: "Password" payzen_endpoint: "REST API server name" @@ -782,8 +800,13 @@ es: stripe_currency: "Stripe currency" gateway_configuration_error: "An error occurred while configuring the payment gateway: " payzen_settings: + payzen_keys: "PayZen keys" edit_keys: "Edit keys" payzen_public_key: "Client public key" + payzen_username: "Username" + payzen_password: "Password" + payzen_endpoint: "REST API server name" + payzen_hmac: "HMAC-SHA-256 key" currency: "Currency" payzen_currency: "PayZen currency" currency_info_html: "Please specify below the currency used for online payment. You should provide a three-letter ISO code, from the list of PayZen supported currencies." @@ -974,15 +997,20 @@ es: to_complete: "To complete" refuse_documents: "Refusing the documents" refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail." - #edit a member - members_edit: + change_role_modal: change_role: "Change role" - warning_role_change: "

    Warning: changing the role of a user is not a harmless operation. Is not currently possible to dismiss a user to a lower privileged role.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.
    " + warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators as managers, they can book reservations for themselves and for others. Moreover, they can change every settings of the application.
    " + new_role: "New role" admin: "Administrator" manager: "Manager" member: "Member" + new_group: "New group" + new_group_help: "Users with a running subscription cannot be changed from their current group." + confirm: "Change role" role_changed: "Role successfully changed from {OLD} to {NEW}." error_while_changing_role: "An error occurred while changing the role. Please try again later." + #edit a member + members_edit: subscription: "Subscription" duration: "Duración:" expires_at: "Caduca en:" @@ -1032,6 +1060,7 @@ es: validate_member_error: "An error occurred: impossible to validate from this member." invalidate_member_error: "An error occurred: impossible to invalidate from this member." supporting_documents: "Supporting documents" + change_role: "Change role" #extend a subscription for free free_extend_modal: extend_subscription: "Extend the subscription" @@ -1242,6 +1271,7 @@ es: export_is_running_you_ll_be_notified_when_its_ready: "Export is running. You'll be notified when it's ready." create_plans_to_start: "Start by creating new subscription plans." click_here: "Click here to create your first one." + average_cart: "Average cart:" #statistics graphs stats_graphs: statistics: "Statistics" @@ -1422,6 +1452,10 @@ es: trainings_info_html: "

    Trainings are fully integrated Fab-manager's agenda. If enabled, your members will be able to book and pay trainings.

    Trainings provides a way to prevent members to book some machines, if they do have not taken the prerequisite course.

    " enable_trainings: "Enable the trainings" trainings_module: "trainings module" + store: "Store" + store_info_html: "You can enable the store module that provides an easy way to sell various products and consumables to your members. This module also allows you to manage stocks and track orders." + enable_store: "Enable the store" + store_module: "store module" invoicing: "Invoicing" invoicing_info_html: "

    You can fully disable the invoicing module.

    This is useful if you have your own invoicing system, and you don't want Fab-manager generates and sends invoices to the members.

    Warning: even if you disable the invoicing module, you must to configure the VAT to prevent errors in accounting and prices. Do it from the « Invoices > Invoicing settings » section.

    " enable_invoicing: "Enable invoicing" @@ -1893,3 +1927,204 @@ es: doc: title: "Documentation" content: "Click here to access the API online documentation." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store categories" + the_orders: "Orders" + back_to_list: "Back to list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete {CATEGORY}?
    If it has sub-categories, they will also be deleted." + save: "Delete" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" + products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + sort: "Sort:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + successfully_cloned: "The product has been duplicated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Product reference (SKU)" + slug: "URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your workshop to your product. This product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be presented in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product. They will be presented in the product sheet, in a separate block. You can only upload PDF documents." + add_product_file: "Add a document" + product_images: "Visuals of the product" + product_images_info: "Advice
    We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." + add_product_image: "Add a visual" + save: "Save" + clone: "Duplicate" + product_stock_form: + stock_up_to_date: "Stock up to date" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stock operations" + save_reminder: "Don't forget to save your operations" + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" + event_type: "Events:" + reason: "Reason" + stocks: "Stock:" + internal: "Private stock" + external: "Public stock" + all: "All types" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" + product_stock_modal: + modal_title: "Manage stock" + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + reason_type: "Reason" + stocks: "Stock:" + quantity: "Quantity" + stock_movement_reason: + inward_stock: "Inward stock" + returned: "Returned by client" + cancelled: "Canceled by client" + inventory_fix: "Inventory fix" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + other_in: "Other (in)" + other_out: "Other (out)" + clone_product_modal: + clone_product: "Duplicate the product" + clone: "Duplicate" + name: "Name" + sku: "Product reference (SKU)" + is_show_in_store: "Available in the store" + active_price_info: "Is this product visible by the members on the store?" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + sort: + newest: "Newest first" + oldest: "Oldest first" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + store_hidden_title: "Store publicly available" + store_hidden_info: "You can hide the store to the eyes of the members and the visitors." + store_hidden: "Hide the store" + save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 4009232af..2996db4cc 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1,6 +1,18 @@ fr: app: admin: + machine_form: + name: "Nom" + illustration: "Illustration" + add_an_illustration: "Ajouter un visuel" + description: "Description" + technical_specifications: "Caractéristiques techniques" + attached_files_pdf: "Pièces jointes (pdf)" + attach_a_file: "Joindre un fichier" + add_an_attachment: "Ajouter une pièce jointe" + disable_machine: "Désactiver la machine" + disabled_help: "Lorsque désactivée, la machine ne sera pas réservable et n'apparaîtra pas par défaut dans la liste des machines." + validate_your_machine: "Valider votre machine" #add a new machine machines_new: declare_a_new_machine: "Déclarer une nouvelle machine" @@ -599,6 +611,7 @@ fr: VAT_rate_training: "Réservation de formations" VAT_rate_event: "Réservation d'événements" VAT_rate_subscription: "Abonnements" + VAT_rate_product: "Produits (boutique)" changed_at: "Changé le" changed_by: "Par" deleted_user: "Utilisateur supprimé" @@ -714,6 +727,10 @@ fr: general_pack_code: "Code comptable pour tous les packs prépayés" accounting_Pack_label: "Libellé pack prépayé" general_pack_label: "Libellé du compte pour tous les packs prépayés" + accounting_Product_code: "Code du produit (boutique)" + general_product_code: "Code comptable pour les produits (boutique)" + accounting_Product_label: "Libellé produit (boutique)" + general_product_label: "Code comptable pour les produits (boutique)" accounting_Error_code: "Code erreurs" general_error_code: "Code comptable pour les factures en erreur" accounting_Error_label: "Libellé erreurs" @@ -754,7 +771,8 @@ fr: payzen_keys_form: payzen_keys_info_html: "

    Pour pouvoir encaisser des paiements en ligne, vous devez configurer les identifiants et les clefs PayZen.

    Retrouvez les dans votre back office marchant.

    " client_keys: "Clef client" - payzen_keys: "Clefs PayZen" + payzen_public_key: "Clef publique client" + api_keys: "Clefs d'API" payzen_username: "Nom d'utilisateur" payzen_password: "Mot de passe" payzen_endpoint: "Nom du serveur de l'API REST" @@ -782,8 +800,13 @@ fr: stripe_currency: "Devise Stripe" gateway_configuration_error: "Une erreur est survenue lors de la configuration de la passerelle de paiement : " payzen_settings: + payzen_keys: "Clefs PayZen" edit_keys: "Modifier les clefs" payzen_public_key: "Clef publique client" + payzen_username: "Nom d'utilisateur" + payzen_password: "Mot de passe" + payzen_endpoint: "Nom du serveur de l'API REST" + payzen_hmac: "Clef HMAC-SHA-256" currency: "Devise" payzen_currency: "Devise PayZen" currency_info_html: "Veuillez indiquer la devise à utiliser lors des paiements en ligne. Vous devez fournir un code ISO à trois lettres, issu de la liste des devises supportées par PayZen." @@ -974,15 +997,20 @@ fr: to_complete: "À compléter" refuse_documents: "Refuser les documents" refuse_documents_info: "Après vérification, vous pouvez notifier le membre que le ou les justificatif(s) déposés ne sont pas recevables. Vous pouvez préciser les motifs de votre refus et lui indiquer les actions à mettre en œuvre. Le membre sera notifié par mail." - #edit a member - members_edit: + change_role_modal: change_role: "Changer le rôle" - warning_role_change: "

    Attention : changer le rôle d'un utilisateur n'est pas une opération anodine. Il n'est actuellement pas possible de destituer un utilisateur vers un rôle de moindre privilège.

    • Les membres ne peuvent que prendre des réservations pour eux-même, en payant par carte bancaire ou par porte-monnaie.
    • Les gestionnaires peuvent prendre des réservations pour eux-même, en payant par carte bancaire ou par porte-monnaie, ainsi que pour les autres membres et gestionnaires, en encaissant les paiements à la caisse.
    • Les administrateurs ne peuvent que prendre des réservations pour les membres et gestionnaires, en encaissant les paiements à la caisse. De plus, ils peuvent modifier l'ensemble des paramètres de l'application.
    " + warning_role_change: "

    Attention : changer le rôle d'un utilisateur n'est pas une opération anodine.

    • Les membres ne peuvent que prendre des réservations pour eux-même, en payant par carte bancaire ou par porte-monnaie.
    • Les gestionnaires peuvent prendre des réservations pour eux-même, en payant par carte bancaire ou par porte-monnaie, ainsi que pour les autres membres et gestionnaires, en encaissant les paiements à la caisse.
    • Les administrateurs peuvent réserver pour eux-mêmes ou pour les autres, comme les gestionnaires. De plus, ils peuvent modifier l'ensemble des paramètres de l'application.
    " + new_role: "Nouveau rôle" admin: "Administrateur" manager: "Gestionnaire" member: "Membre" + new_group: "Nouveau groupe" + new_group_help: "Les utilisateurs ayant une souscription en cours ne peuvent pas être changés de leur groupe actuel." + confirm: "Changer le rôle" role_changed: "Rôle modifié avec succès de {OLD} à {NEW}." error_while_changing_role: "Une erreur est survenue lors de changement de rôle. Merci de réessayer plus tard." + #edit a member + members_edit: subscription: "Abonnement" duration: "Durée :" expires_at: "Expire le :" @@ -1032,6 +1060,7 @@ fr: validate_member_error: "Une erreur est survenue : impossible de valider ce membre." invalidate_member_error: "Une erreur est survenue : impossible d'invalider ce membre." supporting_documents: "Pièces justificatives" + change_role: "Changer le rôle" #extend a subscription for free free_extend_modal: extend_subscription: "Prolonger l'abonnement" @@ -1242,6 +1271,7 @@ fr: export_is_running_you_ll_be_notified_when_its_ready: "L'export est en cours. Vous serez notifié lorsqu'il sera prêt." create_plans_to_start: "Pour commencer, créez de nouvelles formules d'abonnement." click_here: "Cliquez ici pour créer votre première formule." + average_cart: "Panier moyen :" #statistics graphs stats_graphs: statistics: "Statistiques" @@ -1404,7 +1434,7 @@ fr: overlapping_categories_info: "Éviter la réservation de créneaux qui se chevauchent sera effectué en comparant la date et l'heure des catégories de réservations suivantes." default_slot_duration: "Durée par défaut pour les créneaux" duration_minutes: "Durée (en minutes)" - default_slot_duration_info: "Les disponibilités des machines et des espaces sont divisées en plusieurs créneaux de cette durée. Cette valeur peur être changée pour chaque disponibilité." + default_slot_duration_info: "Les disponibilités des machines et des espaces sont divisées en plusieurs créneaux de cette durée. Cette valeur peut être changée pour chaque disponibilité." modules: "Modules" machines: "Machines" machines_info_html: "Le module Réserver une machine peut être désactivé." @@ -1422,6 +1452,10 @@ fr: trainings_info_html: "

    Les formations sont entièrement intégrées dans l'agenda de Fab-manager. Si elles sont activées, vos membres pourrons réserver et payer des formations.

    Les formations fournissent une solution pour éviter que des membres ne réservent des machines, sans avoir suivi la formation préalable.

    " enable_trainings: "Activer les formations" trainings_module: "module formations" + store: "Boutique" + store_info_html: "Vous pouvez activer le module boutique qui fournit un moyen facile de vendre différents produits et consommables à vos membres. Ce module vous permet également de gérer les stocks et de suivre les commandes." + enable_store: "Activer la boutique" + store_module: "module boutique" invoicing: "Facturation" invoicing_info_html: "

    Vous pouvez complètement désactiver le module de facturation.

    Cela est utile si vous possédez votre propre système de facturation, et que vous ne souhaitez pas que Fab-manager génère et envoie des factures aux membres.

    Attention : même si vous désactivez le module de facturation, vous devez configurer la TVA pour éviter des erreurs de prix et de comptabilité. Faites le depuis la section « Factures > Paramètres de facturation ».

    " enable_invoicing: "Activer la facturation" @@ -1626,7 +1660,7 @@ fr: online_payment: "Le module de paiement par carte bancaire est-il actif ?" invoices: "Le module est facturation est-il actif ?" openlab: "Le module de partage de projets (OpenLab) est-il actif ?" - tracking_id_info_html: "Pour activer les suivi statistique des visites utilisant Google Analytics V4, définissez ici votre ID de suivi. Il se présente sous la forme G-XXXXXX. Visitez le site web de Google Analytics pour en obtenir un.
    Attention : si vous activez cette fonctionnalité, une cookie sera créé. Pensez à l'indiquer dans votre politique de confidentialité, ci-dessus." + tracking_id_info_html: "Pour activer les suivi statistique des visites utilisant Google Analytics V4, définissez ici votre ID de suivi. Il se présente sous la forme G-XXXXXX-X. Visitez le site web de Google Analytics pour en obtenir un.
    Attention : si vous activez cette fonctionnalité, un cookie sera créé. Pensez à l'indiquer dans votre politique de confidentialité, ci-dessus." tracking_id: "ID de suivi" open_api_clients: add_new_client: "Créer un compte client" @@ -1893,3 +1927,204 @@ fr: doc: title: "Documentation" content: "Cliquez ici pour accéder à la documentation en ligne de l'API." + store: + manage_the_store: "Gérer la boutique" + settings: "Paramètres" + all_products: "Tous les produits" + categories_of_store: "Catégories de la boutique" + the_orders: "Commandes" + back_to_list: "Retour à la liste" + product_categories: + title: "Catégories" + info: "Informations :
    Trouvez ci-dessous toutes les catégories créées. Les catégories sont disposées sur deux niveaux maximum, vous pouvez les organiser avec un glisser-déposer. L'ordre des catégories sera identique dans la vue publique et dans la liste ci-dessous. Veuillez noter que vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers seront laissés sans catégorie. Si vous supprimez une catégorie qui contient des sous-catégories, celles-ci seront également supprimées." + manage_product_category: + create: "Créer une catégorie de produit" + update: "Modifier la catégorie de produit" + delete: "Supprimer la catégorie de produit" + product_category_modal: + new_product_category: "Créer une catégorie" + edit_product_category: "Modifier une catégorie" + product_category_form: + name: "Nom de la catégorie" + slug: "URL" + select_parent_product_category: "Choisissez une catégorie parent (N1)" + no_parent: "Aucun parent" + create: + error: "Impossible de créer la catégorie : " + success: "La nouvelle catégorie a été créée." + update: + error: "Impossible de modifier la catégorie : " + success: "La catégorie a été modifiée." + delete: + confirm: "Voulez-vous vraiment supprimer {CATEGORY} ?
    S'il a des sous-catégories, elles seront également supprimées." + save: "Supprimer" + error: "Impossible de supprimer la catégorie : " + success: "La catégorie a été supprimée avec succès" + save: "Enregistrer" + required: "Ce champ est obligatoire" + slug_pattern: "Uniquement des groupes de caractères alphanumériques minuscules, séparés par un trait d'union" + categories_filter: + filter_categories: "Par catégories" + filter_apply: "Appliquer" + machines_filter: + filter_machines: "Par machines" + filter_apply: "Appliquer" + keyword_filter: + filter_keywords_reference: "Par mots-clés ou référence" + filter_apply: "Appliquer" + stock_filter: + stock_internal: "Stock interne" + stock_external: "Stock externe" + filter_stock: "Par état de stock" + filter_stock_from: "Compris entre" + filter_stock_to: "et" + filter_apply: "Appliquer" + products: + unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement." + all_products: "Tous les produits" + create_a_product: "Créer un produit" + successfully_deleted: "Le produit a bien été supprimé" + unable_to_delete: "Impossible de supprimer le produit : " + filter: "Filter" + filter_clear: "Tout effacer" + filter_apply: "Appliquer" + filter_categories: "Par catégories" + filter_machines: "Par machines" + filter_keywords_reference: "Par mots-clés ou référence" + filter_stock: "Par état de stock" + stock_internal: "Stock interne" + stock_external: "Stock externe" + filter_stock_from: "Compris entre" + filter_stock_to: "et" + sort: + name_az: "Alphabétique" + name_za: "Alphabétique inverse" + price_low: "Prix : croissant" + price_high: "Prix : décroissant" + store_list_header: + result_count: "Nombre de résultats :" + sort: "Trier :" + visible_only: "Produits visibles uniquement" + product_item: + visible: "visible" + hidden: "caché" + stock: + internal: "Stock interne" + external: "Stock externe" + unit: "unité" + new_product: + add_a_new_product: "Ajouter un nouveau produit" + successfully_created: "Le nouveau produit a été créé." + edit_product: + successfully_updated: "Le produit a été modifié." + successfully_cloned: "Le produit a été dupliqué." + product_form: + product_parameters: "Paramètres du produit" + stock_management: "Gestion des stocks" + name: "Nom du produit" + sku: "Référence du produit (SKU)" + slug: "URL" + is_show_in_store: "Disponible dans la boutique" + is_active_price: "Activer le prix" + active_price_info: "Ce produit est-il visible par les membres dans la boutique ?" + price_and_rule_of_selling_product: "Prix et règle pour la vente du produit" + price: "Prix du produit" + quantity_min: "Nombre minimum d'articles pour la mise au panier" + linking_product_to_category: "Lier ce produit à une catégorie existante" + assigning_category: "Affectation d'une catégorie" + assigning_category_info: "Informations
    Vous ne pouvez déclarer qu'une seule catégorie par produit. Si vous assignez ce produit à une sous-catégorie, il sera automatiquement assigné à sa catégorie parente." + assigning_machines: "Assigner des machines" + assigning_machines_info: "Informations
    Vous pouvez lier une ou plusieurs machines de votre atelier à votre produit. Ce produit sera alors soumis aux filtres de la vue catalogue.
    Les machines sélectionnées ci-dessous seront liées au produit." + product_description: "Description du produit" + product_description_info: "Informations
    Cette description de produit sera présentée dans la fiche produit. Vous avez quelques styles éditoriaux à votre disposition pour créer la fiche produit." + product_files: "Document" + product_files_info: "Informations
    Ajouter des documents liés à ce produit. Ils seront présentés dans la fiche produit, dans un bloc séparé. Vous ne pouvez télécharger que des documents PDF." + add_product_file: "Ajouter un document" + product_images: "Visuels du produit" + product_images_info: "Conseil
    Nous vous conseillons d'utiliser un format carré, JPG ou PNG. Pour le JPG, veuillez utiliser le blanc pour la couleur de fond. Le visuel principal sera le premier présenté dans la fiche produit." + add_product_image: "Ajouter un visuel" + save: "Enregistrer" + clone: "Dupliquer" + product_stock_form: + stock_up_to_date: "Stock à jour" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Opérations de stock en cours" + save_reminder: "N'oubliez pas de sauvegarder vos opérations" + low_stock_threshold: "Définir un seuil de stock bas" + stock_threshold_toggle: "Activer le seuil de stock" + stock_threshold_information: "Informations
    Définissez un seuil de stock bas et recevez une notification quand il est atteint.
    Lorsque le seuil est atteint, la quantité de produit est étiquetée comme limitée." + low_stock: "Stock limité" + threshold_level: "Niveau de seuil minimum" + threshold_alert: "M'avertir lorsque le seuil est atteint" + events_history: "Historique des événements" + event_type: "Événements :" + reason: "Motif" + stocks: "Stock :" + internal: "Stock interne" + external: "Stock externe" + all: "Tous types" + remaining_stock: "Stock restant" + type_in: "Ajouter" + type_out: "Retirer" + cancel: "Annuler cette opération" + product_stock_modal: + modal_title: "Gérer le stock" + internal: "Stock interne" + external: "Stock externe" + new_event: "Nouvel événement de stock" + addition: "Ajout" + withdrawal: "Retrait" + update_stock: "Mettre à jour le stock" + reason_type: "Raison" + stocks: "Stock :" + quantity: "Quantité" + stock_movement_reason: + inward_stock: "Entrée en stock" + returned: "Retourné par le client" + cancelled: "Annulé par le client" + inventory_fix: "Correction de l'inventaire" + sold: "Vendu" + missing: "Manquant au stock" + damaged: "Produit endommagé" + other_in: "Autre (entrant)" + other_out: "Autre (sortant)" + clone_product_modal: + clone_product: "Dupliquer le produit" + clone: "Dupliquer" + name: "Nom" + sku: "URL" + is_show_in_store: "Disponible dans la boutique" + active_price_info: "Ce produit est-il visible par les membres dans la boutique ?" + orders: + heading: "Commandes" + create_order: "Créer une commande" + filter: "Filter" + filter_clear: "Tout effacer" + filter_apply: "Appliquer" + filter_ref: "Par référence" + filter_status: "Par statut" + filter_client: "Par client" + filter_period: "Par période" + filter_period_from: "Du" + filter_period_to: "au" + state: + cart: 'Panier' + in_progress: 'En cours de préparation' + paid: "Payée" + payment_failed: "Erreur de paiement" + canceled: "Annulée" + ready: "Prête" + refunded: "Remboursée" + delivered: "Livrée" + sort: + newest: "Plus récente en premier" + oldest: "Plus ancienne en premier" + store_settings: + title: 'Paramètres' + withdrawal_instructions: 'Instructions de retrait du produit' + withdrawal_info: "Ce texte est affiché sur la page de paiement pour informer le client de la méthode de retrait des produits" + store_hidden_title: "Boutique accessible au public" + store_hidden_info: "Vous pouvez cacher la boutique aux yeux des membres et des visiteurs." + store_hidden: "Masquer la boutique" + save: "Enregistrer" + update_success: "Les paramètres ont bien été mis à jour" diff --git a/config/locales/app.admin.no.yml b/config/locales/app.admin.no.yml index 5c94b8ac1..2402060c8 100644 --- a/config/locales/app.admin.no.yml +++ b/config/locales/app.admin.no.yml @@ -1,6 +1,18 @@ "no": app: admin: + machine_form: + name: "Name" + illustration: "Visual" + add_an_illustration: "Add a visual" + description: "Description" + technical_specifications: "Technical specifications" + attached_files_pdf: "Attached files (pdf)" + attach_a_file: "Attach a file" + add_an_attachment: "Add an attachment" + disable_machine: "Disable machine" + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + validate_your_machine: "Validate your machine" #add a new machine machines_new: declare_a_new_machine: "Sett opp en ny maskin" @@ -599,6 +611,7 @@ VAT_rate_training: "Training reservation" VAT_rate_event: "Event reservation" VAT_rate_subscription: "Subscription" + VAT_rate_product: "Products (store)" changed_at: "Endret" changed_by: "Av" deleted_user: "Slettet bruker" @@ -714,6 +727,10 @@ general_pack_code: "Accounting code for prepaid-packs" accounting_Pack_label: "Prepaid-pack label" general_pack_label: "Account label for prepaid-packs" + accounting_Product_code: "Product code (Store)" + general_product_code: "Accounting code for products (Store)" + accounting_Product_label: "Product label (Store)" + general_product_label: "Account label for products (Store)" accounting_Error_code: "Kode for feil" general_error_code: "Regnskapskode for fakturafeil" accounting_Error_label: "Etikett for feil" @@ -754,7 +771,8 @@ payzen_keys_form: payzen_keys_info_html: "

    To be able to collect online payments, you must configure the PayZen identifiers and keys.

    Retrieve them from your merchant back office.

    " client_keys: "Client key" - payzen_keys: "PayZen keys" + payzen_public_key: "Client public key" + api_keys: "API keys" payzen_username: "Username" payzen_password: "Password" payzen_endpoint: "REST API server name" @@ -782,8 +800,13 @@ stripe_currency: "Stripe-valuta" gateway_configuration_error: "Det oppstod en feil ved konfigurering av betalingsmetoden: " payzen_settings: + payzen_keys: "PayZen keys" edit_keys: "Edit keys" payzen_public_key: "Client public key" + payzen_username: "Username" + payzen_password: "Password" + payzen_endpoint: "REST API server name" + payzen_hmac: "HMAC-SHA-256 key" currency: "Currency" payzen_currency: "PayZen currency" currency_info_html: "Please specify below the currency used for online payment. You should provide a three-letter ISO code, from the list of PayZen supported currencies." @@ -974,15 +997,20 @@ to_complete: "To complete" refuse_documents: "Refusing the documents" refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail." + change_role_modal: + change_role: "Change role" + warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators as managers, they can book reservations for themselves and for others. Moreover, they can change every settings of the application.
    " + new_role: "New role" + admin: "Administrator" + manager: "Manager" + member: "Member" + new_group: "New group" + new_group_help: "Users with a running subscription cannot be changed from their current group." + confirm: "Change role" + role_changed: "Role successfully changed from {OLD} to {NEW}." + error_while_changing_role: "An error occurred while changing the role. Please try again later." #edit a member members_edit: - change_role: "Endre rolle" - warning_role_change: "

    Advarsel: Å endre rollen til en bruker er ikke en harmløs handling. Det er ikke mulig å endre en bruker til en mindre privilegert rolle.

    • Medlemmer kan bare bestille reservasjoner for seg selv og betale med kort eller lommebok.
    • Ledere kan bestille reservasjoner for seg selv, betale med kort eller lommebok, og for andre medlemmer og ledere, ved å ordne betalinger i kassen.
    • Administratorer kan bare bokføre reservasjoner for medlemmer og ledere, ved å samle betalinger i kassen. Videre kan de endre alle programinnstillinger.
    " - admin: "Administrator" - manager: "Leder" - member: "Medlem" - role_changed: "Rolle endret fra {OLD} til {NEW}." - error_while_changing_role: "Det oppstod en feil under endring av rollen. Prøv igjen senere." subscription: "Medlemskap" duration: "Varighet:" expires_at: "Utløper:" @@ -1032,6 +1060,7 @@ validate_member_error: "An error occurred: impossible to validate from this member." invalidate_member_error: "An error occurred: impossible to invalidate from this member." supporting_documents: "Supporting documents" + change_role: "Change role" #extend a subscription for free free_extend_modal: extend_subscription: "Forleng abonnementet" @@ -1242,6 +1271,7 @@ export_is_running_you_ll_be_notified_when_its_ready: "Eksport er startet. Du vil bli varslet når den er klar." create_plans_to_start: "Begynn med å opprette nye medlemskapsplaner." click_here: "Klikk her for å opprette din første." + average_cart: "Average cart:" #statistics graphs stats_graphs: statistics: "Statistikk" @@ -1422,6 +1452,10 @@ trainings_info_html: "

    Trainings are fully integrated Fab-manager's agenda. If enabled, your members will be able to book and pay trainings.

    Trainings provides a way to prevent members to book some machines, if they do have not taken the prerequisite course.

    " enable_trainings: "Enable the trainings" trainings_module: "trainings module" + store: "Store" + store_info_html: "You can enable the store module that provides an easy way to sell various products and consumables to your members. This module also allows you to manage stocks and track orders." + enable_store: "Enable the store" + store_module: "store module" invoicing: "Invoicing" invoicing_info_html: "

    You can fully disable the invoicing module.

    This is useful if you have your own invoicing system, and you don't want Fab-manager generates and sends invoices to the members.

    Warning: even if you disable the invoicing module, you must to configure the VAT to prevent errors in accounting and prices. Do it from the « Invoices > Invoicing settings » section.

    " enable_invoicing: "Enable invoicing" @@ -1893,3 +1927,204 @@ doc: title: "Documentation" content: "Click here to access the API online documentation." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store categories" + the_orders: "Orders" + back_to_list: "Back to list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete {CATEGORY}?
    If it has sub-categories, they will also be deleted." + save: "Delete" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" + products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + sort: "Sort:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + successfully_cloned: "The product has been duplicated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Product reference (SKU)" + slug: "URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your workshop to your product. This product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be presented in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product. They will be presented in the product sheet, in a separate block. You can only upload PDF documents." + add_product_file: "Add a document" + product_images: "Visuals of the product" + product_images_info: "Advice
    We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." + add_product_image: "Add a visual" + save: "Save" + clone: "Duplicate" + product_stock_form: + stock_up_to_date: "Stock up to date" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stock operations" + save_reminder: "Don't forget to save your operations" + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" + event_type: "Events:" + reason: "Reason" + stocks: "Stock:" + internal: "Private stock" + external: "Public stock" + all: "All types" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" + product_stock_modal: + modal_title: "Manage stock" + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + reason_type: "Reason" + stocks: "Stock:" + quantity: "Quantity" + stock_movement_reason: + inward_stock: "Inward stock" + returned: "Returned by client" + cancelled: "Canceled by client" + inventory_fix: "Inventory fix" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + other_in: "Other (in)" + other_out: "Other (out)" + clone_product_modal: + clone_product: "Duplicate the product" + clone: "Duplicate" + name: "Name" + sku: "Product reference (SKU)" + is_show_in_store: "Available in the store" + active_price_info: "Is this product visible by the members on the store?" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + sort: + newest: "Newest first" + oldest: "Oldest first" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + store_hidden_title: "Store publicly available" + store_hidden_info: "You can hide the store to the eyes of the members and the visitors." + store_hidden: "Hide the store" + save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index 15a2bd7b1..e6b848614 100644 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -1,6 +1,18 @@ pt: app: admin: + machine_form: + name: "Name" + illustration: "Visual" + add_an_illustration: "Add a visual" + description: "Description" + technical_specifications: "Technical specifications" + attached_files_pdf: "Attached files (pdf)" + attach_a_file: "Attach a file" + add_an_attachment: "Add an attachment" + disable_machine: "Disable machine" + disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machine list." + validate_your_machine: "Validate your machine" #add a new machine machines_new: declare_a_new_machine: "Criar nova máquina" @@ -149,8 +161,8 @@ pt: settings: title: "Configurações" comments: "Comentários" - disqus: "Discos" - disqus_info: "Se você quiser habilitar seus membros e visitantes para comentar em projetos, você pode habilitar os fóruns de Discos definindo o seguinte parâmetro. Visite o site do Disqus para obter mais informações." + disqus: "Disqus" + disqus_info: "Se você quiser habilitar seus membros e visitantes para comentar em projetos, você pode habilitar os fóruns do Disqus definindo o seguinte parâmetro. Visite o site do Disqus para obter mais informações." shortname: "Shortname" cad_files: "Arquivos CAD" validation: "Validação" @@ -599,6 +611,7 @@ pt: VAT_rate_training: "Reserva de treinamento" VAT_rate_event: "Reserva de Evento" VAT_rate_subscription: "Assinatura" + VAT_rate_product: "Products (store)" changed_at: "Alterado em" changed_by: "Por" deleted_user: "Usuário deletado" @@ -714,6 +727,10 @@ pt: general_pack_code: "Código de contabilidade para pacotes pré-pagos" accounting_Pack_label: "Etiqueta do pacote pré-pago" general_pack_label: "Etiqueta da conta para pacotes pré-pagos" + accounting_Product_code: "Product code (Store)" + general_product_code: "Accounting code for products (Store)" + accounting_Product_label: "Product label (Store)" + general_product_label: "Account label for products (Store)" accounting_Error_code: "Código do erro" general_error_code: "Código de contabilidade para faturas erradas" accounting_Error_label: "Rótulo dos erros" @@ -754,11 +771,12 @@ pt: payzen_keys_form: payzen_keys_info_html: "

    Para coletar pagamentos online, você deve configurar os identificadores e chaves do PayZen.

    Recupere eles da sua área de administração.

    " client_keys: "Chave de cliente" - payzen_keys: "Chaves PayZen" + payzen_public_key: "Client public key" + api_keys: "API keys" payzen_username: "Nome de Usuário" payzen_password: "Senha" payzen_endpoint: "Nome do servidor API REST" - payzen_hmac: "HMAC-SHA-256 key" + payzen_hmac: "Chave HMAC-SHA-256" stripe_keys_form: stripe_keys_info_html: "

    Para coletar pagamentos online, você deve configurar as chaves de API do Stripe.

    Recupere eles do seu painel.

    A atualização dessas chaves ativará uma sincronização de todos os usuários no Stripe, isso pode levar algum tempo. Você receberá uma notificação quando estiver pronto.

    " public_key: "Chave pública" @@ -782,8 +800,13 @@ pt: stripe_currency: "Moeda do Stripe" gateway_configuration_error: "Ocorreu um erro ao configurar o gateway de pagamento: " payzen_settings: + payzen_keys: "PayZen keys" edit_keys: "Editar chaves" payzen_public_key: "Chave pública do cliente" + payzen_username: "Username" + payzen_password: "Password" + payzen_endpoint: "REST API server name" + payzen_hmac: "HMAC-SHA-256 key" currency: "Moeda" payzen_currency: "Moeda PayZen" currency_info_html: "Por favor, especifique abaixo a moeda usada para pagamento online. Você deve fornecer um código ISO de três letras, presente na lista de moedas com suporte a Stripe." @@ -974,15 +997,20 @@ pt: to_complete: "Completar" refuse_documents: "Recusando documentos" refuse_documents_info: "Após a verificação, poderá notificar o membro de que os documentos enviados não foram aceitos. Você pode especificar as razões de sua recusa e indicar as ações a tomar. O membro será notificado por e-mail." + change_role_modal: + change_role: "Change role" + warning_role_change: "

    Warning: changing the role of a user is not a harmless operation.

    • Members can only book reservations for themselves, paying by card or wallet.
    • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
    • Administrators as managers, they can book reservations for themselves and for others. Moreover, they can change every settings of the application.
    " + new_role: "New role" + admin: "Administrator" + manager: "Manager" + member: "Member" + new_group: "New group" + new_group_help: "Users with a running subscription cannot be changed from their current group." + confirm: "Change role" + role_changed: "Role successfully changed from {OLD} to {NEW}." + error_while_changing_role: "An error occurred while changing the role. Please try again later." #edit a member members_edit: - change_role: "Alterar cargo" - warning_role_change: "

    Aviso: alterar a função de um usuário não é uma operação inofensiva. Atualmente não é possível descartar um usuário para um papel mais baixo.

    • membros só podem reservar reservas para si próprios, pagando com cartão ou carteira.
    • Gerentes pode reservar reservas para si mesmos pagando com cartão ou carteira, e por outros membros e gerentes, coletando pagamentos no checkout.
    • Administradores só podem reservar reservas para membros e gerentes, coletando pagamentos no pagamento. Além disso, podem alterar cada configuração da aplicação.
    " - admin: "Administrador" - manager: "Gestor" - member: "Membro" - role_changed: "Função alterada com sucesso de {OLD} para {NEW}." - error_while_changing_role: "Ocorreu um erro ao alterar a função. Por favor, tente novamente mais tarde." subscription: "Assinatura" duration: "Duração:" expires_at: "Experia em:" @@ -1032,6 +1060,7 @@ pt: validate_member_error: "Ocorreu um erro: impossível validar este membro." invalidate_member_error: "Ocorreu um erro: impossível invalidar este membro." supporting_documents: "Documentos" + change_role: "Change role" #extend a subscription for free free_extend_modal: extend_subscription: "Estender assinatura" @@ -1242,6 +1271,7 @@ pt: export_is_running_you_ll_be_notified_when_its_ready: "A exportação está em execução. Você será notificado quando estiver pronto." create_plans_to_start: "Comece criando novos planos de assinatura." click_here: "Clique aqui para criar o seu primeiro." + average_cart: "Average cart:" #statistics graphs stats_graphs: statistics: "Estatísticas" @@ -1422,6 +1452,10 @@ pt: trainings_info_html: "

    Os treinamentos estão totalmente integrados na agenda do Fabmanager. Se ativado, seus membros poderão reservar e pagar treinamentos.

    A forma de impedir que os membros agendem determinadas máquinas é através dos pré-requisitos para uso das mesmas

    " enable_trainings: "Ativar treinamentos" trainings_module: "módulo de treinamentos" + store: "Store" + store_info_html: "You can enable the store module that provides an easy way to sell various products and consumables to your members. This module also allows you to manage stocks and track orders." + enable_store: "Enable the store" + store_module: "store module" invoicing: "Faturamento" invoicing_info_html: "

    Você pode desativar completamente o módulo de faturamento.

    Isso é útil se você tiver o seu próprio sistema de faturação, e não quer que o Fab-manager gere e envie faturas para os membros.

    Aviso: mesmo se você desativar o módulo de faturação, você deve configurar o IVA para evitar erros na contabilidade e nos preços. Faça isso na seção « Faturas > Configurações de faturação ».

    " enable_invoicing: "Habilitar faturamento" @@ -1444,7 +1478,7 @@ pt: recaptcha_secret_key: "chave secreta reCAPTCHA" feature_tour_display: "exibir tour de recursos" email_from: "endereço do expedidor" - disqus_shortname: "Atalho Disqus" + disqus_shortname: "Shortname do Disqus" COUNT_items_removed: "{COUNT, plural, one {} =1{Um item} other{{COUNT} itens}} removeu" item_added: "1 artigo adicionado" openlab_app_id: "ID OpenLab" @@ -1893,3 +1927,204 @@ pt: doc: title: "Documentação" content: "Clique aqui para acessar a documentação online da API." + store: + manage_the_store: "Manage the Store" + settings: "Settings" + all_products: "All products" + categories_of_store: "Store categories" + the_orders: "Orders" + back_to_list: "Back to list" + product_categories: + title: "Categories" + info: "Information:
    Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted." + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" + product_category_modal: + new_product_category: "Create a category" + edit_product_category: "Modify a category" + product_category_form: + name: "Name of category" + slug: "URL" + select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" + create: + error: "Unable to create the category: " + success: "The new category has been created." + update: + error: "Unable to modify the category: " + success: "The category has been modified." + delete: + confirm: "Do you really want to delete {CATEGORY}?
    If it has sub-categories, they will also be deleted." + save: "Delete" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" + required: "This field is required" + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" + products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "By categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock_from: "From" + filter_stock_to: "to" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_list_header: + result_count: "Result count:" + sort: "Sort:" + visible_only: "Visible products only" + product_item: + visible: "visible" + hidden: "hidden" + stock: + internal: "Private stock" + external: "Public stock" + unit: "unit" + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + successfully_cloned: "The product has been duplicated." + product_form: + product_parameters: "Product parameters" + stock_management: "Stock management" + name: "Name of product" + sku: "Product reference (SKU)" + slug: "URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + active_price_info: "Is this product visible by the members on the store?" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
    You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
    You can link one or more machines from your workshop to your product. This product will then be subject to the filters on the catalogue view.
    The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
    This product description will be presented in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + product_files: "Document" + product_files_info: "Information
    Add documents related to this product. They will be presented in the product sheet, in a separate block. You can only upload PDF documents." + add_product_file: "Add a document" + product_images: "Visuals of the product" + product_images_info: "Advice
    We advise you to use a square format, JPG or PNG. For JPG, please use white for the background colour. The main visual will be the first presented in the product sheet." + add_product_image: "Add a visual" + save: "Save" + clone: "Duplicate" + product_stock_form: + stock_up_to_date: "Stock up to date" + date_time: "{DATE} - {TIME}" + ongoing_operations: "Ongoing stock operations" + save_reminder: "Don't forget to save your operations" + low_stock_threshold: "Define a low stock threshold" + stock_threshold_toggle: "Activate stock threshold" + stock_threshold_information: "Information
    Define a low stock threshold and receive a notification when it's reached.
    When the threshold is reached, the product quantity is labeled as low." + low_stock: "Low stock" + threshold_level: "Minimum threshold level" + threshold_alert: "Notify me when the threshold is reached" + events_history: "Events history" + event_type: "Events:" + reason: "Reason" + stocks: "Stock:" + internal: "Private stock" + external: "Public stock" + all: "All types" + remaining_stock: "Remaining stock" + type_in: "Add" + type_out: "Remove" + cancel: "Cancel this operation" + product_stock_modal: + modal_title: "Manage stock" + internal: "Private stock" + external: "Public stock" + new_event: "New stock event" + addition: "Addition" + withdrawal: "Withdrawal" + update_stock: "Update stock" + reason_type: "Reason" + stocks: "Stock:" + quantity: "Quantity" + stock_movement_reason: + inward_stock: "Inward stock" + returned: "Returned by client" + cancelled: "Canceled by client" + inventory_fix: "Inventory fix" + sold: "Sold" + missing: "Missing in stock" + damaged: "Damaged product" + other_in: "Other (in)" + other_out: "Other (out)" + clone_product_modal: + clone_product: "Duplicate the product" + clone: "Duplicate" + name: "Name" + sku: "Product reference (SKU)" + is_show_in_store: "Available in the store" + active_price_info: "Is this product visible by the members on the store?" + orders: + heading: "Orders" + create_order: "Create an order" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_ref: "By reference" + filter_status: "By status" + filter_client: "By client" + filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + sort: + newest: "Newest first" + oldest: "Oldest first" + store_settings: + title: 'Settings' + withdrawal_instructions: 'Product withdrawal instructions' + withdrawal_info: "This text is displayed on the checkout page to inform the client about the products withdrawal method" + store_hidden_title: "Store publicly available" + store_hidden_info: "You can hide the store to the eyes of the members and the visitors." + store_hidden: "Hide the store" + save: "Save" + update_success: "The settings were successfully updated" diff --git a/config/locales/app.admin.zu.yml b/config/locales/app.admin.zu.yml index f1b48a1c1..27d20ae23 100644 --- a/config/locales/app.admin.zu.yml +++ b/config/locales/app.admin.zu.yml @@ -1,6 +1,18 @@ zu: app: admin: + machine_form: + name: "crwdns31659:0crwdne31659:0" + illustration: "crwdns31661:0crwdne31661:0" + add_an_illustration: "crwdns31663:0crwdne31663:0" + description: "crwdns31665:0crwdne31665:0" + technical_specifications: "crwdns31667:0crwdne31667:0" + attached_files_pdf: "crwdns31669:0crwdne31669:0" + attach_a_file: "crwdns31671:0crwdne31671:0" + add_an_attachment: "crwdns31673:0crwdne31673:0" + disable_machine: "crwdns31675:0crwdne31675:0" + disabled_help: "crwdns31677:0crwdne31677:0" + validate_your_machine: "crwdns31679:0crwdne31679:0" #add a new machine machines_new: declare_a_new_machine: "crwdns24050:0crwdne24050:0" @@ -599,6 +611,7 @@ zu: VAT_rate_training: "crwdns25128:0crwdne25128:0" VAT_rate_event: "crwdns25130:0crwdne25130:0" VAT_rate_subscription: "crwdns25132:0crwdne25132:0" + VAT_rate_product: "crwdns31222:0crwdne31222:0" changed_at: "crwdns25134:0crwdne25134:0" changed_by: "crwdns25136:0crwdne25136:0" deleted_user: "crwdns25138:0crwdne25138:0" @@ -714,6 +727,10 @@ zu: general_pack_code: "crwdns25358:0crwdne25358:0" accounting_Pack_label: "crwdns25360:0crwdne25360:0" general_pack_label: "crwdns25362:0crwdne25362:0" + accounting_Product_code: "crwdns31224:0crwdne31224:0" + general_product_code: "crwdns31226:0crwdne31226:0" + accounting_Product_label: "crwdns31228:0crwdne31228:0" + general_product_label: "crwdns31230:0crwdne31230:0" accounting_Error_code: "crwdns25364:0crwdne25364:0" general_error_code: "crwdns25366:0crwdne25366:0" accounting_Error_label: "crwdns25368:0crwdne25368:0" @@ -754,7 +771,8 @@ zu: payzen_keys_form: payzen_keys_info_html: "crwdns25436:0crwdne25436:0" client_keys: "crwdns25438:0crwdne25438:0" - payzen_keys: "crwdns25440:0crwdne25440:0" + payzen_public_key: "crwdns31232:0crwdne31232:0" + api_keys: "crwdns31234:0crwdne31234:0" payzen_username: "crwdns25442:0crwdne25442:0" payzen_password: "crwdns25444:0crwdne25444:0" payzen_endpoint: "crwdns25446:0crwdne25446:0" @@ -782,8 +800,13 @@ zu: stripe_currency: "crwdns25486:0crwdne25486:0" gateway_configuration_error: "crwdns25488:0crwdne25488:0" payzen_settings: + payzen_keys: "crwdns31236:0crwdne31236:0" edit_keys: "crwdns25490:0crwdne25490:0" payzen_public_key: "crwdns25492:0crwdne25492:0" + payzen_username: "crwdns31238:0crwdne31238:0" + payzen_password: "crwdns31240:0crwdne31240:0" + payzen_endpoint: "crwdns31242:0crwdne31242:0" + payzen_hmac: "crwdns31244:0crwdne31244:0" currency: "crwdns25494:0crwdne25494:0" payzen_currency: "crwdns25496:0crwdne25496:0" currency_info_html: "crwdns25498:0crwdne25498:0" @@ -974,15 +997,20 @@ zu: to_complete: "crwdns25828:0crwdne25828:0" refuse_documents: "crwdns25830:0crwdne25830:0" refuse_documents_info: "crwdns25832:0crwdne25832:0" + change_role_modal: + change_role: "crwdns31152:0crwdne31152:0" + warning_role_change: "crwdns31154:0crwdne31154:0" + new_role: "crwdns31156:0crwdne31156:0" + admin: "crwdns31158:0crwdne31158:0" + manager: "crwdns31160:0crwdne31160:0" + member: "crwdns31162:0crwdne31162:0" + new_group: "crwdns31164:0crwdne31164:0" + new_group_help: "crwdns31166:0crwdne31166:0" + confirm: "crwdns31168:0crwdne31168:0" + role_changed: "crwdns31170:0{OLD}crwdnd31170:0{NEW}crwdne31170:0" + error_while_changing_role: "crwdns31172:0crwdne31172:0" #edit a member members_edit: - change_role: "crwdns25834:0crwdne25834:0" - warning_role_change: "crwdns25836:0crwdne25836:0" - admin: "crwdns25838:0crwdne25838:0" - manager: "crwdns25840:0crwdne25840:0" - member: "crwdns25842:0crwdne25842:0" - role_changed: "crwdns25844:0{OLD}crwdnd25844:0{NEW}crwdne25844:0" - error_while_changing_role: "crwdns25846:0crwdne25846:0" subscription: "crwdns25848:0crwdne25848:0" duration: "crwdns25850:0crwdne25850:0" expires_at: "crwdns25852:0crwdne25852:0" @@ -1032,6 +1060,7 @@ zu: validate_member_error: "crwdns25940:0crwdne25940:0" invalidate_member_error: "crwdns25942:0crwdne25942:0" supporting_documents: "crwdns25944:0crwdne25944:0" + change_role: "crwdns31246:0crwdne31246:0" #extend a subscription for free free_extend_modal: extend_subscription: "crwdns25946:0crwdne25946:0" @@ -1242,6 +1271,7 @@ zu: export_is_running_you_ll_be_notified_when_its_ready: "crwdns26298:0crwdne26298:0" create_plans_to_start: "crwdns26300:0crwdne26300:0" click_here: "crwdns26302:0crwdne26302:0" + average_cart: "crwdns31248:0crwdne31248:0" #statistics graphs stats_graphs: statistics: "crwdns26304:0crwdne26304:0" @@ -1422,6 +1452,10 @@ zu: trainings_info_html: "crwdns26648:0crwdne26648:0" enable_trainings: "crwdns26650:0crwdne26650:0" trainings_module: "crwdns26652:0crwdne26652:0" + store: "crwdns31250:0crwdne31250:0" + store_info_html: "crwdns31252:0crwdne31252:0" + enable_store: "crwdns31254:0crwdne31254:0" + store_module: "crwdns31256:0crwdne31256:0" invoicing: "crwdns26654:0crwdne26654:0" invoicing_info_html: "crwdns26656:0crwdne26656:0" enable_invoicing: "crwdns26658:0crwdne26658:0" @@ -1893,3 +1927,204 @@ zu: doc: title: "crwdns27384:0crwdne27384:0" content: "crwdns27386:0crwdne27386:0" + store: + manage_the_store: "crwdns31258:0crwdne31258:0" + settings: "crwdns31260:0crwdne31260:0" + all_products: "crwdns31262:0crwdne31262:0" + categories_of_store: "crwdns31264:0crwdne31264:0" + the_orders: "crwdns31266:0crwdne31266:0" + back_to_list: "crwdns31268:0crwdne31268:0" + product_categories: + title: "crwdns31270:0crwdne31270:0" + info: "crwdns31272:0crwdne31272:0" + manage_product_category: + create: "crwdns31274:0crwdne31274:0" + update: "crwdns31276:0crwdne31276:0" + delete: "crwdns31278:0crwdne31278:0" + product_category_modal: + new_product_category: "crwdns31280:0crwdne31280:0" + edit_product_category: "crwdns31282:0crwdne31282:0" + product_category_form: + name: "crwdns31284:0crwdne31284:0" + slug: "crwdns31286:0crwdne31286:0" + select_parent_product_category: "crwdns31288:0crwdne31288:0" + no_parent: "crwdns31290:0crwdne31290:0" + create: + error: "crwdns31292:0crwdne31292:0" + success: "crwdns31294:0crwdne31294:0" + update: + error: "crwdns31296:0crwdne31296:0" + success: "crwdns31298:0crwdne31298:0" + delete: + confirm: "crwdns31300:0{CATEGORY}crwdne31300:0" + save: "crwdns31302:0crwdne31302:0" + error: "crwdns31304:0crwdne31304:0" + success: "crwdns31306:0crwdne31306:0" + save: "crwdns31308:0crwdne31308:0" + required: "crwdns31310:0crwdne31310:0" + slug_pattern: "crwdns31312:0crwdne31312:0" + categories_filter: + filter_categories: "crwdns31314:0crwdne31314:0" + filter_apply: "crwdns31316:0crwdne31316:0" + machines_filter: + filter_machines: "crwdns31318:0crwdne31318:0" + filter_apply: "crwdns31320:0crwdne31320:0" + keyword_filter: + filter_keywords_reference: "crwdns31322:0crwdne31322:0" + filter_apply: "crwdns31324:0crwdne31324:0" + stock_filter: + stock_internal: "crwdns31326:0crwdne31326:0" + stock_external: "crwdns31328:0crwdne31328:0" + filter_stock: "crwdns31330:0crwdne31330:0" + filter_stock_from: "crwdns31332:0crwdne31332:0" + filter_stock_to: "crwdns31334:0crwdne31334:0" + filter_apply: "crwdns31336:0crwdne31336:0" + products: + unexpected_error_occurred: "crwdns31338:0crwdne31338:0" + all_products: "crwdns31340:0crwdne31340:0" + create_a_product: "crwdns31342:0crwdne31342:0" + successfully_deleted: "crwdns31344:0crwdne31344:0" + unable_to_delete: "crwdns31346:0crwdne31346:0" + filter: "crwdns31348:0crwdne31348:0" + filter_clear: "crwdns31350:0crwdne31350:0" + filter_apply: "crwdns31352:0crwdne31352:0" + filter_categories: "crwdns31354:0crwdne31354:0" + filter_machines: "crwdns31356:0crwdne31356:0" + filter_keywords_reference: "crwdns31358:0crwdne31358:0" + filter_stock: "crwdns31360:0crwdne31360:0" + stock_internal: "crwdns31362:0crwdne31362:0" + stock_external: "crwdns31364:0crwdne31364:0" + filter_stock_from: "crwdns31366:0crwdne31366:0" + filter_stock_to: "crwdns31368:0crwdne31368:0" + sort: + name_az: "crwdns31370:0crwdne31370:0" + name_za: "crwdns31372:0crwdne31372:0" + price_low: "crwdns31374:0crwdne31374:0" + price_high: "crwdns31376:0crwdne31376:0" + store_list_header: + result_count: "crwdns31378:0crwdne31378:0" + sort: "crwdns31380:0crwdne31380:0" + visible_only: "crwdns31382:0crwdne31382:0" + product_item: + visible: "crwdns31384:0crwdne31384:0" + hidden: "crwdns31386:0crwdne31386:0" + stock: + internal: "crwdns31388:0crwdne31388:0" + external: "crwdns31390:0crwdne31390:0" + unit: "crwdns31392:0crwdne31392:0" + new_product: + add_a_new_product: "crwdns31394:0crwdne31394:0" + successfully_created: "crwdns31396:0crwdne31396:0" + edit_product: + successfully_updated: "crwdns31398:0crwdne31398:0" + successfully_cloned: "crwdns31400:0crwdne31400:0" + product_form: + product_parameters: "crwdns31402:0crwdne31402:0" + stock_management: "crwdns31404:0crwdne31404:0" + name: "crwdns31406:0crwdne31406:0" + sku: "crwdns31408:0crwdne31408:0" + slug: "crwdns31410:0crwdne31410:0" + is_show_in_store: "crwdns31412:0crwdne31412:0" + is_active_price: "crwdns31414:0crwdne31414:0" + active_price_info: "crwdns31416:0crwdne31416:0" + price_and_rule_of_selling_product: "crwdns31418:0crwdne31418:0" + price: "crwdns31420:0crwdne31420:0" + quantity_min: "crwdns31422:0crwdne31422:0" + linking_product_to_category: "crwdns31424:0crwdne31424:0" + assigning_category: "crwdns31426:0crwdne31426:0" + assigning_category_info: "crwdns31428:0crwdne31428:0" + assigning_machines: "crwdns31430:0crwdne31430:0" + assigning_machines_info: "crwdns31432:0crwdne31432:0" + product_description: "crwdns31434:0crwdne31434:0" + product_description_info: "crwdns31436:0crwdne31436:0" + product_files: "crwdns31438:0crwdne31438:0" + product_files_info: "crwdns31440:0crwdne31440:0" + add_product_file: "crwdns31442:0crwdne31442:0" + product_images: "crwdns31444:0crwdne31444:0" + product_images_info: "crwdns31446:0crwdne31446:0" + add_product_image: "crwdns31448:0crwdne31448:0" + save: "crwdns31450:0crwdne31450:0" + clone: "crwdns31452:0crwdne31452:0" + product_stock_form: + stock_up_to_date: "crwdns31454:0crwdne31454:0" + date_time: "crwdns31456:0{DATE}crwdnd31456:0{TIME}crwdne31456:0" + ongoing_operations: "crwdns31458:0crwdne31458:0" + save_reminder: "crwdns31460:0crwdne31460:0" + low_stock_threshold: "crwdns31462:0crwdne31462:0" + stock_threshold_toggle: "crwdns31464:0crwdne31464:0" + stock_threshold_information: "crwdns31466:0crwdne31466:0" + low_stock: "crwdns31468:0crwdne31468:0" + threshold_level: "crwdns31470:0crwdne31470:0" + threshold_alert: "crwdns31472:0crwdne31472:0" + events_history: "crwdns31474:0crwdne31474:0" + event_type: "crwdns31476:0crwdne31476:0" + reason: "crwdns31478:0crwdne31478:0" + stocks: "crwdns31480:0crwdne31480:0" + internal: "crwdns31482:0crwdne31482:0" + external: "crwdns31484:0crwdne31484:0" + all: "crwdns31486:0crwdne31486:0" + remaining_stock: "crwdns31488:0crwdne31488:0" + type_in: "crwdns31490:0crwdne31490:0" + type_out: "crwdns31492:0crwdne31492:0" + cancel: "crwdns31494:0crwdne31494:0" + product_stock_modal: + modal_title: "crwdns31496:0crwdne31496:0" + internal: "crwdns31498:0crwdne31498:0" + external: "crwdns31500:0crwdne31500:0" + new_event: "crwdns31502:0crwdne31502:0" + addition: "crwdns31504:0crwdne31504:0" + withdrawal: "crwdns31506:0crwdne31506:0" + update_stock: "crwdns31508:0crwdne31508:0" + reason_type: "crwdns31510:0crwdne31510:0" + stocks: "crwdns31512:0crwdne31512:0" + quantity: "crwdns31514:0crwdne31514:0" + stock_movement_reason: + inward_stock: "crwdns31516:0crwdne31516:0" + returned: "crwdns31518:0crwdne31518:0" + cancelled: "crwdns31520:0crwdne31520:0" + inventory_fix: "crwdns31522:0crwdne31522:0" + sold: "crwdns31524:0crwdne31524:0" + missing: "crwdns31526:0crwdne31526:0" + damaged: "crwdns31528:0crwdne31528:0" + other_in: "crwdns31530:0crwdne31530:0" + other_out: "crwdns31532:0crwdne31532:0" + clone_product_modal: + clone_product: "crwdns31648:0crwdne31648:0" + clone: "crwdns31650:0crwdne31650:0" + name: "crwdns31652:0crwdne31652:0" + sku: "crwdns31654:0crwdne31654:0" + is_show_in_store: "crwdns31656:0crwdne31656:0" + active_price_info: "crwdns31658:0crwdne31658:0" + orders: + heading: "crwdns31538:0crwdne31538:0" + create_order: "crwdns31540:0crwdne31540:0" + filter: "crwdns31542:0crwdne31542:0" + filter_clear: "crwdns31544:0crwdne31544:0" + filter_apply: "crwdns31546:0crwdne31546:0" + filter_ref: "crwdns31548:0crwdne31548:0" + filter_status: "crwdns31550:0crwdne31550:0" + filter_client: "crwdns31552:0crwdne31552:0" + filter_period: "crwdns31554:0crwdne31554:0" + filter_period_from: "crwdns31556:0crwdne31556:0" + filter_period_to: "crwdns31558:0crwdne31558:0" + state: + cart: 'crwdns31560:0crwdne31560:0' + in_progress: 'crwdns31562:0crwdne31562:0' + paid: "crwdns31564:0crwdne31564:0" + payment_failed: "crwdns31566:0crwdne31566:0" + canceled: "crwdns31568:0crwdne31568:0" + ready: "crwdns31570:0crwdne31570:0" + refunded: "crwdns31572:0crwdne31572:0" + delivered: "crwdns31574:0crwdne31574:0" + sort: + newest: "crwdns31576:0crwdne31576:0" + oldest: "crwdns31578:0crwdne31578:0" + store_settings: + title: 'crwdns31580:0crwdne31580:0' + withdrawal_instructions: 'crwdns31582:0crwdne31582:0' + withdrawal_info: "crwdns31584:0crwdne31584:0" + store_hidden_title: "crwdns31586:0crwdne31586:0" + store_hidden_info: "crwdns31588:0crwdne31588:0" + store_hidden: "crwdns31590:0crwdne31590:0" + save: "crwdns31592:0crwdne31592:0" + update_success: "crwdns31594:0crwdne31594:0" diff --git a/config/locales/app.public.de.yml b/config/locales/app.public.de.yml index 463641eeb..65a1110b8 100644 --- a/config/locales/app.public.de.yml +++ b/config/locales/app.public.de.yml @@ -22,6 +22,7 @@ de: my_events: "Meine Veranstaltungen" my_invoices: "Meine Rechnungen" my_payment_schedules: "Meine Zahlungspläne" + my_orders: "My orders" my_wallet: "Mein Guthaben" #contextual help help: "Hilfe" @@ -43,6 +44,7 @@ de: projects_gallery: "Projekt-Galerie" subscriptions: "Abonnements" public_calendar: "Kalender" + fablab_store: "Store" #left menu (admin) trainings_monitoring: "Schulungen" manage_the_calendar: "Kalender" @@ -51,6 +53,7 @@ de: subscriptions_and_prices: "Abonnements und Preise" manage_the_events: "Veranstaltungen" manage_the_machines: "Maschinen" + manage_the_store: "Store" manage_the_spaces: "Räume" projects: "Projekte" statistics: "Statistiken" @@ -217,6 +220,11 @@ de: new_availability: "Verfügbare Reservierungen" book: "Buchen" _or_the_: " oder die " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Maschinen anzeigen" status_enabled: "Aktiviert" @@ -372,6 +380,86 @@ de: characteristics: "Eigenschaften" files_to_download: "Dateien zum Herunterladen" projects_using_the_space: "Projekte, die den Raum nutzen" + #public store + store: + fablab_store: "Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Product added to the cart." + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + minimum_purchase: "Minimum purchase: " + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + stock_status: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + store_product_item: + minimum_purchase: "Minimum purchase: " + add: "Add" + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + product_price: + per_unit: "/ unit" + free: "Free" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + minimum_purchase: "Minimum purchase: " + stock_limit: "You have reached the current stock limit" + unit: "Unit" + total: "Total" + offer_product: "Offer the product" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Coupon" + checkout_total: "Cart total" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + select_user: "Please select a user before continuing." + update_item: "Update" + errors: + product_not_found: "This product is no longer available, please remove it from your cart." + out_of_stock: "This product is out of stock, please remove it from your cart." + stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items." + price_changed_PRICE: "The product price was modified to {PRICE}" + unauthorized_offering_product: "You can't offer anything to yourself" + orders_dashboard: + heading: "My orders" + sort: + newest: "Newest first" + oldest: "Oldest first" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Vielen Dank für Ihre Aufmerksamkeit" diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index fc53c41f3..b3f2d1c2f 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -22,6 +22,7 @@ en: my_events: "My Events" my_invoices: "My Invoices" my_payment_schedules: "My payment schedules" + my_orders: "My orders" my_wallet: "My Wallet" #contextual help help: "Help" @@ -43,6 +44,7 @@ en: projects_gallery: "Projects gallery" subscriptions: "Subscriptions" public_calendar: "Calendar" + fablab_store: "Store" #left menu (admin) trainings_monitoring: "Trainings" manage_the_calendar: "Calendar" @@ -51,6 +53,7 @@ en: subscriptions_and_prices: "Subscriptions and Prices" manage_the_events: "Events" manage_the_machines: "Machines" + manage_the_store: "Store" manage_the_spaces: "Spaces" projects: "Projects" statistics: "Statistics" @@ -217,6 +220,11 @@ en: new_availability: "Open reservations" book: "Book" _or_the_: " or the " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Show machines" status_enabled: "Enabled" @@ -372,6 +380,86 @@ en: characteristics: "Characteristics" files_to_download: "Files to download" projects_using_the_space: "Projects using the space" + #public store + store: + fablab_store: "Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Product added to the cart." + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + minimum_purchase: "Minimum purchase: " + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + stock_status: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + store_product_item: + minimum_purchase: "Minimum purchase: " + add: "Add" + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + product_price: + per_unit: "/ unit" + free: "Free" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + minimum_purchase: "Minimum purchase: " + stock_limit: "You have reached the current stock limit" + unit: "Unit" + total: "Total" + offer_product: "Offer the product" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Coupon" + checkout_total: "Cart total" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + select_user: "Please select a user before continuing." + update_item: "Update" + errors: + product_not_found: "This product is no longer available, please remove it from your cart." + out_of_stock: "This product is out of stock, please remove it from your cart." + stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items." + price_changed_PRICE: "The product price was modified to {PRICE}" + unauthorized_offering_product: "You can't offer anything to yourself" + orders_dashboard: + heading: "My orders" + sort: + newest: "Newest first" + oldest: "Oldest first" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.es.yml b/config/locales/app.public.es.yml index 09e22f0b2..e21ca1568 100644 --- a/config/locales/app.public.es.yml +++ b/config/locales/app.public.es.yml @@ -22,6 +22,7 @@ es: my_events: "Mis eventos" my_invoices: "Mis facturas" my_payment_schedules: "My payment schedules" + my_orders: "My orders" my_wallet: "Mi cartera" #contextual help help: "Ayuda" @@ -43,6 +44,7 @@ es: projects_gallery: "Galería de proyectos" subscriptions: "Suscripciones" public_calendar: "Agenda" + fablab_store: "Store" #left menu (admin) trainings_monitoring: "Cursos" manage_the_calendar: "Agenda" @@ -51,6 +53,7 @@ es: subscriptions_and_prices: "Suscripciones y precios" manage_the_events: "Eventos" manage_the_machines: "Máquinas" + manage_the_store: "Store" manage_the_spaces: "Espacios" projects: "Proyectos" statistics: "Estadísticas" @@ -217,6 +220,11 @@ es: new_availability: "Open reservations" book: "Reservar" _or_the_: " o el " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Mostrar máquinas" status_enabled: "Activadas" @@ -372,6 +380,86 @@ es: characteristics: "Características" files_to_download: "Archivos para descargar" projects_using_the_space: "Proyectos que usan el espacio" + #public store + store: + fablab_store: "Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Product added to the cart." + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + minimum_purchase: "Minimum purchase: " + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + stock_status: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + store_product_item: + minimum_purchase: "Minimum purchase: " + add: "Add" + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + product_price: + per_unit: "/ unit" + free: "Free" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + minimum_purchase: "Minimum purchase: " + stock_limit: "You have reached the current stock limit" + unit: "Unit" + total: "Total" + offer_product: "Offer the product" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Coupon" + checkout_total: "Cart total" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + select_user: "Please select a user before continuing." + update_item: "Update" + errors: + product_not_found: "This product is no longer available, please remove it from your cart." + out_of_stock: "This product is out of stock, please remove it from your cart." + stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items." + price_changed_PRICE: "The product price was modified to {PRICE}" + unauthorized_offering_product: "You can't offer anything to yourself" + orders_dashboard: + heading: "My orders" + sort: + newest: "Newest first" + oldest: "Oldest first" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Thank you for your attention" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index 881334a0e..c934e7990 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -22,6 +22,7 @@ fr: my_events: "Mes événements" my_invoices: "Mes factures" my_payment_schedules: "Mes échéanciers" + my_orders: "Mes commandes" my_wallet: "Mon porte-monnaie" #contextual help help: "Aide" @@ -43,6 +44,7 @@ fr: projects_gallery: "Galerie de projets" subscriptions: "Abonnements" public_calendar: "Agenda" + fablab_store: "Boutique" #left menu (admin) trainings_monitoring: "Formations" manage_the_calendar: "Agenda" @@ -51,6 +53,7 @@ fr: subscriptions_and_prices: "Abonnements & Tarifs" manage_the_events: "Événements" manage_the_machines: "Machines" + manage_the_store: "Boutique" manage_the_spaces: "Espaces" projects: "Projets" statistics: "Statistiques" @@ -217,6 +220,11 @@ fr: new_availability: "Ouvrir des réservations" book: "Réserver" _or_the_: " ou la " + store_ad: + title: "Découvrez notre boutique" + buy: "Consultez les produits des projets des membres ainsi que les consommables liés aux différentes machines et outils de l'atelier." + sell: "Si vous voulez également vendre vos créations, veuillez nous le faire savoir." + link: "Vers la boutique" machines_filters: show_machines: "Afficher les machines" status_enabled: "Actives" @@ -372,6 +380,86 @@ fr: characteristics: "Caractéristiques" files_to_download: "Fichiers à télécharger" projects_using_the_space: "Projets utilisant l'espace" + #public store + store: + fablab_store: "Boutique" + unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer plus tard." + add_to_cart_success: "Produit ajouté au panier." + products: + all_products: "Tous les produits" + filter: "Filtrer" + filter_clear: "Tout effacer" + filter_apply: "Appliquer" + filter_categories: "Catégories" + filter_machines: "Par machines" + filter_keywords_reference: "Par mots-clés ou référence" + in_stock_only: "Produits disponibles uniquement" + sort: + name_az: "Alphabétique" + name_za: "Alphabétique inverse" + price_low: "Prix : croissant" + price_high: "Prix : décroissant" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Produit ajouté au panier." + unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer plus tard." + show_more: "Afficher plus" + show_less: "Afficher moins" + documentation: "Documentation" + minimum_purchase: "Achat minimum : " + add_to_cart: "Ajouter au panier" + stock_limit: "Vous avez atteint la limite actuelle de stock" + stock_status: + available: "Disponible" + limited_stock: "Stock limité" + out_of_stock: "Rupture de stock" + store_product_item: + minimum_purchase: "Achat minimum : " + add: "Ajouter" + add_to_cart: "Ajouter au panier" + stock_limit: "Vous avez atteint la limite actuelle de stock" + product_price: + per_unit: "/ unité" + free: "Gratuit" + cart: + my_cart: "Mon panier" + cart_button: + my_cart: "Mon panier" + store_cart: + checkout: "Valider mon panier" + cart_is_empty: "Votre panier est vide" + pickup: "Retirer vos produits" + reference_short: "réf. :" + minimum_purchase: "Achat minimum : " + stock_limit: "Vous avez atteint la limite actuelle de stock" + unit: "Unité" + total: "Total" + offer_product: "Offrir le produit" + checkout_header: "Montant total pour votre panier" + checkout_products_COUNT: "Votre panier contient {COUNT} {COUNT, plural, one {}=1{produit} other{produits}}" + checkout_products_total: "Total des produits" + checkout_gift_total: "Total remise" + checkout_coupon: "Code promo" + checkout_total: "Total du panier" + checkout_error: "Une erreur inattendue s'est produite. Veuillez contacter l'administrateur." + checkout_success: "Achat confirmé. Merci !" + select_user: "Veuillez sélectionner un utilisateur avant de continuer." + update_item: "Mettre à jour" + errors: + product_not_found: "Ce produit n'est plus disponible, veuillez le retirer de votre panier." + out_of_stock: "Ce produit est en rupture de stock, veuillez le retirer de votre panier." + stock_limit_QUANTITY: "Seulement {QUANTITY} {QUANTITY, plural, one {}=1{unité} other{unités}} restant en stock, veuillez ajuster la quantité d'articles." + quantity_min_QUANTITY: "Le nombre minimum de produits a été changé à {QUANTITY}, veuillez ajuster la quantité d'articles." + price_changed_PRICE: "Le prix du produit a été modifié à {PRICE}" + unauthorized_offering_product: "Vous ne pouvez rien offrir à vous-même" + orders_dashboard: + heading: "Mes commandes" + sort: + newest: "Plus récente en premier" + oldest: "Plus ancienne en premier" + member_select: + select_a_member: "Sélectionnez un membre" + start_typing: "Commencez à écrire..." tour: conclusion: title: "Merci de votre attention" diff --git a/config/locales/app.public.no.yml b/config/locales/app.public.no.yml index e6453557a..064e6d448 100644 --- a/config/locales/app.public.no.yml +++ b/config/locales/app.public.no.yml @@ -22,6 +22,7 @@ my_events: "Mine arrangementer" my_invoices: "Mine fakturaer" my_payment_schedules: "Mine betalingsplaner" + my_orders: "My orders" my_wallet: "Min lommebok" #contextual help help: "Hjelp" @@ -43,6 +44,7 @@ projects_gallery: "Prosjektgalleri" subscriptions: "Medlemskap" public_calendar: "Kalender" + fablab_store: "Store" #left menu (admin) trainings_monitoring: "Opplæringer/kurs" manage_the_calendar: "Kalender" @@ -51,6 +53,7 @@ subscriptions_and_prices: "Abonnementer og priser" manage_the_events: "Arrangementer" manage_the_machines: "Maskiner" + manage_the_store: "Store" manage_the_spaces: "Plasser/rom" projects: "Prosjekter" statistics: "Statistikk" @@ -217,6 +220,11 @@ new_availability: "Åpne reservasjoner" book: "Reserver" _or_the_: " eller den " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Vis maskiner" status_enabled: "Aktivert" @@ -372,6 +380,86 @@ characteristics: "Egenskaper" files_to_download: "Filer som kan lastes ned" projects_using_the_space: "Prosjekter som bruker plassen/rommet" + #public store + store: + fablab_store: "Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Product added to the cart." + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + minimum_purchase: "Minimum purchase: " + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + stock_status: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + store_product_item: + minimum_purchase: "Minimum purchase: " + add: "Add" + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + product_price: + per_unit: "/ unit" + free: "Free" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + minimum_purchase: "Minimum purchase: " + stock_limit: "You have reached the current stock limit" + unit: "Unit" + total: "Total" + offer_product: "Offer the product" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Coupon" + checkout_total: "Cart total" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + select_user: "Please select a user before continuing." + update_item: "Update" + errors: + product_not_found: "This product is no longer available, please remove it from your cart." + out_of_stock: "This product is out of stock, please remove it from your cart." + stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items." + price_changed_PRICE: "The product price was modified to {PRICE}" + unauthorized_offering_product: "You can't offer anything to yourself" + orders_dashboard: + heading: "My orders" + sort: + newest: "Newest first" + oldest: "Oldest first" + member_select: + select_a_member: "Select a member" + start_typing: "Start typing..." tour: conclusion: title: "Takk for din oppmerksomhet" diff --git a/config/locales/app.public.pt.yml b/config/locales/app.public.pt.yml index efd003eb1..5345197c2 100644 --- a/config/locales/app.public.pt.yml +++ b/config/locales/app.public.pt.yml @@ -22,6 +22,7 @@ pt: my_events: "Meus Eventos" my_invoices: "Minhas Contas" my_payment_schedules: "Meus agendamentos de pagamento" + my_orders: "My orders" my_wallet: "Minha Carteira" #contextual help help: "Ajuda" @@ -43,6 +44,7 @@ pt: projects_gallery: "Galeria de Projetos" subscriptions: "Assinaturas" public_calendar: "Calendário" + fablab_store: "Store" #left menu (admin) trainings_monitoring: "Treinamentos" manage_the_calendar: "Agenda" @@ -51,6 +53,7 @@ pt: subscriptions_and_prices: "Assinaturas e Preços" manage_the_events: "Eventos" manage_the_machines: "Máquinas" + manage_the_store: "Store" manage_the_spaces: "Espaços" projects: "Projetos" statistics: "Estatísticas" @@ -217,6 +220,11 @@ pt: new_availability: "Reservas em aberto" book: "Reservar" _or_the_: " ou o " + store_ad: + title: "Discover our store" + buy: "Check out products from members' projects along with consumable related to the different machines and tools of the workshop." + sell: "If you also want to sell your creations, please let us know." + link: "To the store" machines_filters: show_machines: "Mostrar máquinas" status_enabled: "Ativadas" @@ -372,6 +380,86 @@ pt: characteristics: "Características" files_to_download: "Arquivo para download" projects_using_the_space: "Projetos usando espaço" + #public store + store: + fablab_store: "Store" + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + add_to_cart_success: "Product added to the cart." + products: + all_products: "All the products" + filter: "Filter" + filter_clear: "Clear all" + filter_apply: "Apply" + filter_categories: "Categories" + filter_machines: "By machines" + filter_keywords_reference: "By keywords or reference" + in_stock_only: "Available products only" + sort: + name_az: "A-Z" + name_za: "Z-A" + price_low: "Price: low to high" + price_high: "Price: high to low" + store_product: + ref: "ref: {REF}" + add_to_cart_success: "Product added to the cart." + unexpected_error_occurred: "An unexpected error occurred. Please try again later." + show_more: "Display more" + show_less: "Display less" + documentation: "Documentation" + minimum_purchase: "Minimum purchase: " + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + stock_status: + available: "Available" + limited_stock: "Limited stock" + out_of_stock: "Out of stock" + store_product_item: + minimum_purchase: "Minimum purchase: " + add: "Add" + add_to_cart: "Add to cart" + stock_limit: "You have reached the current stock limit" + product_price: + per_unit: "/ unit" + free: "Free" + cart: + my_cart: "My Cart" + cart_button: + my_cart: "My Cart" + store_cart: + checkout: "Checkout" + cart_is_empty: "Your cart is empty" + pickup: "Pickup your products" + reference_short: "ref:" + minimum_purchase: "Minimum purchase: " + stock_limit: "You have reached the current stock limit" + unit: "Unit" + total: "Total" + offer_product: "Offer the product" + checkout_header: "Total amount for your cart" + checkout_products_COUNT: "Your cart contains {COUNT} {COUNT, plural, =1{product} other{products}}" + checkout_products_total: "Products total" + checkout_gift_total: "Discount total" + checkout_coupon: "Cupom" + checkout_total: "Total do carrinho" + checkout_error: "An unexpected error occurred. Please contact the administrator." + checkout_success: "Purchase confirmed. Thanks!" + select_user: "Please select a user before continuing." + update_item: "Update" + errors: + product_not_found: "This product is no longer available, please remove it from your cart." + out_of_stock: "This product is out of stock, please remove it from your cart." + stock_limit_QUANTITY: "Only {QUANTITY} {QUANTITY, plural, =1{unit} other{units}} left in stock, please adjust the quantity of items." + quantity_min_QUANTITY: "Minimum number of product was changed to {QUANTITY}, please adjust the quantity of items." + price_changed_PRICE: "The product price was modified to {PRICE}" + unauthorized_offering_product: "You can't offer anything to yourself" + orders_dashboard: + heading: "My orders" + sort: + newest: "Mais recentes primeiro" + oldest: "Mais antigos primeiro" + member_select: + select_a_member: "Selecionar um membro" + start_typing: "Comece a digitar..." tour: conclusion: title: "Obrigado pela sua atenção" diff --git a/config/locales/app.public.zu.yml b/config/locales/app.public.zu.yml index 8616466ad..a78ad4dcf 100644 --- a/config/locales/app.public.zu.yml +++ b/config/locales/app.public.zu.yml @@ -22,6 +22,7 @@ zu: my_events: "crwdns27814:0crwdne27814:0" my_invoices: "crwdns27816:0crwdne27816:0" my_payment_schedules: "crwdns27818:0crwdne27818:0" + my_orders: "crwdns30310:0crwdne30310:0" my_wallet: "crwdns27820:0crwdne27820:0" #contextual help help: "crwdns27822:0crwdne27822:0" @@ -43,6 +44,7 @@ zu: projects_gallery: "crwdns27848:0crwdne27848:0" subscriptions: "crwdns27850:0crwdne27850:0" public_calendar: "crwdns27852:0crwdne27852:0" + fablab_store: "crwdns30612:0crwdne30612:0" #left menu (admin) trainings_monitoring: "crwdns27854:0crwdne27854:0" manage_the_calendar: "crwdns27856:0crwdne27856:0" @@ -51,6 +53,7 @@ zu: subscriptions_and_prices: "crwdns27862:0crwdne27862:0" manage_the_events: "crwdns27864:0crwdne27864:0" manage_the_machines: "crwdns27866:0crwdne27866:0" + manage_the_store: "crwdns30314:0crwdne30314:0" manage_the_spaces: "crwdns27868:0crwdne27868:0" projects: "crwdns27870:0crwdne27870:0" statistics: "crwdns27872:0crwdne27872:0" @@ -217,6 +220,11 @@ zu: new_availability: "crwdns28152:0crwdne28152:0" book: "crwdns28154:0crwdne28154:0" _or_the_: "crwdns28156:0crwdne28156:0" + store_ad: + title: "crwdns30316:0crwdne30316:0" + buy: "crwdns30318:0crwdne30318:0" + sell: "crwdns30320:0crwdne30320:0" + link: "crwdns30322:0crwdne30322:0" machines_filters: show_machines: "crwdns28158:0crwdne28158:0" status_enabled: "crwdns28160:0crwdne28160:0" @@ -372,6 +380,86 @@ zu: characteristics: "crwdns28418:0crwdne28418:0" files_to_download: "crwdns28420:0crwdne28420:0" projects_using_the_space: "crwdns28422:0crwdne28422:0" + #public store + store: + fablab_store: "crwdns30614:0crwdne30614:0" + unexpected_error_occurred: "crwdns30326:0crwdne30326:0" + add_to_cart_success: "crwdns30328:0crwdne30328:0" + products: + all_products: "crwdns30330:0crwdne30330:0" + filter: "crwdns30332:0crwdne30332:0" + filter_clear: "crwdns30334:0crwdne30334:0" + filter_apply: "crwdns30336:0crwdne30336:0" + filter_categories: "crwdns30338:0crwdne30338:0" + filter_machines: "crwdns30340:0crwdne30340:0" + filter_keywords_reference: "crwdns30342:0crwdne30342:0" + in_stock_only: "crwdns30344:0crwdne30344:0" + sort: + name_az: "crwdns30346:0crwdne30346:0" + name_za: "crwdns30348:0crwdne30348:0" + price_low: "crwdns30350:0crwdne30350:0" + price_high: "crwdns30352:0crwdne30352:0" + store_product: + ref: "crwdns30674:0{REF}crwdne30674:0" + add_to_cart_success: "crwdns30676:0crwdne30676:0" + unexpected_error_occurred: "crwdns30354:0crwdne30354:0" + show_more: "crwdns30356:0crwdne30356:0" + show_less: "crwdns30358:0crwdne30358:0" + documentation: "crwdns30360:0crwdne30360:0" + minimum_purchase: "crwdns30678:0crwdne30678:0" + add_to_cart: "crwdns30680:0crwdne30680:0" + stock_limit: "crwdns31596:0crwdne31596:0" + stock_status: + available: "crwdns30682:0crwdne30682:0" + limited_stock: "crwdns30684:0crwdne30684:0" + out_of_stock: "crwdns30686:0crwdne30686:0" + store_product_item: + minimum_purchase: "crwdns30368:0crwdne30368:0" + add: "crwdns30370:0crwdne30370:0" + add_to_cart: "crwdns30372:0crwdne30372:0" + stock_limit: "crwdns31598:0crwdne31598:0" + product_price: + per_unit: "crwdns30688:0crwdne30688:0" + free: "crwdns30690:0crwdne30690:0" + cart: + my_cart: "crwdns30376:0crwdne30376:0" + cart_button: + my_cart: "crwdns30378:0crwdne30378:0" + store_cart: + checkout: "crwdns30380:0crwdne30380:0" + cart_is_empty: "crwdns30382:0crwdne30382:0" + pickup: "crwdns30384:0crwdne30384:0" + reference_short: "crwdns30386:0crwdne30386:0" + minimum_purchase: "crwdns30388:0crwdne30388:0" + stock_limit: "crwdns30390:0crwdne30390:0" + unit: "crwdns30392:0crwdne30392:0" + total: "crwdns30394:0crwdne30394:0" + offer_product: "crwdns30640:0crwdne30640:0" + checkout_header: "crwdns30396:0crwdne30396:0" + checkout_products_COUNT: "crwdns30398:0COUNT={COUNT}crwdnd30398:0COUNT={COUNT}crwdne30398:0" + checkout_products_total: "crwdns30400:0crwdne30400:0" + checkout_gift_total: "crwdns30402:0crwdne30402:0" + checkout_coupon: "crwdns30404:0crwdne30404:0" + checkout_total: "crwdns30406:0crwdne30406:0" + checkout_error: "crwdns30408:0crwdne30408:0" + checkout_success: "crwdns30410:0crwdne30410:0" + select_user: "crwdns30692:0crwdne30692:0" + update_item: "crwdns30696:0crwdne30696:0" + errors: + product_not_found: "crwdns30698:0crwdne30698:0" + out_of_stock: "crwdns30700:0crwdne30700:0" + stock_limit_QUANTITY: "crwdns30702:0QUANTITY={QUANTITY}crwdnd30702:0QUANTITY={QUANTITY}crwdne30702:0" + quantity_min_QUANTITY: "crwdns30704:0{QUANTITY}crwdne30704:0" + price_changed_PRICE: "crwdns30706:0{PRICE}crwdne30706:0" + unauthorized_offering_product: "crwdns31681:0crwdne31681:0" + orders_dashboard: + heading: "crwdns30642:0crwdne30642:0" + sort: + newest: "crwdns30644:0crwdne30644:0" + oldest: "crwdns30646:0crwdne30646:0" + member_select: + select_a_member: "crwdns30412:0crwdne30412:0" + start_typing: "crwdns30414:0crwdne30414:0" tour: conclusion: title: "crwdns28424:0crwdne28424:0" diff --git a/config/locales/app.shared.de.yml b/config/locales/app.shared.de.yml index b31195f77..551b3aa37 100644 --- a/config/locales/app.shared.de.yml +++ b/config/locales/app.shared.de.yml @@ -550,3 +550,94 @@ de: validate_button: "Neue Karte validieren" form_multi_select: create_label: "Add {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main visual" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Order creation" + last_update: "Last update" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + show_order: + back_to_list: "Back to list" + see_invoice: "See invoice" + tracking: "Order tracking" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations: "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + pickup: "Pickup your products" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + payment: + by_wallet: "by wallet" + settlement_by_debit_card: "Settlement by debit card" + settlement_done_at_the_reception: "Settlement done at the reception" + settlement_by_wallet: "Settlement by wallet" + on_DATE_at_TIME: "on {DATE} at {TIME}," + for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}" + and: 'and' + order_actions: + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + confirm: 'Confirm' + confirmation_required: "Confirmation required" + confirm_order_in_progress_html: "Please confirm that this order in being prepared." + order_in_progress_success: "Order is under preparation" + confirm_order_ready_html: "Please confirm that this order is ready." + order_ready_note: 'You can leave a message to the customer about withdrawal instructions' + order_ready_success: "Order is ready" + confirm_order_delivered_html: "Please confirm that this order was delivered." + order_delivered_success: "Order was delivered" + confirm_order_canceled_html: "Do you really want to cancel this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_canceled_success: "Order was canceled" + confirm_order_refunded_html: "Do you really want to refund this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_refunded_success: "Order was refunded" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" + active_filters_tags: + keyword: "Keyword: {KEYWORD}" + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 34724b6d0..5b693e267 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,3 +550,94 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main visual" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Order creation" + last_update: "Last update" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + show_order: + back_to_list: "Back to list" + see_invoice: "See invoice" + tracking: "Order tracking" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations : "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + pickup: "Pickup your products" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + payment: + by_wallet: "by wallet" + settlement_by_debit_card: "Settlement by debit card" + settlement_done_at_the_reception: "Settlement done at the reception" + settlement_by_wallet: "Settlement by wallet" + on_DATE_at_TIME: "on {DATE} at {TIME}," + for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}" + and: 'and' + order_actions: + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + confirm: 'Confirm' + confirmation_required: "Confirmation required" + confirm_order_in_progress_html: "Please confirm that this order in being prepared." + order_in_progress_success: "Order is under preparation" + confirm_order_ready_html: "Please confirm that this order is ready." + order_ready_note: 'You can leave a message to the customer about withdrawal instructions' + order_ready_success: "Order is ready" + confirm_order_delivered_html: "Please confirm that this order was delivered." + order_delivered_success: "Order was delivered" + confirm_order_canceled_html: "Do you really want to cancel this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_canceled_success: "Order was canceled" + confirm_order_refunded_html: "Do you really want to refund this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_refunded_success: "Order was refunded" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" + active_filters_tags: + keyword: "Keyword: {KEYWORD}" + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/config/locales/app.shared.es.yml b/config/locales/app.shared.es.yml index a44670710..c6ed0eae9 100644 --- a/config/locales/app.shared.es.yml +++ b/config/locales/app.shared.es.yml @@ -550,3 +550,94 @@ es: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main visual" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Order creation" + last_update: "Last update" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + show_order: + back_to_list: "Back to list" + see_invoice: "See invoice" + tracking: "Order tracking" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations: "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + pickup: "Pickup your products" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + payment: + by_wallet: "by wallet" + settlement_by_debit_card: "Settlement by debit card" + settlement_done_at_the_reception: "Settlement done at the reception" + settlement_by_wallet: "Settlement by wallet" + on_DATE_at_TIME: "on {DATE} at {TIME}," + for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}" + and: 'and' + order_actions: + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + confirm: 'Confirm' + confirmation_required: "Confirmation required" + confirm_order_in_progress_html: "Please confirm that this order in being prepared." + order_in_progress_success: "Order is under preparation" + confirm_order_ready_html: "Please confirm that this order is ready." + order_ready_note: 'You can leave a message to the customer about withdrawal instructions' + order_ready_success: "Order is ready" + confirm_order_delivered_html: "Please confirm that this order was delivered." + order_delivered_success: "Order was delivered" + confirm_order_canceled_html: "Do you really want to cancel this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_canceled_success: "Order was canceled" + confirm_order_refunded_html: "Do you really want to refund this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_refunded_success: "Order was refunded" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" + active_filters_tags: + keyword: "Keyword: {KEYWORD}" + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index d39a36486..963aa0cc7 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,3 +550,94 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" + form_checklist: + select_all: "Tout sélectionner" + unselect_all: "Tout déselectionner" + form_file_upload: + browse: "Parcourir" + edit: "Modifier" + form_image_upload: + browse: "Parcourir" + edit: "Modifier" + main_image: "Visuel principal" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Date de création" + last_update: "Dernière mise à jour" + state: + cart: 'Panier' + in_progress: 'En cours de préparation' + paid: "Payée" + payment_failed: "Erreur de paiement" + canceled: "Annulée" + ready: "Prête" + refunded: "Remboursée" + delivered: "Livrée" + show_order: + back_to_list: "Retour à la liste" + see_invoice: "Voir la facture" + tracking: "Suivi de commande" + client: "Client" + created_at: "Date de création" + last_update: "Dernière mise à jour" + cart: "Panier" + reference_short: "réf. :" + unit: "Unité" + item_total: "Total" + payment_informations: "Informations de paiement" + amount: "Montant" + products_total: "Total des produits" + gift_total: "Total remise" + coupon: "Code promo" + cart_total: "Total du panier" + pickup: "Retirer vos produits" + state: + cart: 'Panier' + in_progress: 'En cours de préparation' + paid: "Payée" + payment_failed: "Erreur de paiement" + canceled: "Annulée" + ready: "Prête" + refunded: "Remboursée" + delivered: "Livrée" + payment: + by_wallet: "par porte-monnaie" + settlement_by_debit_card: "Paiement par carte bancaire" + settlement_done_at_the_reception: "Règlement effectué à l'accueil" + settlement_by_wallet: "Règlement effectué par porte-monnaie" + on_DATE_at_TIME: "le {DATE} à {TIME}," + for_an_amount_of_AMOUNT: "pour un montant de {AMOUNT}" + and: 'et' + order_actions: + state: + cart: 'Panier' + in_progress: 'En cours de préparation' + paid: "Payée" + payment_failed: "Erreur de paiement" + canceled: "Annulée" + ready: "Prête" + refunded: "Remboursée" + delivered: "Livrée" + confirm: 'Confirmer' + confirmation_required: "Confirmation requise" + confirm_order_in_progress_html: "Veuillez confirmer que cette commande en cours de préparation." + order_in_progress_success: "La commande est en cours de préparation" + confirm_order_ready_html: "Veuillez confirmer que cette commande est prête." + order_ready_note: 'Laissez votre message' + order_ready_success: "La commande est prête" + confirm_order_delivered_html: "Veuillez confirmer que cette commande a été livrée." + order_delivered_success: "La commande a été livrée" + confirm_order_canceled_html: "Voulez-vous vraiment annuler cette commande ?

    Si cela impacte le stock, veuillez refléter le changement dans modifier le produit > gestion des stocks. Ceci ne sera pas automatique.

    " + order_canceled_success: "La commande a été annulée" + confirm_order_refunded_html: "Voulez-vous vraiment rembourser cette commande ?

    Si cela impacte sur le stock, veuillez refléter le changement dans modifier le produit > gestion des stocks. Ceci ne sera pas automatique.

    " + order_refunded_success: "La commande a été remboursée" + unsaved_form_alert: + modal_title: "Vous avez des modifications non enregistrées" + confirmation_message: "Si vous quittez cette page, vos modifications seront perdues. Êtes-vous sûr(e) de vouloir continuer ?" + confirmation_button: "Oui, ne pas enregistrer" + active_filters_tags: + keyword: "Mot-clef : {KEYWORD}" + stock_internal: "Stock interne" + stock_external: "Stock externe" diff --git a/config/locales/app.shared.no.yml b/config/locales/app.shared.no.yml index 9c711ca94..b658b6e81 100644 --- a/config/locales/app.shared.no.yml +++ b/config/locales/app.shared.no.yml @@ -550,3 +550,94 @@ validate_button: "Valider det nye kortet" form_multi_select: create_label: "Add {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main visual" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Order creation" + last_update: "Last update" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + show_order: + back_to_list: "Back to list" + see_invoice: "See invoice" + tracking: "Order tracking" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations: "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + pickup: "Pickup your products" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + payment: + by_wallet: "by wallet" + settlement_by_debit_card: "Settlement by debit card" + settlement_done_at_the_reception: "Settlement done at the reception" + settlement_by_wallet: "Settlement by wallet" + on_DATE_at_TIME: "on {DATE} at {TIME}," + for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}" + and: 'and' + order_actions: + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + confirm: 'Confirm' + confirmation_required: "Confirmation required" + confirm_order_in_progress_html: "Please confirm that this order in being prepared." + order_in_progress_success: "Order is under preparation" + confirm_order_ready_html: "Please confirm that this order is ready." + order_ready_note: 'You can leave a message to the customer about withdrawal instructions' + order_ready_success: "Order is ready" + confirm_order_delivered_html: "Please confirm that this order was delivered." + order_delivered_success: "Order was delivered" + confirm_order_canceled_html: "Do you really want to cancel this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_canceled_success: "Order was canceled" + confirm_order_refunded_html: "Do you really want to refund this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_refunded_success: "Order was refunded" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" + active_filters_tags: + keyword: "Keyword: {KEYWORD}" + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/config/locales/app.shared.pt.yml b/config/locales/app.shared.pt.yml index f5c84c526..6c414b3ef 100644 --- a/config/locales/app.shared.pt.yml +++ b/config/locales/app.shared.pt.yml @@ -550,3 +550,94 @@ pt: validate_button: "Verificar o novo cartão" form_multi_select: create_label: "Adicionar {VALUE}" + form_checklist: + select_all: "Select all" + unselect_all: "Unselect all" + form_file_upload: + browse: "Browse" + edit: "Edit" + form_image_upload: + browse: "Browse" + edit: "Edit" + main_image: "Main visual" + store: + order_item: + total: "Total" + client: "Client" + created_at: "Order creation" + last_update: "Last update" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + show_order: + back_to_list: "Back to list" + see_invoice: "See invoice" + tracking: "Order tracking" + client: "Client" + created_at: "Creation date" + last_update: "Last update" + cart: "Cart" + reference_short: "ref:" + unit: "Unit" + item_total: "Total" + payment_informations: "Payment informations" + amount: "Amount" + products_total: "Products total" + gift_total: "Discount total" + coupon: "Coupon" + cart_total: "Cart total" + pickup: "Pickup your products" + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + payment: + by_wallet: "by wallet" + settlement_by_debit_card: "Settlement by debit card" + settlement_done_at_the_reception: "Settlement done at the reception" + settlement_by_wallet: "Settlement by wallet" + on_DATE_at_TIME: "on {DATE} at {TIME}," + for_an_amount_of_AMOUNT: "for an amount of {AMOUNT}" + and: 'and' + order_actions: + state: + cart: 'Cart' + in_progress: 'Under preparation' + paid: "Paid" + payment_failed: "Payment error" + canceled: "Canceled" + ready: "Ready" + refunded: "Refunded" + delivered: "Delivered" + confirm: 'Confirm' + confirmation_required: "Confirmation required" + confirm_order_in_progress_html: "Please confirm that this order in being prepared." + order_in_progress_success: "Order is under preparation" + confirm_order_ready_html: "Please confirm that this order is ready." + order_ready_note: 'You can leave a message to the customer about withdrawal instructions' + order_ready_success: "Order is ready" + confirm_order_delivered_html: "Please confirm that this order was delivered." + order_delivered_success: "Order was delivered" + confirm_order_canceled_html: "Do you really want to cancel this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_canceled_success: "Order was canceled" + confirm_order_refunded_html: "Do you really want to refund this order?

    If this impacts stock, please reflect the change in edit product > stock management. This won't be automatic.

    " + order_refunded_success: "Order was refunded" + unsaved_form_alert: + modal_title: "You have some unsaved changes" + confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" + confirmation_button: "Yes, don't save" + active_filters_tags: + keyword: "Keyword: {KEYWORD}" + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/config/locales/app.shared.zu.yml b/config/locales/app.shared.zu.yml index 1bcbfbd98..eaf1c0cc7 100644 --- a/config/locales/app.shared.zu.yml +++ b/config/locales/app.shared.zu.yml @@ -550,3 +550,94 @@ zu: validate_button: "crwdns29486:0crwdne29486:0" form_multi_select: create_label: "crwdns29488:0{VALUE}crwdne29488:0" + form_checklist: + select_all: "crwdns30416:0crwdne30416:0" + unselect_all: "crwdns30418:0crwdne30418:0" + form_file_upload: + browse: "crwdns30420:0crwdne30420:0" + edit: "crwdns30422:0crwdne30422:0" + form_image_upload: + browse: "crwdns30424:0crwdne30424:0" + edit: "crwdns30426:0crwdne30426:0" + main_image: "crwdns31206:0crwdne31206:0" + store: + order_item: + total: "crwdns30430:0crwdne30430:0" + client: "crwdns30432:0crwdne30432:0" + created_at: "crwdns30434:0crwdne30434:0" + last_update: "crwdns30436:0crwdne30436:0" + state: + cart: 'crwdns30438:0crwdne30438:0' + in_progress: 'crwdns30440:0crwdne30440:0' + paid: "crwdns30442:0crwdne30442:0" + payment_failed: "crwdns30444:0crwdne30444:0" + canceled: "crwdns30446:0crwdne30446:0" + ready: "crwdns30448:0crwdne30448:0" + refunded: "crwdns30450:0crwdne30450:0" + delivered: "crwdns30452:0crwdne30452:0" + show_order: + back_to_list: "crwdns30454:0crwdne30454:0" + see_invoice: "crwdns30456:0crwdne30456:0" + tracking: "crwdns30458:0crwdne30458:0" + client: "crwdns30460:0crwdne30460:0" + created_at: "crwdns30462:0crwdne30462:0" + last_update: "crwdns30464:0crwdne30464:0" + cart: "crwdns30466:0crwdne30466:0" + reference_short: "crwdns30468:0crwdne30468:0" + unit: "crwdns30470:0crwdne30470:0" + item_total: "crwdns30472:0crwdne30472:0" + payment_informations: "crwdns30474:0crwdne30474:0" + amount: "crwdns30476:0crwdne30476:0" + products_total: "crwdns30478:0crwdne30478:0" + gift_total: "crwdns30480:0crwdne30480:0" + coupon: "crwdns30482:0crwdne30482:0" + cart_total: "crwdns30484:0crwdne30484:0" + pickup: "crwdns30708:0crwdne30708:0" + state: + cart: 'crwdns30486:0crwdne30486:0' + in_progress: 'crwdns30488:0crwdne30488:0' + paid: "crwdns30490:0crwdne30490:0" + payment_failed: "crwdns30492:0crwdne30492:0" + canceled: "crwdns30494:0crwdne30494:0" + ready: "crwdns30496:0crwdne30496:0" + refunded: "crwdns30498:0crwdne30498:0" + delivered: "crwdns30500:0crwdne30500:0" + payment: + by_wallet: "crwdns30502:0crwdne30502:0" + settlement_by_debit_card: "crwdns30504:0crwdne30504:0" + settlement_done_at_the_reception: "crwdns30506:0crwdne30506:0" + settlement_by_wallet: "crwdns30508:0crwdne30508:0" + on_DATE_at_TIME: "crwdns30510:0{DATE}crwdnd30510:0{TIME}crwdne30510:0" + for_an_amount_of_AMOUNT: "crwdns30512:0{AMOUNT}crwdne30512:0" + and: 'crwdns30514:0crwdne30514:0' + order_actions: + state: + cart: 'crwdns30516:0crwdne30516:0' + in_progress: 'crwdns30518:0crwdne30518:0' + paid: "crwdns30520:0crwdne30520:0" + payment_failed: "crwdns30522:0crwdne30522:0" + canceled: "crwdns30524:0crwdne30524:0" + ready: "crwdns30526:0crwdne30526:0" + refunded: "crwdns30528:0crwdne30528:0" + delivered: "crwdns30530:0crwdne30530:0" + confirm: 'crwdns30532:0crwdne30532:0' + confirmation_required: "crwdns30534:0crwdne30534:0" + confirm_order_in_progress_html: "crwdns30712:0crwdne30712:0" + order_in_progress_success: "crwdns30538:0crwdne30538:0" + confirm_order_ready_html: "crwdns30714:0crwdne30714:0" + order_ready_note: 'crwdns30542:0crwdne30542:0' + order_ready_success: "crwdns30544:0crwdne30544:0" + confirm_order_delivered_html: "crwdns30716:0crwdne30716:0" + order_delivered_success: "crwdns30622:0crwdne30622:0" + confirm_order_canceled_html: "crwdns30718:0crwdne30718:0" + order_canceled_success: "crwdns30626:0crwdne30626:0" + confirm_order_refunded_html: "crwdns30720:0crwdne30720:0" + order_refunded_success: "crwdns30630:0crwdne30630:0" + unsaved_form_alert: + modal_title: "crwdns30558:0crwdne30558:0" + confirmation_message: "crwdns30560:0crwdne30560:0" + confirmation_button: "crwdns30562:0crwdne30562:0" + active_filters_tags: + keyword: "crwdns30722:0{KEYWORD}crwdne30722:0" + stock_internal: "crwdns30564:0crwdne30564:0" + stock_external: "crwdns30566:0crwdne30566:0" diff --git a/config/locales/base.pt.yml b/config/locales/base.pt.yml index 6e0add1da..988660474 100644 --- a/config/locales/base.pt.yml +++ b/config/locales/base.pt.yml @@ -2,4 +2,4 @@ pt: time: formats: # See http://apidock.com/ruby/DateTime/strftime for a list of available directives - hour_minute: "%I:%M %p" + hour_minute: "%H:%M" diff --git a/config/locales/de.yml b/config/locales/de.yml index 9fdebeafd..c49b7ac31 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -10,6 +10,11 @@ de: week: one: 'Eine Woche' other: '%{count} Wochen' + activerecord: + attributes: + product: + amount: "The price" + slug: "URL" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ de: invalid_duration: "Der zulässige Zeitraum muss zwischen 1 Tag und 1 Jahr lang sein. Ihr Zeitraum ist %{DAYS} Tage lang." must_be_in_the_past: "Der Zeitraum darf ausschließlich vor dem heutigen Datum liegen." registration_disabled: "Registrierung ist deaktiviert" + undefined_in_store: "must be defined to make the product available in the store" + gateway_error: "Payement gateway error: %{MESSAGE}" + gateway_amount_too_small: "Payments under %{AMOUNT} are not supported. Please order directly at the reception." + gateway_amount_too_large: "Payments above %{AMOUNT} are not supported. Please order directly at the reception." + product_in_use: "This product have already been ordered" + slug_already_used: "is already used" apipie: api_documentation: "API-Dokumentation" code: "HTTP-Code" @@ -57,7 +68,6 @@ de: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "Die Gruppe kann während eines Abonnements nicht geändert werden" - admins_cant_change_group: "Ein Administrator kann nicht aus seiner eigenen Gruppe entfernt werden" please_input_the_authentication_code_sent_to_the_address: "Bitte geben Sie den Authentifizierungscode ein, der an die E-Mail-Adresse %{EMAIL} gesendet wurde" your_authentication_code_is_not_valid: "Der Authentifizierungscode ist ungültig." current_authentication_method_no_code: "Die aktuelle Authentifizierungsmethode erfordert keinen Migrationscode" @@ -121,6 +131,7 @@ de: error_invoice: "Fehlerhafte Rechnung. Die folgenden Artikel sind nicht gebucht. Bitte kontaktieren Sie das FabLab für eine Rückerstattung." prepaid_pack: "Prepaid-Stundenpaket" pack_item: "Paket von %{COUNT} Stunden für %{ITEM}" + order: "Your order on the store" #PDF payment schedule generation payment_schedules: schedule_reference: "Zahlungsplan Referenz: %{REF}" @@ -153,6 +164,7 @@ de: Event_reservation: "Veranstaltungsreservierung" Space_reservation: "Raumreservierung" wallet: "Guthabenkonto" + shop_order: "shop order" vat_export: start_date: "Anfangsdatum" end_date: "Enddatum" @@ -355,6 +367,9 @@ de: import_over: "%{CATEGORY} Import ist beendet. " members: "Mitglieder" view_results: "Ergebnisse anzeigen." + notify_admin_low_stock_threshold: + low_stock: "Low stock for %{PRODUCT}. " + view_product: "View the product." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Erhalten Sie %{PERCENT}% Rabatt mit dem Code %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Erhalten Sie %{AMOUNT}% Rabatt mit dem Code %{CODE}" @@ -406,11 +421,18 @@ de: refusal: "Your proof of identity are not accepted" notify_admin_user_proof_of_identity_refusal: refusal: "Member's proof of identity %{NAME} refused." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" + notify_user_order_is_refunded: + order_refunded: "Your command %{REFERENCE} is refunded" #statistics tools for admins statistics: subscriptions: "Abonnements" machines_hours: "Maschinen-Slots" spaces: "Räume" + orders: "Orders" trainings: "Schulungen" events: "Veranstaltungen" registrations: "Anmeldungen" @@ -434,6 +456,9 @@ de: account_creation: "Benutzerkontenerstellung" project_publication: "Projektveröffentlichung" duration: "Dauer" + store: "Store" + paid-processed: "Paid and/or processed" + aborted: "Aborted" #statistics exports to the Excel file format export: entries: "Einträge" @@ -453,13 +478,12 @@ de: price_category: reduced_fare: "Ermäßigter Tarif" reduced_fare_if_you_are_under_25_student_or_unemployed: "Ermäßigter Tarif, wenn Sie unter 25, studierend oder arbeitslos sind." - group: - #name of the user's group for administrators - admins: 'Administratoren' cart_items: free_extension: "Kostenlose Verlängerung eines Abonnements bis %{DATE}" statistic_profile: birthday_in_past: "Geburtsdatum muss in der Vergangenheit liegen" + order: + please_contact_FABLAB: "Please contact us for withdrawal instructions." settings: locked_setting: "die Einstellung ist gesperrt." about_title: "Seitentitel \"Über\"" @@ -593,3 +617,6 @@ de: machines_module: "Machines module" user_change_group: "Allow users to change their group" show_username_in_admin_list: "Show the username in the admin's members list" + store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" + store_hidden: "Store hidden to the public" diff --git a/config/locales/en.yml b/config/locales/en.yml index d5d937dce..18f76841f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -10,6 +10,11 @@ en: week: one: 'one week' other: '%{count} weeks' + activerecord: + attributes: + product: + amount: "The price" + slug: "URL" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ en: invalid_duration: "The allowed duration must be between 1 day and 1 year. Your period is %{DAYS} days long." must_be_in_the_past: "The period must be strictly prior to today's date." registration_disabled: "Registration is disabled" + undefined_in_store: "must be defined to make the product available in the store" + gateway_error: "Payement gateway error: %{MESSAGE}" + gateway_amount_too_small: "Payments under %{AMOUNT} are not supported. Please order directly at the reception." + gateway_amount_too_large: "Payments above %{AMOUNT} are not supported. Please order directly at the reception." + product_in_use: "This product have already been ordered" + slug_already_used: "is already used" apipie: api_documentation: "API Documentation" code: "HTTP code" @@ -57,7 +68,6 @@ en: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "Unable to change the group while a subscription is running" - admins_cant_change_group: "Unable to remove an administrator from his dedicated group" please_input_the_authentication_code_sent_to_the_address: "Please input the authentication code sent to the e-mail address %{EMAIL}" your_authentication_code_is_not_valid: "Your authentication code is not valid." current_authentication_method_no_code: "The current authentication method does not require any migration code" @@ -121,6 +131,7 @@ en: error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund." prepaid_pack: "Prepaid pack of hours" pack_item: "Pack of %{COUNT} hours for the %{ITEM}" + order: "Your order on the store" #PDF payment schedule generation payment_schedules: schedule_reference: "Payment schedule reference: %{REF}" @@ -153,6 +164,7 @@ en: Event_reservation: "event reserv." Space_reservation: "space reserv." wallet: "wallet" + shop_order: "shop order" vat_export: start_date: "Start date" end_date: "End date" @@ -355,6 +367,9 @@ en: import_over: "%{CATEGORY} import is over. " members: "Members" view_results: "View results." + notify_admin_low_stock_threshold: + low_stock: "Low stock for %{PRODUCT}. " + view_product: "View the product." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Enjoy a discount of %{PERCENT}% with code %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Enjoy a discount of %{AMOUNT} with code %{CODE}" @@ -406,11 +421,18 @@ en: refusal: "Your proof of identity are not accepted" notify_admin_user_proof_of_identity_refusal: refusal: "Member's proof of identity %{NAME} refused." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" + notify_user_order_is_refunded: + order_refunded: "Your command %{REFERENCE} is refunded" #statistics tools for admins statistics: subscriptions: "Subscriptions" machines_hours: "Machines slots" spaces: "Spaces" + orders: "Orders" trainings: "Trainings" events: "Events" registrations: "Registrations" @@ -434,6 +456,9 @@ en: account_creation: "Account creation" project_publication: "Project publication" duration: "Duration" + store: "Store" + paid-processed: "Paid and/or processed" + aborted: "Aborted" #statistics exports to the Excel file format export: entries: "Entries" @@ -453,13 +478,12 @@ en: price_category: reduced_fare: "Reduced fare" reduced_fare_if_you_are_under_25_student_or_unemployed: "Reduced fare if you are under 25, student or unemployed." - group: - #name of the user's group for administrators - admins: 'Administrators' cart_items: free_extension: "Free extension of a subscription, until %{DATE}" statistic_profile: birthday_in_past: "The date of birth must be in the past" + order: + please_contact_FABLAB: "Please contact us for withdrawal instructions." settings: locked_setting: "the setting is locked." about_title: "\"About\" page title" @@ -593,3 +617,6 @@ en: machines_module: "Machines module" user_change_group: "Allow users to change their group" show_username_in_admin_list: "Show the username in the admin's members list" + store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" + store_hidden: "Store hidden to the public" diff --git a/config/locales/es.yml b/config/locales/es.yml index fc99ae741..75b12142c 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -10,6 +10,11 @@ es: week: one: 'una semana' other: '%{count} semanas' + activerecord: + attributes: + product: + amount: "The price" + slug: "URL" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ es: invalid_duration: "La duración permitida es de 1 día a 1 año. Su período es %{DAYS} días de largo." must_be_in_the_past: "El período debe ser estrictamente anterior a la fecha de hoy." registration_disabled: "Registration is disabled" + undefined_in_store: "must be defined to make the product available in the store" + gateway_error: "Payement gateway error: %{MESSAGE}" + gateway_amount_too_small: "Payments under %{AMOUNT} are not supported. Please order directly at the reception." + gateway_amount_too_large: "Payments above %{AMOUNT} are not supported. Please order directly at the reception." + product_in_use: "This product have already been ordered" + slug_already_used: "is already used" apipie: api_documentation: "Documentación API" code: "HTTP code" @@ -57,7 +68,6 @@ es: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "No se puede cambiar de grupo mientras haya una suscripción en curso" - admins_cant_change_group: "No se puede eliminar un administrador de su grupo dedicado" please_input_the_authentication_code_sent_to_the_address: "Por favor Ingrese el código de autenticación enviado a la dirección de correo electrónico %{EMAIL}" your_authentication_code_is_not_valid: "Su código de autenticación no es válido." current_authentication_method_no_code: "El método de autenticación actual no requiere ningún código de migración" @@ -121,6 +131,7 @@ es: error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund." prepaid_pack: "Prepaid pack of hours" pack_item: "Pack of %{COUNT} hours for the %{ITEM}" + order: "Your order on the store" #PDF payment schedule generation payment_schedules: schedule_reference: "Payment schedule reference: %{REF}" @@ -153,6 +164,7 @@ es: Event_reservation: "reserv. evento" Space_reservation: "reserv. espacio" wallet: "cartera" + shop_order: "shop order" vat_export: start_date: "Start date" end_date: "End date" @@ -355,6 +367,9 @@ es: import_over: "La importación de %{CATEGORY} esta terminada. " members: "Usuarios" view_results: "Ver resultados." + notify_admin_low_stock_threshold: + low_stock: "Low stock for %{PRODUCT}. " + view_product: "View the product." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Disfruta de un descuento de %{PERCENT}% con el código %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Disfruta de un descuento de %{AMOUNT} con el código %{CODE}" @@ -406,11 +421,18 @@ es: refusal: "Your proof of identity are not accepted" notify_admin_user_proof_of_identity_refusal: refusal: "Member's proof of identity %{NAME} refused." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" + notify_user_order_is_refunded: + order_refunded: "Your command %{REFERENCE} is refunded" #statistics tools for admins statistics: subscriptions: "Suscripciones" machines_hours: "Machine slots" spaces: "Espacios" + orders: "Orders" trainings: "Cursos" events: "Eventos" registrations: "Registros" @@ -434,6 +456,9 @@ es: account_creation: "Creación de cuenta" project_publication: "Publicación de proyectos" duration: "Duración" + store: "Store" + paid-processed: "Paid and/or processed" + aborted: "Aborted" #statistics exports to the Excel file format export: entries: "Entradas" @@ -453,13 +478,12 @@ es: price_category: reduced_fare: "Tarifa reducida" reduced_fare_if_you_are_under_25_student_or_unemployed: "Tarifa reducida si tienes menos de 25 años, eres estudiante o estás desempleado." - group: - #name of the user's group for administrators - admins: 'Administradores' cart_items: free_extension: "Free extension of a subscription, until %{DATE}" statistic_profile: birthday_in_past: "The date of birth must be in the past" + order: + please_contact_FABLAB: "Please contact us for withdrawal instructions." settings: locked_setting: "the setting is locked." about_title: "\"About\" page title" @@ -593,3 +617,6 @@ es: machines_module: "Machines module" user_change_group: "Allow users to change their group" show_username_in_admin_list: "Show the username in the admin's members list" + store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" + store_hidden: "Store hidden to the public" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index e96d35021..62458c1c7 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -10,6 +10,11 @@ fr: week: one: 'une semaine' other: '%{count} semaines' + activerecord: + attributes: + product: + amount: "Le Prix" + slug: "URL" errors: #CarrierWave messages: @@ -19,9 +24,9 @@ fr: extension_whitelist_error: "Vous n'êtes pas autorisé à envoyer des fichiers %{extension}, les types autorisés sont : %{allowed_types}" extension_blacklist_error: "Vous n'êtes pas autorisé à envoyer des fichiers %{extension}, les types interdits sont : %{prohibited_types}" content_type_whitelist_error: "Vous n'êtes pas autorisé à envoyer des fichiers %{content_type}, les types autorisés sont : %{allowed_types}" - rmagick_processing_error: "Impossible de manipuler avec rmagick, peut-être n'est-ce pas une image ?" - mime_types_processing_error: "Impossible de traiter le fichier avec MIME::Types , peut-être pas de type de contenu valide ?" - mini_magick_processing_error: "Impossible de manipuler le fichier, peut-être n'est-ce pas une image ?" + rmagick_processing_error: "Impossible de manipuler avec rmagick. Peut-être que ce n'est pas une image ?" + mime_types_processing_error: "Impossible de traiter le fichier avec MIME::Types. Le type de contenu n'est peut-être pas valide ?" + mini_magick_processing_error: "Impossible de manipuler le fichier. Peut-être que ce n'est pas une image ?" wrong_size: "ne fait pas la bonne taille (doit comporter %{file_size})" size_too_small: "est trop petit (au moins %{file_size})" size_too_big: "est trop grand (pas plus de %{file_size})" @@ -38,6 +43,12 @@ fr: invalid_duration: "La durée doit être comprise entre 1 jour et 1 an. Votre période dure %{DAYS} jours." must_be_in_the_past: "La période doit être strictement antérieure à la date du jour." registration_disabled: "L'inscription est désactivée" + undefined_in_store: "doit être défini pour rendre le produit disponible dans la boutique" + gateway_error: "Erreur de la passerelle de paiement : %{MESSAGE}" + gateway_amount_too_small: "Les paiements inférieurs à %{AMOUNT} ne sont pas pris en charge. Merci de passer commande directement à l'accueil." + gateway_amount_too_large: "Les paiements supérieurs à %{AMOUNT} ne sont pas pris en charge. Merci de passer commande directement à l'accueil." + product_in_use: "Ce produit a déjà été commandé" + slug_already_used: "est déjà utilisée" apipie: api_documentation: "Documentation de l'API" code: "Code HTTP " @@ -57,7 +68,6 @@ fr: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "Impossible de changer le groupe tant qu'un abonnement est en cours" - admins_cant_change_group: "Impossible de supprimer un administrateur de son groupe dédié" please_input_the_authentication_code_sent_to_the_address: "Merci d'enter le code d'authentification qui a été envoyé à l'adresse de courriel %{EMAIL}" your_authentication_code_is_not_valid: "Votre code d'authentification n'est pas valide." current_authentication_method_no_code: "La méthode d'authentification actuelle ne requiert pas de code de migration" @@ -121,6 +131,7 @@ fr: error_invoice: "Facture en erreur. Les éléments ci-dessous n'ont pas été réservés. Veuillez contacter le Fablab pour un remboursement." prepaid_pack: "Paquet d'heures prépayé" pack_item: "Pack de %{COUNT} heures pour la %{ITEM}" + order: "Votre commande sur la boutique" #PDF payment schedule generation payment_schedules: schedule_reference: "Référence de l'échéancier : %{REF}" @@ -153,6 +164,7 @@ fr: Event_reservation: "réserv. événement" Space_reservation: "réserv. espace" wallet: "porte-monnaie" + shop_order: "commande de la boutique" vat_export: start_date: "Date de début" end_date: "Date de fin" @@ -355,6 +367,9 @@ fr: import_over: "L'import %{CATEGORY} est terminé. " members: "des membres" view_results: "Voir les résultats." + notify_admin_low_stock_threshold: + low_stock: "Stock limité pour %{PRODUCT}. " + view_product: "Voir le produit." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Bénéficiez d'une remise de %{PERCENT} % avec le code %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Bénéficiez d'une remise de %{AMOUNT} avec le code %{CODE}" @@ -406,11 +421,18 @@ fr: refusal: "Votre justificatif n'est pas accepté" notify_admin_user_proof_of_identity_refusal: refusal: "Le justificatif du membre %{NAME} a été refusé." + notify_user_order_is_ready: + order_ready: "Votre commande %{REFERENCE} est prête" + notify_user_order_is_canceled: + order_canceled: "Votre commande %{REFERENCE} est annulée" + notify_user_order_is_refunded: + order_refunded: "Votre commande %{REFERENCE} est remboursée" #statistics tools for admins statistics: subscriptions: "Abonnements" machines_hours: "Créneaux machines" spaces: "Espaces" + orders: "Commandes" trainings: "Formations" events: "Événements" registrations: "Inscriptions" @@ -434,6 +456,9 @@ fr: account_creation: "Création de compte" project_publication: "Publication de projet" duration: "Durée" + store: "Boutique" + paid-processed: "Payée et/ou traitée" + aborted: "Interrompue" #statistics exports to the Excel file format export: entries: "Entrées" @@ -453,13 +478,12 @@ fr: price_category: reduced_fare: "Tarif réduit" reduced_fare_if_you_are_under_25_student_or_unemployed: "Tarif réduit si vous avez moins de 25 ans, que vous êtes étudiant ou demandeur d'emploi." - group: - #name of the user's group for administrators - admins: 'Administrateurs' cart_items: free_extension: "Extension gratuite d'un abonnement, jusqu'au %{DATE}" statistic_profile: birthday_in_past: "La date de naissance doit être dans le passé" + order: + please_contact_FABLAB: "Veuillez nous contacter pour des instructions de retrait." settings: locked_setting: "le paramètre est verrouillé." about_title: "Le titre de la page \"À propos\"" @@ -593,3 +617,6 @@ fr: machines_module: "Module machines" user_change_group: "Permettre aux utilisateurs de changer leur groupe" show_username_in_admin_list: "Afficher le nom d'utilisateur dans la liste des membres de l'administrateur" + store_module: "Module boutique" + store_withdrawal_instructions: "Instructions de retrait" + store_hidden: "Boutique masquée au public" diff --git a/config/locales/mails.de.yml b/config/locales/mails.de.yml index f5fa9d6b6..b3bb6e805 100644 --- a/config/locales/mails.de.yml +++ b/config/locales/mails.de.yml @@ -111,7 +111,7 @@ de: notify_member_invoice_ready: subject: "Rechnung Ihres FabLabs" body: - please_find_attached_html: "Die angehängte Datei enthält Ihre Rechnung von {DATE}, über den Betrag von {AMOUNT} in Bezug auf Ihr/e {TYPE, select, Reservation{Reservierung} other{Abonnement}}." #messageFormat interpolation + please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} OrderItem{order} other{subscription}}." #messageFormat interpolation invoice_in_your_dashboard_html: "Sie können auf Ihre Rechnung in %{DASHBOARD} auf der FabLab-Website zugreifen." your_dashboard: "Ihr Dashboard" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ de: you_made_an_import: "Sie haben einen Import von %{CATEGORY} gestartet" category_members: "der Mitglieder" click_to_view_results: "Klicken Sie hier, um die Ergebnisse anzuzeigen" + notify_admin_low_stock_threshold: + subject: "Low stock alert" + body: + low_stock: "A new stock movement of %{PRODUCT} has exceeded the low stock threshold." + stocks_state_html: "Current stock status:
    • internal: %{INTERNAL}
    • external: %{EXTERNAL}
    " + manage_stock: "Manage stocks for this product" notify_member_about_coupon: subject: "Gutschein" body: @@ -374,3 +380,15 @@ de: user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:" shared: hello: "Hallo %{user_name}" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command was canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} was canceled." + notify_user_order_is_refunded: + subject: "Your command was refunded" + body: + notify_user_order_is_refunded: "Your command %{REFERENCE} was refunded." diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 4c8a16b76..10af23938 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -111,7 +111,7 @@ en: notify_member_invoice_ready: subject: "Your FabLab's invoice" body: - please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} other{subscription}}." #messageFormat interpolation + please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} OrderItem{order} other{subscription}}." #messageFormat interpolation invoice_in_your_dashboard_html: "You can access your invoice in %{DASHBOARD} on the Fab Lab website." your_dashboard: "your dashboard" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ en: you_made_an_import: "You have initiated an import %{CATEGORY}" category_members: "of the members" click_to_view_results: "Click here to view results" + notify_admin_low_stock_threshold: + subject: "Low stock alert" + body: + low_stock: "A new stock movement of %{PRODUCT} has exceeded the low stock threshold." + stocks_state_html: "Current stock status:
    • internal: %{INTERNAL}
    • external: %{EXTERNAL}
    " + manage_stock: "Manage stocks for this product" notify_member_about_coupon: subject: "Coupon" body: @@ -374,3 +380,15 @@ en: user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:" shared: hello: "Hello %{user_name}" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command was canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} was canceled." + notify_user_order_is_refunded: + subject: "Your command was refunded" + body: + notify_user_order_is_refunded: "Your command %{REFERENCE} was refunded." diff --git a/config/locales/mails.es.yml b/config/locales/mails.es.yml index d18a1c79c..0a7e02e6a 100644 --- a/config/locales/mails.es.yml +++ b/config/locales/mails.es.yml @@ -111,7 +111,7 @@ es: notify_member_invoice_ready: subject: "La factura de su FabLab" body: - please_find_attached_html: "Por favor, encuentre como archivo adjunto su factura de {DATE}, por un valor de {AMOUNT} referente a {TYPE, select, Reservation{reservation} other{subscription}}." #messageFormat interpolation + please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} OrderItem{order} other{subscription}}." #messageFormat interpolation invoice_in_your_dashboard_html: "Puede acceder a su factura en %{DASHBOARD} en la web del FabLab." your_dashboard: "Su Panel" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ es: you_made_an_import: "Ha iniciado una importación de %{CATEGORY}" category_members: "de los miembros" click_to_view_results: "Haga clic aquí para ver los resultados" + notify_admin_low_stock_threshold: + subject: "Low stock alert" + body: + low_stock: "A new stock movement of %{PRODUCT} has exceeded the low stock threshold." + stocks_state_html: "Current stock status:
    • internal: %{INTERNAL}
    • external: %{EXTERNAL}
    " + manage_stock: "Manage stocks for this product" notify_member_about_coupon: subject: "Cupón" body: @@ -374,3 +380,15 @@ es: user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:" shared: hello: "¡Hola %{user_name}!" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command was canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} was canceled." + notify_user_order_is_refunded: + subject: "Your command was refunded" + body: + notify_user_order_is_refunded: "Your command %{REFERENCE} was refunded." diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index fdb262f58..f15feeee8 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -111,7 +111,7 @@ fr: notify_member_invoice_ready: subject: "Votre facture du FabLab" body: - please_find_attached_html: "Vous trouverez en pièce jointe votre facture du {DATE}, d'un montant de {AMOUNT} concernant votre {TYPE, select, Reservation{réservation} other{abonnement}}." #messageFormat interpolation + please_find_attached_html: "Veuillez trouver en pièce jointe votre facture de {DATE}, avec un montant de {AMOUNT} concernant votre {TYPE, select, Reservation{réservation} OrderItem{commande} other{abonnement}}. ." #messageFormat interpolation invoice_in_your_dashboard_html: "Vous pouvez à tout moment retrouver votre facture dans %{DASHBOARD} sur le site du Fab Lab." your_dashboard: "votre tableau de bord" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ fr: you_made_an_import: "Vous avez initié un import %{CATEGORY}" category_members: "des membres" click_to_view_results: "Cliquez ici pour voir les résultats" + notify_admin_low_stock_threshold: + subject: "Alerte de stock limité" + body: + low_stock: "Un nouveau mouvement de stock de %{PRODUCT} a dépassé le seuil de stock limité." + stocks_state_html: "Statut actuel des stocks :
    • interne : %{INTERNAL}
    • externe : %{EXTERNAL}
    " + manage_stock: "Gérer les stocks de ce produit" notify_member_about_coupon: subject: "Code promo" body: @@ -374,3 +380,15 @@ fr: user_proof_of_identity_files_refusal: "Le justificatif du membre %{NAME} a été refusé par %{OPERATOR} :" shared: hello: "Bonjour %{user_name}" + notify_user_order_is_ready: + subject: "Votre commande est prête" + body: + notify_user_order_is_ready: "Votre commande %{REFERENCE} est prête :" + notify_user_order_is_canceled: + subject: "Votre commande est annulée" + body: + notify_user_order_is_canceled: "Votre commande %{REFERENCE} est annulée." + notify_user_order_is_refunded: + subject: "Votre commande est remboursée" + body: + notify_user_order_is_refunded: "Votre commande %{REFERENCE} est remboursée :" diff --git a/config/locales/mails.no.yml b/config/locales/mails.no.yml index 7572464cb..e26f6adf9 100644 --- a/config/locales/mails.no.yml +++ b/config/locales/mails.no.yml @@ -111,7 +111,7 @@ notify_member_invoice_ready: subject: "Your FabLab's invoice" body: - please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} other{subscription}}." #messageFormat interpolation + please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} OrderItem{order} other{subscription}}." #messageFormat interpolation invoice_in_your_dashboard_html: "You can access your invoice in %{DASHBOARD} on the Fab Lab website." your_dashboard: "your dashboard" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ you_made_an_import: "You have initiated an import %{CATEGORY}" category_members: "of the members" click_to_view_results: "Click here to view results" + notify_admin_low_stock_threshold: + subject: "Low stock alert" + body: + low_stock: "A new stock movement of %{PRODUCT} has exceeded the low stock threshold." + stocks_state_html: "Current stock status:
    • internal: %{INTERNAL}
    • external: %{EXTERNAL}
    " + manage_stock: "Manage stocks for this product" notify_member_about_coupon: subject: "Coupon" body: @@ -374,3 +380,15 @@ user_proof_of_identity_files_refusal: "Member %{NAME}'s supporting documents were rejected by %{OPERATOR}:" shared: hello: "Hello %{user_name}" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command was canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} was canceled." + notify_user_order_is_refunded: + subject: "Your command was refunded" + body: + notify_user_order_is_refunded: "Your command %{REFERENCE} was refunded." diff --git a/config/locales/mails.pt.yml b/config/locales/mails.pt.yml index 7538fcd0a..6318ff29c 100644 --- a/config/locales/mails.pt.yml +++ b/config/locales/mails.pt.yml @@ -111,7 +111,7 @@ pt: notify_member_invoice_ready: subject: "Fatura do seu FabLab" body: - please_find_attached_html: "Por favor, encontre como anexo a sua fatura de {DATE}, com o montante de {AMOUNT} sobre o seu {TYPE, select, Reservation{reserva} other{assinatura}}." #messageFormat interpolation + please_find_attached_html: "Please find as attached file your invoice from {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} OrderItem{order} other{subscription}}." #messageFormat interpolation invoice_in_your_dashboard_html: "Você pode acessar sua fatura em %{DASHBOARD} no site Fab Lab." your_dashboard: "seu dashboard" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ pt: you_made_an_import: "Você iniciou uma importação %{CATEGORY}" category_members: "dos membros" click_to_view_results: "Clique aqui para ver os resultados" + notify_admin_low_stock_threshold: + subject: "Low stock alert" + body: + low_stock: "A new stock movement of %{PRODUCT} has exceeded the low stock threshold." + stocks_state_html: "Current stock status:
    • internal: %{INTERNAL}
    • external: %{EXTERNAL}
    " + manage_stock: "Manage stocks for this product" notify_member_about_coupon: subject: "Cupom" body: @@ -374,3 +380,15 @@ pt: user_proof_of_identity_files_refusal: "Os documentos do membro %{NAME} foram rejeitados por %{OPERATOR}:" shared: hello: "Olá %{user_name}" + notify_user_order_is_ready: + subject: "Your command is ready" + body: + notify_user_order_is_ready: "Your command %{REFERENCE} is ready:" + notify_user_order_is_canceled: + subject: "Your command was canceled" + body: + notify_user_order_is_canceled: "Your command %{REFERENCE} was canceled." + notify_user_order_is_refunded: + subject: "Your command was refunded" + body: + notify_user_order_is_refunded: "Your command %{REFERENCE} was refunded." diff --git a/config/locales/mails.zu.yml b/config/locales/mails.zu.yml index c3fd9ed27..c07d6b968 100644 --- a/config/locales/mails.zu.yml +++ b/config/locales/mails.zu.yml @@ -111,7 +111,7 @@ zu: notify_member_invoice_ready: subject: "crwdns29626:0crwdne29626:0" body: - please_find_attached_html: "crwdns29628:0DATE={DATE}crwdnd29628:0AMOUNT={AMOUNT}crwdnd29628:0TYPE={TYPE}crwdne29628:0" #messageFormat interpolation + please_find_attached_html: "crwdns31646:0DATE={DATE}crwdnd31646:0AMOUNT={AMOUNT}crwdnd31646:0TYPE={TYPE}crwdne31646:0" #messageFormat interpolation invoice_in_your_dashboard_html: "crwdns29630:0%{DASHBOARD}crwdne29630:0" your_dashboard: "crwdns29632:0crwdne29632:0" notify_member_reservation_reminder: @@ -246,6 +246,12 @@ zu: you_made_an_import: "crwdns29810:0%{CATEGORY}crwdne29810:0" category_members: "crwdns29812:0crwdne29812:0" click_to_view_results: "crwdns29814:0crwdne29814:0" + notify_admin_low_stock_threshold: + subject: "crwdns31144:0crwdne31144:0" + body: + low_stock: "crwdns31146:0%{PRODUCT}crwdne31146:0" + stocks_state_html: "crwdns31148:0%{INTERNAL}crwdnd31148:0%{EXTERNAL}crwdne31148:0" + manage_stock: "crwdns31150:0crwdne31150:0" notify_member_about_coupon: subject: "crwdns29816:0crwdne29816:0" body: @@ -374,3 +380,15 @@ zu: user_proof_of_identity_files_refusal: "crwdns29972:0%{NAME}crwdnd29972:0%{OPERATOR}crwdne29972:0" shared: hello: "crwdns29974:0%{user_name}crwdne29974:0" + notify_user_order_is_ready: + subject: "crwdns30576:0crwdne30576:0" + body: + notify_user_order_is_ready: "crwdns30578:0%{REFERENCE}crwdne30578:0" + notify_user_order_is_canceled: + subject: "crwdns30580:0crwdne30580:0" + body: + notify_user_order_is_canceled: "crwdns30582:0%{REFERENCE}crwdne30582:0" + notify_user_order_is_refunded: + subject: "crwdns30584:0crwdne30584:0" + body: + notify_user_order_is_refunded: "crwdns30586:0%{REFERENCE}crwdne30586:0" diff --git a/config/locales/no.yml b/config/locales/no.yml index a8949d93b..6a05cc42a 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -10,6 +10,11 @@ week: one: 'en uke' other: '%{count} weeks' + activerecord: + attributes: + product: + amount: "The price" + slug: "URL" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ invalid_duration: "Den tillatte varigheten må være mellom 1 dag og 1 år. Din periode er %{DAYS} dager lang." must_be_in_the_past: "Perioden må være før dagens dato." registration_disabled: "Registration is disabled" + undefined_in_store: "must be defined to make the product available in the store" + gateway_error: "Payement gateway error: %{MESSAGE}" + gateway_amount_too_small: "Payments under %{AMOUNT} are not supported. Please order directly at the reception." + gateway_amount_too_large: "Payments above %{AMOUNT} are not supported. Please order directly at the reception." + product_in_use: "This product have already been ordered" + slug_already_used: "is already used" apipie: api_documentation: "API-dokumentasjon" code: "HTTP kode" @@ -57,7 +68,6 @@ #members management members: unable_to_change_the_group_while_a_subscription_is_running: "Kan ikke endre gruppen mens et abonnement kjører" - admins_cant_change_group: "Kan ikke fjerne en administrator fra sin dedikerte gruppe" please_input_the_authentication_code_sent_to_the_address: "Skriv inn autentiseringskoden sendt til e-postadressen %{EMAIL}" your_authentication_code_is_not_valid: "Din autentiseringskode er ikke gyldig." current_authentication_method_no_code: "Den nåværende autentiseringsmetoden krever ikke migrasjonskode" @@ -121,6 +131,7 @@ error_invoice: "Feil i faktura. Elementene under varen som ikke er bestilt. Kontakt oss for refusjon." prepaid_pack: "Forhåndsbetalt pakke med timer" pack_item: "Pakke med %{COUNT} timer for %{ITEM}" + order: "Your order on the store" #PDF payment schedule generation payment_schedules: schedule_reference: "Referanse til betalingsplanen: %{REF}" @@ -153,6 +164,7 @@ Event_reservation: "arrangementetsreserv." Space_reservation: "reserv., plass/rom" wallet: "lommebok" + shop_order: "shop order" vat_export: start_date: "Start date" end_date: "End date" @@ -355,6 +367,9 @@ import_over: "%{CATEGORY} import is over. " members: "Members" view_results: "View results." + notify_admin_low_stock_threshold: + low_stock: "Low stock for %{PRODUCT}. " + view_product: "View the product." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Enjoy a discount of %{PERCENT}% with code %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Enjoy a discount of %{AMOUNT} with code %{CODE}" @@ -406,11 +421,18 @@ refusal: "Your proof of identity are not accepted" notify_admin_user_proof_of_identity_refusal: refusal: "Member's proof of identity %{NAME} refused." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" + notify_user_order_is_refunded: + order_refunded: "Your command %{REFERENCE} is refunded" #statistics tools for admins statistics: subscriptions: "Subscriptions" machines_hours: "Machines slots" spaces: "Spaces" + orders: "Orders" trainings: "Trainings" events: "Events" registrations: "Registrations" @@ -434,6 +456,9 @@ account_creation: "Konto opprettet" project_publication: "Prosjekt publisert" duration: "Varighet" + store: "Store" + paid-processed: "Paid and/or processed" + aborted: "Aborted" #statistics exports to the Excel file format export: entries: "Oppføringer" @@ -453,13 +478,12 @@ price_category: reduced_fare: "Redusert avgift" reduced_fare_if_you_are_under_25_student_or_unemployed: "Redusert pris hvis du er under 25, student eller arbeidsløs." - group: - #name of the user's group for administrators - admins: 'Administratorer' cart_items: free_extension: "Free extension of a subscription, until %{DATE}" statistic_profile: birthday_in_past: "The date of birth must be in the past" + order: + please_contact_FABLAB: "Please contact us for withdrawal instructions." settings: locked_setting: "innstillingen er låst." about_title: "\"Om\" sidetittel" @@ -593,3 +617,6 @@ machines_module: "Machines module" user_change_group: "Allow users to change their group" show_username_in_admin_list: "Show the username in the admin's members list" + store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" + store_hidden: "Store hidden to the public" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 0cd5920e5..b41bbfa06 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -10,6 +10,11 @@ pt: week: one: 'uma semana' other: '%{count} semanas' + activerecord: + attributes: + product: + amount: "The price" + slug: "URL" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ pt: invalid_duration: "A duração permitida deve ter entre 1 dia e 1 ano. Sua menstruação tem %{DAYS} dias." must_be_in_the_past: "O período deve ser estritamente anterior à data de hoje." registration_disabled: "Registo está desabilitado" + undefined_in_store: "must be defined to make the product available in the store" + gateway_error: "Payement gateway error: %{MESSAGE}" + gateway_amount_too_small: "Payments under %{AMOUNT} are not supported. Please order directly at the reception." + gateway_amount_too_large: "Payments above %{AMOUNT} are not supported. Please order directly at the reception." + product_in_use: "This product have already been ordered" + slug_already_used: "is already used" apipie: api_documentation: "Documentação da API" code: "Código HTTP" @@ -57,7 +68,6 @@ pt: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "Não é possível alterar o grupo enquanto uma assinatura está sendo executada" - admins_cant_change_group: "Não é possível remover o administrador do seu grupo dedicado" please_input_the_authentication_code_sent_to_the_address: "Por favor insira o código de autenticação enviado para seu endereço de email %{EMAIL}" your_authentication_code_is_not_valid: "Seu código de autentiicação não é válido." current_authentication_method_no_code: "O método de autenticação atual não requer nenhum código de migração" @@ -121,6 +131,7 @@ pt: error_invoice: "Esta fatura está incorreta. Os itens abaixo que não foram reservados. Por favor contate o FabLab para um reembolso." prepaid_pack: "Pacote de horas pré-pago" pack_item: "Pacote de %{COUNT} horas para a %{ITEM}" + order: "Your order on the store" #PDF payment schedule generation payment_schedules: schedule_reference: "Agendamento de pagamento: %{REF}" @@ -153,6 +164,7 @@ pt: Event_reservation: "reserva de evento." Space_reservation: "reserva de espaço." wallet: "carteira" + shop_order: "shop order" vat_export: start_date: "Data de início" end_date: "Data de término" @@ -355,6 +367,9 @@ pt: import_over: "A importação de %{CATEGORY} terminou. " members: "Membros" view_results: "Ver resultados." + notify_admin_low_stock_threshold: + low_stock: "Low stock for %{PRODUCT}. " + view_product: "View the product." notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "Desfrute de um desconto de %{PERCENT}% com o código %{CODE}" enjoy_a_discount_of_AMOUNT_with_code_CODE: "Desfrute de um desconto de %{AMOUNT} com o código %{CODE}" @@ -406,11 +421,18 @@ pt: refusal: "Seu comprovante de identidade não foi aceito" notify_admin_user_proof_of_identity_refusal: refusal: "Prova de identidade do membro %{NAME} recusada." + notify_user_order_is_ready: + order_ready: "Your command %{REFERENCE} is ready" + notify_user_order_is_canceled: + order_canceled: "Your command %{REFERENCE} is canceled" + notify_user_order_is_refunded: + order_refunded: "Your command %{REFERENCE} is refunded" #statistics tools for admins statistics: subscriptions: "Assinaturas" machines_hours: "Slots de máquina" spaces: "Espaços" + orders: "Orders" trainings: "Treinamentos" events: "Eventos" registrations: "Inscrições" @@ -434,6 +456,9 @@ pt: account_creation: "Criação de conta" project_publication: "Publicação de projeto" duration: "Duração" + store: "Store" + paid-processed: "Paid and/or processed" + aborted: "Aborted" #statistics exports to the Excel file format export: entries: "Entradas" @@ -453,13 +478,12 @@ pt: price_category: reduced_fare: "Tarifa reduzida" reduced_fare_if_you_are_under_25_student_or_unemployed: "Tarifa reduzida se tiver menos de 25 anos, estudante ou desempregado." - group: - #name of the user's group for administrators - admins: 'Administradores' cart_items: free_extension: "Extensão gratuita de uma assinatura, até %{DATE}" statistic_profile: birthday_in_past: "A data de nascimento deve estar no passado" + order: + please_contact_FABLAB: "Please contact us for withdrawal instructions." settings: locked_setting: "a configuração está bloqueada." about_title: "\"Sobre\" título da página" @@ -593,3 +617,6 @@ pt: machines_module: "Módulo de Máquinas" user_change_group: "Permitir que os usuários mudem de grupo" show_username_in_admin_list: "Mostrar o nome de usuário na lista de membros do administrador" + store_module: "Store module" + store_withdrawal_instructions: "Withdrawal instructions" + store_hidden: "Store hidden to the public" diff --git a/config/locales/rails.en.yml b/config/locales/rails.en.yml index bea29cf4b..1d05de180 100644 --- a/config/locales/rails.en.yml +++ b/config/locales/rails.en.yml @@ -212,4 +212,4 @@ en: default: "%a, %d %b %Y %H:%M:%S %z" long: "%B %d, %Y %H:%M" short: "%d %b %H:%M" - pm: pm \ No newline at end of file + pm: pm diff --git a/config/locales/zu.yml b/config/locales/zu.yml index 486790776..4d580eb98 100644 --- a/config/locales/zu.yml +++ b/config/locales/zu.yml @@ -10,6 +10,11 @@ zu: week: one: 'crwdns3161:1crwdne3161:1' other: 'crwdns3161:5%{count}crwdne3161:5' + activerecord: + attributes: + product: + amount: "crwdns31683:0crwdne31683:0" + slug: "crwdns31685:0crwdne31685:0" errors: #CarrierWave messages: @@ -38,6 +43,12 @@ zu: invalid_duration: "crwdns3207:0%{DAYS}crwdne3207:0" must_be_in_the_past: "crwdns3209:0crwdne3209:0" registration_disabled: "crwdns22285:0crwdne22285:0" + undefined_in_store: "crwdns31687:0crwdne31687:0" + gateway_error: "crwdns31689:0%{MESSAGE}crwdne31689:0" + gateway_amount_too_small: "crwdns31691:0%{AMOUNT}crwdne31691:0" + gateway_amount_too_large: "crwdns31693:0%{AMOUNT}crwdne31693:0" + product_in_use: "crwdns31695:0crwdne31695:0" + slug_already_used: "crwdns31697:0crwdne31697:0" apipie: api_documentation: "crwdns3257:0crwdne3257:0" code: "crwdns20914:0crwdne20914:0" @@ -57,7 +68,6 @@ zu: #members management members: unable_to_change_the_group_while_a_subscription_is_running: "crwdns3275:0crwdne3275:0" - admins_cant_change_group: "crwdns20496:0crwdne20496:0" please_input_the_authentication_code_sent_to_the_address: "crwdns3277:0%{EMAIL}crwdne3277:0" your_authentication_code_is_not_valid: "crwdns3279:0crwdne3279:0" current_authentication_method_no_code: "crwdns3281:0crwdne3281:0" @@ -121,6 +131,7 @@ zu: error_invoice: "crwdns21480:0crwdne21480:0" prepaid_pack: "crwdns22022:0crwdne22022:0" pack_item: "crwdns22024:0%{COUNT}crwdnd22024:0%{ITEM}crwdne22024:0" + order: "crwdns31699:0crwdne31699:0" #PDF payment schedule generation payment_schedules: schedule_reference: "crwdns21094:0%{REF}crwdne21094:0" @@ -153,6 +164,7 @@ zu: Event_reservation: "crwdns3407:0crwdne3407:0" Space_reservation: "crwdns3409:0crwdne3409:0" wallet: "crwdns3411:0crwdne3411:0" + shop_order: "crwdns31701:0crwdne31701:0" vat_export: start_date: "crwdns22259:0crwdne22259:0" end_date: "crwdns22261:0crwdne22261:0" @@ -355,6 +367,9 @@ zu: import_over: "crwdns3667:0%{CATEGORY}crwdne3667:0" members: "crwdns3669:0crwdne3669:0" view_results: "crwdns3671:0crwdne3671:0" + notify_admin_low_stock_threshold: + low_stock: "crwdns31703:0%{PRODUCT}crwdne31703:0" + view_product: "crwdns31705:0crwdne31705:0" notify_member_about_coupon: enjoy_a_discount_of_PERCENT_with_code_CODE: "crwdns3673:0%{PERCENT}crwdnd3673:0%{CODE}crwdne3673:0" enjoy_a_discount_of_AMOUNT_with_code_CODE: "crwdns3675:0%{AMOUNT}crwdnd3675:0%{CODE}crwdne3675:0" @@ -406,11 +421,18 @@ zu: refusal: "crwdns23008:0crwdne23008:0" notify_admin_user_proof_of_identity_refusal: refusal: "crwdns23010:0%{NAME}crwdne23010:0" + notify_user_order_is_ready: + order_ready: "crwdns31707:0%{REFERENCE}crwdne31707:0" + notify_user_order_is_canceled: + order_canceled: "crwdns31709:0%{REFERENCE}crwdne31709:0" + notify_user_order_is_refunded: + order_refunded: "crwdns31711:0%{REFERENCE}crwdne31711:0" #statistics tools for admins statistics: subscriptions: "crwdns3689:0crwdne3689:0" machines_hours: "crwdns4561:0crwdne4561:0" spaces: "crwdns3693:0crwdne3693:0" + orders: "crwdns31713:0crwdne31713:0" trainings: "crwdns3695:0crwdne3695:0" events: "crwdns3697:0crwdne3697:0" registrations: "crwdns3699:0crwdne3699:0" @@ -434,6 +456,9 @@ zu: account_creation: "crwdns3735:0crwdne3735:0" project_publication: "crwdns3737:0crwdne3737:0" duration: "crwdns20282:0crwdne20282:0" + store: "crwdns31715:0crwdne31715:0" + paid-processed: "crwdns31717:0crwdne31717:0" + aborted: "crwdns31719:0crwdne31719:0" #statistics exports to the Excel file format export: entries: "crwdns3739:0crwdne3739:0" @@ -453,13 +478,12 @@ zu: price_category: reduced_fare: "crwdns3765:0crwdne3765:0" reduced_fare_if_you_are_under_25_student_or_unemployed: "crwdns3767:0crwdne3767:0" - group: - #name of the user's group for administrators - admins: 'crwdns3769:0crwdne3769:0' cart_items: free_extension: "crwdns22091:0%{DATE}crwdne22091:0" statistic_profile: birthday_in_past: "crwdns22127:0crwdne22127:0" + order: + please_contact_FABLAB: "crwdns31721:0crwdne31721:0" settings: locked_setting: "crwdns21632:0crwdne21632:0" about_title: "crwdns21634:0crwdne21634:0" @@ -593,3 +617,6 @@ zu: machines_module: "crwdns23038:0crwdne23038:0" user_change_group: "crwdns23040:0crwdne23040:0" show_username_in_admin_list: "crwdns24016:0crwdne24016:0" + store_module: "crwdns31723:0crwdne31723:0" + store_withdrawal_instructions: "crwdns31725:0crwdne31725:0" + store_hidden: "crwdns31727:0crwdne31727:0" diff --git a/config/routes.rb b/config/routes.rb index 2c8c4adab..4759349aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -150,6 +150,30 @@ Rails.application.routes.draw do resources :profile_custom_fields + resources :product_categories do + patch 'position', on: :member + end + + resources :products do + put 'clone', on: :member + get 'stock_movements', on: :member + end + resources :cart, only: %i[create] do + put 'add_item', on: :collection + put 'remove_item', on: :collection + put 'set_quantity', on: :collection + put 'set_offer', on: :collection + put 'refresh_item', on: :collection + post 'validate', on: :collection + end + resources :checkout, only: %i[] do + post 'payment', on: :collection + post 'confirm_payment', on: :collection + end + resources :orders, except: %i[create] do + get 'withdrawal_instructions', on: :member + end + # for admin resources :trainings do get :availabilities, on: :member @@ -259,7 +283,7 @@ Rails.application.routes.draw do end end - %w[account event machine project subscription training user space].each do |path| + %w[account event machine project subscription training user space order].each do |path| post "/stats/#{path}/_search", to: "api/statistics##{path}" post "/stats/#{path}/export", to: "api/statistics#export_#{path}" end diff --git a/db/migrate/20220620072750_create_product_categories.rb b/db/migrate/20220620072750_create_product_categories.rb new file mode 100644 index 000000000..eb04b9704 --- /dev/null +++ b/db/migrate/20220620072750_create_product_categories.rb @@ -0,0 +1,12 @@ +class CreateProductCategories < ActiveRecord::Migration[5.2] + def change + create_table :product_categories do |t| + t.string :name + t.string :slug + t.integer :parent_id, index: true + t.integer :position + + t.timestamps + end + end +end diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb new file mode 100644 index 000000000..3876ca037 --- /dev/null +++ b/db/migrate/20220712153708_create_products.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateProducts < ActiveRecord::Migration[5.2] + def change + create_table :products do |t| + t.string :name + t.string :slug + t.string :sku + t.text :description + t.boolean :is_active, default: false + t.belongs_to :product_category, foreign_key: true + t.integer :amount + t.integer :quantity_min + t.jsonb :stock, default: { internal: 0, external: 0 } + t.boolean :low_stock_alert, default: false + t.integer :low_stock_threshold + + t.timestamps + end + end +end diff --git a/db/migrate/20220712160137_create_join_table_product_machine.rb b/db/migrate/20220712160137_create_join_table_product_machine.rb new file mode 100644 index 000000000..874005fd0 --- /dev/null +++ b/db/migrate/20220712160137_create_join_table_product_machine.rb @@ -0,0 +1,8 @@ +class CreateJoinTableProductMachine < ActiveRecord::Migration[5.2] + def change + create_join_table :products, :machines do |t| + # t.index [:product_id, :machine_id] + # t.index [:machine_id, :product_id] + end + end +end diff --git a/db/migrate/20220803091913_add_is_main_to_assets.rb b/db/migrate/20220803091913_add_is_main_to_assets.rb new file mode 100644 index 000000000..3201a80a5 --- /dev/null +++ b/db/migrate/20220803091913_add_is_main_to_assets.rb @@ -0,0 +1,5 @@ +class AddIsMainToAssets < ActiveRecord::Migration[5.2] + def change + add_column :assets, :is_main, :boolean + end +end diff --git a/db/migrate/20220805083431_create_product_stock_movements.rb b/db/migrate/20220805083431_create_product_stock_movements.rb new file mode 100644 index 000000000..d7c7af564 --- /dev/null +++ b/db/migrate/20220805083431_create_product_stock_movements.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateProductStockMovements < ActiveRecord::Migration[5.2] + def change + create_table :product_stock_movements do |t| + t.belongs_to :product, foreign_key: true + t.integer :quantity + t.string :reason + t.string :stock_type + t.integer :remaining_stock + t.datetime :date + + t.timestamps + end + end +end diff --git a/db/migrate/20220808161314_create_orders.rb b/db/migrate/20220808161314_create_orders.rb new file mode 100644 index 000000000..180236fee --- /dev/null +++ b/db/migrate/20220808161314_create_orders.rb @@ -0,0 +1,14 @@ +class CreateOrders < ActiveRecord::Migration[5.2] + def change + create_table :orders do |t| + t.belongs_to :statistic_profile, foreign_key: true + t.integer :operator_id + t.string :token + t.string :reference + t.string :state + t.integer :amount + + t.timestamps + end + end +end diff --git a/db/migrate/20220818160821_create_order_items.rb b/db/migrate/20220818160821_create_order_items.rb new file mode 100644 index 000000000..456d52173 --- /dev/null +++ b/db/migrate/20220818160821_create_order_items.rb @@ -0,0 +1,16 @@ +# frozen_string_literal:true + +# OrderItem for save article of Order +class CreateOrderItems < ActiveRecord::Migration[5.2] + def change + create_table :order_items do |t| + t.belongs_to :order, foreign_key: true + t.references :orderable, polymorphic: true + t.integer :amount + t.integer :quantity + t.boolean :is_offered + + t.timestamps + end + end +end diff --git a/db/migrate/20220822081222_add_payment_state_to_order.rb b/db/migrate/20220822081222_add_payment_state_to_order.rb new file mode 100644 index 000000000..f803a40ee --- /dev/null +++ b/db/migrate/20220822081222_add_payment_state_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentStateToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_state, :string + end +end diff --git a/db/migrate/20220826074619_rename_amount_to_total_in_order.rb b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb new file mode 100644 index 000000000..bf583b032 --- /dev/null +++ b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb @@ -0,0 +1,5 @@ +class RenameAmountToTotalInOrder < ActiveRecord::Migration[5.2] + def change + rename_column :orders, :amount, :total + end +end diff --git a/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb new file mode 100644 index 000000000..57eb279f5 --- /dev/null +++ b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb @@ -0,0 +1,5 @@ +class AddOrderItemIdToProductStockMovement < ActiveRecord::Migration[5.2] + def change + add_column :product_stock_movements, :order_item_id, :integer + end +end diff --git a/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb new file mode 100644 index 000000000..d07d7bd14 --- /dev/null +++ b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb @@ -0,0 +1,6 @@ +class AddWalletAmountAndWalletTransactionIdToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :wallet_amount, :integer + add_column :orders, :wallet_transaction_id, :integer + end +end diff --git a/db/migrate/20220826091819_add_payment_method_to_order.rb b/db/migrate/20220826091819_add_payment_method_to_order.rb new file mode 100644 index 000000000..3be3c2740 --- /dev/null +++ b/db/migrate/20220826091819_add_payment_method_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentMethodToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_method, :string + end +end diff --git a/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb new file mode 100644 index 000000000..c199208f0 --- /dev/null +++ b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb @@ -0,0 +1,7 @@ +class RenameOperatorIdToOperatorProfileIdInOrder < ActiveRecord::Migration[5.2] + def change + rename_column :orders, :operator_id, :operator_profile_id + add_index :orders, :operator_profile_id + add_foreign_key :orders, :invoicing_profiles, column: :operator_profile_id, primary_key: :id + end +end diff --git a/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb b/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb new file mode 100644 index 000000000..966fdc47c --- /dev/null +++ b/db/migrate/20220826133518_add_footprint_and_environment_to_order.rb @@ -0,0 +1,6 @@ +class AddFootprintAndEnvironmentToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :footprint, :string + add_column :orders, :environment, :string + end +end diff --git a/db/migrate/20220826140921_add_coupon_id_to_order.rb b/db/migrate/20220826140921_add_coupon_id_to_order.rb new file mode 100644 index 000000000..cca23c0bd --- /dev/null +++ b/db/migrate/20220826140921_add_coupon_id_to_order.rb @@ -0,0 +1,5 @@ +class AddCouponIdToOrder < ActiveRecord::Migration[5.2] + def change + add_reference :orders, :coupon, index: true, foreign_key: true + end +end diff --git a/db/migrate/20220826175129_add_paid_total_to_order.rb b/db/migrate/20220826175129_add_paid_total_to_order.rb new file mode 100644 index 000000000..5681a3ed5 --- /dev/null +++ b/db/migrate/20220826175129_add_paid_total_to_order.rb @@ -0,0 +1,5 @@ +class AddPaidTotalToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :paid_total, :integer + end +end diff --git a/db/migrate/20220909131300_add_invoice_id_to_order.rb b/db/migrate/20220909131300_add_invoice_id_to_order.rb new file mode 100644 index 000000000..2d9fa804f --- /dev/null +++ b/db/migrate/20220909131300_add_invoice_id_to_order.rb @@ -0,0 +1,5 @@ +class AddInvoiceIdToOrder < ActiveRecord::Migration[5.2] + def change + add_reference :orders, :invoice, index: true, foreign_key: true + end +end diff --git a/db/migrate/20220914145334_remove_payment_state_from_orders.rb b/db/migrate/20220914145334_remove_payment_state_from_orders.rb new file mode 100644 index 000000000..3e439536e --- /dev/null +++ b/db/migrate/20220914145334_remove_payment_state_from_orders.rb @@ -0,0 +1,5 @@ +class RemovePaymentStateFromOrders < ActiveRecord::Migration[5.2] + def change + remove_column :orders, :payment_state + end +end diff --git a/db/migrate/20220915133100_create_order_activities.rb b/db/migrate/20220915133100_create_order_activities.rb new file mode 100644 index 000000000..0d87fa87c --- /dev/null +++ b/db/migrate/20220915133100_create_order_activities.rb @@ -0,0 +1,12 @@ +class CreateOrderActivities < ActiveRecord::Migration[5.2] + def change + create_table :order_activities do |t| + t.belongs_to :order, foreign_key: true + t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' } + t.string :activity_type + t.text :note + + t.timestamps + end + end +end diff --git a/db/migrate/20220920131912_add_index_on_product_slug.rb b/db/migrate/20220920131912_add_index_on_product_slug.rb new file mode 100644 index 000000000..4924b4098 --- /dev/null +++ b/db/migrate/20220920131912_add_index_on_product_slug.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Products' slugs should validate uniqness in database +class AddIndexOnProductSlug < ActiveRecord::Migration[5.2] + def change + add_index :products, :slug, unique: true + end +end diff --git a/db/migrate/20221003133019_add_index_on_product_category_slug.rb b/db/migrate/20221003133019_add_index_on_product_category_slug.rb new file mode 100644 index 000000000..3b7c90dc6 --- /dev/null +++ b/db/migrate/20221003133019_add_index_on_product_category_slug.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# ProductCategory's slugs should validate uniqness in database +class AddIndexOnProductCategorySlug < ActiveRecord::Migration[5.2] + def change + add_index :product_categories, :slug, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6ea5b3b4d..9e6a688de 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_135828) do +ActiveRecord::Schema.define(version: 2022_10_03_133019) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.integer "signaled_id" t.string "signaled_type" + t.integer "signaled_id" t.string "first_name" t.string "last_name" t.string "email" @@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.string "locality" t.string "country" t.string "postal_code" - t.integer "placeable_id" t.string "placeable_type" + t.integer "placeable_id" t.datetime "created_at" t.datetime "updated_at" end @@ -64,12 +64,13 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do end create_table "assets", id: :serial, force: :cascade do |t| - t.integer "viewable_id" t.string "viewable_type" + t.integer "viewable_id" t.string "attachment" t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.boolean "is_main" end create_table "auth_provider_mappings", id: :serial, force: :cascade do |t| @@ -146,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do end create_table "credits", id: :serial, force: :cascade do |t| - t.integer "creditable_id" t.string "creditable_type" + t.integer "creditable_id" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -367,17 +368,22 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["machine_id"], name: "index_machines_availabilities_on_machine_id" end + create_table "machines_products", id: false, force: :cascade do |t| + t.bigint "product_id", null: false + t.bigint "machine_id", null: false + end + create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.integer "attached_object_id" t.string "attached_object_type" + t.integer "attached_object_id" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: {} + t.jsonb "meta_data", default: "{}" t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -439,6 +445,53 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.datetime "updated_at", null: false end + create_table "order_activities", force: :cascade do |t| + t.bigint "order_id" + t.bigint "operator_profile_id" + t.string "activity_type" + t.text "note" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["operator_profile_id"], name: "index_order_activities_on_operator_profile_id" + t.index ["order_id"], name: "index_order_activities_on_order_id" + end + + create_table "order_items", force: :cascade do |t| + t.bigint "order_id" + t.string "orderable_type" + t.bigint "orderable_id" + t.integer "amount" + t.integer "quantity" + t.boolean "is_offered" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_id"], name: "index_order_items_on_order_id" + t.index ["orderable_type", "orderable_id"], name: "index_order_items_on_orderable_type_and_orderable_id" + end + + create_table "orders", force: :cascade do |t| + t.bigint "statistic_profile_id" + t.integer "operator_profile_id" + t.string "token" + t.string "reference" + t.string "state" + t.integer "total" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "wallet_amount" + t.integer "wallet_transaction_id" + t.string "payment_method" + t.string "footprint" + t.string "environment" + t.bigint "coupon_id" + t.integer "paid_total" + t.bigint "invoice_id" + t.index ["coupon_id"], name: "index_orders_on_coupon_id" + t.index ["invoice_id"], name: "index_orders_on_invoice_id" + t.index ["operator_profile_id"], name: "index_orders_on_operator_profile_id" + t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id" + end + create_table "organizations", id: :serial, force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -570,8 +623,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.integer "priceable_id" t.string "priceable_type" + t.integer "priceable_id" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -581,6 +634,48 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["priceable_type", "priceable_id"], name: "index_prices_on_priceable_type_and_priceable_id" end + create_table "product_categories", force: :cascade do |t| + t.string "name" + t.string "slug" + t.integer "parent_id" + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id"], name: "index_product_categories_on_parent_id" + t.index ["slug"], name: "index_product_categories_on_slug", unique: true + end + + create_table "product_stock_movements", force: :cascade do |t| + t.bigint "product_id" + t.integer "quantity" + t.string "reason" + t.string "stock_type" + t.integer "remaining_stock" + t.datetime "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "order_item_id" + t.index ["product_id"], name: "index_product_stock_movements_on_product_id" + end + + create_table "products", force: :cascade do |t| + t.string "name" + t.string "slug" + t.string "sku" + t.text "description" + t.boolean "is_active", default: false + t.bigint "product_category_id" + t.integer "amount" + t.integer "quantity_min" + t.jsonb "stock", default: {"external"=>0, "internal"=>0} + t.boolean "low_stock_alert", default: false + t.integer "low_stock_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_category_id"], name: "index_products_on_product_category_id" + t.index ["slug"], name: "index_products_on_slug", unique: true + end + create_table "profile_custom_fields", force: :cascade do |t| t.string "label" t.boolean "required", default: false @@ -729,8 +824,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.integer "reservable_id" t.string "reservable_type" + t.integer "reservable_id" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -739,8 +834,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.integer "resource_id" t.string "resource_type" + t.integer "resource_id" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" @@ -1020,8 +1115,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.boolean "is_allow_newsletter" t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" - t.string "mapped_from_sso" t.datetime "validated_at" + t.string "mapped_from_sso" t.index ["auth_token"], name: "index_users_on_auth_token" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true @@ -1088,6 +1183,13 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "invoices", "statistic_profiles" add_foreign_key "invoices", "wallet_transactions" add_foreign_key "invoicing_profiles", "users" + add_foreign_key "order_activities", "invoicing_profiles", column: "operator_profile_id" + add_foreign_key "order_activities", "orders" + add_foreign_key "order_items", "orders" + add_foreign_key "orders", "coupons" + add_foreign_key "orders", "invoices" + add_foreign_key "orders", "invoicing_profiles", column: "operator_profile_id" + add_foreign_key "orders", "statistic_profiles" add_foreign_key "organizations", "invoicing_profiles" add_foreign_key "payment_gateway_objects", "payment_gateway_objects" add_foreign_key "payment_schedule_items", "invoices" @@ -1102,6 +1204,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "product_stock_movements", "products" + add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" add_foreign_key "project_users", "users" diff --git a/db/seeds.rb b/db/seeds.rb index fc2b7a9fc..f34daaf50 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,3 +1,8 @@ +# frozen_string_literal: true + +# This file fills the database with some initial data. +# Some of them are just some placeholders to prevent having an empty palce when starting fab-manager first. +# Other data are required default values, for various settings. if StatisticIndex.count.zero? StatisticIndex.create!([ @@ -82,12 +87,10 @@ if Group.count.zero? ]) end -Group.create! name: I18n.t('group.admins'), slug: 'admins' unless Group.find_by(slug: 'admins') - # Create the default admin if none exists yet if Role.where(name: 'admin').joins(:users).count.zero? - admin = User.new(username: 'admin', email: ENV['ADMIN_EMAIL'], password: ENV['ADMIN_PASSWORD'], - password_confirmation: Rails.application.secrets.admin_password, group_id: Group.find_by(slug: 'admins').id, + admin = User.new(username: 'admin', email: ENV.fetch('ADMIN_EMAIL', nil), password: ENV.fetch('ADMIN_PASSWORD', nil), + password_confirmation: Rails.application.secrets.admin_password, group_id: Group.first.id, profile_attributes: { first_name: 'admin', last_name: 'admin', phone: '0123456789' }, statistic_profile_attributes: { gender: true, birthday: Date.current }) admin.add_role 'admin' @@ -111,27 +114,48 @@ end if Licence.count.zero? Licence.create!([ - { name: 'Attribution (BY)', description: 'Le titulaire des droits autorise toute exploitation de l’œuvre, y compris à' \ - ' des fins commerciales, ainsi que la création d’œuvres dérivées, dont la distribution est également autorisé sans ' \ - 'restriction, à condition de l’attribuer à son l’auteur en citant son nom. Cette licence est recommandée pour la ' \ - 'diffusion et l’utilisation maximale des œuvres.' }, - { name: 'Attribution + Pas de modification (BY ND)', description: 'Le titulaire des droits autorise toute utilisation' \ - ' de l’œuvre originale (y compris à des fins commerciales), mais n’autorise pas la création d’œuvres dérivées.' }, - { name: "Attribution + Pas d'Utilisation Commerciale + Pas de Modification (BY NC ND)", description: 'Le titulaire ' \ - 'des droits autorise l’utilisation de l’œuvre originale à des fins non commerciales, mais n’autorise pas la ' \ - 'création d’œuvres dérivés.' }, - { name: "Attribution + Pas d'Utilisation Commerciale (BY NC)", description: 'Le titulaire des droits autorise ' \ - 'l’exploitation de l’œuvre, ainsi que la création d’œuvres dérivées, à condition qu’il ne s’agisse pas d’une ' \ - 'utilisation commerciale (les utilisations commerciales restant soumises à son autorisation).' }, - { name: "Attribution + Pas d'Utilisation Commerciale + Partage dans les mêmes conditions (BY NC SA)", description: - 'Le titulaire des droits autorise l’exploitation de l’œuvre originale à des fins non commerciales, ainsi que la ' \ - 'création d’œuvres dérivées, à condition qu’elles soient distribuées sous une licence identique à celle qui régit ' \ - 'l’œuvre originale.' }, - { name: 'Attribution + Partage dans les mêmes conditions (BY SA)', description: 'Le titulaire des droits autorise ' \ - 'toute utilisation de l’œuvre originale (y compris à des fins commerciales) ainsi que la création d’œuvres dérivées' \ - ', à condition qu’elles soient distribuées sous une licence identique à celle qui régit l’œuvre originale. Cette' \ - 'licence est souvent comparée aux licences « copyleft » des logiciels libres. C’est la licence utilisée par ' \ - 'Wikipedia.' } + { + name: 'Attribution (BY)', + description: + 'Le titulaire des droits autorise toute exploitation de l’œuvre, y compris à des ' \ + 'fins commerciales, ainsi que la création d’œuvres dérivées, dont la distribution est également autorisé sans ' \ + 'restriction, à condition de l’attribuer à son l’auteur en citant son nom. Cette licence est recommandée pour la ' \ + 'diffusion et l’utilisation maximale des œuvres.' + }, + { + name: 'Attribution + Pas de modification (BY ND)', + description: + 'Le titulaire des droits autorise toute utilisation de l’œuvre originale (y compris à des fins commerciales), ' \ + 'mais n’autorise pas la création d’œuvres dérivées.' + }, + { + name: "Attribution + Pas d'Utilisation Commerciale + Pas de Modification (BY NC ND)", + description: + 'Le titulaire des droits autorise l’utilisation de l’œuvre originale à des fins non commerciales, ' \ + 'mais n’autorise pas la création d’œuvres dérivés.' + }, + { + name: "Attribution + Pas d'Utilisation Commerciale (BY NC)", + description: + 'Le titulaire des droits autorise l’exploitation de l’œuvre, ainsi que la création d’œuvres dérivées, ' \ + 'à condition qu’il ne s’agisse pas d’une utilisation commerciale (les utilisations commerciales ' \ + 'restant soumises à son autorisation).' + }, + { + name: "Attribution + Pas d'Utilisation Commerciale + Partage dans les mêmes conditions (BY NC SA)", + description: + 'Le titulaire des droits autorise l’exploitation de l’œuvre originale à des fins non commerciales, ainsi que la ' \ + 'création d’œuvres dérivées, à condition qu’elles soient distribuées sous une licence identique à celle qui ' \ + 'régit l’œuvre originale.' + }, + { + name: 'Attribution + Partage dans les mêmes conditions (BY SA)', + description: + 'Le titulaire des droits autorise toute utilisation de l’œuvre originale (y compris à des fins commerciales) ' \ + 'ainsi que la création d’œuvres dérivées, à condition qu’elles soient distribuées sous une licence identique ' \ + 'à celle qui régit l’œuvre originale. Cette licence est souvent comparée aux licences « copyleft » des logiciels ' \ + 'libres. C’est la licence utilisée par Wikipedia.' + } ]) end @@ -149,75 +173,117 @@ end if Training.count.zero? Training.create!([ - { name: 'Formation Imprimante 3D', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do ' \ - 'eiusmod tempor incididunt ut labore et dolore magna aliqua.' }, - { name: 'Formation Laser / Vinyle', description: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris' \ - ' nisi ut aliquip ex ea commodo consequat.' }, - { name: 'Formation Petite fraiseuse numerique', description: 'Duis aute irure dolor in reprehenderit in voluptate ' \ - 'velit esse cillum dolore eu fugiat nulla pariatur.' }, - { name: 'Formation Shopbot Grande Fraiseuse', description: 'Excepteur sint occaecat cupidatat non proident, sunt in ' \ - 'culpa qui officia deserunt mollit anim id est laborum.' }, - { name: 'Formation logiciel 2D', description: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' \ - 'accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi ' \ - 'architecto beatae vitae dicta sunt explicabo.' } + { + name: 'Formation Imprimante 3D', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do ' \ + 'eiusmod tempor incididunt ut labore et dolore magna aliqua.' + }, + { + name: 'Formation Laser / Vinyle', + description: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris ' \ + 'nisi ut aliquip ex ea commodo consequat.' + }, + { + name: 'Formation Petite fraiseuse numerique', + description: 'Duis aute irure dolor in reprehenderit in voluptate ' \ + 'velit esse cillum dolore eu fugiat nulla pariatur.' + }, + { + name: 'Formation Shopbot Grande Fraiseuse', + description: 'Excepteur sint occaecat cupidatat non proident, sunt in ' \ + 'culpa qui officia deserunt mollit anim id est laborum.' + }, + { + name: 'Formation logiciel 2D', + description: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' \ + 'accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis ' \ + 'et quasi architecto beatae vitae dicta sunt explicabo.' + } ]) TrainingsPricing.all.each do |p| - p.update_columns(amount: (rand * 50 + 5).floor * 100) + p.update(amount: ((rand * 50) + 5).floor * 100) end end if Machine.count.zero? Machine.create!([ - { name: 'Découpeuse laser', description: "Préparation à l'utilisation de l'EPILOG Legend 36EXT\r\nInformations" \ - " générales \r\n Pour la découpe, il suffit d'apporter votre fichier vectorisé type illustrator, svg ou dxf" \ - " avec des \"lignes de coupe\" d'une épaisseur inférieur à 0,01 mm et la machine s'occupera du reste!\r\n La " \ - 'gravure est basée sur le spectre noir et blanc. Les nuances sont obtenues par différentes profondeurs de gravure ' \ - "correspondant aux niveaux de gris de votre image. Il suffit pour cela d'apporter une image scannée ou un fichier " \ - "photo en noir et blanc pour pouvoir reproduire celle-ci sur votre support! \r\nQuels types de matériaux pouvons " \ - "nous graver/découper?\r\n Du bois au tissu, du plexiglass au cuir, cette machine permet de découper et graver " \ - "la plupart des matériaux sauf les métaux. La gravure est néanmoins possible sur les métaux recouverts d'une couche" \ - " de peinture ou les aluminiums anodisés. \r\n Concernant l'épaisseur des matériaux découpés, il est " \ - "préférable de ne pas dépasser 5 mm pour le bois et 6 mm pour le plexiglass.\r\n", spec: "Puissance: 40W\r\nSurface" \ - " de travail: 914x609 mm \r\nEpaisseur maximale de la matière: 305mm\r\nSource laser: tube laser type CO2\r\n" \ - 'Contrôles de vitesse et de puissance: ces deux paramètres sont ajustables en fonction du matériau (de 1% à 100%) .' \ - "\r\n", slug: 'decoupeuse-laser' }, - { name: 'Découpeuse vinyle', description: "Préparation à l'utilisation de la Roland CAMM-1 GX24\r\nInformations " \ - "générales \r\n Envie de réaliser un tee shirt personnalisé ? Un sticker à l'effigie votre groupe " \ - "préféré ? Un masque pour la réalisation d'un circuit imprimé? Pour cela, il suffit simplement de venir avec votre" \ - " fichier vectorisé (ne pas oublier de vectoriser les textes) type illustrator svg ou dxf.\r\n \r\nMatériaux " \ - "utilisés:\r\n Cette machine permet de découper principalement du vinyle,vinyle réfléchissant, flex.\r\n", + { + name: 'Découpeuse laser', + description: + "Préparation à l'utilisation de l'EPILOG Legend 36EXT\r\nInformations générales \r\n " \ + "Pour la découpe, il suffit d'apporter votre fichier vectorisé type illustrator, svg ou dxf avec des " \ + "\"lignes de coupe\" d'une épaisseur inférieur à 0,01 mm et la machine s'occupera du reste!\r\n La gravure " \ + 'est basée sur le spectre noir et blanc. Les nuances sont obtenues par différentes profondeurs de gravure ' \ + "correspondant aux niveaux de gris de votre image. Il suffit pour cela d'apporter une image scannée ou un " \ + "fichier photo en noir et blanc pour pouvoir reproduire celle-ci sur votre support! \r\nQuels types de " \ + "matériaux pouvons nous graver/découper?\r\n Du bois au tissu, du plexiglass au cuir, cette machine " \ + 'permet de découper et graver la plupart des matériaux sauf les métaux. La gravure est néanmoins possible ' \ + "sur les métaux recouverts d'une couche de peinture ou les aluminiums anodisés. \r\n " \ + "Concernant l'épaisseur des matériaux découpés, il est préférable de ne pas dépasser 5 mm pour le bois " \ + "et 6 mm pour le plexiglass.\r\n", + spec: + "Puissance: 40W\r\nSurface de travail: 914x609 mm \r\n" \ + "Epaisseur maximale de la matière: 305mm\r\nSource laser: tube laser type CO2\r\nContrôles de vitesse " \ + "et de puissance: ces deux paramètres sont ajustables en fonction du matériau (de 1% à 100%).\r\n", + slug: 'decoupeuse-laser' + }, + { + name: 'Découpeuse vinyle', + description: + "Préparation à l'utilisation de la Roland CAMM-1 GX24\r\nInformations générales \r\n " \ + "Envie de réaliser un tee shirt personnalisé ? Un sticker à l'effigie votre groupe préféré ? " \ + "Un masque pour la réalisation d'un circuit imprimé? Pour cela, il suffit simplement de venir avec votre " \ + "fichier vectorisé (ne pas oublier de vectoriser les textes) type illustrator svg ou dxf.\r\n \r\nMatériaux " \ + "utilisés:\r\n Cette machine permet de découper principalement du vinyle,vinyle réfléchissant, flex.\r\n", spec: "Largeurs de support acceptées: de 50 mm à 700 mm\r\nVitesse de découpe: 50 cm/sec\r\nRésolution mécanique: " \ - "0,0125 mm/pas\r\n", slug: 'decoupeuse-vinyle' }, - { name: 'Shopbot / Grande fraiseuse', description: "La fraiseuse numérique ShopBot PRS standard\r\nInformations " \ - "générales\r\nCette machine est un fraiseuse 3 axes idéale pour l'usinage de pièces de grandes dimensions. De la " \ - "réalisation d'une chaise ou d'un meuble jusqu'à la construction d'une maison ou d'un assemblage immense, le " \ - "ShopBot ouvre de nombreuses portes à votre imagination! \r\nMatériaux usinables\r\nLes principaux matériaux " \ - "usinables sont le bois, le plastique, le laiton et bien d'autres.\r\nCette machine n'usine pas les métaux.\r\n", + "0,0125 mm/pas\r\n", + slug: 'decoupeuse-vinyle' + }, + { + name: 'Shopbot / Grande fraiseuse', + description: + "La fraiseuse numérique ShopBot PRS standard\r\nInformations " \ + "générales\r\nCette machine est un fraiseuse 3 axes idéale pour l'usinage de pièces de grandes dimensions. De la " \ + "réalisation d'une chaise ou d'un meuble jusqu'à la construction d'une maison ou d'un assemblage immense, le " \ + "ShopBot ouvre de nombreuses portes à votre imagination! \r\nMatériaux usinables\r\nLes principaux matériaux " \ + "usinables sont le bois, le plastique, le laiton et bien d'autres.\r\nCette machine n'usine pas les métaux.\r\n", spec: "Surface maximale de travail: 2440x1220x150 (Z) mm\r\nLogiciel utilisé: Partworks 2D & 3D\r\nRésolution " \ - "mécanique: 0,015 mm\r\nPrécision de la position: +/- 0,127mm\r\nFormats acceptés: DXF, STL \r\n", - slug: 'shopbot-grande-fraiseuse' }, - { name: 'Imprimante 3D', description: "L'utimaker est une imprimante 3D low cost utilisant une technologie FFF " \ - "(Fused Filament Fabrication) avec extrusion thermoplastique.\r\nC'est une machine idéale pour réaliser rapidement " \ - "des prototypes 3D dans des couleurs différentes.\r\n", spec: "Surface maximale de travail: 210x210x220mm \r\n" \ - "Résolution méchanique: 0,02 mm \r\nPrécision de position: +/- 0,05 \r\nLogiciel utilisé: Cura\r\nFormats de " \ - "fichier acceptés: STL \r\nMatériaux utilisés: PLA (en stock).", slug: 'imprimante-3d' }, - { name: 'Petite Fraiseuse', description: "La fraiseuse numérique Roland Modela MDX-20\r\nInformations générales" \ - "\r\nCette machine est utilisée pour l'usinage et le scannage 3D de précision. Elle permet principalement d'usiner" \ - ' des circuits imprimés et des moules de petite taille. Le faible diamètre des fraises utilisées (Ø 0,3 mm à Ø 6mm' \ - ") induit que certains temps d'usinages peuvent êtres long (> 12h), c'est pourquoi cette fraiseuse peut être " \ - "laissée en autonomie toute une nuit afin d'obtenir le plus précis des usinages au FabLab.\r\nMatériaux usinables:" \ - "\r\nLes principaux matériaux usinables sont le bois, plâtre, résine, cire usinable, cuivre.\r\n", + "mécanique: 0,015 mm\r\nPrécision de la position: +/- 0,127mm\r\nFormats acceptés: DXF, STL \r\n", + slug: 'shopbot-grande-fraiseuse' + }, + { + name: 'Imprimante 3D', + description: + "L'utimaker est une imprimante 3D low cost utilisant une technologie FFF " \ + "(Fused Filament Fabrication) avec extrusion thermoplastique.\r\nC'est une machine idéale pour réaliser " \ + "rapidement des prototypes 3D dans des couleurs différentes.\r\n", + spec: "Surface maximale de travail: 210x210x220mm \r\n" \ + "Résolution méchanique: 0,02 mm \r\nPrécision de position: +/- 0,05 \r\nLogiciel utilisé: Cura\r\nFormats de " \ + "fichier acceptés: STL \r\nMatériaux utilisés: PLA (en stock).", + slug: 'imprimante-3d' + }, + { + name: 'Petite Fraiseuse', + description: + "La fraiseuse numérique Roland Modela MDX-20\r\nInformations générales\r\nCette machine est utilisée " \ + "pour l'usinage et le scannage 3D de précision. Elle permet principalement d'usiner des circuits imprimés " \ + 'et des moules de petite taille. Le faible diamètre des fraises utilisées (Ø 0,3 mm à Ø 6mm) induit que ' \ + "certains temps d'usinages peuvent êtres long (> 12h), c'est pourquoi cette fraiseuse peut être laissée en " \ + "autonomie toute une nuit afin d'obtenir le plus précis des usinages au FabLab.\r\nMatériaux usinables:" \ + "\r\nLes principaux matériaux usinables sont le bois, plâtre, résine, cire usinable, cuivre.\r\n", spec: "Taille du plateau X/Y : 220 mm x 160 mm\r\nVolume maximal de travail: 203,2 mm (X), 152,4 mm (Y), 60,5 mm " \ - "(Z)\r\nPrécision usinage: 0,00625 mm\r\nPrécision scannage: réglable de 0,05 à 5 mm (axes X,Y) et 0,025 mm (axe Z)" \ - "\r\nVitesse d'analyse (scannage): 4-15 mm/sec\r\n \r\n \r\nLogiciel utilisé pour le fraisage: Roland Modela player" \ - " 4 \r\nLogiciel utilisé pour l'usinage de circuits imprimés: Cad.py (linux)\r\nFormats acceptés: STL,PNG 3D\r\n" \ - "Format d'exportation des données scannées: DXF, VRML, STL, 3DMF, IGES, Grayscale, Point Group et BMP\r\n", - slug: 'petite-fraiseuse' } + "(Z)\r\nPrécision usinage: 0,00625 mm\r\nPrécision scannage: réglable de 0,05 à 5 mm (axes X,Y) et 0,025 mm " \ + "(axe Z)\r\nVitesse d'analyse (scannage): 4-15 mm/sec\r\n \r\n \r\nLogiciel utilisé pour le fraisage: " \ + "Roland Modela player 4 \r\nLogiciel utilisé pour l'usinage de circuits imprimés: Cad.py (linux)\r\n" \ + "Formats acceptés: STL,PNG 3D\r\nFormat d'exportation des données scannées: DXF, VRML, STL, 3DMF, IGES, " \ + "Grayscale, Point Group et BMP\r\n", + slug: 'petite-fraiseuse' + } ]) Price.all.each do |p| - p.update_columns(amount: (rand * 50 + 5).floor * 100) + p.update(amount: ((rand * 50) + 5).floor * 100) end end @@ -273,19 +339,19 @@ Setting.set('twitter_name', 'Fab_Manager') unless Setting.find_by(name: 'twitter unless Setting.find_by(name: 'machine_explications_alert').try(:value) setting = Setting.find_or_initialize_by(name: 'machine_explications_alert') - setting.value = 'Tout achat de créneau machine est définitif. Aucune' \ - ' annulation ne pourra être effectuée, néanmoins au plus tard 24h avant le créneau fixé, vous pouvez en' \ - " modifier la date et l'horaire à votre convenance et en fonction du calendrier proposé. Passé ce délais," \ - ' aucun changement ne pourra être effectué.' + setting.value = 'Tout achat de créneau machine est définitif. Aucune ' \ + 'annulation ne pourra être effectuée, néanmoins au plus tard 24h avant le créneau fixé, vous pouvez en ' \ + "modifier la date et l'horaire à votre convenance et en fonction du calendrier proposé. Passé ce délais, " \ + 'aucun changement ne pourra être effectué.' setting.save end unless Setting.find_by(name: 'training_explications_alert').try(:value) setting = Setting.find_or_initialize_by(name: 'training_explications_alert') - setting.value = 'Toute réservation de formation est définitive.' \ - ' Aucune annulation ne pourra être effectuée, néanmoins au plus tard 24h avant le créneau fixé, vous pouvez' \ - " en modifier la date et l'horaire à votre convenance et en fonction du calendrier proposé. Passé ce délais," \ - ' aucun changement ne pourra être effectué.' + setting.value = 'Toute réservation de formation est définitive. ' \ + 'Aucune annulation ne pourra être effectuée, néanmoins au plus tard 24h avant le créneau fixé, vous pouvez ' \ + "en modifier la date et l'horaire à votre convenance et en fonction du calendrier proposé. Passé ce délais, " \ + 'aucun changement ne pourra être effectué.' setting.save end @@ -633,8 +699,8 @@ Stylesheet.build_home! unless Setting.find_by(name: 'training_information_message').try(:value) setting = Setting.find_or_initialize_by(name: 'training_information_message') - setting.value = "Avant de réserver une formation, nous vous conseillons de consulter nos offres d'abonnement qui" \ - ' proposent des conditions avantageuses sur le prix des formations et les créneaux machines.' + setting.value = "Avant de réserver une formation, nous vous conseillons de consulter nos offres d'abonnement qui " \ + 'proposent des conditions avantageuses sur le prix des formations et les créneaux machines.' setting.save end @@ -680,8 +746,8 @@ unless Setting.find_by(name: 'privacy_draft').try(:value) éventuelles modifications.

    I. DONNÉES PERSONNELLES

    D’une manière générale, il vous est possible de visiter le site de _________ sans communiquer aucune information personnelle vous concernant. En toute hypothèse, vous n’êtes en aucune manière obligé de transmettre ces informations à _________.

    Néanmoins, en cas de refus, il se peut que vous ne puissiez pas bénéficier de - certaines informations ou services que vous avez demandé. A ce titre en effet, _________ peut être amené dans certains cas à vous - demander de renseigner vos nom, prénom, pseudonyme, sexe, adresse mail, numéro de téléphone, entreprise et date de naissance (ci-après + certaines informations ou services que vous avez demandés. À ce titre en effet, _________ peut être amené dans certains cas à vous + demander de renseigner votre nom, prénom, pseudonyme, sexe, adresse mail, numéro de téléphone, entreprise et date de naissance (ci-après vos « Informations Personnelles »). En fournissant ces informations, vous acceptez expressément qu’elles soient traitées par _________, aux fins indiquées au point 2 ci-dessous.

    Conformément au Règlement Général sur la Protection des Données (General Data Protection Regulation) adopté par le Parlement européen le 14 avril 2016, et à la Loi Informatique et Libertés du 6 janvier 1978 @@ -725,10 +791,10 @@ unless Setting.find_by(name: 'privacy_draft').try(:value) adressée à l’adresse postale indiquée au point 1, vous trouverez en cliquant sur le lien suivant un modèle de courrier élaboré par la CNIL.

    6. Délais de réponse

    _________ s’engage à répondre à votre demande d’accès, de rectification - ou d’opposition ou toute autre demande complémentaire d’informations dans un délai raisonnable qui ne saurait dépasser 1 mois à compter + ou d’opposition ou toute autre demande complémentaire d’informations dans un délai raisonnable qui ne saurait dépasser 1 mois à compter de la réception de votre demande.

    7. Prestataires habilités et transfert vers un pays tiers de l’Union Européenne

    _________ vous informe qu’il a recours à ses prestataires habilités pour faciliter le recueil et le traitement des données que vous nous avez - communiqué. Ces prestataires peuvent être situés en dehors de l’Union Européenne et ont communication des données recueillies par le + communiquées. Ces prestataires peuvent être situés en dehors de l’Union Européenne et ont communication des données recueillies par le biais des divers formulaires présents sur le Site.

    _________ s’est préalablement assuré de la mise en œuvre par ses prestataires de garanties adéquates et du respect de conditions strictes en matière de confidentialité, d’usage et de protection des données. Tout particulièrement, la vigilance s’est portée sur l’existence d’un fondement légal pour effectuer un quelconque transfert de données vers un @@ -755,16 +821,18 @@ unless Setting.find_by(name: 'privacy_draft').try(:value) le site web de _________, leur nom, leur finalité ainsi que leur durée de conservation.

    2. Configuration de vos préférences sur les cookies

    Vous pouvez accepter ou refuser le dépôt de cookies à tout moment.

    Lors de votre première connexion sur le site web de _________, une bannière présentant brièvement des informations relatives au dépôt de cookies et de technologies similaires apparaît en - bas de votre écran. Cette bannière vous demande de choisir explicitement d'acceptez ou non le dépôt de cookies sur votre terminal. + bas de votre écran. Cette bannière vous demande de choisir explicitement d'accepter ou non le dépôt de cookies sur votre terminal.

    Après avoir fait votre choix, vous pouvez le modifier ultérieurement  en vous connectant à votre compte utilisateur puis en naviguant dans la section intitulée « mes paramètres », accessible via un clic sur votre nom, en haut à droite de l'écran.

    Selon le type de cookie en cause, le recueil de votre consentement au dépôt et à la lecture de cookies sur votre terminal peut être impératif.

    a. Les cookies exemptés de consentement

    Conformément aux recommandations de la Commission Nationale de l’Informatique et des Libertés (CNIL), certains cookies sont dispensés du recueil préalable de votre consentement dans la mesure où ils sont strictement nécessaires au fonctionnement du site internet ou ont pour finalité exclusive de permettre ou faciliter la communication - par voie électronique. Il s’agit des cookies suivants :

    o Identifiant de session et authentification sur l'API. - Ces cookies sont intégralement soumis à la présente politique dans la mesure où ils sont émis et gérés par _________.

    - o Stripe, permettant de gérer les paiements par carte bancaire et dont la politique de confidentialité est accessible sur ce + par voie électronique. Il s’agit des cookies suivants :

    o Identifiant de session et d'authentification sur l'API de + Fab-manager.

    o Identifiant de panier d'achat, permettant de sauvegarder le contenu de votre panier d'achat, même lorsque + vous n'êtes pas connecté.

    +

    Les cookies ci-dessus sont intégralement soumis à la présente politique dans la mesure où ils sont émis et gérés par _________.

    +

    o Stripe, permettant de gérer les paiements par carte bancaire et dont la politique de confidentialité est accessible sur ce lien.

    o Disqus, permettant de poster des commentaires sur les fiches projet et dont la politique de confidentialité est accessible sur ce lien .

    b. Les cookies nécessitant le recueil préalable de votre consentement

    Cette @@ -778,7 +846,7 @@ unless Setting.find_by(name: 'privacy_draft').try(:value) confidentialité est disponible (uniquement en anglais) à partir du lien suivant.

    c. Vous disposez de divers outils de paramétrage des cookies

    La plupart des navigateurs Internet sont configurés par défaut de façon à ce que le dépôt de cookies soit autorisé. Votre navigateur vous offre - l’opportunité de modifier ces paramètres standards de manière à ce que l’ensemble des cookies soit rejeté systématiquement ou bien à ce + l’opportunité de modifier ces paramètres standards de manière que l’ensemble des cookies soit rejeté systématiquement ou bien à ce qu’une partie seulement des cookies soit acceptée ou refusée en fonction de leur émetteur.

    ATTENTION : Nous attirons votre attention sur le fait que le refus du dépôt de cookies sur votre terminal est néanmoins susceptible d’altérer votre expérience d’utilisateur ainsi que votre accès à certains services ou fonctionnalités du présent site web. Le cas échéant, _________ décline toute @@ -787,21 +855,21 @@ unless Setting.find_by(name: 'privacy_draft').try(:value) Ces conséquences ne sauraient constituer un dommage et vous ne pourrez prétendre à aucune indemnité de ce fait.

    Votre navigateur vous permet également de supprimer les cookies existants sur votre terminal ou encore de vous signaler lorsque de nouveaux cookies sont susceptibles d’être déposés sur votre terminal. Ces paramètres n’ont - pas d’incidence sur votre navigation mais vous font perdre tout le bénéfice apporté par le cookie.

    Veuillez ci-dessous prendre + pas d’incidence sur votre navigation, mais vous font perdre tout le bénéfice apporté par le cookie.

    Veuillez ci-dessous prendre connaissance des multiples outils mis à votre disposition afin que vous puissiez paramétrer les cookies déposés sur votre terminal.

    d. Le paramétrage de votre navigateur Internet

    Chaque navigateur Internet propose ses propres paramètres de gestion des cookies. Pour savoir de quelle manière modifier vos préférences en matière de cookies, vous trouverez ci-dessous les liens vers l’aide nécessaire pour accéder au menu de votre navigateur prévu à cet effet :

    Pour de plus amples informations concernant les outils de maîtrise des cookies, vous pouvez consulter le - site internet de la CNIL.

    + site internet de la CNIL.

    HTML setting.save end @@ -919,6 +987,10 @@ Setting.set('extended_prices_in_same_day', false) unless Setting.find_by(name: ' Setting.set('show_username_in_admin_list', false) unless Setting.find_by(name: 'show_username_in_admin_list').try(:value) +Setting.set('store_module', false) unless Setting.find_by(name: 'store_module').try(:value) + +Setting.set('store_hidden', true) unless Setting.find_by(name: 'store_hidden').try(:value) + if StatisticCustomAggregation.count.zero? # available reservations hours for machines machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2) @@ -928,8 +1000,8 @@ if StatisticCustomAggregation.count.zero? es_index: 'fablab', es_type: 'availabilities', field: 'available_hours', - query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"bookable_hours"}}}, "query":{"bool":{"must":[{"range":' \ - '{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"machines"}}]}}}' + query: '{"size":0, "aggregations":{"%s":{"sum":{"field":"bookable_hours"}}}, "query":{"bool":{"must":[{"range":' \ + '{"start_at":{"gte":"%s", "lte":"%s"}}}, {"match":{"available_type":"machines"}}]}}}' ) available_hours.save! @@ -941,8 +1013,8 @@ if StatisticCustomAggregation.count.zero? es_index: 'fablab', es_type: 'availabilities', field: 'available_tickets', - query: '{"size":0, "aggregations":{"%{aggs_name}":{"sum":{"field":"nb_total_places"}}}, "query":{"bool":{"must":[{"range":' \ - '{"start_at":{"gte":"%{start_date}", "lte":"%{end_date}"}}}, {"match":{"available_type":"training"}}]}}}' + query: '{"size":0, "aggregations":{"%s":{"sum":{"field":"nb_total_places"}}}, "query":{"bool":{"must":[{"range":' \ + '{"start_at":{"gte":"%s", "lte":"%s"}}}, {"match":{"available_type":"training"}}]}}}' ) available_tickets.save! end @@ -957,6 +1029,27 @@ unless StatisticIndex.find_by(es_type_key: 'space') ]) end +unless StatisticIndex.find_by(es_type_key: 'order') + index = StatisticIndex.create!(es_type_key: 'order', label: I18n.t('statistics.orders')) + type = StatisticType.create!({ statistic_index_id: index.id, key: 'store', label: I18n.t('statistics.store'), graph: true, simple: true }) + StatisticSubType.create!([ + { key: 'paid-processed', label: I18n.t('statistics.paid-processed'), statistic_types: [type] }, + { key: 'aborted', label: I18n.t('statistics.aborted'), statistic_types: [type] } + ]) + + # average cart price for orders + average_cart = StatisticCustomAggregation.new( + statistic_type_id: type.id, + es_index: 'stats', + es_type: 'order', + field: 'average_cart', + query: '{"size":0, "aggregations":{"%s":{"avg":{"field":"ca", ' \ + '"script":"BigDecimal.valueOf(_value).setScale(1, RoundingMode.HALF_UP)", "missing": 0}}}, ' \ + '"query":{"bool":{"must":[{"range": {"date":{"gte":"%s", "lte":"%s"}}}]}}}' + ) + average_cart.save! +end + ProfileCustomField.find_or_create_by(label: 'N° SIRET') ProfileCustomField.find_or_create_by(label: 'Code NAF') ProfileCustomField.find_or_create_by(label: 'N° TVA intracommunautaire') diff --git a/doc/README.md b/doc/README.md index 24b581207..6ada46aff 100644 --- a/doc/README.md +++ b/doc/README.md @@ -40,7 +40,7 @@ The following guides are designed for the people that perform software maintenan - [ElasticSearch](elastic_upgrade.md) ### Translator's documentation -If you intend to translate Fab-manager to a new, or an already supported language, you'll find here the information you need. +If you intend to translate Fab-manager to a new, or an already supported language, you'll find here the information you need. - [Guide for translators](translation_readme.md) ### Developer's documentation diff --git a/doc/postgresql_readme.md b/doc/postgresql_readme.md index 465261364..36a1087dc 100644 --- a/doc/postgresql_readme.md +++ b/doc/postgresql_readme.md @@ -18,18 +18,19 @@ Use the following commands to dump the PostgreSQL database to an archive file cd /apps/fabmanager/ docker-compose exec postgres bash cd /var/lib/postgresql/data/ -pg_dump -U postgres fabmanager_production > fabmanager_production_$(date -I).sql -tar cvzf fabmanager_production_$(date -I).tar.gz fabmanager_production_$(date -I).sql +DB=$(psql -U postgres -c \\l | grep production | awk '{print $1}') +pg_dump -U postgres "$DB" > "$DB_$(date -I).sql" +tar cvzf "fabmanager_database_dump_$(date -I).tar.gz" "$DB_$(date -I).sql" ``` If you're connected to your server thought SSH, you can download the resulting dump file using the following: ```bash -scp root@remote.server.fab:/apps/fabmanager/postgresql/fabmanager_production_$(date -I).tar.gz . +scp root@remote.server.fab:/apps/fabmanager/postgresql/fabmanager_database_dump_$(date -I).tar.gz . ``` Restore the dump with the following: ```bash -tar xvf fabmanager_production_$(date -I).tar.gz +tar xvf fabmanager_database_dump_$(date -I).tar.gz sudo cp fabmanager_production_$(date -I).sql /apps/fabmanager/postgresql/ cd /apps/fabmanager/ docker-compose down @@ -76,7 +77,7 @@ This is currently not supported, because of some PostgreSQL specific instruction - `db/migrate/20200511075933_fix_accounting_periods.rb` is using `CREATE RULE` and `DROP RULE`; - `app/models/project.rb` is using the `pg_search` gem. - `db/migrate/20200622135401_add_pg_search_dmetaphone_support_functions.rb` is using [fuzzystrmatch](http://www.postgresql.org/docs/current/static/fuzzystrmatch.html) module and defines a PL/pgSQL function (`pg_search_dmetaphone()`); - - `db/migrate/20200623134900_add_search_vector_to_project.rb` is using [tsvector](https://www.postgresql.org/docs/10/datatype-textsearch.html), a PostgreSQL datatype and [GIN (Generalized Inverted Index)](https://www.postgresql.org/docs/9.1/textsearch-indexes.html) a PostgreSQL index type; + - `db/migrate/20200623134900_add_search_vector_to_project.rb` is using [tsvector](https://www.postgresql.org/docs/10/datatype-textsearch.html), a PostgreSQL datatype and [GIN (Generalized Inverted Index)](https://www.postgresql.org/docs/9.1/textsearch-indexes.html) a PostgreSQL index type; - `db/migrate/20200623141305_update_search_vector_of_projects.rb` defines a PL/pgSQL function (`fill_search_vector_for_project()`) and create an SQL trigger for this function; - `db/migrate/20200629123011_update_pg_trgm.rb` is using [ALTER EXTENSION](https://www.postgresql.org/docs/10/sql-alterextension.html); - `db/migrate/20201027101809_create_payment_schedule_items.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html); diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index ea98c972b..feaef549a 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true +require 'payment/helper' + # PayZen payement gateway module PayZen; end ## Provides various methods around the PayZen payment gateway -class PayZen::Helper +class PayZen::Helper < Payment::Helper class << self ## Is the PayZen gateway enabled? def enabled? @@ -13,11 +15,15 @@ class PayZen::Helper res = true %w[payzen_username payzen_password payzen_endpoint payzen_public_key payzen_hmac payzen_currency].each do |pz_setting| - res = false unless Setting.get(pz_setting).present? + res = false if Setting.get(pz_setting).blank? end res end + def human_error(error) + I18n.t('errors.messages.gateway_error', { MESSAGE: error.message }) + end + ## generate an unique string reference for the content of a cart def generate_ref(cart_items, customer) require 'sha3' @@ -57,12 +63,25 @@ class PayZen::Helper ## Generate a hash map compatible with PayZen 'V4/Customer/ShoppingCart' def generate_shopping_cart(cart_items, customer, operator) - cart = if cart_items.is_a? ShoppingCart + cart = case cart_items + when ShoppingCart, Order cart_items else cs = CartService.new(operator) cs.from_hash(cart_items) end + if cart.is_a? Order + return { + cartItemInfo: cart.order_items.map do |item| + { + productAmount: item.amount.to_i.to_s, + productLabel: item.orderable_id, + productQty: item.quantity.to_s, + productType: customer.organization? ? 'SERVICE_FOR_BUSINESS' : 'SERVICE_FOR_INDIVIDUAL' + } + end + } + end { cartItemInfo: cart.items.map do |item| { @@ -84,9 +103,10 @@ class PayZen::Helper # if key is not defined, we use kr-hash-key parameter to choose it if key.nil? - if hash_key == 'sha256_hmac' + case hash_key + when 'sha256_hmac' key = Setting.get('payzen_hmac') - elsif hash_key == 'password' + when 'password' key = Setting.get('payzen_password') else raise ::PayzenError, 'invalid hash-key parameter' diff --git a/lib/payment/helper.rb b/lib/payment/helper.rb new file mode 100644 index 000000000..79d68f0b5 --- /dev/null +++ b/lib/payment/helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Payments module +module Payment; end + +# Generic gateway helpers +class Payment::Helper + def self.enabled?; end + def self.human_error(_error); end +end diff --git a/lib/stripe/helper.rb b/lib/stripe/helper.rb index 3959d5278..507ba8ba7 100644 --- a/lib/stripe/helper.rb +++ b/lib/stripe/helper.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true +require 'payment/helper' + # Stripe payement gateway module Stripe; end ## Provides various methods around the Stripe payment gateway -class Stripe::Helper +class Stripe::Helper < Payment::Helper class << self ## Is the Stripe gateway enabled? def enabled? @@ -13,9 +15,26 @@ class Stripe::Helper res = true %w[stripe_public_key stripe_secret_key stripe_currency].each do |pz_setting| - res = false unless Setting.get(pz_setting).present? + res = false if Setting.get(pz_setting).blank? end res end + + def human_error(error) + message = error.message + case error.code + when 'amount_too_small' + message.match(/\d+\.\d+\s\w+/) do |res| + message = I18n.t('errors.messages.gateway_amount_too_small', { AMOUNT: res }) + end + when 'amount_too_large' + message.match(/\d+\.\d+\s\w+/) do |res| + message = I18n.t('errors.messages.gateway_amount_too_large', { AMOUNT: res }) + end + else + message = I18n.t('errors.messages.gateway_error', { MESSAGE: message }) + end + message + end end end diff --git a/lib/stripe/service.rb b/lib/stripe/service.rb index d3536f646..49b13b62a 100644 --- a/lib/stripe/service.rb +++ b/lib/stripe/service.rb @@ -7,7 +7,6 @@ module Stripe; end ## create remote objects on stripe class Stripe::Service < Payment::Service - # Build the subscription base on the given shopping cart and create it on the remote stripe API def subscribe(payment_method_id, shopping_cart) price_details = shopping_cart.total @@ -15,7 +14,7 @@ class Stripe::Service < Payment::Service payment_schedule = price_details[:schedule][:payment_schedule] payment_schedule.payment_schedule_items = price_details[:schedule][:items] first_item = price_details[:schedule][:items].min_by(&:due_date) - subscription = shopping_cart.items.find { |item| item.class == CartItem::Subscription }.to_object + subscription = shopping_cart.items.find { |item| item.instance_of?(CartItem::Subscription) }.to_object reservable_stp_id = shopping_cart.items.find { |item| item.is_a?(CartItem::Reservation) }&.to_object &.reservable&.payment_gateway_object&.gateway_object_id @@ -53,15 +52,16 @@ class Stripe::Service < Payment::Service end def create_user(user_id) - StripeWorker.perform_async(:create_stripe_customer, user_id) + StripeWorker.perform_async('create_stripe_customer', user_id) end def create_coupon(coupon_id) coupon = Coupon.find(coupon_id) stp_coupon = { id: coupon.code } - if coupon.type == 'percent_off' + case coupon.type + when 'percent_off' stp_coupon[:percent_off] = coupon.percent_off - elsif coupon.type == stripe_amount('amount_off') + when stripe_amount('amount_off') stp_coupon[:amount_off] = coupon.amount_off stp_coupon[:currency] = Setting.get('stripe_currency') end @@ -97,7 +97,7 @@ class Stripe::Service < Payment::Service if stp_subscription.status == 'canceled' # the subscription was canceled by the gateway => notify & update the status notify_payment_schedule_gateway_canceled(payment_schedule_item) - payment_schedule_item.update_attributes(state: 'gateway_canceled') + payment_schedule_item.update(state: 'gateway_canceled') return end stp_invoice = Stripe::Invoice.retrieve(stp_subscription.latest_invoice, api_key: stripe_key) @@ -107,7 +107,7 @@ class Stripe::Service < Payment::Service payment_method: 'card', payment_id: stp_invoice.payment_intent, payment_type: 'Stripe::PaymentIntent') - payment_schedule_item.update_attributes(state: 'paid', payment_method: 'card') + payment_schedule_item.update(state: 'paid', payment_method: 'card') pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item) pgo.gateway_object = stp_invoice pgo.save! @@ -115,29 +115,30 @@ class Stripe::Service < Payment::Service ##### Payment error notify_payment_schedule_item_failed(payment_schedule_item) stp_payment_intent = Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: stripe_key) - payment_schedule_item.update_attributes(state: stp_payment_intent.status, - client_secret: stp_payment_intent.client_secret) + payment_schedule_item.update(state: stp_payment_intent.status, + client_secret: stp_payment_intent.client_secret) pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item) pgo.gateway_object = stp_invoice pgo.save! elsif stp_invoice.status == 'draft' - return # Could be that the stripe invoice does not yet reflect the payment made by the member, because we called that service just after payment is made. We call return here and PaymentScheduleItemWorker will anyway call that method every hour + # Could be that the stripe invoice does not yet reflect the payment made by the member, because we called that service + # just after payment is made. We just return here and PaymentScheduleItemWorker will anyway call that method every hour else notify_payment_schedule_item_error(payment_schedule_item) - payment_schedule_item.update_attributes(state: 'error') + payment_schedule_item.update(state: 'error') end end def pay_payment_schedule_item(payment_schedule_item) stripe_key = Setting.get('stripe_secret_key') - stp_invoice = Stripe::Invoice.pay(@payment_schedule_item.payment_gateway_object.gateway_object_id, {}, { api_key: stripe_key }) - PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) + stp_invoice = Stripe::Invoice.pay(payment_schedule_item.payment_gateway_object.gateway_object_id, {}, { api_key: stripe_key }) + PaymentScheduleItemWorker.new.perform(payment_schedule_item.id) { status: stp_invoice.status } rescue Stripe::StripeError => e stripe_key = Setting.get('stripe_secret_key') - stp_invoice = Stripe::Invoice.retrieve(@payment_schedule_item.payment_gateway_object.gateway_object_id, api_key: stripe_key) - PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) + stp_invoice = Stripe::Invoice.retrieve(payment_schedule_item.payment_gateway_object.gateway_object_id, api_key: stripe_key) + PaymentScheduleItemWorker.new.perform(payment_schedule_item.id) { status: stp_invoice.status, error: e } end @@ -162,7 +163,6 @@ class Stripe::Service < Payment::Service private - # Create the provided PaymentSchedule on Stripe, using the Subscription API def create_remote_subscription(shopping_cart, payment_schedule, items, price, payment_method_id) stripe_key = Setting.get('stripe_secret_key') diff --git a/lib/tasks/fablab/fix_invoices.rake b/lib/tasks/fablab/fix_invoices.rake index fd503f369..41420dee0 100644 --- a/lib/tasks/fablab/fix_invoices.rake +++ b/lib/tasks/fablab/fix_invoices.rake @@ -29,22 +29,23 @@ namespace :fablab do puts "Date: #{invoice.created_at}" print 'Delete [d], create the missing reservation [c] OR keep as error[e] ? > ' - confirm = STDIN.gets.chomp - if confirm == 'd' + confirm = $stdin.gets.chomp + case confirm + when 'd' puts "Destroying #{invoice.id}..." invoice.destroy - elsif confirm == 'c' + when 'c' if invoice.invoiced_type != 'Reservation' - STDERR.puts "WARNING: Invoice #{invoice.id} is about #{invoice.invoiced_type}. Please handle manually." - STDERR.puts 'Ignoring...' + warn "WARNING: Invoice #{invoice.id} is about #{invoice.invoiced_type}. Please handle manually." + warn 'Ignoring...' next end reservable = find_reservable(ii) if reservable if reservable.is_a? Event - STDERR.puts "WARNING: invoice #{invoice.id} is linked to Event #{reservable.id}. This is unsupported, please handle manually." - STDERR.puts 'Ignoring...' + warn "WARNING: invoice #{invoice.id} is linked to Event #{reservable.id}. This is unsupported, please handle manually." + warn 'Ignoring...' next end reservation = ::Reservation.create!( @@ -55,10 +56,10 @@ namespace :fablab do ) invoice.update_attributes(invoiced: reservation) else - STDERR.puts "WARNING: Unable to guess the reservable for invoice #{invoice.id}, please handle manually." - STDERR.puts 'Ignoring...' + warn "WARNING: Unable to guess the reservable for invoice #{invoice.id}, please handle manually." + warn 'Ignoring...' end - elsif confirm == 'e' + when 'e' invoice.update_attributes(invoiced_type: 'Error') else puts "Operation #{confirm} unknown. Ignoring invoice #{invoice.id}..." @@ -112,7 +113,7 @@ namespace :fablab do availability = reservable.availabilities.where('start_at <= ? AND end_at >= ?', slot[0], slot[1]).first unless availability - STDERR.puts "WARNING: Unable to find an availability for #{reservable.class.name} #{reservable.id}, at #{slot[0]}, creating..." + warn "WARNING: Unable to find an availability for #{reservable.class.name} #{reservable.id}, at #{slot[0]}, creating..." availability = reservable.availabilities.create!(start_at: slot[0], end_at: slot[1]) end availability diff --git a/lib/tasks/fablab/setup.rake b/lib/tasks/fablab/setup.rake index 2a07c1ff8..ef5ae460c 100644 --- a/lib/tasks/fablab/setup.rake +++ b/lib/tasks/fablab/setup.rake @@ -12,7 +12,9 @@ namespace :fablab do desc 'add missing VAT rate to history' task :add_vat_rate, %i[rate date] => :environment do |_task, args| - raise 'Missing argument. Usage exemple: rails fablab:setup:add_vat_rate[20,2014-01-01]. Use 0 to disable' unless args.rate && args.date + unless args.rate && args.date + raise 'Missing argument. Usage exemple: rails fablab:setup:add_vat_rate[20,2014-01-01]. Use 0 to disable' + end if args.rate == '0' setting = Setting.find_by(name: 'invoice_VAT-active') @@ -107,5 +109,36 @@ namespace :fablab do setting.save end end + + desc 'migrate administrators to normal groups and validate them' + task set_admins_group: :environment do + groups = Group.where.not(slug: 'admins').where(disabled: [false, nil]).order(:id) + User.admins.each do |admin| + print "\e[91m::\e[0m \e[1mMove admin #{admin.profile} to group\e[0m:\n" + admin.update(group_id: select_group(groups)) + PaymentGatewayService.new.create_user(admin.id) + end + print "\e[91m::\e[0m \e[1mRemoving the 'admins' group...\e[0m\n" + Group.find_by(slug: 'admins').destroy + if Setting.get('user_validation_required') + print "\e[91m::\e[0m \e[1mValidating the 'admins'...\e[0m\n" + User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? } + end + print "\e[32m✅\e[0m \e[1mDone\e[0m\n" + end + + def select_group(groups) + groups.each do |g| + print "#{g.id}) #{g.name}\n" + end + print '> ' + group_id = $stdin.gets.chomp + if groups.map(&:id).include?(group_id.to_i) + group_id + else + warn "\e[91m[ ❌ ] Please select a valid group number \e[39m" + select_group(groups) + end + end end end diff --git a/package.json b/package.json index c0ef2ea03..1b390e6da 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/modifiers": "^6.0.0", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", @@ -118,6 +121,7 @@ "jasny-bootstrap": "3.1", "jquery": ">=3.5.0", "jquery-ujs": "^1.2.2", + "js-cookie": "^3.0.1", "medium-editor": "^5.23.3", "mini-css-extract-plugin": "^2.6.0", "moment": "2.29", @@ -134,17 +138,21 @@ "rails-erb-loader": "^5.5.2", "react": "^17.0.2", "react-cool-onclickoutside": "^1.7.0", + "react-custom-events": "^1.1.1", "react-dom": "^17.0.2", "react-hook-form": "^7.30.0", "react-i18next": "^11.15.6", "react-modal": "^3.11.2", "react-select": "^5.3.2", + "react-sortablejs": "^6.1.4", "react-switch": "^6.0.0", "react2angular": "^4.0.6", "resolve-url-loader": "^4.0.0", "sass": "^1.49.9", "sass-loader": "^12.6.0", "shakapacker": "6.2.0", + "slugify": "^1.6.5", + "sortablejs": "^1.15.0", "style-loader": "^3.3.1", "summernote": "0.8.18", "terser-webpack-plugin": "5", diff --git a/public/default-image.png b/public/default-image.png new file mode 100644 index 000000000..ea4f8a803 Binary files /dev/null and b/public/default-image.png differ diff --git a/test/fixtures/addresses.yml b/test/fixtures/addresses.yml index bf160f322..d86f9fa48 100644 --- a/test/fixtures/addresses.yml +++ b/test/fixtures/addresses.yml @@ -86,3 +86,39 @@ address_7: placeable_type: InvoicingProfile created_at: 2016-08-02 11:16:24.412236000 Z updated_at: 2016-08-02 11:16:24.412236000 Z + +address_7: + id: 7 + address: 14 rue du Général Choucroute, 44000 NANTES + street_number: + route: + locality: + postal_code: + placeable_id: 10 + placeable_type: InvoicingProfile + created_at: 2016-08-02 11:16:24.412236000 Z + updated_at: 2016-08-02 11:16:24.412236000 Z + +address_8: + id: 8 + address: 127 allée des armées en déroute, 17120 SEMUSSAC + street_number: + route: + locality: + postal_code: + placeable_id: 1 + placeable_type: InvoicingProfile + created_at: 2016-08-02 11:16:24.412236000 Z + updated_at: 2016-08-02 11:16:24.412236000 Z + +address_9: + id: 9 + address: 14 rue du Général Choucroute, 44000 NANTES + street_number: + route: + locality: + postal_code: + placeable_id: 9 + placeable_type: InvoicingProfile + created_at: 2016-08-02 11:16:24.412236000 Z + updated_at: 2016-08-02 11:16:24.412236000 Z diff --git a/test/fixtures/assets.yml b/test/fixtures/assets.yml index c4214fbcf..a9edcdd28 100644 --- a/test/fixtures/assets.yml +++ b/test/fixtures/assets.yml @@ -70,3 +70,103 @@ asset_8: type: UserAvatar created_at: 2018-12-27 14:48:52.141382000 Z updated_at: 2018-12-27 14:48:52.141382000 Z + +asset_2707: + id: 2707 + viewable_id: 3 + viewable_type: Product + attachment: appset-icon.png + type: ProductImage + created_at: '2022-09-13 10:05:07.212923' + updated_at: '2022-09-27 10:17:51.976313' + is_main: true +asset_2729: + id: 2729 + viewable_id: 16 + viewable_type: Product + attachment: osb.jpeg + type: ProductImage + created_at: '2022-09-14 13:50:12.588896' + updated_at: '2022-09-27 10:13:39.414052' + is_main: true +asset_2727: + id: 2727 + viewable_id: 15 + viewable_type: Product + attachment: cp.jpeg + type: ProductImage + created_at: '2022-09-14 13:49:08.237136' + updated_at: '2022-09-27 10:13:51.282923' + is_main: true +asset_2721: + id: 2721 + viewable_id: 12 + viewable_type: Product + attachment: casqueapointe.jpg + type: ProductImage + created_at: '2022-09-14 13:42:36.731315' + updated_at: '2022-09-27 10:14:23.371827' + is_main: true +asset_2731: + id: 2731 + viewable_id: 17 + viewable_type: Product + attachment: 3plis.jpeg + type: ProductImage + created_at: '2022-09-14 13:51:12.634244' + updated_at: '2022-09-27 10:13:28.657539' + is_main: true +asset_2725: + id: 2725 + viewable_id: 14 + viewable_type: Product + attachment: melamine.jpeg + type: ProductImage + created_at: '2022-09-14 13:48:05.033284' + updated_at: '2022-09-27 10:14:00.569077' + is_main: true +asset_2723: + id: 2723 + viewable_id: 13 + viewable_type: Product + attachment: mdf.jpeg + type: ProductImage + created_at: '2022-09-14 13:47:18.129274' + updated_at: '2022-09-27 10:14:10.137542' + is_main: true +asset_2733: + id: 2733 + viewable_id: 18 + viewable_type: Product + attachment: lamelle-colle.jpeg + type: ProductImage + created_at: '2022-09-14 13:52:08.666869' + updated_at: '2022-09-27 10:13:10.979122' + is_main: true +asset_2719: + id: 2719 + viewable_id: 11 + viewable_type: Product + attachment: bulldozer.png + type: ProductImage + created_at: '2022-09-14 13:41:16.209363' + updated_at: '2022-09-27 10:14:33.153221' + is_main: true +asset_2743: + id: 2743 + viewable_id: 22 + viewable_type: Product + attachment: filament_pla_1_75_bleu.jpeg + type: ProductImage + created_at: '2022-10-03 14:00:50.082820' + updated_at: '2022-10-03 14:00:50.082820' + is_main: true +asset_2744: + id: 2744 + viewable_id: 23 + viewable_type: Product + attachment: filament-pla_blanc.webp + type: ProductImage + created_at: '2022-10-03 14:01:43.711513' + updated_at: '2022-10-03 14:01:43.711513' + is_main: true diff --git a/test/fixtures/coupons.yml b/test/fixtures/coupons.yml index 2bf8adab7..a483c2fb5 100644 --- a/test/fixtures/coupons.yml +++ b/test/fixtures/coupons.yml @@ -35,3 +35,15 @@ cash2: max_usages: active: true validity_per_user: once +twentyp: + id: 30 + name: 20% test + code: REDUC20 + percent_off: 20 + valid_until: + max_usages: + active: true + created_at: '2021-06-18 14:53:54.770895' + updated_at: '2021-06-18 14:53:54.770895' + validity_per_user: forever + amount_off: diff --git a/test/fixtures/groups.yml b/test/fixtures/groups.yml index fd1385f7b..5588d91e9 100644 --- a/test/fixtures/groups.yml +++ b/test/fixtures/groups.yml @@ -11,10 +11,3 @@ group_2: created_at: 2016-04-04 14:11:33.656537000 Z updated_at: 2016-04-04 14:11:33.656537000 Z slug: student - -admins: - id: 3 - name: Administrateurs - created_at: 2017-09-18 10:24:33.651615210 Z - updated_at: 2017-09-18 10:24:33.651615210 Z - slug: admins \ No newline at end of file diff --git a/test/fixtures/history_values.yml b/test/fixtures/history_values.yml index a6099c563..b1913dafd 100644 --- a/test/fixtures/history_values.yml +++ b/test/fixtures/history_values.yml @@ -494,7 +494,7 @@ history_value_51: history_value_52: id: 52 setting_id: 52 - value: '7062' + value: Machine reservation created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.872288' footprint: 0ee671e962f92a3af95538fdd65a64ff410b208ab790c4e6f7b0a9f5a13dc1a2 @@ -503,7 +503,7 @@ history_value_52: history_value_53: id: 53 setting_id: 53 - value: Machine reservation + value: '7062' created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.904414' footprint: ad09bac8c58754444a9970108d613aefaeceed540609688f5668c68ca5c03531 @@ -512,7 +512,7 @@ history_value_53: history_value_54: id: 54 setting_id: 54 - value: '7063' + value: Training reservation created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.930713' footprint: 89f17add783f9977c4b13bc22f5776d0427c3d9af8c83206af57de7c72c153b0 @@ -521,7 +521,7 @@ history_value_54: history_value_55: id: 55 setting_id: 55 - value: Training reservation + value: '7063' created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.955492' footprint: f2e20f936e3fc917bc82e97adb321f957c2cd6ac992f3223ffe2be529ea1649f @@ -530,7 +530,7 @@ history_value_55: history_value_56: id: 56 setting_id: 56 - value: '7064' + value: Event reservation created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.977369' footprint: 5ce909aa92740dbd65647ff3a5d5fdc75ad6c2a02b5fa9bbeea1aa27f907416a @@ -539,7 +539,7 @@ history_value_56: history_value_57: id: 57 setting_id: 57 - value: Event reservation + value: '7064' created_at: '2019-09-20 11:02:32.125400' updated_at: '2021-05-31 15:00:36.993279' footprint: ed7ecf389b9560728ce43257bef442a2404f463ebbc0fbaeaad05f113cca21df @@ -824,3 +824,39 @@ history_value_86: updated_at: '2021-06-15 12:05:21.513651' footprint: 350ca0033f3e65b6e8186dbb0545b55ecb3768e1a08501549b81d95e34809440 invoicing_profile_id: 1 + +history_value_87: + id: 87 + setting_id: 86 + value: Prepaid pack + created_at: '2022-10-26 12:46:16.125400000 Z' + updated_at: '2022-10-26 12:46:16.125400000 Z' + footprint: + invoicing_profile_id: 1 + +history_value_88: + id: 88 + setting_id: 87 + value: '7066' + created_at: '2022-10-26 12:46:16.125400000 Z' + updated_at: '2022-10-26 12:46:16.125400000 Z' + footprint: + invoicing_profile_id: 1 + +history_value_89: + id: 89 + setting_id: 88 + value: Shop order + created_at: '2022-10-26 12:46:16.125400000 Z' + updated_at: '2022-10-26 12:46:16.125400000 Z' + footprint: + invoicing_profile_id: 1 + +history_value_90: + id: 90 + setting_id: 89 + value: '7067' + created_at: '2022-10-26 12:46:16.125400000 Z' + updated_at: '2022-10-26 12:46:16.125400000 Z' + footprint: + invoicing_profile_id: 1 diff --git a/test/fixtures/invoice_items.yml b/test/fixtures/invoice_items.yml index 01cb8bcc3..fc5d8f92a 100644 --- a/test/fixtures/invoice_items.yml +++ b/test/fixtures/invoice_items.yml @@ -72,3 +72,159 @@ invoice_item_6: object_type: Subscription object_id: 4 main: true +invoice_item_11702: + id: 11702 + invoice_id: 5811 + amount: 4000 + created_at: '2022-09-20 15:14:23.015625' + updated_at: '2022-09-20 15:14:23.180166' + description: Tablette lamellé-collé x 1 + invoice_item_id: + footprint: 1d112a787e89d444f2817b909d7857a3a26fc2a3cd4c4404fb50bec282988a02 + object_type: OrderItem + object_id: 1 + main: true +invoice_item_11703: + id: 11703 + invoice_id: 5811 + amount: 500 + created_at: '2022-09-20 15:14:23.287522' + updated_at: '2022-09-20 15:14:23.289957' + description: Panneaux de MDF x 1 + invoice_item_id: + footprint: 67f143564abfeab7cb93988a02987a5a2b04a2632d0f9cfde61fa39c398cd02b + object_type: OrderItem + object_id: 2 + main: false +invoice_item_11704: + id: 11704 + invoice_id: 5812 + amount: 6000 + created_at: '2022-09-20 15:14:48.367843' + updated_at: '2022-09-20 15:14:48.385139' + description: Panneau de contre-plaqué x 4 + invoice_item_id: + footprint: ea51c614632c018f0fae53c2bf8503c71d18c082ae3246155971e19ac2db143a + object_type: OrderItem + object_id: 3 + main: true +invoice_item_11712: + id: 11712 + invoice_id: 5816 + amount: 119 + created_at: '2022-10-04 12:36:03.103560' + updated_at: '2022-10-04 12:36:03.282529' + description: Filament PLA blanc x 1 + invoice_item_id: + footprint: 4c5c8a1d7884502ea7791aeed1701cf3761562855d51ed19396b344c665c308b + object_type: OrderItem + object_id: 16 + main: true +invoice_item_11713: + id: 11713 + invoice_id: 5816 + amount: 200 + created_at: '2022-10-04 12:36:03.286776' + updated_at: '2022-10-04 12:36:03.290235' + description: Filament PLA bleu x 1 + invoice_item_id: + footprint: 12c1a2c7fdb0f4b35d0c24137e6e0bdbbb39fa5c6500aa7837c67f92084d5071 + object_type: OrderItem + object_id: 17 + main: false +invoice_item_11714: + id: 11714 + invoice_id: 5817 + amount: 119 + created_at: '2022-10-04 13:54:42.977460' + updated_at: '2022-10-04 13:54:42.992564' + description: Filament PLA blanc x 1 + invoice_item_id: + footprint: cdb07fe1f0b986b9a53b6ec71f91a96e8b8d438592e88fd0041133b2ab46c8ca + object_type: OrderItem + object_id: 18 + main: true +invoice_item_11715: + id: 11715 + invoice_id: 5817 + amount: 1500 + created_at: '2022-10-04 13:54:43.021426' + updated_at: '2022-10-04 13:54:43.024326' + description: Panneau de contre-plaqué x 1 + invoice_item_id: + footprint: 3ac08745b737bc370424ee9f1b68b421000eb98e0f3e65d7395d905d75de05c6 + object_type: OrderItem + object_id: 19 + main: false +invoice_item_11716: + id: 11716 + invoice_id: 5818 + amount: 1000 + created_at: '2022-10-04 14:04:12.745449' + updated_at: '2022-10-04 14:04:12.749266' + description: Bulldozer x 1 + invoice_item_id: + footprint: 1f4b6489579d7f045c846b97bc8ed402ec91f6fd7a5c2fa93b74eb7d21c7de39 + object_type: OrderItem + object_id: 20 + main: true +invoice_item_11717: + id: 11717 + invoice_id: 5819 + amount: 2 + created_at: '2022-10-04 14:17:52.856695' + updated_at: '2022-10-04 14:17:52.871262' + description: Sticker Hello x 2 + invoice_item_id: + footprint: 0fbecff886d9c2ffc332def13fa2c8869be8a40d2be7a3126a77974bd920c563 + object_type: OrderItem + object_id: 21 + main: true +invoice_item_11718: + id: 11718 + invoice_id: 5819 + amount: 4000 + created_at: '2022-10-04 14:17:52.875854' + updated_at: '2022-10-04 14:17:52.878780' + description: Tablette lamellé-collé x 1 + invoice_item_id: + footprint: ad160b9357518b31f63661fe38998b8c1d97fda61bc744876826ae638a8142a0 + object_type: OrderItem + object_id: 22 + main: false +invoice_item_11719: + id: 11719 + invoice_id: 5820 + amount: 12000 + created_at: '2022-10-04 14:25:37.319701' + updated_at: '2022-10-04 14:25:37.322782' + description: Tablette lamellé-collé x 3 + invoice_item_id: + footprint: 409a6f0b9f67510038a8b9a407db6d09b97f12d7169d6f6f121eedb2941d1bfc + object_type: OrderItem + object_id: 11 + main: true +invoice_item_11720: + id: 11720 + invoice_id: 5821 + amount: 12000 + created_at: '2022-10-04 14:32:28.209257' + updated_at: '2022-10-04 14:32:28.226556' + description: Tablette lamellé-collé x 3 + invoice_item_id: + footprint: 733610b9c8e33f6a63f497d867d40386c623f12a526814d09d88a96f53741b7b + object_type: OrderItem + object_id: 23 + main: true +invoice_item_11721: + id: 11721 + invoice_id: 5822 + amount: 3000 + created_at: '2022-10-04 14:35:40.603969' + updated_at: '2022-10-04 14:35:40.608505' + description: Panneau de 3 plis mélèze x 1 + invoice_item_id: + footprint: c1ad8c20d080ebc8267ff0d1b668feb8c989c2561be75d311f29f49a03405895 + object_type: OrderItem + object_id: 24 + main: true diff --git a/test/fixtures/invoices.yml b/test/fixtures/invoices.yml index 2fd5ea166..c852bd79e 100644 --- a/test/fixtures/invoices.yml +++ b/test/fixtures/invoices.yml @@ -124,3 +124,183 @@ invoice_6: invoicing_profile_id: 8 operator_profile_id: 1 statistic_profile_id: 8 +invoice_5811: + id: 5811 + total: 4500 + created_at: '2022-09-20 15:14:22.873707' + updated_at: '2022-09-20 15:14:23.496793' + reference: '2209002' + payment_method: local + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: '094590df6330de6a2b5d2ce7230673c7178f2639ca8ceb51ba272795349fff95' + environment: development + invoicing_profile_id: 3 + operator_profile_id: 1 + statistic_profile_id: 3 +invoice_5812: + id: 5812 + total: 6000 + created_at: '2022-09-20 15:14:48.345927' + updated_at: '2022-09-20 15:14:48.409110' + reference: '2209004' + payment_method: local + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: ea5cc57a8af956f9a3e66ec1f9bd7fe5fe51e1220955cacb11a3cc99d2e6aa54 + environment: development + invoicing_profile_id: 7 + operator_profile_id: 1 + statistic_profile_id: 7 +invoice_5816: + id: 5816 + total: 319 + created_at: '2022-10-04 12:36:03.060832' + updated_at: '2022-10-04 12:36:03.445507' + reference: 2210002/VL + payment_method: card + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: ca8879f07cf1bd29500a9df29ce110843fb204ab0da8140bb4c6e8e908a65e0b + environment: development + invoicing_profile_id: 4 + operator_profile_id: 4 + statistic_profile_id: 4 +invoice_5817: + id: 5817 + total: 1295 + created_at: '2022-10-04 13:54:42.975196' + updated_at: '2022-10-04 13:54:43.070098' + reference: 2210004/VL + payment_method: card + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: 30 + footprint: 838ae505dfba54f6a69cd341abf8f97fd9370c2acb81e37c6e70d98049d80646 + environment: development + invoicing_profile_id: 4 + operator_profile_id: 4 + statistic_profile_id: 4 +invoice_5818: + id: 5818 + total: 1000 + created_at: '2022-10-04 14:04:12.742685' + updated_at: '2022-10-04 14:04:12.774844' + reference: 2210006/VL + payment_method: card + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: 93db8c6ded7066a71927b6950cf5a9ea69aff25036df3a5a4259615347f2c8b2 + environment: development + invoicing_profile_id: 4 + operator_profile_id: 4 + statistic_profile_id: 4 +invoice_5819: + id: 5819 + total: 4002 + created_at: '2022-10-04 14:17:52.854636' + updated_at: '2022-10-04 14:17:52.898044' + reference: 2210008/VL + payment_method: card + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: bfd2f78a99aadd137f3f28fe8258a46ff8afa69b458f0a6f2dce1205fab22784 + environment: development + invoicing_profile_id: 4 + operator_profile_id: 4 + statistic_profile_id: 4 +invoice_5820: + id: 5820 + total: 12000 + created_at: '2022-10-04 14:25:37.291945' + updated_at: '2022-10-04 14:25:37.341401' + reference: '2210010' + payment_method: local + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: db6a663a25e19229ee93868765e1463ec7bd7856b173c5dd326d70d98369cfc0 + environment: development + invoicing_profile_id: 3 + operator_profile_id: 1 + statistic_profile_id: 3 +invoice_5821: + id: 5821 + total: 12000 + created_at: '2022-10-04 14:32:28.204985' + updated_at: '2022-10-04 14:32:28.292591' + reference: '2210012' + payment_method: local + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: 3ed9a13e5c7155bc3b186f3215b90345cc5520822728796e7c1c00ad5128ed01 + environment: development + invoicing_profile_id: 2 + operator_profile_id: 1 + statistic_profile_id: 2 +invoice_5822: + id: 5822 + total: 3000 + created_at: '2022-10-04 14:35:40.584472' + updated_at: '2022-10-04 14:35:40.637091' + reference: '2210014' + payment_method: local + avoir_date: + invoice_id: + type: + subscription_to_expire: + description: + wallet_amount: + wallet_transaction_id: + coupon_id: + footprint: 63d069e72b4992244861e9450b0c3ba3b9b30638af972a631b81578091d2925d + environment: development + invoicing_profile_id: 2 + operator_profile_id: 1 + statistic_profile_id: 2 diff --git a/test/fixtures/order_activities.yml b/test/fixtures/order_activities.yml new file mode 100644 index 000000000..707e9d505 --- /dev/null +++ b/test/fixtures/order_activities.yml @@ -0,0 +1,129 @@ +order_activity_1: + id: 1 + order_id: 1 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-09-20 15:14:22.596166' + updated_at: '2022-09-20 15:14:22.596166' +order_activity_2: + id: 2 + order_id: 2 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-09-20 15:14:48.328689' + updated_at: '2022-09-20 15:14:48.328689' +order_activity_6: + id: 6 + order_id: 9 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 12:36:02.719677' + updated_at: '2022-10-04 12:36:02.719677' +order_activity_7: + id: 7 + order_id: 2 + operator_profile_id: 2437 + activity_type: ready + note: Merci de venir retirer la commande + created_at: '2022-10-04 13:37:49.932146' + updated_at: '2022-10-04 13:37:49.932146' +order_activity_11: + id: 11 + order_id: 9 + operator_profile_id: 2437 + activity_type: ready + note: '' + created_at: '2022-10-04 13:50:30.609274' + updated_at: '2022-10-04 13:50:30.609274' +order_activity_12: + id: 12 + order_id: 10 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 13:54:42.749983' + updated_at: '2022-10-04 13:54:42.749983' +order_activity_13: + id: 13 + order_id: 10 + operator_profile_id: 2437 + activity_type: ready + note: '' + created_at: '2022-10-04 13:54:56.790702' + updated_at: '2022-10-04 13:54:56.790702' +order_activity_14: + id: 14 + order_id: 11 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 14:04:12.721800' + updated_at: '2022-10-04 14:04:12.721800' +order_activity_15: + id: 15 + order_id: 11 + operator_profile_id: 2437 + activity_type: ready + note: '' + created_at: '2022-10-04 14:04:28.489152' + updated_at: '2022-10-04 14:04:28.489152' +order_activity_16: + id: 16 + order_id: 12 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 14:17:52.835587' + updated_at: '2022-10-04 14:17:52.835587' +order_activity_17: + id: 17 + order_id: 12 + operator_profile_id: 2437 + activity_type: ready + note: "

    je ne sais pas que te dire mon ami

    " + created_at: '2022-10-04 14:22:54.560670' + updated_at: '2022-10-04 14:22:54.560670' +order_activity_18: + id: 18 + order_id: 5 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 14:25:37.278540' + updated_at: '2022-10-04 14:25:37.278540' +order_activity_19: + id: 19 + order_id: 14 + operator_profile_id: + activity_type: paid + note: + created_at: '2022-10-04 14:32:27.965847' + updated_at: '2022-10-04 14:32:27.965847' +order_activity_20: + id: 20 + order_id: 14 + operator_profile_id: 2437 + activity_type: ready + note: "

    Lorem ipsum dolor sit amet

    " + created_at: '2022-10-04 14:33:17.067080' + updated_at: '2022-10-04 14:33:17.067080' +order_activity_21: + id: 21 + order_id: 15 + operator_profile_id: + activity_type: paid + note: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> +order_activity_22: + id: 22 + order_id: 15 + operator_profile_id: 2437 + activity_type: ready + note: "

    Votre commande est prête, merci de venir la récupérer jeudi + après 14 h.

    " + created_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> diff --git a/test/fixtures/order_items.yml b/test/fixtures/order_items.yml new file mode 100644 index 000000000..10d1da467 --- /dev/null +++ b/test/fixtures/order_items.yml @@ -0,0 +1,170 @@ +order_item_1: + id: 1 + order_id: 1 + orderable_type: Product + orderable_id: 18 + amount: 4000 + quantity: 1 + is_offered: + created_at: '2022-09-20 15:14:05.605885' + updated_at: '2022-09-20 15:14:05.605885' +order_item_2: + id: 2 + order_id: 1 + orderable_type: Product + orderable_id: 13 + amount: 500 + quantity: 1 + is_offered: + created_at: '2022-09-20 15:14:09.022448' + updated_at: '2022-09-20 15:14:09.022448' +order_item_3: + id: 3 + order_id: 2 + orderable_type: Product + orderable_id: 15 + amount: 1500 + quantity: 4 + is_offered: + created_at: '2022-09-20 15:14:34.899658' + updated_at: '2022-09-20 15:14:34.899658' +order_item_11: + id: 11 + order_id: 5 + orderable_type: Product + orderable_id: 18 + amount: 4000 + quantity: 3 + is_offered: + created_at: '2022-09-28 08:31:30.377083' + updated_at: '2022-10-04 08:34:25.130457' +order_item_16: + id: 16 + order_id: 9 + orderable_type: Product + orderable_id: 23 + amount: 119 + quantity: 1 + is_offered: + created_at: '2022-10-04 12:35:39.730968' + updated_at: '2022-10-04 12:35:39.730968' +order_item_17: + id: 17 + order_id: 9 + orderable_type: Product + orderable_id: 22 + amount: 200 + quantity: 1 + is_offered: + created_at: '2022-10-04 12:35:40.562076' + updated_at: '2022-10-04 12:35:40.562076' +order_item_18: + id: 18 + order_id: 10 + orderable_type: Product + orderable_id: 23 + amount: 119 + quantity: 1 + is_offered: + created_at: '2022-10-04 13:54:19.042858' + updated_at: '2022-10-04 13:54:19.042858' +order_item_19: + id: 19 + order_id: 10 + orderable_type: Product + orderable_id: 15 + amount: 1500 + quantity: 1 + is_offered: + created_at: '2022-10-04 13:54:22.649153' + updated_at: '2022-10-04 13:54:22.649153' +order_item_20: + id: 20 + order_id: 11 + orderable_type: Product + orderable_id: 11 + amount: 1000 + quantity: 1 + is_offered: + created_at: '2022-10-04 14:03:59.721736' + updated_at: '2022-10-04 14:03:59.721736' +order_item_21: + id: 21 + order_id: 12 + orderable_type: Product + orderable_id: 20 + amount: 1 + quantity: 2 + is_offered: + created_at: '2022-10-04 14:05:23.583721' + updated_at: '2022-10-04 14:17:41.866526' +order_item_22: + id: 22 + order_id: 12 + orderable_type: Product + orderable_id: 18 + amount: 4000 + quantity: 1 + is_offered: + created_at: '2022-10-04 14:17:33.257776' + updated_at: '2022-10-04 14:17:33.257776' +order_item_23: + id: 23 + order_id: 14 + orderable_type: Product + orderable_id: 18 + amount: 4000 + quantity: 3 + is_offered: + created_at: '2022-10-04 14:31:51.106853' + updated_at: '2022-10-04 14:31:54.194063' +order_item_24: + id: 24 + order_id: 15 + orderable_type: Product + orderable_id: 17 + amount: 3000 + quantity: 1 + is_offered: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> +order_item_25: + id: 25 + order_id: 18 + orderable_type: Product + orderable_id: 13 + amount: 500 + quantity: 1 + is_offered: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> +order_item_26: + id: 26 + order_id: 19 + orderable_type: Product + orderable_id: 3 + amount: 52300 + quantity: 5 + is_offered: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> +order_item_27: + id: 27 + order_id: 20 + orderable_type: Product + orderable_id: 3 + amount: 52300 + quantity: 5 + is_offered: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> +order_item_28: + id: 28 + order_id: 20 + orderable_type: Product + orderable_id: 13 + amount: 500 + quantity: 2 + is_offered: + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> diff --git a/test/fixtures/orders.yml b/test/fixtures/orders.yml new file mode 100644 index 000000000..d52c00405 --- /dev/null +++ b/test/fixtures/orders.yml @@ -0,0 +1,252 @@ +order_1: + id: 1 + statistic_profile_id: 3 + operator_profile_id: 1 + token: 3R9wtsPjyYMHKqy-I2V5Cg1661868752184 + reference: + state: paid + total: 4500 + created_at: '2022-08-30 14:12:32.213832' + updated_at: '2022-09-20 15:14:23.501369' + wallet_amount: + wallet_transaction_id: + payment_method: local + footprint: + environment: test + coupon_id: + paid_total: 4500 + invoice_id: 5811 +order_2: + id: 2 + statistic_profile_id: 7 + operator_profile_id: 1 + token: TDKq6p4f3WgDRMIvplEqpg1663686863871 + reference: '005877-09-22' + state: ready + total: 6000 + created_at: '2022-09-20 15:14:23.887098' + updated_at: '2022-10-04 13:37:49.935380' + wallet_amount: + wallet_transaction_id: + payment_method: local + footprint: + environment: test + coupon_id: + paid_total: 6000 + invoice_id: 5812 +order_5: + id: 5 + statistic_profile_id: 3 + operator_profile_id: 1 + token: UbTOaTePhajDJPPH42e3yw1664353619348 + reference: '005882-09-22' + state: paid + total: 12000 + created_at: '2022-09-28 08:26:59.368029' + updated_at: '2022-10-04 14:25:37.345897' + wallet_amount: + wallet_transaction_id: + payment_method: local + footprint: + environment: test + coupon_id: + paid_total: 12000 + invoice_id: 5820 +order_9: + id: 9 + statistic_profile_id: 4 + operator_profile_id: 4 + token: 8jOZwd2vkE86x26cg167iQ1664886886040 + reference: '005888-10-22' + state: ready + total: 319 + created_at: '2022-10-04 12:34:46.054976' + updated_at: '2022-10-04 13:50:30.611456' + wallet_amount: + wallet_transaction_id: + payment_method: card + footprint: + environment: test + coupon_id: + paid_total: 319 + invoice_id: 5816 +order_10: + id: 10 + statistic_profile_id: 4 + operator_profile_id: 4 + token: ryrsm4yj21DTJv1qIgMuGA1664886964105 + reference: '005890-10-22' + state: ready + total: 1619 + created_at: '2022-10-04 12:36:04.117389' + updated_at: '2022-10-04 13:54:56.792550' + wallet_amount: + wallet_transaction_id: + payment_method: card + footprint: + environment: test + coupon_id: 30 + paid_total: 1296 + invoice_id: 5817 +order_11: + id: 11 + statistic_profile_id: 4 + operator_profile_id: 4 + token: ogvv31XzLE0zFLc_c8k4Sw1664891683573 + reference: '005892-10-22' + state: ready + total: 1000 + created_at: '2022-10-04 13:54:43.617309' + updated_at: '2022-10-04 14:04:28.490690' + wallet_amount: + wallet_transaction_id: + payment_method: card + footprint: + environment: test + coupon_id: + paid_total: 1000 + invoice_id: 5818 +order_12: + id: 12 + statistic_profile_id: 4 + operator_profile_id: 4 + token: ttG9U892Bu0gbu8OnJkwTw1664892253183 + reference: '005894-10-22' + state: ready + total: 4002 + created_at: '2022-10-04 14:04:13.206078' + updated_at: '2022-10-04 14:22:54.562186' + wallet_amount: + wallet_transaction_id: + payment_method: card + footprint: + environment: test + coupon_id: + paid_total: 4002 + invoice_id: 5819 +order_14: + id: 14 + statistic_profile_id: 2 + operator_profile_id: 1 + token: QsDdf_8YHL6mxUSMno-deg1664893537749 + reference: '005898-10-22' + state: ready + total: 12000 + created_at: '2022-10-04 14:25:37.773536' + updated_at: '2022-10-04 14:33:17.068764' + wallet_amount: + wallet_transaction_id: + payment_method: local + footprint: + environment: test + coupon_id: + paid_total: 12000 + invoice_id: 5821 +order_15: + id: 15 + statistic_profile_id: 2 + operator_profile_id: 1 + token: JOLm4fAZjFSkmWzAX3KObw1664893948693 + reference: '005900-10-22' + state: ready + total: 3000 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: local + footprint: + environment: test + coupon_id: + paid_total: 3000 + invoice_id: 5822 +order_16: + id: 16 + statistic_profile_id: 10 + operator_profile_id: + token: 9VWkmJDSx7QixRusL7ppWg1666628033284 + reference: '005901-<%= DateTime.current.utc.strftime('%m') %>-<%= DateTime.current.utc.strftime('%d') %>' + state: cart + total: 0 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: + footprint: + environment: test + coupon_id: + paid_total: + invoice_id: +order_17: + id: 17 + statistic_profile_id: + operator_profile_id: + token: MkI5z9qVxe_YdNYCR_WN6g1666628074732 + reference: '005902-<%= DateTime.current.utc.strftime('%m') %>-<%= DateTime.current.utc.strftime('%d') %>' + state: cart + total: 0 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: + footprint: + environment: test + coupon_id: + paid_total: + invoice_id: +order_18: + id: 18 + statistic_profile_id: 9 + operator_profile_id: + token: KbSmmD_gi9w_CrpwtK9OwA1666687433963 + reference: '005902-<%= DateTime.current.utc.strftime('%m') %>-<%= DateTime.current.utc.strftime('%d') %>' + state: cart + total: 500 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: + footprint: + environment: test + coupon_id: + paid_total: + invoice_id: +order_19: + id: 19 + statistic_profile_id: + operator_profile_id: + token: 4bB96D-MlqJGBr5T8eui-g1666690417460 + reference: '005903-<%= DateTime.current.utc.strftime('%m') %>-<%= DateTime.current.utc.strftime('%d') %>' + state: cart + total: 261500 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: + footprint: + environment: test + coupon_id: + paid_total: + invoice_id: +order_20: + id: 20 + statistic_profile_id: + operator_profile_id: 1 + token: 0DKxbAOzSXRx-amXyhmDdg1666691976019 + reference: '005904-<%= DateTime.current.utc.strftime('%m') %>-<%= DateTime.current.utc.strftime('%d') %>' + state: cart + total: 262500 + created_at: <%= DateTime.current.utc.change({:hour => 10}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + updated_at: <%= DateTime.current.utc.change({:hour => 15}).strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> + wallet_amount: + wallet_transaction_id: + payment_method: + footprint: + environment: test + coupon_id: + paid_total: + invoice_id: diff --git a/test/fixtures/payment_gateway_objects.yml b/test/fixtures/payment_gateway_objects.yml index 29d5ea6b0..9546629a2 100644 --- a/test/fixtures/payment_gateway_objects.yml +++ b/test/fixtures/payment_gateway_objects.yml @@ -230,3 +230,46 @@ pgo33: item_type: PaymentSchedule item_id: 13 payment_gateway_object_id: 32 + +pgo34: + id: 34 + item_type: 'User' + item_id: 9 + gateway_object_type: 'Stripe::Customer' + gateway_object_id: 'cus_IhIynmoJbzLpwX' + +pgo35: + id: 35 + item_type: 'User' + item_id: 10 + gateway_object_type: 'Stripe::Customer' + gateway_object_id: 'cus_IhIynmoJbzLpwX' +pgo36: + id: 36 + gateway_object_id: pi_3LpALs2sOmf47Nz91QyFI7nO + gateway_object_type: Stripe::PaymentIntent + item_type: Invoice + item_id: 5816 + payment_gateway_object_id: +pgo37: + id: 37 + gateway_object_id: pi_3LpBa12sOmf47Nz91QFYVXVf + gateway_object_type: Stripe::PaymentIntent + item_type: Invoice + item_id: 5817 + payment_gateway_object_id: +pgo38: + id: 38 + gateway_object_id: pi_3LpBjD2sOmf47Nz90bOsclbx + gateway_object_type: Stripe::PaymentIntent + item_type: Invoice + item_id: 5818 + payment_gateway_object_id: +pgo39: + id: 39 + gateway_object_id: pi_3LpBwR2sOmf47Nz90EILNKvi + gateway_object_type: Stripe::PaymentIntent + item_type: Invoice + item_id: 5819 + payment_gateway_object_id: + diff --git a/test/fixtures/plans.yml b/test/fixtures/plans.yml index e6de5e3cb..254ef45b5 100644 --- a/test/fixtures/plans.yml +++ b/test/fixtures/plans.yml @@ -51,7 +51,7 @@ plan_3: type: Plan base_name: Mensuel tarif réduit ui_weight: 0 - interval_count: 1* + interval_count: 1 slug: mensuel-tarif-reduit plan_schedulable: @@ -72,3 +72,21 @@ plan_schedulable: interval_count: 1 monthly_payment: true slug: abonnement-mensualisable + +plan_5: + id: 5 + name: Abonnement crazy fabbers + amount: 1498 + interval: month + group_id: 2 + stp_plan_id: mensuel-tarif-reduit-student-month-20160404171827 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + training_credit_nb: 1 + is_rolling: + description: + type: Plan + base_name: Crazy fabbers + ui_weight: 1 + interval_count: 2 + slug: crazy-fabbers diff --git a/test/fixtures/prices.yml b/test/fixtures/prices.yml index 78781b2d6..79bff0440 100644 --- a/test/fixtures/prices.yml +++ b/test/fixtures/prices.yml @@ -460,3 +460,80 @@ price_54: duration: 60 created_at: 2016-04-04 15:18:28.860220000 Z updated_at: 2016-04-04 15:18:50.517702000 Z + +price_55: + id: 55 + group_id: 2 + plan_id: 5 + priceable_id: 1 + priceable_type: Machine + amount: 121 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_56: + id: 56 + group_id: 2 + plan_id: 5 + priceable_id: 2 + priceable_type: Machine + amount: 1116 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_57: + id: 57 + group_id: 2 + plan_id: 5 + priceable_id: 3 + priceable_type: Machine + amount: 1423 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_58: + id: 58 + group_id: 2 + plan_id: 5 + priceable_id: 4 + priceable_type: Machine + amount: 1238 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_59: + id: 59 + group_id: 2 + plan_id: 5 + priceable_id: 5 + priceable_type: Machine + amount: 724 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_60: + id: 60 + group_id: 2 + plan_id: 5 + priceable_id: 6 + priceable_type: Machine + amount: 666 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z + +price_61: + id: 61 + group_id: 2 + plan_id: 5 + priceable_id: 1 + priceable_type: Space + amount: 1373 + duration: 60 + created_at: 2022-10-26 17:08:21.681615000 Z + updated_at: 2022-10-26 17:08:21.681615000 Z diff --git a/test/fixtures/product_categories.yml b/test/fixtures/product_categories.yml new file mode 100644 index 000000000..8f58007f6 --- /dev/null +++ b/test/fixtures/product_categories.yml @@ -0,0 +1,49 @@ +product_category_1: + id: 1 + name: Consommables + slug: consommables + parent_id: + position: 0 + created_at: '2022-08-23 07:55:35.573968' + updated_at: '2022-08-23 07:55:35.573968' +product_category_2: + id: 2 + name: Fournitures + slug: fournitures + parent_id: + position: 1 + created_at: '2022-08-23 07:55:49.380035' + updated_at: '2022-10-05 11:42:52.965226' +product_category_3: + id: 3 + name: Filament imprimante 3D + slug: filament-imprimante-3d + parent_id: 1 + position: 0 + created_at: '2022-08-23 07:56:05.254962' + updated_at: '2022-08-23 07:56:05.254962' +product_category_4: + id: 4 + name: Objets créés au fablab + slug: objets-crees-au-fablab + parent_id: + position: 2 + created_at: '2022-09-14 13:39:03.170717' + updated_at: '2022-10-05 11:42:52.965226' +product_category_5: + id: 5 + name: Bois + slug: bois + parent_id: 1 + position: 1 + created_at: '2022-09-14 14:57:58.136610' + updated_at: '2022-09-14 14:57:58.136610' +product_category_6: + id: 6 + name: Stickers + slug: stickers + parent_id: 4 + position: 0 + created_at: '2022-09-28 08:12:35.823487' + updated_at: '2022-09-28 08:12:35.823487' + diff --git a/test/fixtures/product_stock_movements.yml b/test/fixtures/product_stock_movements.yml new file mode 100644 index 000000000..2a3b7fc9a --- /dev/null +++ b/test/fixtures/product_stock_movements.yml @@ -0,0 +1,275 @@ +product_stock_movement_26: + id: 26 + product_id: 3 + quantity: 1 + reason: inward_stock + stock_type: internal + remaining_stock: 1 + date: '2022-09-13 12:52:52.656974' + created_at: '2022-09-13 12:52:52.669247' + updated_at: '2022-09-13 12:52:52.669247' + order_item_id: +product_stock_movement_33: + id: 33 + product_id: 11 + quantity: 10 + reason: inward_stock + stock_type: external + remaining_stock: 10 + date: '2022-09-14 13:41:16.204940' + created_at: '2022-09-14 13:41:16.212290' + updated_at: '2022-09-14 13:41:16.212290' + order_item_id: +product_stock_movement_34: + id: 34 + product_id: 12 + quantity: 1 + reason: returned + stock_type: internal + remaining_stock: 1 + date: '2022-09-14 13:42:53.702489' + created_at: '2022-09-14 13:42:53.706634' + updated_at: '2022-09-14 13:42:53.706634' + order_item_id: +product_stock_movement_35: + id: 35 + product_id: 13 + quantity: 1000 + reason: inward_stock + stock_type: external + remaining_stock: 1000 + date: '2022-09-14 13:47:18.120615' + created_at: '2022-09-14 13:47:18.131919' + updated_at: '2022-09-14 13:47:18.131919' + order_item_id: +product_stock_movement_36: + id: 36 + product_id: 14 + quantity: 800 + reason: inward_stock + stock_type: external + remaining_stock: 800 + date: '2022-09-14 13:48:05.025884' + created_at: '2022-09-14 13:48:05.035926' + updated_at: '2022-09-14 13:48:05.035926' + order_item_id: +product_stock_movement_37: + id: 37 + product_id: 15 + quantity: 600 + reason: inward_stock + stock_type: external + remaining_stock: 600 + date: '2022-09-14 13:49:08.203980' + created_at: '2022-09-14 13:49:08.240896' + updated_at: '2022-09-14 13:49:08.240896' + order_item_id: +product_stock_movement_38: + id: 38 + product_id: 16 + quantity: 100 + reason: inward_stock + stock_type: external + remaining_stock: 100 + date: '2022-09-14 13:50:12.581463' + created_at: '2022-09-14 13:50:12.591335' + updated_at: '2022-09-14 13:50:12.591335' + order_item_id: +product_stock_movement_39: + id: 39 + product_id: 17 + quantity: 100 + reason: inward_stock + stock_type: external + remaining_stock: 100 + date: '2022-09-14 13:51:12.627746' + created_at: '2022-09-14 13:51:12.636692' + updated_at: '2022-09-14 13:51:12.636692' + order_item_id: +product_stock_movement_40: + id: 40 + product_id: 18 + quantity: 20 + reason: inward_stock + stock_type: external + remaining_stock: 20 + date: '2022-09-14 13:52:08.655537' + created_at: '2022-09-14 13:52:08.672419' + updated_at: '2022-09-14 13:52:08.672419' + order_item_id: +product_stock_movement_41: + id: 41 + product_id: 18 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 19 + date: '2022-09-20 15:14:22.629666' + created_at: '2022-09-20 15:14:22.651877' + updated_at: '2022-09-20 15:14:22.651877' + order_item_id: 1 +product_stock_movement_42: + id: 42 + product_id: 13 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 999 + date: '2022-09-20 15:14:22.688402' + created_at: '2022-09-20 15:14:22.692461' + updated_at: '2022-09-20 15:14:22.692461' + order_item_id: 2 +product_stock_movement_43: + id: 43 + product_id: 15 + quantity: -4 + reason: sold + stock_type: external + remaining_stock: 596 + date: '2022-09-20 15:14:48.330513' + created_at: '2022-09-20 15:14:48.334360' + updated_at: '2022-09-20 15:14:48.334360' + order_item_id: 3 +product_stock_movement_56: + id: 56 + product_id: 22 + quantity: 1000 + reason: inward_stock + stock_type: external + remaining_stock: 1000 + date: '2022-10-03 14:00:49.950506' + created_at: '2022-10-03 14:00:50.107429' + updated_at: '2022-10-03 14:00:50.107429' + order_item_id: +product_stock_movement_57: + id: 57 + product_id: 22 + quantity: 1000 + reason: inward_stock + stock_type: internal + remaining_stock: 1000 + date: '2022-10-03 14:00:49.950569' + created_at: '2022-10-03 14:00:50.136314' + updated_at: '2022-10-03 14:00:50.136314' + order_item_id: +product_stock_movement_58: + id: 58 + product_id: 23 + quantity: 1000 + reason: inward_stock + stock_type: internal + remaining_stock: 1000 + date: '2022-10-03 14:02:00.518281' + created_at: '2022-10-03 14:02:00.524640' + updated_at: '2022-10-03 14:02:00.524640' + order_item_id: +product_stock_movement_59: + id: 59 + product_id: 23 + quantity: 1000 + reason: inward_stock + stock_type: external + remaining_stock: 1000 + date: '2022-10-03 14:02:00.518337' + created_at: '2022-10-03 14:02:00.526611' + updated_at: '2022-10-03 14:02:00.526611' + order_item_id: +product_stock_movement_61: + id: 61 + product_id: 23 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 999 + date: '2022-10-04 12:36:02.765304' + created_at: '2022-10-04 12:36:02.788575' + updated_at: '2022-10-04 12:36:02.788575' + order_item_id: 16 +product_stock_movement_62: + id: 62 + product_id: 22 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 999 + date: '2022-10-04 12:36:02.827107' + created_at: '2022-10-04 12:36:02.832197' + updated_at: '2022-10-04 12:36:02.832197' + order_item_id: 17 +product_stock_movement_63: + id: 63 + product_id: 23 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 998 + date: '2022-10-04 13:54:42.768616' + created_at: '2022-10-04 13:54:42.783563' + updated_at: '2022-10-04 13:54:42.783563' + order_item_id: 18 +product_stock_movement_64: + id: 64 + product_id: 15 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 595 + date: '2022-10-04 13:54:42.785294' + created_at: '2022-10-04 13:54:42.788610' + updated_at: '2022-10-04 13:54:42.788610' + order_item_id: 19 +product_stock_movement_65: + id: 65 + product_id: 11 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 9 + date: '2022-10-04 14:04:12.723429' + created_at: '2022-10-04 14:04:12.729897' + updated_at: '2022-10-04 14:04:12.729897' + order_item_id: 20 +product_stock_movement_67: + id: 67 + product_id: 18 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 18 + date: '2022-10-04 14:17:52.842942' + created_at: '2022-10-04 14:17:52.846168' + updated_at: '2022-10-04 14:17:52.846168' + order_item_id: 22 +product_stock_movement_68: + id: 68 + product_id: 18 + quantity: -3 + reason: sold + stock_type: external + remaining_stock: 15 + date: '2022-10-04 14:25:37.280032' + created_at: '2022-10-04 14:25:37.283491' + updated_at: '2022-10-04 14:25:37.283491' + order_item_id: 11 +product_stock_movement_69: + id: 69 + product_id: 18 + quantity: -3 + reason: sold + stock_type: external + remaining_stock: 12 + date: '2022-10-04 14:32:28.002830' + created_at: '2022-10-04 14:32:28.023296' + updated_at: '2022-10-04 14:32:28.023296' + order_item_id: 23 +product_stock_movement_70: + id: 70 + product_id: 17 + quantity: -1 + reason: sold + stock_type: external + remaining_stock: 99 + date: '2022-10-04 14:35:40.556113' + created_at: '2022-10-04 14:35:40.560251' + updated_at: '2022-10-04 14:35:40.560251' + order_item_id: 24 diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml new file mode 100644 index 000000000..31354d803 --- /dev/null +++ b/test/fixtures/products.yml @@ -0,0 +1,206 @@ +product_3: + id: 3 + name: Caisse en bois + slug: caisse-en-bois + sku: '1694813216840' + description: "

    C'est un superbe caisse en bois massif

    " + is_active: true + product_category_id: 4 + amount: 52300 + quantity_min: 5 + stock: + external: 100 + internal: 1 + low_stock_alert: true + low_stock_threshold: 10 + created_at: '2022-09-13 10:05:07.010322' + updated_at: '2022-09-14 13:39:55.309956' +product_11: + id: 11 + name: Bulldozer + slug: bulldozer + sku: '816516515' + description: "

    Jouet pour enfants

    " + is_active: true + product_category_id: 4 + amount: 1000 + quantity_min: 1 + stock: + external: 40 + internal: 1 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:41:16.207154' + updated_at: '2022-10-04 14:04:12.727452' +product_12: + id: 12 + name: Casque à pointe + slug: casque-a-pointe + sku: '10210706' + description: "

    Authentique casque à pointe allemand de la 1ère guerre mondiale

    " + is_active: true + product_category_id: 4 + amount: 142300 + quantity_min: 1 + stock: + external: 0 + internal: 1 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:42:36.729625' + updated_at: '2022-09-14 13:42:53.704455' +product_13: + id: 13 + name: Panneaux de MDF + slug: panneaux-de-mdf + sku: 3-8612 + description: '' + is_active: true + product_category_id: 5 + amount: 500 + quantity_min: 1 + stock: + external: 999 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:47:18.123531' + updated_at: '2022-09-20 15:14:22.690742' +product_14: + id: 14 + name: Panneau de mélaminé + slug: panneau-de-melamine + sku: 12-4512 + description: '' + is_active: true + product_category_id: 5 + amount: 1000 + quantity_min: 1 + stock: + external: 800 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:48:05.028266' + updated_at: '2022-09-14 14:58:28.577253' +product_15: + id: 15 + name: Panneau de contre-plaqué + slug: panneau-de-contre-plaque + sku: 12-4613 + description: '' + is_active: true + product_category_id: 5 + amount: 1500 + quantity_min: 1 + stock: + external: 595 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:49:08.233064' + updated_at: '2022-10-04 13:54:42.787290' +product_16: + id: 16 + name: Panneau d'OSB + slug: panneau-osb + sku: 14-14488 + description: '' + is_active: true + product_category_id: 5 + amount: 1300 + quantity_min: 1 + stock: + external: 500 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:50:12.584041' + updated_at: '2022-09-14 14:58:38.374112' +product_17: + id: 17 + name: Panneau de 3 plis mélèze + slug: panneau-de-3-plis-meleze + sku: 14-47887 + description: '' + is_active: true + product_category_id: 5 + amount: 3000 + quantity_min: 1 + stock: + external: 99 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:51:12.630109' + updated_at: '2022-10-04 14:35:40.558485' +product_18: + id: 18 + name: Tablette lamellé-collé + slug: tablette-lamelle-colle + sku: 14-879895 + description: '' + is_active: true + product_category_id: 5 + amount: 4000 + quantity_min: 1 + stock: + external: 12 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-14 13:52:08.659248' + updated_at: '2022-10-04 14:32:28.021046' +product_20: + id: 20 + name: Sticker Hello + slug: sticker-hello + sku: 63-44801 + description:

    un joli sticker en forme de smiley qui dit bonjour

    + is_active: false + product_category_id: 6 + amount: 1 + quantity_min: 1 + stock: + external: 15 + internal: 0 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-09-28 08:16:32.348880' + updated_at: '2022-10-04 14:17:52.839249' +product_22: + id: 22 + name: Filament PLA bleu + slug: filament-pla-bleu + sku: 984-777 + description:

    Filament + PLA 1,75 mm de couleur bleue

    + is_active: true + product_category_id: 3 + amount: 200 + quantity_min: 1 + stock: + external: 999 + internal: 1000 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-10-03 14:00:49.967342' + updated_at: '2022-10-04 12:36:02.830248' +product_23: + id: 23 + name: Filament PLA blanc + slug: filament-pla-blanc + sku: 178-774 + description: "

    Filament PLA 1,75 mm blanc

    " + is_active: true + product_category_id: 3 + amount: 119 + quantity_min: 1 + stock: + external: 998 + internal: 1000 + low_stock_alert: false + low_stock_threshold: + created_at: '2022-10-03 14:01:43.706366' + updated_at: '2022-10-04 13:54:42.782174' diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml index ff21c31e7..9ee963d75 100644 --- a/test/fixtures/settings.yml +++ b/test/fixtures/settings.yml @@ -502,3 +502,27 @@ setting_85: name: public_agenda_module created_at: 2020-04-15 14:38:40.000421500 Z updated_at: 2020-04-15 14:38:40.000421500 Z + +setting_86: + id: 86 + name: accounting_Pack_label + created_at: 2022-10-26 12:46:16.125400000 Z + updated_at: 2022-10-26 12:46:16.125400000 Z + +setting_87: + id: 87 + name: accounting_Pack_code + created_at: 2022-10-26 12:46:16.125400000 Z + updated_at: 2022-10-26 12:46:16.125400000 Z + +setting_88: + id: 88 + name: accounting_Product_label + created_at: 2022-10-26 12:46:16.125400000 Z + updated_at: 2022-10-26 12:46:16.125400000 Z + +setting_89: + id: 89 + name: accounting_Product_code + created_at: 2022-10-26 12:46:16.125400000 Z + updated_at: 2022-10-26 12:46:16.125400000 Z diff --git a/test/fixtures/statistic_profiles.yml b/test/fixtures/statistic_profiles.yml index 1cb959067..b63b746f0 100644 --- a/test/fixtures/statistic_profiles.yml +++ b/test/fixtures/statistic_profiles.yml @@ -3,7 +3,7 @@ admin: user_id: 1 gender: true birthday: 2016-04-04 - group_id: 3 + group_id: 1 jdupont: id: 2 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index c98eaebec..dc1a21bc7 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -21,7 +21,7 @@ user_1: created_at: 2001-01-01 14:11:33.852719000 Z updated_at: 2016-04-05 08:36:08.362215000 Z is_allow_contact: true - group_id: 3 + group_id: 1 slug: admin is_active: true provider: diff --git a/test/fixtures/users_roles.yml b/test/fixtures/users_roles.yml index 291b7625b..19f33c177 100644 --- a/test/fixtures/users_roles.yml +++ b/test/fixtures/users_roles.yml @@ -27,3 +27,18 @@ users_role_6: user_id: 6 role_id: 4 +users_role_7: + user_id: 7 + role_id: 2 + +users_role_8: + user_id: 8 + role_id: 2 + +users_role_9: + user_id: 9 + role_id: 2 + +users_role_10: + user_id: 10 + role_id: 2 diff --git a/test/helpers/invoice_helper.rb b/test/helpers/invoice_helper.rb index 0faba67e5..829f52c31 100644 --- a/test/helpers/invoice_helper.rb +++ b/test/helpers/invoice_helper.rb @@ -35,7 +35,7 @@ module InvoiceHelper # Line of text should be of form 'Label $10.00' # @returns {float} def parse_amount_from_invoice_line(line) - line[line.rindex(' ') + 1..].tr(I18n.t('number.currency.format.unit'), '').to_f + line[line.rindex(' ') + 1..].tr(I18n.t('number.currency.format.unit'), '').gsub(/[$,]/, '').to_f end # check VAT and total excluding taxes diff --git a/test/integration/exports/accounting_export_test.rb b/test/integration/exports/accounting_export_test.rb index 52bde6eed..edb9d06e1 100644 --- a/test/integration/exports/accounting_export_test.rb +++ b/test/integration/exports/accounting_export_test.rb @@ -36,7 +36,7 @@ class Exports::AccountingExportTest < ActionDispatch::IntegrationTest # Check the export was created correctly res = json_response(response.body) - e = Export.where(id: res[:export_id]).first + e = Export.find(res[:export_id]) assert_not_nil e, 'Export was not created in database' # Run the worker @@ -52,90 +52,135 @@ class Exports::AccountingExportTest < ActionDispatch::IntegrationTest data = CSV.read(e.file, headers: true, col_sep: e.key) # test values - # first line = client line - journal_code = Setting.get('accounting_journal_code') - assert_equal journal_code, data[0][I18n.t('accounting_export.journal_code')], 'Wrong journal code' - first_invoice = Invoice.first - entry_date = first_invoice.created_at.to_date - assert_equal entry_date, DateTime.parse(data[0][I18n.t('accounting_export.date')]), 'Wrong date' - - if first_invoice.paid_by_card? - card_client_code = Setting.get('accounting_card_client_code') - assert_equal card_client_code, data[0][I18n.t('accounting_export.account_code')], 'Account code for card client is wrong' - - card_client_label = Setting.get('accounting_card_client_label') - assert_equal card_client_label, data[0][I18n.t('accounting_export.account_label')], 'Account label for card client is wrong' - else - warn "WARNING: unable to test accurately accounting export: invoice #{first_invoice.id} was not paid by card" - end - - assert_equal first_invoice.reference, data[0][I18n.t('accounting_export.piece')], 'Piece (invoice reference) is wrong' - - if first_invoice.subscription_invoice? - assert_match I18n.t('accounting_export.subscription'), - data[0][I18n.t('accounting_export.line_label')], - 'Line label does not contains the reference to the invoiced item' - else - warn "WARNING: unable to test accurately accounting export: invoice #{first_invoice.id} does not have a subscription" - end - - if first_invoice.wallet_transaction_id.nil? - assert_equal first_invoice.total / 100.00, data[0][I18n.t('accounting_export.debit_origin')].to_f, 'Origin debit amount does not match' - assert_equal first_invoice.total / 100.00, data[0][I18n.t('accounting_export.debit_euro')].to_f, 'Euro debit amount does not match' - else - warn "WARNING: unable to test accurately accounting export: invoice #{first_invoice.id} is using wallet" - end - - assert_equal 0, data[0][I18n.t('accounting_export.credit_origin')].to_f, 'Credit origin amount does not match' - assert_equal 0, data[0][I18n.t('accounting_export.credit_euro')].to_f, 'Credit euro amount does not match' - + # first line = client line + check_client_line(first_invoice, data[0]) # second line = sold item line - assert_equal journal_code, data[1][I18n.t('accounting_export.journal_code')], 'Wrong journal code' - assert_equal entry_date, DateTime.parse(data[1][I18n.t('accounting_export.date')]), 'Wrong date' + check_item_line(first_invoice, first_invoice.invoice_items.first, data[1]) - if first_invoice.subscription_invoice? - subscription_code = Setting.get('accounting_subscription_code') - assert_equal subscription_code, data[1][I18n.t('accounting_export.account_code')], 'Account code for subscription is wrong' + # ensure invoice 4 is not exported (0€ invoice) + zero_invoice = Invoice.find(4) + assert_nil(data.map { |line| line[I18n.t('accounting_export.piece')] }.find { |document| document == zero_invoice.reference }, + 'Invoice at 0 should not be exported') - subscription_label = Setting.get('accounting_subscription_label') - assert_equal subscription_label, data[1][I18n.t('accounting_export.account_label')], 'Account label for subscription is wrong' - end - - assert_equal first_invoice.reference, data[1][I18n.t('accounting_export.piece')], 'Piece (invoice reference) is wrong' - assert_match I18n.t('accounting_export.subscription'), - data[1][I18n.t('accounting_export.line_label')], - 'Line label should be empty for non client lines' - - item = first_invoice.invoice_items.first - assert_equal item.amount / 100.00, data[1][I18n.t('accounting_export.credit_origin')].to_f, 'Origin credit amount does not match' - assert_equal item.amount / 100.00, data[1][I18n.t('accounting_export.credit_euro')].to_f, 'Euro credit amount does not match' - - assert_equal 0, data[1][I18n.t('accounting_export.debit_origin')].to_f, 'Debit origin amount does not match' - assert_equal 0, data[1][I18n.t('accounting_export.debit_euro')].to_f, 'Debit euro amount does not match' - - # test with another invoice + # test with a reservation invoice machine_invoice = Invoice.find(5) - client_row = data[data.length - 4] - item_row = data[data.length - 3] + check_client_line(machine_invoice, data[6]) + check_item_line(machine_invoice, machine_invoice.invoice_items.first, data[7]) - if machine_invoice.main_item.object_type == 'Reservation' && machine_invoice.main_item.object.reservable_type == 'Machine' - assert_match I18n.t('accounting_export.Machine_reservation'), - client_row[I18n.t('accounting_export.line_label')], - 'Line label does not contains the reference to the invoiced item' - - machine_code = Setting.get('accounting_Machine_code') - assert_equal machine_code, item_row[I18n.t('accounting_export.account_code')], 'Account code for machine reservation is wrong' - - machine_label = Setting.get('accounting_Machine_label') - assert_equal machine_label, item_row[I18n.t('accounting_export.account_label')], 'Account label for machine reservation is wrong' - - else - warn "WARNING: unable to test accurately accounting export: invoice #{machine_invoice.id} is not a Machine reservation" - end + # test with a shop order invoice (local payment) + shop_invoice = Invoice.find(5811) + check_client_line(shop_invoice, data[10]) + check_item_line(shop_invoice, shop_invoice.invoice_items.first, data[11]) + check_item_line(shop_invoice, shop_invoice.invoice_items.last, data[12]) # Clean CSV file require 'fileutils' FileUtils.rm(e.file) end + + def check_client_line(invoice, client_line) + check_journal_code(client_line) + check_entry_date(invoice, client_line) + check_client_accounts(invoice, client_line) + check_entry_label(invoice, client_line) + check_document(invoice, client_line) + + if invoice.wallet_transaction_id.nil? + assert_equal invoice.total / 100.00, client_line[I18n.t('accounting_export.debit_origin')].to_f, + 'Origin debit amount does not match' + assert_equal invoice.total / 100.00, client_line[I18n.t('accounting_export.debit_euro')].to_f, 'Euro debit amount does not match' + else + warn "WARNING: unable to test accurately accounting export: invoice #{invoice.id} is using wallet" + end + + assert_equal 0, client_line[I18n.t('accounting_export.credit_origin')].to_f, 'Credit origin amount does not match' + assert_equal 0, client_line[I18n.t('accounting_export.credit_euro')].to_f, 'Credit euro amount does not match' + end + + def check_item_line(invoice, invoice_item, item_line) + check_journal_code(item_line) + check_entry_date(invoice, item_line) + + check_subscription_accounts(invoice, item_line) + check_reservation_accounts(invoice, item_line) + check_document(invoice, item_line) + check_entry_label(invoice, item_line) + + assert_equal invoice_item.amount / 100.00, item_line[I18n.t('accounting_export.credit_origin')].to_f, + 'Origin credit amount does not match' + assert_equal invoice_item.amount / 100.00, item_line[I18n.t('accounting_export.credit_euro')].to_f, 'Euro credit amount does not match' + + assert_equal 0, item_line[I18n.t('accounting_export.debit_origin')].to_f, 'Debit origin amount does not match' + assert_equal 0, item_line[I18n.t('accounting_export.debit_euro')].to_f, 'Debit euro amount does not match' + end + + def check_journal_code(line) + journal_code = Setting.get('accounting_journal_code') + assert_equal journal_code, line[I18n.t('accounting_export.journal_code')], 'Wrong journal code' + end + + def check_entry_date(invoice, line) + entry_date = invoice.created_at.to_date + assert_equal entry_date, DateTime.parse(line[I18n.t('accounting_export.date')]), 'Wrong date' + end + + def check_client_accounts(invoice, client_line) + if invoice.wallet_transaction && invoice.wallet_amount.positive? + wallet_client_code = Setting.get('accounting_wallet_client_code') + assert_equal wallet_client_code, client_line[I18n.t('accounting_export.account_code')], 'Account code for wallet client is wrong' + + wallet_client_label = Setting.get('accounting_wallet_client_label') + assert_equal wallet_client_label, client_line[I18n.t('accounting_export.account_label')], 'Account label for wallet client is wrong' + end + mean = invoice.paid_by_card? ? 'card' : 'other' + + client_code = Setting.get("accounting_#{mean}_client_code") + assert_equal client_code, client_line[I18n.t('accounting_export.account_code')], 'Account code for client is wrong' + + client_label = Setting.get("accounting_#{mean}_client_label") + assert_equal client_label, client_line[I18n.t('accounting_export.account_label')], 'Account label for client is wrong' + end + + def check_subscription_accounts(invoice, item_line) + return unless invoice.subscription_invoice? + + subscription_code = Setting.get('accounting_subscription_code') + assert_equal subscription_code, item_line[I18n.t('accounting_export.account_code')], 'Account code for subscription is wrong' + + subscription_label = Setting.get('accounting_subscription_label') + assert_equal subscription_label, item_line[I18n.t('accounting_export.account_label')], 'Account label for subscription is wrong' + end + + def check_reservation_accounts(invoice, item_line) + return unless invoice.main_item.object_type == 'Reservation' + + code = Setting.get("accounting_#{invoice.main_item.object.reservable_type}_code") + assert_equal code, item_line[I18n.t('accounting_export.account_code')], 'Account code for reservation is wrong' + + label = Setting.get("accounting_#{invoice.main_item.object.reservable_type}_label") + assert_equal label, item_line[I18n.t('accounting_export.account_label')], 'Account label for reservation is wrong' + end + + def check_document(invoice, line) + assert_equal(invoice.reference, line[I18n.t('accounting_export.piece')], 'Document (invoice reference) is wrong') + end + + def check_entry_label(invoice, line) + if invoice.subscription_invoice? + assert_match I18n.t('accounting_export.subscription'), + line[I18n.t('accounting_export.line_label')], + 'Entry label does not contains the reference to the subscription' + end + if invoice.main_item.object_type == 'Reservation' + assert_match I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation"), + line[I18n.t('accounting_export.line_label')], + 'Entry label does not contains the reference to the reservation' + end + return unless invoice.main_item.object_type == 'WalletTransaction' + + assert_match I18n.t('accounting_export.wallet'), + line[I18n.t('accounting_export.line_label')], + 'Entry label does not contains the reference to the wallet' + end end diff --git a/test/integration/invoices/round_test.rb b/test/integration/invoices/round_test.rb new file mode 100644 index 000000000..6f3f6d166 --- /dev/null +++ b/test/integration/invoices/round_test.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Invoices; end + +class Invoices::RoundTest < ActionDispatch::IntegrationTest + def setup + @vlonchamp = User.find_by(username: 'vlonchamp') + @admin = User.find_by(username: 'admin') + login_as(@admin, scope: :user) + end + + test 'invoice using percent coupon rounded up' do + machine = Machine.first + availability = machine.availabilities.first + plan = Plan.find(5) + + # enable the VAT + Setting.set('invoice_VAT-active', true) + Setting.set('invoice_VAT-rate', 20) + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + coupon_code: 'REDUC20', + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + + # in the invoice, we should have: + # - machine reservation = 121 (97, coupon applied) + # - subscription = 1498 (1198, coupon applied) + ### intermediate total = 1619 + # - coupon (20%) = -323.8 => round to 324 + ### total incl. taxes = 1295 + # - vat = 216 + # - total exct. taxes = 1079 + ### amount paid = 1295 + + invoice = Invoice.last + assert_equal 121, invoice.main_item.amount + assert_equal 1498, invoice.other_items.last.amount + assert_equal 1295, invoice.total + + coupon_service = CouponService.new + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + assert_equal 97, coupon_service.ventilate(total_without_coupon, invoice.main_item.amount, invoice.coupon) + assert_equal 1198, coupon_service.ventilate(total_without_coupon, invoice.other_items.last.amount, invoice.coupon) + assert_equal 324, total_without_coupon - invoice.total + + vat_service = VatHistoryService.new + vat_rate_groups = vat_service.invoice_vat(invoice) + assert_equal 216, vat_rate_groups.values.pluck(:total_vat).reduce(:+) + assert_equal 1079, invoice.invoice_items.map(&:net_amount).reduce(:+) + end + + test 'invoice using percent coupon rounded down' do + machine = Machine.find(3) + availability = machine.availabilities.first + plan = Plan.find(5) + + # enable the VAT + Setting.set('invoice_VAT-active', true) + Setting.set('invoice_VAT-rate', 20) + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + coupon_code: 'REDUC20', + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + + # in the invoice, we should have: + # - machine reservation = 1423 (1138, coupon applied) + # - subscription = 1498 (1198, coupon applied) + ### intermediate total = 2921 + # - coupon (20%) = -584.2 => round to 585 + ### total incl. taxes = 2336 + # - vat = 390 + # - total exct. taxes = 1946 + ### amount paid = 2336 + + invoice = Invoice.last + assert_equal 1423, invoice.main_item.amount + assert_equal 1498, invoice.other_items.last.amount + assert_equal 2336, invoice.total + + coupon_service = CouponService.new + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + assert_equal 1138, coupon_service.ventilate(total_without_coupon, invoice.main_item.amount, invoice.coupon) + assert_equal 1198, coupon_service.ventilate(total_without_coupon, invoice.other_items.last.amount, invoice.coupon) + assert_equal 585, total_without_coupon - invoice.total + + vat_service = VatHistoryService.new + vat_rate_groups = vat_service.invoice_vat(invoice) + assert_equal 390, vat_rate_groups.values.pluck(:total_vat).reduce(:+) + assert_equal 1946, invoice.invoice_items.map(&:net_amount).reduce(:+) + end + + test 'invoice using amount coupon rounded up' do + machine = Machine.first + availability = machine.availabilities.first + plan = Plan.find(5) + + # enable the VAT + Setting.set('invoice_VAT-active', true) + Setting.set('invoice_VAT-rate', 19.6) + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + coupon_code: 'GIME3EUR', + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + + # in the invoice, we should have: + # - machine reservation = 121 (99, coupon applied) + # - subscription = 1498 (1220, coupon applied) + ### intermediate total = 1619 + # - coupon (20%) = -300 + ### total incl. taxes = 1319 + # - vat = 216 + # - total exct. taxes = 1103 + ### amount paid = 1319 + + invoice = Invoice.last + assert_equal 121, invoice.main_item.amount + assert_equal 1498, invoice.other_items.last.amount + assert_equal 1319, invoice.total + + coupon_service = CouponService.new + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + assert_equal 99, coupon_service.ventilate(total_without_coupon, invoice.main_item.amount, invoice.coupon) + assert_equal 1220, coupon_service.ventilate(total_without_coupon, invoice.other_items.last.amount, invoice.coupon) + assert_equal 300, total_without_coupon - invoice.total + + vat_service = VatHistoryService.new + vat_rate_groups = vat_service.invoice_vat(invoice) + assert_equal 216, vat_rate_groups.values.pluck(:total_vat).reduce(:+) + assert_equal 1103, invoice.invoice_items.map(&:net_amount).reduce(:+) + end + + test 'invoice using amount coupon rounded down' do + machine = Machine.find(3) + availability = machine.availabilities.first + plan = Plan.find(5) + + # enable the VAT + Setting.set('invoice_VAT-active', true) + Setting.set('invoice_VAT-rate', 20) + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + coupon_code: 'GIME3EUR', + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + + # in the invoice, we should have: + # - machine reservation = 1423 (1277, coupon applied) + # - subscription = 1498 (1344, coupon applied) + ### intermediate total = 2921 + # - coupon (20%) = -300 + ### total incl. taxes = 2621 + # - vat = 430 + # - total exct. taxes = 2191 + ### amount paid = 2621 + + invoice = Invoice.last + assert_equal 1423, invoice.main_item.amount + assert_equal 1498, invoice.other_items.last.amount + assert_equal 2621, invoice.total + + coupon_service = CouponService.new + total_without_coupon = coupon_service.invoice_total_no_coupon(invoice) + assert_equal 1277, coupon_service.ventilate(total_without_coupon, invoice.main_item.amount, invoice.coupon) + assert_equal 1344, coupon_service.ventilate(total_without_coupon, invoice.other_items.last.amount, invoice.coupon) + assert_equal 300, total_without_coupon - invoice.total + + vat_service = VatHistoryService.new + vat_rate_groups = vat_service.invoice_vat(invoice) + assert_equal 437, vat_rate_groups.values.pluck(:total_vat).reduce(:+) + assert_equal 2184, invoice.invoice_items.map(&:net_amount).reduce(:+) + end +end diff --git a/test/integration/reservations/create_as_admin_test.rb b/test/integration/reservations/create_as_admin_test.rb deleted file mode 100644 index 25653b6be..000000000 --- a/test/integration/reservations/create_as_admin_test.rb +++ /dev/null @@ -1,581 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -module Reservations; end - -class Reservations::CreateAsAdminTest < ActionDispatch::IntegrationTest - setup do - @user_without_subscription = User.members.without_subscription.first - @user_with_subscription = User.members.with_subscription.second - @admin = User.with_role(:admin).first - login_as(@admin, scope: :user) - end - - test 'user without subscription reserves a machine with success' do - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @user_without_subscription.id, - items: [ - { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.original_invoice - assert_equal 1, reservation.original_invoice.invoice_items.count - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user without subscription reserves a training with success' do - training = Training.first - availability = training.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @user_without_subscription.id, - items: [ - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.original_invoice - assert_equal 1, reservation.original_invoice.invoice_items.count - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - # invoice_items - invoice_item = InvoiceItem.last - - assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user with subscription reserves a machine with success' do - plan = @user_with_subscription.subscribed_plan - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @user_with_subscription.id, - items: [ - { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - }, - { - slot_id: availability.slots.last.id - } - ] - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - - # subscription assertions - assert_equal 1, @user_with_subscription.subscriptions.count - assert_not_nil @user_with_subscription.subscribed_plan - assert_equal plan.id, @user_with_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.original_invoice - assert_equal 2, reservation.original_invoice.invoice_items.count - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_items = InvoiceItem.last(2) - machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount - - assert(invoice_items.any? { |ii| ii.amount.zero? }) - assert(invoice_items.any? { |ii| ii.amount == machine_price }) - - # users_credits assertions - users_credit = UsersCredit.last - - assert_equal @user_with_subscription, users_credit.user - assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user without subscription reserves a machine and pay by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @vlonchamp.id, - items: [ - { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.original_invoice - assert_equal 1, reservation.original_invoice.invoice_items.count - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal @vlonchamp.wallet.amount, 0 - assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal transaction.transaction_type, 'debit' - assert_equal transaction.amount, 10 - assert_equal transaction.amount, invoice.wallet_amount / 100.0 - assert_equal transaction.id, invoice.wallet_transaction_id - end - - test 'user reserves a machine and a subscription pay by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first - plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - wallet_transactions_count = WalletTransaction.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @vlonchamp.id, - items: [ - { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - }, - { - subscription: { - plan_id: plan.id - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - assert_equal wallet_transactions_count + 1, WalletTransaction.count - - # subscription assertions - assert_equal 1, @vlonchamp.subscriptions.count - assert_not_nil @vlonchamp.subscribed_plan - assert_equal plan.id, @vlonchamp.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.original_invoice - assert_equal 2, reservation.original_invoice.invoice_items.count - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - assert_equal invoice.total, 2000 - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal @vlonchamp.wallet.amount, 0 - assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal transaction.transaction_type, 'debit' - assert_equal transaction.amount, 10 - assert_equal transaction.amount, invoice.wallet_amount / 100.0 - assert_equal transaction.id, invoice.wallet_transaction_id - end - - test 'user without subscription reserves a machine and pay wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @vlonchamp.id, - items: [ - { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @vlonchamp.subscriptions.count - assert_nil @vlonchamp.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert_not_nil reservation.original_invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user reserves a training and a subscription with success' do - training = Training.first - availability = training.availabilities.first - plan = Plan.where(group_id: @user_without_subscription.group.id, type: 'Plan').first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post '/api/local_payment/confirm_payment', params: { - customer_id: @user_without_subscription.id, - items: [ - { - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id, - offered: false - } - ] - } - }, - { - subscription: { - plan_id: plan.id - } - } - ] - }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal Mime[:json], response.content_type - result = json_response(response.body) - - # Check the DB objects have been created as they should - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - - # subscription assertions - assert_equal 1, @user_without_subscription.subscriptions.count - assert_not_nil @user_without_subscription.subscribed_plan - assert_equal plan.id, @user_without_subscription.subscribed_plan.id - - # reservation assertions - invoice = Invoice.find(result[:id]) - reservation = invoice.main_item.object - - assert reservation.original_invoice - assert_equal 2, reservation.original_invoice.invoice_items.count - - # credits assertions - assert_equal 1, @user_without_subscription.credits.count - assert_equal 'Training', @user_without_subscription.credits.last.creditable_type - assert_equal training.id, @user_without_subscription.credits.last.creditable_id - - # invoice assertions - invoice = reservation.original_invoice - - assert invoice.payment_gateway_object.blank? - refute invoice.total.blank? - assert_equal plan.amount, invoice.total - - # invoice_items - invoice_items = InvoiceItem.last(2) - - assert(invoice_items.any? { |ii| ii.amount == plan.amount && ii.object_type == Subscription.name }) - assert(invoice_items.any? { |ii| ii.amount.zero? }) - - # invoice assertions - item = InvoiceItem.find_by(object: reservation) - invoice = item.invoice - assert_invoice_pdf invoice - assert_not_nil invoice.debug_footprint - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user reserves a training and a subscription with payment schedule' do - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - subscriptions_count = Subscription.count - users_credit_count = UsersCredit.count - payment_schedule_count = PaymentSchedule.count - payment_schedule_items_count = PaymentScheduleItem.count - - training = Training.find(1) - availability = training.availabilities.first - plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') - - VCR.use_cassette('reservations_admin_training_subscription_with_payment_schedule') do - post '/api/local_payment/confirm_payment', params: { - payment_method: 'check', - payment_schedule: true, - customer_id: @user_without_subscription.id, - items: [ - { - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - slots_reservations_attributes: [ - { - slot_id: availability.slots.first.id - } - ] - } - }, - { - subscription: { - plan_id: plan.id - } - } - ] - }.to_json, headers: default_headers - end - - # get the objects - reservation = Reservation.last - payment_schedule = PaymentSchedule.last - - # Check response format & status - assert_equal 201, response.status, response.body - assert_equal Mime[:json], response.content_type - assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' - assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" - assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" - assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" - assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' - assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' - assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' - - # subscription assertions - assert_equal 1, @user_without_subscription.subscriptions.count - assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" - assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" - assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" - - # payment schedule assertions - assert reservation.original_payment_schedule - assert_equal payment_schedule.id, reservation.original_payment_schedule.id - assert_not_nil payment_schedule.reference - assert_equal 'check', payment_schedule.payment_method - assert_empty payment_schedule.payment_gateway_objects - assert_nil payment_schedule.wallet_transaction - assert_nil payment_schedule.wallet_amount - assert_nil payment_schedule.coupon_id - assert_equal 'test', payment_schedule.environment - assert payment_schedule.check_footprint - assert_equal @user_without_subscription.invoicing_profile.id, payment_schedule.invoicing_profile_id - assert_equal @admin.invoicing_profile.id, payment_schedule.operator_profile_id - - # Check the answer - result = json_response(response.body) - assert_equal reservation.original_payment_schedule.id, result[:id], 'payment schedule id does not match' - - # reservation assertions - assert_equal result[:main_object][:id], reservation.id - assert_equal payment_schedule.main_object.object, reservation - end -end diff --git a/test/integration/reservations/local_payment_test.rb b/test/integration/reservations/local_payment_test.rb new file mode 100644 index 000000000..44b017821 --- /dev/null +++ b/test/integration/reservations/local_payment_test.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reservations; end + +class Reservations::LocalPaymentTest < ActionDispatch::IntegrationTest + setup do + @user_without_subscription = User.members.without_subscription.first + @user_with_subscription = User.members.with_subscription.second + @admin = User.with_role(:admin).first + login_as(@admin, scope: :user) + end + + test 'user without subscription reserves a machine with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @user_without_subscription.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 1, reservation.original_invoice.invoice_items.count + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user without subscription reserves a training with success' do + training = Training.first + availability = training.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @user_without_subscription.id, + items: [ + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 1, reservation.original_invoice.invoice_items.count + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + # invoice_items + invoice_item = InvoiceItem.last + + assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user with subscription reserves a machine with success' do + plan = @user_with_subscription.subscribed_plan + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @user_with_subscription.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + }, + { + slot_id: availability.slots.last.id + } + ] + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + + # subscription assertions + assert_equal 1, @user_with_subscription.subscriptions.count + assert_not_nil @user_with_subscription.subscribed_plan + assert_equal plan.id, @user_with_subscription.subscribed_plan.id + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 2, reservation.original_invoice.invoice_items.count + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + + # invoice_items assertions + invoice_items = InvoiceItem.last(2) + machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount + + assert(invoice_items.any? { |ii| ii.amount.zero? }) + assert(invoice_items.any? { |ii| ii.amount == machine_price }) + + # users_credits assertions + users_credit = UsersCredit.last + + assert_equal @user_with_subscription, users_credit.user + assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user reserves a training and a subscription with success' do + training = Training.first + availability = training.availabilities.first + plan = Plan.where(group_id: @user_without_subscription.group.id, type: 'Plan').first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @user_without_subscription.id, + items: [ + { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id, + offered: false + } + ] + } + }, + { + subscription: { + plan_id: plan&.id + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + result = json_response(response.body) + + # Check the DB objects have been created as they should + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan + assert_equal plan&.id, @user_without_subscription.subscribed_plan.id + + # reservation assertions + invoice = Invoice.find(result[:id]) + reservation = invoice.main_item.object + + assert reservation.original_invoice + assert_equal 2, reservation.original_invoice.invoice_items.count + + # credits assertions + assert_equal 1, @user_without_subscription.credits.count + assert_equal 'Training', @user_without_subscription.credits.last.creditable_type + assert_equal training.id, @user_without_subscription.credits.last.creditable_id + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert_equal plan&.amount, invoice.total + + # invoice_items + invoice_items = InvoiceItem.last(2) + + assert(invoice_items.any? { |ii| ii.amount == plan&.amount && ii.object_type == Subscription.name }) + assert(invoice_items.any? { |ii| ii.amount.zero? }) + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end +end diff --git a/test/integration/reservations/local_payment_with_wallet_test.rb b/test/integration/reservations/local_payment_with_wallet_test.rb new file mode 100644 index 000000000..be4488b43 --- /dev/null +++ b/test/integration/reservations/local_payment_with_wallet_test.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reservations; end + +class Reservations::LocalPaymentWithWalletTest < ActionDispatch::IntegrationTest + setup do + @vlonchamp = User.find_by(username: 'vlonchamp') + @admin = User.with_role(:admin).first + login_as(@admin, scope: :user) + end + + test 'user reserves a machine and a subscription pay by wallet with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # subscription assertions + assert_equal 1, @vlonchamp.subscriptions.count + assert_not_nil @vlonchamp.subscribed_plan + assert_equal plan.id, @vlonchamp.subscribed_plan.id + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 2, reservation.original_invoice.invoice_items.count + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert_equal invoice.total, 2000 + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal @vlonchamp.wallet.amount, 0 + assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal transaction.transaction_type, 'debit' + assert_equal transaction.amount, 10 + assert_equal transaction.amount, invoice.wallet_amount / 100.0 + assert_equal transaction.id, invoice.wallet_transaction_id + end + + test 'user without subscription reserves a machine and pay wallet with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @vlonchamp.subscriptions.count + assert_nil @vlonchamp.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert_not_nil reservation.original_invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user without subscription reserves a machine and pay by wallet with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @vlonchamp.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @vlonchamp.subscriptions.count + assert_nil @vlonchamp.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 1, reservation.original_invoice.invoice_items.count + + # invoice assertions + invoice = reservation.original_invoice + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal @vlonchamp.wallet.amount, 0 + assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal transaction.transaction_type, 'debit' + assert_equal transaction.amount, 10 + assert_equal transaction.amount, invoice.wallet_amount / 100.0 + assert_equal transaction.id, invoice.wallet_transaction_id + end +end diff --git a/test/integration/reservations/pay_with_wallet_test.rb b/test/integration/reservations/pay_with_wallet_test.rb index 67c6d5a76..564c7610f 100644 --- a/test/integration/reservations/pay_with_wallet_test.rb +++ b/test/integration/reservations/pay_with_wallet_test.rb @@ -271,7 +271,7 @@ class Reservations::PayWithWalletTest < ActionDispatch::IntegrationTest # Check the answer result = json_response(response.body) assert_equal payment_schedule.id, result[:id], 'payment schedule id does not match' - subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.object - assert_equal plan.id, subscription.plan_id, 'subscribed plan does not match' + subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.object + assert_equal plan.id, subscription&.plan_id, 'subscribed plan does not match' end end diff --git a/test/integration/reservations/payment_schedule_test.rb b/test/integration/reservations/payment_schedule_test.rb new file mode 100644 index 000000000..deb0eb0df --- /dev/null +++ b/test/integration/reservations/payment_schedule_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reservations; end + +class Reservations::PaymentScheduleTest < ActionDispatch::IntegrationTest + setup do + @user_without_subscription = User.members.without_subscription.first + @admin = User.with_role(:admin).first + login_as(@admin, scope: :user) + end + + test 'user reserves a training and a subscription with payment schedule' do + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + users_credit_count = UsersCredit.count + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + + training = Training.find(1) + availability = training.availabilities.first + plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('reservations_admin_training_subscription_with_payment_schedule') do + post '/api/local_payment/confirm_payment', params: { + payment_method: 'check', + payment_schedule: true, + customer_id: @user_without_subscription.id, + items: [ + { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + }, + { + subscription: { + plan_id: plan.id + } + } + ] + }.to_json, headers: default_headers + end + + # get the objects + reservation = Reservation.last + payment_schedule = PaymentSchedule.last + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' + assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" + assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" + assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" + assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' + + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" + assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" + assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" + + # payment schedule assertions + assert reservation.original_payment_schedule + assert_equal payment_schedule.id, reservation.original_payment_schedule.id + assert_not_nil payment_schedule.reference + assert_equal 'check', payment_schedule.payment_method + assert_empty payment_schedule.payment_gateway_objects + assert_nil payment_schedule.wallet_transaction + assert_nil payment_schedule.wallet_amount + assert_nil payment_schedule.coupon_id + assert_equal 'test', payment_schedule.environment + assert payment_schedule.check_footprint + assert_equal @user_without_subscription.invoicing_profile.id, payment_schedule.invoicing_profile_id + assert_equal @admin.invoicing_profile.id, payment_schedule.operator_profile_id + + # Check the answer + result = json_response(response.body) + assert_equal reservation.original_payment_schedule.id, result[:id], 'payment schedule id does not match' + + # reservation assertions + assert_equal result[:main_object][:id], reservation.id + assert_equal payment_schedule.main_object.object, reservation + end +end diff --git a/test/integration/reservations/privileged_user_test.rb b/test/integration/reservations/privileged_user_test.rb new file mode 100644 index 000000000..a68d03e8d --- /dev/null +++ b/test/integration/reservations/privileged_user_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Reservations; end + +class Reservations::PrivilegedUserTest < ActionDispatch::IntegrationTest + setup do + @admin = User.with_role(:admin).first + login_as(@admin, scope: :user) + end + + test 'admin cannot reserves for himself with local payment' do + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post '/api/local_payment/confirm_payment', params: { + customer_id: @admin.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + } + ] + }.to_json, headers: default_headers + + # general assertions + assert_equal 403, response.status + assert_equal reservations_count, Reservation.count + assert_equal invoice_count, Invoice.count + assert_equal invoice_items_count, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @admin.subscriptions.count + assert_nil @admin.subscribed_plan + end + + test 'admin reserves a machine for himself with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + subscriptions_count = Subscription.count + + VCR.use_cassette('reservations_create_for_machine_as_admin_for_himself_success') do + post '/api/stripe/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + customer_id: @admin.id, + items: [ + { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_reservations_attributes: [ + { + slot_id: availability.slots.first.id + } + ] + } + } + ] + } + }.to_json, headers: default_headers + end + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal subscriptions_count, Subscription.count + + # subscription assertions + assert_equal 0, @admin.subscriptions.count + assert_nil @admin.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.original_invoice + assert_equal 1, reservation.original_invoice.invoice_items.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @admin.group_id, plan_id: nil).amount, invoice_item.amount + assert invoice_item.check_footprint + + # invoice assertions + item = InvoiceItem.find_by(object: reservation) + invoice = item.invoice + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert_not invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end +end diff --git a/test/integration/store/admin_pay_order_test.rb b/test/integration/store/admin_pay_order_test.rb new file mode 100644 index 000000000..3b303f112 --- /dev/null +++ b/test/integration/store/admin_pay_order_test.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Store; end + +class Store::AdminPayOrderTest < ActionDispatch::IntegrationTest + setup do + @admin = User.find_by(username: 'admin') + @pjproudhon = User.find_by(username: 'pjproudhon') + @caisse_en_bois = Product.find_by(slug: 'caisse-en-bois') + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @cart1 = Order.find_by(token: '0DKxbAOzSXRx-amXyhmDdg1666691976019') + end + + test 'admin pay himself order by cart with success' do + login_as(@admin, scope: :user) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + VCR.use_cassette('store_order_admin_pay_by_cart_success') do + post '/api/checkout/payment', + params: { + payment_id: stripe_payment_method, + order_token: @cart1.token, + customer_id: @admin.id + }.to_json, headers: default_headers + end + + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert_not @cart1.payment_gateway_object.blank? + assert_not invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'card' + assert_equal @cart1.paid_total, 262_500 + + stock_movement = @caisse_en_bois.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -5 + assert_equal stock_movement.order_item_id, @cart1.order_items.first.id + + stock_movement = @panneaux.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -2 + assert_equal stock_movement.order_item_id, @cart1.order_items.last.id + + activity = @cart1.order_activities.last + assert_equal activity.activity_type, 'paid' + assert_equal activity.operator_profile_id, @admin.invoicing_profile.id + end + + test 'admin pay himself order by cart and wallet with success' do + login_as(@admin, scope: :user) + + service = WalletService.new(user: @admin, wallet: @admin.wallet) + service.credit(1000) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + VCR.use_cassette('store_order_admin_pay_by_cart_and_wallet_success') do + post '/api/checkout/payment', + params: { + payment_id: stripe_payment_method, + order_token: @cart1.token, + customer_id: @admin.id + }.to_json, headers: default_headers + end + + @admin.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert_not @cart1.payment_gateway_object.blank? + assert_not invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'card' + assert_equal @cart1.paid_total, 162_500 + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # wallet + assert_equal 0, @admin.wallet.amount + assert_equal 2, @admin.wallet.wallet_transactions.count + transaction = @admin.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.wallet_amount / 100.0, transaction.amount + assert_equal @cart1.wallet_transaction_id, transaction.id + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'admin pay user order by local with success' do + login_as(@admin, scope: :user) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + customer_id: @pjproudhon.id + }.to_json, headers: default_headers + + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert @cart1.payment_gateway_object.blank? + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'local' + assert_equal @cart1.paid_total, 262_500 + + stock_movement = @caisse_en_bois.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -5 + assert_equal stock_movement.order_item_id, @cart1.order_items.first.id + + stock_movement = @panneaux.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -2 + assert_equal stock_movement.order_item_id, @cart1.order_items.last.id + + activity = @cart1.order_activities.last + assert_equal activity.activity_type, 'paid' + assert_equal activity.operator_profile_id, @admin.invoicing_profile.id + end + + test 'admin pay user offered order by local with success' do + login_as(@admin, scope: :user) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + @cart1 = Cart::SetOfferService.new.call(@cart1, @caisse_en_bois, true) + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + customer_id: @pjproudhon.id + }.to_json, headers: default_headers + + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert @cart1.payment_gateway_object.blank? + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'local' + assert_equal @cart1.paid_total, 1_000 + + stock_movement = @caisse_en_bois.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -5 + assert_equal stock_movement.order_item_id, @cart1.order_items.first.id + + stock_movement = @panneaux.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -2 + assert_equal stock_movement.order_item_id, @cart1.order_items.last.id + + activity = @cart1.order_activities.last + assert_equal activity.activity_type, 'paid' + assert_equal activity.operator_profile_id, @admin.invoicing_profile.id + end + + test 'admin pay himself order by wallet with success' do + login_as(@admin, scope: :user) + + service = WalletService.new(user: @admin, wallet: @admin.wallet) + service.credit(@cart1.total / 100) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + customer_id: @admin.id + }.to_json, headers: default_headers + + @admin.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal @cart1.state, 'paid' + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + # wallet + assert_equal 0, @admin.wallet.amount + assert_equal 2, @admin.wallet.wallet_transactions.count + transaction = @admin.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 0 + assert_equal @cart1.wallet_amount / 100.0, transaction.amount + assert_equal @cart1.payment_method, 'wallet' + assert_equal @cart1.wallet_transaction_id, transaction.id + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'admin pay user order by wallet with success' do + login_as(@admin, scope: :user) + + service = WalletService.new(user: @admin, wallet: @pjproudhon.wallet) + service.credit(@cart1.total / 100) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + customer_id: @pjproudhon.id + }.to_json, headers: default_headers + + @pjproudhon.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal @cart1.state, 'paid' + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + # wallet + assert_equal 0, @pjproudhon.wallet.amount + assert_equal 2, @pjproudhon.wallet.wallet_transactions.count + transaction = @pjproudhon.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 0 + assert_equal @cart1.wallet_amount / 100.0, transaction.amount + assert_equal @cart1.payment_method, 'wallet' + assert_equal @cart1.wallet_transaction_id, transaction.id + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'admin pay user order by wallet and coupon with success' do + login_as(@admin, scope: :user) + + service = WalletService.new(user: @admin, wallet: @pjproudhon.wallet) + service.credit(@cart1.total / 100) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + customer_id: @pjproudhon.id, + coupon_code: 'GIME3EUR' + }.to_json, headers: default_headers + + @pjproudhon.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal @cart1.state, 'paid' + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + assert_equal Coupon.find_by(code: 'GIME3EUR').id, @cart1.coupon_id + + # invoice_items assertions + invoice_item = InvoiceItem.last + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + # wallet + assert_equal 3, @pjproudhon.wallet.amount + assert_equal 2, @pjproudhon.wallet.wallet_transactions.count + transaction = @pjproudhon.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 0 + assert_equal @cart1.wallet_amount, 262_200 + assert_equal 2622, transaction.amount + end +end diff --git a/test/integration/store/user_pay_order_test.rb b/test/integration/store/user_pay_order_test.rb new file mode 100644 index 000000000..6d72fc802 --- /dev/null +++ b/test/integration/store/user_pay_order_test.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Store; end + +class Store::UserPayOrderTest < ActionDispatch::IntegrationTest + setup do + @admin = User.find_by(username: 'admin') + @pjproudhon = User.find_by(username: 'pjproudhon') + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @cart1 = Order.find_by(token: 'KbSmmD_gi9w_CrpwtK9OwA1666687433963') + end + + test 'user pay order by cart with success' do + login_as(@pjproudhon, scope: :user) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + VCR.use_cassette('store_order_pay_by_cart_success') do + post '/api/checkout/payment', + params: { + payment_id: stripe_payment_method, + order_token: @cart1.token + }.to_json, headers: default_headers + end + + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert_not invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'card' + assert_equal @cart1.paid_total, 500 + stock_movement = @panneaux.product_stock_movements.last + assert_equal stock_movement.stock_type, 'external' + assert_equal stock_movement.reason, 'sold' + assert_equal stock_movement.quantity, -1 + assert_equal stock_movement.order_item_id, @cart1.order_items.first.id + activity = @cart1.order_activities.last + assert_equal activity.activity_type, 'paid' + assert_equal activity.operator_profile_id, @pjproudhon.invoicing_profile.id + end + + test 'user pay order by cart and wallet with success' do + login_as(@pjproudhon, scope: :user) + + service = WalletService.new(user: @admin, wallet: @pjproudhon.wallet) + service.credit(1) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + VCR.use_cassette('store_order_pay_by_cart_and_wallet_success') do + post '/api/checkout/payment', + params: { + payment_id: stripe_payment_method, + order_token: @cart1.token + }.to_json, headers: default_headers + end + + @pjproudhon.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert_not @cart1.payment_gateway_object.blank? + assert_not invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + assert_equal @cart1.state, 'paid' + assert_equal @cart1.payment_method, 'card' + assert_equal @cart1.paid_total, 400 + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # wallet + assert_equal 0, @pjproudhon.wallet.amount + assert_equal 2, @pjproudhon.wallet.wallet_transactions.count + transaction = @pjproudhon.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 400 + assert_equal @cart1.wallet_amount / 100.0, transaction.amount + assert_equal @cart1.payment_method, 'card' + assert_equal @cart1.wallet_transaction_id, transaction.id + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'user pay order by wallet with success' do + login_as(@pjproudhon, scope: :user) + + service = WalletService.new(user: @admin, wallet: @pjproudhon.wallet) + service.credit(@cart1.total / 100) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token + }.to_json, headers: default_headers + + @pjproudhon.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal @cart1.state, 'paid' + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + # wallet + assert_equal 0, @pjproudhon.wallet.amount + assert_equal 2, @pjproudhon.wallet.wallet_transactions.count + transaction = @pjproudhon.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 0 + assert_equal @cart1.wallet_amount / 100.0, transaction.amount + assert_equal @cart1.payment_method, 'wallet' + assert_equal @cart1.wallet_transaction_id, transaction.id + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'user pay order by wallet and coupon with success' do + login_as(@pjproudhon, scope: :user) + + service = WalletService.new(user: @admin, wallet: @pjproudhon.wallet) + service.credit(@cart1.total / 100) + + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post '/api/checkout/payment', + params: { + order_token: @cart1.token, + coupon_code: 'GIME3EUR' + }.to_json, headers: default_headers + + @pjproudhon.wallet.reload + @cart1.reload + + # general assertions + assert_equal 200, response.status + assert_equal @cart1.state, 'paid' + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + assert_equal Coupon.find_by(code: 'GIME3EUR').id, @cart1.coupon_id + + # invoice_items assertions + invoice_item = InvoiceItem.last + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.last + assert_invoice_pdf invoice + assert_not_nil invoice.debug_footprint + + assert invoice.payment_gateway_object.blank? + assert_not invoice.total.blank? + assert invoice.check_footprint + + # notification + assert_not_empty Notification.where(attached_object: invoice) + + # wallet + assert_equal 3, @pjproudhon.wallet.amount + assert_equal 2, @pjproudhon.wallet.wallet_transactions.count + transaction = @pjproudhon.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal @cart1.paid_total, 0 + assert_equal @cart1.wallet_amount, 200 + assert_equal 2, transaction.amount + end +end diff --git a/test/services/availabilities_service_test.rb b/test/services/availabilities_service_test.rb index 256d17cfb..2333cf204 100644 --- a/test/services/availabilities_service_test.rb +++ b/test/services/availabilities_service_test.rb @@ -13,28 +13,32 @@ class AvailabilitiesServiceTest < ActiveSupport::TestCase test 'no machines availabilities during given window' do service = Availabilities::AvailabilitiesService.new(@no_subscription) - slots = service.machines([Machine.find(3)], @no_subscription, { start: DateTime.current.beginning_of_day, end: 1.day.from_now.end_of_day }) + slots = service.machines([Machine.find(3)], @no_subscription, + { start: DateTime.current.beginning_of_day, end: 1.day.from_now.end_of_day }) assert_empty slots end test 'no machines availabilities for user tags' do service = Availabilities::AvailabilitiesService.new(@no_subscription) - slots = service.machines([Machine.find(3)], @no_subscription, { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day }) + slots = service.machines([Machine.find(3)], @no_subscription, + { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day }) assert_empty slots end test 'no past availabilities for members' do service = Availabilities::AvailabilitiesService.new(@no_subscription) - slots = service.machines([Machine.find(2)], @no_subscription, { start: DateTime.parse('2015-06-15').beginning_of_day, end: DateTime.parse('2015-06-15').end_of_day }) + slots = service.machines([Machine.find(2)], @no_subscription, + { start: DateTime.parse('2015-06-15').beginning_of_day, end: DateTime.parse('2015-06-15').end_of_day }) assert_empty slots end test 'admin cannot see past availabilities further than 1 month' do service = Availabilities::AvailabilitiesService.new(@admin) - slots = service.machines([Machine.find(2)], @no_subscription, { start: DateTime.parse('2015-06-15').beginning_of_day, end: DateTime.parse('2015-06-15').end_of_day }) + slots = service.machines([Machine.find(2)], @no_subscription, + { start: DateTime.parse('2015-06-15').beginning_of_day, end: DateTime.parse('2015-06-15').end_of_day }) assert_empty slots end @@ -52,7 +56,8 @@ class AvailabilitiesServiceTest < ActiveSupport::TestCase test 'machines availabilities' do service = Availabilities::AvailabilitiesService.new(@no_subscription) - slots = service.machines([Machine.find(1)], @no_subscription, { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day }) + slots = service.machines([Machine.find(1)], @no_subscription, + { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day }) assert_not_empty slots availability = Availability.find(7) @@ -78,7 +83,7 @@ class AvailabilitiesServiceTest < ActiveSupport::TestCase slots = service.trainings(trainings, @no_subscription, { start: DateTime.current.beginning_of_day, end: 2.days.from_now.end_of_day }) assert_not_empty slots - if DateTime.current.hour > 10 + if DateTime.current.hour >= 6 assert_equal Availability.find(2).slots.count, slots.count else assert_equal Availability.find(1).slots.count + Availability.find(2).slots.count, slots.count @@ -87,7 +92,8 @@ class AvailabilitiesServiceTest < ActiveSupport::TestCase test 'events availability' do service = Availabilities::AvailabilitiesService.new(@no_subscription) - slots = service.events([Event.find(4)], @no_subscription, { start: DateTime.current.beginning_of_day, end: 30.days.from_now.end_of_day }) + slots = service.events([Event.find(4)], @no_subscription, + { start: DateTime.current.beginning_of_day, end: 30.days.from_now.end_of_day }) assert_not_empty slots availability = Availability.find(17) diff --git a/test/services/cart/add_item_service_test.rb b/test/services/cart/add_item_service_test.rb new file mode 100644 index 000000000..cebbc5257 --- /dev/null +++ b/test/services/cart/add_item_service_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::AddItemServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @caisse_en_bois = Product.find_by(slug: 'caisse-en-bois') + @cart = Order.find_by(token: 'MkI5z9qVxe_YdNYCR_WN6g1666628074732') + end + + test 'add a product to cart' do + cart = Cart::AddItemService.new.call(@cart, @panneaux, 10) + assert_equal cart.total, @panneaux.amount * 10 + assert_equal cart.order_items.length, 1 + assert_equal cart.order_items.first.amount, @panneaux.amount + assert_equal cart.order_items.first.quantity, 10 + end + + test 'add a product with quantity min' do + cart = Cart::AddItemService.new.call(@cart, @caisse_en_bois) + assert_equal cart.total, @caisse_en_bois.amount * @caisse_en_bois.quantity_min + assert_equal cart.order_items.length, 1 + assert_equal cart.order_items.first.amount, @caisse_en_bois.amount + assert_equal cart.order_items.first.quantity, @caisse_en_bois.quantity_min + end + + test 'add two product to cart' do + cart = Cart::AddItemService.new.call(@cart, @panneaux, 10) + cart = Cart::AddItemService.new.call(@cart, @caisse_en_bois) + assert_equal cart.total, (@caisse_en_bois.amount * 5) + (@panneaux.amount * 10) + assert_equal cart.order_items.length, 2 + assert_equal cart.order_items.first.amount, @panneaux.amount + assert_equal cart.order_items.first.quantity, 10 + assert_equal cart.order_items.last.amount, @caisse_en_bois.amount + assert_equal cart.order_items.last.quantity, 5 + end + + test 'cannot add a product out of stock' do + assert_raise Cart::OutStockError do + Cart::AddItemService.new.call(@cart, @caisse_en_bois, 101) + end + end + + test 'cannot add a product inactive' do + assert_raise Cart::InactiveProductError do + product_inactive = Product.find_by(slug: 'sticker-hello') + Cart::AddItemService.new.call(@cart, product_inactive, 1) + end + end +end diff --git a/test/services/cart/check_cart_service_test.rb b/test/services/cart/check_cart_service_test.rb new file mode 100644 index 000000000..396de6c75 --- /dev/null +++ b/test/services/cart/check_cart_service_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::CheckCartServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @cart = Order.find_by(token: 'KbSmmD_gi9w_CrpwtK9OwA1666687433963') + end + + test 'product is inactive in cart' do + @panneaux.is_active = false + @panneaux.save + errors = Cart::CheckCartService.new.call(@cart) + assert_equal errors[:details].length, 1 + assert_equal errors[:details].first[:errors].length, 1 + assert_equal errors[:details].first[:errors].first[:error], 'is_active' + assert_equal errors[:details].first[:errors].first[:value], false + end + + test 'product is out of stock in cart' do + @panneaux.stock['external'] = 0 + @panneaux.save + errors = Cart::CheckCartService.new.call(@cart) + assert_equal errors[:details].length, 1 + assert_equal errors[:details].first[:errors].length, 1 + assert_equal errors[:details].first[:errors].first[:error], 'stock' + assert_equal errors[:details].first[:errors].first[:value], 0 + end + + test 'product is less than quantity min in cart' do + @panneaux.quantity_min = 2 + @panneaux.save + errors = Cart::CheckCartService.new.call(@cart) + assert_equal errors[:details].length, 1 + assert_equal errors[:details].first[:errors].length, 1 + assert_equal errors[:details].first[:errors].first[:error], 'quantity_min' + assert_equal errors[:details].first[:errors].first[:value], 2 + end + + test 'product amount changed in cart' do + @panneaux.amount = 600 + @panneaux.save + errors = Cart::CheckCartService.new.call(@cart) + assert_equal errors[:details].length, 1 + assert_equal errors[:details].first[:errors].length, 1 + assert_equal errors[:details].first[:errors].first[:error], 'amount' + assert_equal errors[:details].first[:errors].first[:value], 600 / 100.0 + end +end diff --git a/test/services/cart/find_or_create_service_test.rb b/test/services/cart/find_or_create_service_test.rb new file mode 100644 index 000000000..6659ab5ff --- /dev/null +++ b/test/services/cart/find_or_create_service_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::FindOrCreateServiceTest < ActiveSupport::TestCase + setup do + @jdupond = User.find_by(username: 'jdupond') + @acamus = User.find_by(username: 'acamus') + @admin = User.find_by(username: 'admin') + end + + test 'anoymous user create a new cart' do + cart = Cart::FindOrCreateService.new(nil).call(nil) + assert_equal cart.state, 'cart' + assert_equal cart.total, 0 + assert_nil cart.statistic_profile_id + assert_nil cart.operator_profile_id + end + + test 'user create a new cart' do + cart = Cart::FindOrCreateService.new(@jdupond).call(nil) + assert_equal cart.state, 'cart' + assert_equal cart.statistic_profile_id, @jdupond.statistic_profile.id + assert_equal cart.total, 0 + assert_nil cart.operator_profile_id + assert_equal Order.where(statistic_profile_id: @jdupond.statistic_profile.id, state: 'cart').count, 1 + end + + test 'find cart by token' do + cart = Cart::FindOrCreateService.new(nil).call('MkI5z9qVxe_YdNYCR_WN6g1666628074732') + assert_equal cart.state, 'cart' + assert_equal cart.total, 0 + assert_nil cart.statistic_profile_id + assert_nil cart.operator_profile_id + end + + test 'get last cart' do + cart = Cart::FindOrCreateService.new(@acamus).call(nil) + assert_equal cart.token, '9VWkmJDSx7QixRusL7ppWg1666628033284' + end + + test 'cannot get cart of other user by token' do + cart = Cart::FindOrCreateService.new(@jdupond).call('9VWkmJDSx7QixRusL7ppWg1666628033284') + assert_equal cart.state, 'cart' + assert_equal cart.total, 0 + assert_nil cart.operator_profile_id + assert_not_equal cart.token, '9VWkmJDSx7QixRusL7ppWg1666628033284' + end + + test 'migrate a cart to user' do + cart = Cart::FindOrCreateService.new(@jdupond).call('MkI5z9qVxe_YdNYCR_WN6g1666628074732') + assert_equal cart.state, 'cart' + assert_equal cart.total, 0 + assert_equal cart.statistic_profile_id, @jdupond.statistic_profile.id + assert_nil cart.operator_profile_id + assert_equal Order.where(statistic_profile_id: @jdupond.statistic_profile.id, state: 'cart').count, 1 + end + + test 'user have only one cart' do + cart = Cart::FindOrCreateService.new(@acamus).call('MkI5z9qVxe_YdNYCR_WN6g1666628074732') + assert_equal cart.token, '9VWkmJDSx7QixRusL7ppWg1666628033284' + assert_equal cart.state, 'cart' + assert_equal cart.total, 0 + assert_equal cart.statistic_profile_id, @acamus.statistic_profile.id + assert_nil cart.operator_profile_id + assert_equal Order.where(statistic_profile_id: @acamus.statistic_profile.id, state: 'cart').count, 1 + assert_nil Order.find_by(token: 'MkI5z9qVxe_YdNYCR_WN6g1666628074732') + end + + test 'admin get a cart' do + cart = Cart::FindOrCreateService.new(@admin).call(nil) + assert_equal cart.state, 'cart' + assert_equal cart.total, 262_500 + assert_equal cart.operator_profile_id, @admin.invoicing_profile.id + assert_nil cart.statistic_profile_id + assert_equal Order.where(operator_profile_id: @admin.invoicing_profile.id, state: 'cart').count, 1 + end +end diff --git a/test/services/cart/refresh_item_service_test.rb b/test/services/cart/refresh_item_service_test.rb new file mode 100644 index 000000000..80eb91015 --- /dev/null +++ b/test/services/cart/refresh_item_service_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::RefreshItemServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @cart1 = Order.find_by(token: 'KbSmmD_gi9w_CrpwtK9OwA1666687433963') + @cart2 = Order.find_by(token: 'MkI5z9qVxe_YdNYCR_WN6g1666628074732') + end + + test 'refresh total and item amount if product change amount' do + @panneaux.amount = 10_000 + @panneaux.save + cart = Cart::RefreshItemService.new.call(@cart1, @panneaux) + assert_equal cart.total, 10_000 + assert_equal cart.order_items.first.amount, 10_000 + end + + test 'cannot refresh total and item amount if product isnt in cart' do + assert_raise ActiveRecord::RecordNotFound do + Cart::RefreshItemService.new.call(@cart2, @panneaux) + end + end +end diff --git a/test/services/cart/remove_item_service_test.rb b/test/services/cart/remove_item_service_test.rb new file mode 100644 index 000000000..1dc1a493a --- /dev/null +++ b/test/services/cart/remove_item_service_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::RemoveItemServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @cart1 = Order.find_by(token: 'KbSmmD_gi9w_CrpwtK9OwA1666687433963') + @cart2 = Order.find_by(token: 'MkI5z9qVxe_YdNYCR_WN6g1666628074732') + end + + test 'remove a product to cart' do + cart = Cart::RemoveItemService.new.call(@cart1, @panneaux) + assert_equal cart.total, 0 + assert_equal cart.order_items.length, 0 + end + + test 'cannot remove a product that isnt in cart' do + assert_raise ActiveRecord::RecordNotFound do + Cart::RemoveItemService.new.call(@cart2, @panneaux) + end + end +end diff --git a/test/services/cart/set_offer_service_test.rb b/test/services/cart/set_offer_service_test.rb new file mode 100644 index 000000000..925aec162 --- /dev/null +++ b/test/services/cart/set_offer_service_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::SetOfferServiceTest < ActiveSupport::TestCase + setup do + @caisse_en_bois = Product.find_by(slug: 'caisse-en-bois') + @filament = Product.find_by(slug: 'filament-pla-blanc') + @cart = Order.find_by(token: '0DKxbAOzSXRx-amXyhmDdg1666691976019') + end + + test 'set offer product in cart' do + cart = Cart::SetOfferService.new.call(@cart, @caisse_en_bois, true) + assert_equal cart.total, 1000 + assert_equal cart.order_items.first.amount, @caisse_en_bois.amount + assert_equal cart.order_items.first.is_offered, true + end + + test 'cannot set offer if product that isnt in cart' do + assert_raise ActiveRecord::RecordNotFound do + Cart::SetOfferService.new.call(@cart, @filament, true) + end + end +end diff --git a/test/services/cart/set_quantity_service_test.rb b/test/services/cart/set_quantity_service_test.rb new file mode 100644 index 000000000..8a9f568c8 --- /dev/null +++ b/test/services/cart/set_quantity_service_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::SetQuantityServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + @caisse_en_bois = Product.find_by(slug: 'caisse-en-bois') + @cart1 = Order.find_by(token: 'KbSmmD_gi9w_CrpwtK9OwA1666687433963') + @cart2 = Order.find_by(token: 'MkI5z9qVxe_YdNYCR_WN6g1666628074732') + @cart3 = Order.find_by(token: '4bB96D-MlqJGBr5T8eui-g1666690417460') + end + + test 'change quantity of product in cart' do + cart = Cart::SetQuantityService.new.call(@cart1, @panneaux, 10) + assert_equal cart.total, @panneaux.amount * 10 + assert_equal cart.order_items.length, 1 + end + + test 'change quantity of product greater than stock' do + assert_raise Cart::OutStockError do + Cart::SetQuantityService.new.call(@cart1, @panneaux, 1000) + end + end + + test 'cannot change quantity less than product quantity min' do + cart = Cart::SetQuantityService.new.call(@cart3, @caisse_en_bois, 1) + assert_equal cart.total, @caisse_en_bois.amount * @caisse_en_bois.quantity_min + assert_equal cart.order_items.first.quantity, @caisse_en_bois.quantity_min + end + + test 'cannot change quantity if product that isnt in cart' do + assert_raise ActiveRecord::RecordNotFound do + Cart::SetQuantityService.new.call(@cart2, @panneaux, 10) + end + end +end diff --git a/test/services/cart/update_total_service_test.rb b/test/services/cart/update_total_service_test.rb new file mode 100644 index 000000000..37124156f --- /dev/null +++ b/test/services/cart/update_total_service_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Cart::UpdateTotalServiceTest < ActiveSupport::TestCase + setup do + @panneaux = Product.find_by(slug: 'panneaux-de-mdf') + end + + test 'total equal to product amount multiplied quantity' do + order = Order.new + order.order_items.push OrderItem.new(orderable: @panneaux, amount: @panneaux.amount, quantity: 10) + cart = Cart::UpdateTotalService.new.call(order) + assert_equal cart.total, @panneaux.amount * 10 + end + + test 'total equal to zero if product offered' do + order = Order.new + order.order_items.push OrderItem.new(orderable: @panneaux, amount: @panneaux.amount, quantity: 10, is_offered: true) + cart = Cart::UpdateTotalService.new.call(order) + assert_equal cart.total, 0 + end +end diff --git a/test/services/statistic_service_test.rb b/test/services/statistics/reservation_subscription_statistic_service_test.rb similarity index 82% rename from test/services/statistic_service_test.rb rename to test/services/statistics/reservation_subscription_statistic_service_test.rb index e04412be6..7a0e7bcac 100644 --- a/test/services/statistic_service_test.rb +++ b/test/services/statistics/reservation_subscription_statistic_service_test.rb @@ -2,7 +2,7 @@ require 'test_helper' -class StatisticServiceTest < ActionDispatch::IntegrationTest +class ReservationSubscriptionStatisticServiceTest < ActionDispatch::IntegrationTest setup do @user = User.members.without_subscription.first @admin = User.with_role(:admin).first @@ -50,6 +50,26 @@ class StatisticServiceTest < ActionDispatch::IntegrationTest } ] }.to_json, headers: default_headers + + # Create a training reservation (1 day ago) + training = Training.find(1) + tr_slot = Availability.find(2).slots.first + post '/api/local_payment/confirm_payment', params: { + customer_id: @user.id, + items: [ + { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_reservations_attributes: [ + { + slot_id: tr_slot.id + } + ] + } + } + ] + }.to_json, headers: default_headers travel_back # Crate another machine reservation (today) @@ -105,6 +125,13 @@ class StatisticServiceTest < ActionDispatch::IntegrationTest assert_equal machine.friendly_id, stat_hour['subType'] check_statistics_on_user(stat_hour) + # training + stat_training = Stats::Training.search(query: { bool: { must: [{ term: { date: 1.day.ago.to_date.iso8601 } }, + { term: { type: 'booking' } }] } }).first + assert_not_nil stat_training + assert_equal training.friendly_id, stat_training['subType'] + check_statistics_on_user(stat_training) + # subscription Stats::Subscription.refresh_index! diff --git a/test/services/statistics/store_statistic_service_test.rb b/test/services/statistics/store_statistic_service_test.rb new file mode 100644 index 000000000..8bc689f95 --- /dev/null +++ b/test/services/statistics/store_statistic_service_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +class StoreStatisticServiceTest < ActionDispatch::IntegrationTest + setup do + @order = Order.find(15) + end + + test 'build stats about orders' do + # Build the stats for the last 3 days, we expect the above invoices (reservations+subscription) to appear in the resulting stats + ::Statistics::BuilderService.generate_statistic({ start_date: DateTime.current.beginning_of_day, + end_date: DateTime.current.end_of_day }) + + Stats::Order.refresh_index! + + # we should find order id 15 (created today) + stat_order = Stats::Order.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } }, + { term: { type: 'store' } }] } }).first + assert_not_nil stat_order + assert_equal @order.id, stat_order['orderId'] + check_statistics_on_user(stat_order) + end + + def check_statistics_on_user(stat) + assert_equal @order.statistic_profile.str_gender, stat['gender'] + assert_equal @order.statistic_profile.age.to_i, stat['age'] + assert_equal @order.statistic_profile.group.slug, stat['group'] + end +end diff --git a/test/services/wallet_service_test.rb b/test/services/wallet_service_test.rb index 35ecbcad3..5fe5ada5c 100644 --- a/test/services/wallet_service_test.rb +++ b/test/services/wallet_service_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class WalletServiceTest < ActiveSupport::TestCase @@ -54,9 +56,9 @@ class WalletServiceTest < ActiveSupport::TestCase test 'rollback debited amount if has an error when create wallet transaction' do service = WalletService.new(wallet: @vlonchamp_wallet) expected_amount = @vlonchamp_wallet.amount - transaction = service.debit(5) + transaction = service.debit('error') @vlonchamp_wallet.reload - assert_equal @vlonchamp_wallet.amount, expected_amount + assert_equal expected_amount, @vlonchamp_wallet.amount assert_not transaction end end diff --git a/test/vcr_cassettes/reservations_create_for_machine_as_admin_for_himself_success.yml b/test/vcr_cassettes/reservations_create_for_machine_as_admin_for_himself_success.yml new file mode 100644 index 000000000..85d353194 --- /dev/null +++ b/test/vcr_cassettes/reservations_create_for_machine_as_admin_for_himself_success.yml @@ -0,0 +1,560 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods + body: + encoding: UTF-8 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2023&card[cvc]=314 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.0.2-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.0, GNU ld (GNU Binutils) + 2.39.0) #1 SMP PREEMPT_DYNAMIC Sat, 15 Oct 2022 14:00:49 +0000","hostname":"Sylvain-desktop"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 25 Oct 2022 12:24:00 GMT + Content-Type: + - application/json + Content-Length: + - '930' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - d2caa6d5-17aa-492b-92c9-a674cdf166bf + Original-Request: + - req_IyjaBwMZQGEkLi + Request-Id: + - req_IyjaBwMZQGEkLi + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1LwmAm2sOmf47Nz9J3oTpRmL", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1666700640, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Tue, 25 Oct 2022 12:24:00 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents + body: + encoding: UTF-8 + string: payment_method=pm_1LwmAm2sOmf47Nz9J3oTpRmL&amount=3200¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8CyNk3UTi8lvCc + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_IyjaBwMZQGEkLi","request_duration_ms":676}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.0.2-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.0, GNU ld (GNU Binutils) + 2.39.0) #1 SMP PREEMPT_DYNAMIC Sat, 15 Oct 2022 14:00:49 +0000","hostname":"Sylvain-desktop"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 25 Oct 2022 12:24:02 GMT + Content-Type: + - application/json + Content-Length: + - '4467' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 36cf63a0-1991-46f2-b878-e57be73a8d20 + Original-Request: + - req_9igfE4GGSynHxB + Request-Id: + - req_9igfE4GGSynHxB + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3LwmAn2sOmf47Nz91stZM07n", + "object": "payment_intent", + "amount": 3200, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 3200, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3LwmAn2sOmf47Nz91lGaYKm6", + "object": "charge", + "amount": 3200, + "amount_captured": 3200, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3LwmAn2sOmf47Nz91eAundgZ", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1666700641, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 6, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3LwmAn2sOmf47Nz91stZM07n", + "payment_method": "pm_1LwmAm2sOmf47Nz9J3oTpRmL", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KOKq35oGMgbxVBhdgwo6LBaPqJIeyW4O5X-cvbYdENfEYHKFIXhtA-WRqLqkwoqCJh37CjVAqDerZ6Ny", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3LwmAn2sOmf47Nz91lGaYKm6/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3LwmAn2sOmf47Nz91stZM07n" + }, + "client_secret": "pi_3LwmAn2sOmf47Nz91stZM07n_secret_senD8pGcSSa6gwoY1dGU87TUy", + "confirmation_method": "manual", + "created": 1666700641, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1LwmAm2sOmf47Nz9J3oTpRmL", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Tue, 25 Oct 2022 12:24:02 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents/pi_3LwmAn2sOmf47Nz91stZM07n + body: + encoding: UTF-8 + string: description=Invoice+reference%3A+2210001 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_9igfE4GGSynHxB","request_duration_ms":1804}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.0.2-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.0, GNU ld (GNU Binutils) + 2.39.0) #1 SMP PREEMPT_DYNAMIC Sat, 15 Oct 2022 14:00:49 +0000","hostname":"Sylvain-desktop"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 25 Oct 2022 12:24:03 GMT + Content-Type: + - application/json + Content-Length: + - '4491' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 4922e47c-d879-40e3-9fe8-03d31aae42fc + Original-Request: + - req_t3nUSQYBoywCRu + Request-Id: + - req_t3nUSQYBoywCRu + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3LwmAn2sOmf47Nz91stZM07n", + "object": "payment_intent", + "amount": 3200, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 3200, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3LwmAn2sOmf47Nz91lGaYKm6", + "object": "charge", + "amount": 3200, + "amount_captured": 3200, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3LwmAn2sOmf47Nz91eAundgZ", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1666700641, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 6, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3LwmAn2sOmf47Nz91stZM07n", + "payment_method": "pm_1LwmAm2sOmf47Nz9J3oTpRmL", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KOOq35oGMgYTK7s3Lw06LBbw-9lgITJWB30pZngrYDHYjDJ0XFWVlXrbaaqPhzCBYn5QQ1aQPnDFheEh", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3LwmAn2sOmf47Nz91lGaYKm6/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3LwmAn2sOmf47Nz91stZM07n" + }, + "client_secret": "pi_3LwmAn2sOmf47Nz91stZM07n_secret_senD8pGcSSa6gwoY1dGU87TUy", + "confirmation_method": "manual", + "created": 1666700641, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": "Invoice reference: 2210001", + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1LwmAm2sOmf47Nz9J3oTpRmL", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Tue, 25 Oct 2022 12:24:03 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/store_order_admin_pay_by_cart_and_wallet_success.yml b/test/vcr_cassettes/store_order_admin_pay_by_cart_and_wallet_success.yml new file mode 100644 index 000000000..450b56403 --- /dev/null +++ b/test/vcr_cassettes/store_order_admin_pay_by_cart_and_wallet_success.yml @@ -0,0 +1,338 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods + body: + encoding: UTF-8 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2023&card[cvc]=314 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 17:22:23 GMT + Content-Type: + - application/json + Content-Length: + - '930' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 3d420ca2-b79a-451b-88bc-56efb989ae3e + Original-Request: + - req_7tnZKRYjblvjJF + Request-Id: + - req_7tnZKRYjblvjJF + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1M1Z1f2sOmf47Nz9y4qaYQap", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1667841743, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Mon, 07 Nov 2022 17:22:24 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents + body: + encoding: UTF-8 + string: payment_method=pm_1M1Z1f2sOmf47Nz9y4qaYQap&amount=162500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8CyNk3UTi8lvCc + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_7tnZKRYjblvjJF","request_duration_ms":697}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 17:22:25 GMT + Content-Type: + - application/json + Content-Length: + - '4476' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 5294d6d7-d766-4aa5-bac1-be52f491fa20 + Original-Request: + - req_c0S6XFCR5hlcc9 + Request-Id: + - req_c0S6XFCR5hlcc9 + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3M1Z1g2sOmf47Nz91KcAbrWR", + "object": "payment_intent", + "amount": 162500, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 162500, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3M1Z1g2sOmf47Nz91YJQvMPK", + "object": "charge", + "amount": 162500, + "amount_captured": 162500, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3M1Z1g2sOmf47Nz913ZvXNvF", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1667841744, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 45, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3M1Z1g2sOmf47Nz91KcAbrWR", + "payment_method": "pm_1M1Z1f2sOmf47Nz9y4qaYQap", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNH9pJsGMgbMAdBS9Kg6LBbMQFUWP1mmaiHjAUCgW-WYHRH5dwIHTlYhiTVjiSL5fqEMQr17GSJhWPA-", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3M1Z1g2sOmf47Nz91YJQvMPK/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3M1Z1g2sOmf47Nz91KcAbrWR" + }, + "client_secret": "pi_3M1Z1g2sOmf47Nz91KcAbrWR_secret_ocSaI8LfCIvNBzfXl5iwRB9kS", + "confirmation_method": "manual", + "created": 1667841744, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1M1Z1f2sOmf47Nz9y4qaYQap", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Mon, 07 Nov 2022 17:22:26 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/store_order_admin_pay_by_cart_success.yml b/test/vcr_cassettes/store_order_admin_pay_by_cart_success.yml new file mode 100644 index 000000000..da812fed8 --- /dev/null +++ b/test/vcr_cassettes/store_order_admin_pay_by_cart_success.yml @@ -0,0 +1,340 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods + body: + encoding: UTF-8 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2023&card[cvc]=314 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_RmcFNPXaqrPUG5","request_duration_ms":2}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 16:37:43 GMT + Content-Type: + - application/json + Content-Length: + - '930' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 79f4b0fa-8f75-413e-9d49-d676004060f3 + Original-Request: + - req_XjXHG1cRekGj4r + Request-Id: + - req_XjXHG1cRekGj4r + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1M1YKR2sOmf47Nz9zhudFmj4", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1667839063, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Mon, 07 Nov 2022 16:37:43 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents + body: + encoding: UTF-8 + string: payment_method=pm_1M1YKR2sOmf47Nz9zhudFmj4&amount=262500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8CyNk3UTi8lvCc + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_XjXHG1cRekGj4r","request_duration_ms":747}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 16:37:45 GMT + Content-Type: + - application/json + Content-Length: + - '4476' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - e3937b40-2f56-48ad-a6f0-45cd4f8561b6 + Original-Request: + - req_OOfatPeQh69g1l + Request-Id: + - req_OOfatPeQh69g1l + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3M1YKR2sOmf47Nz91drXiltW", + "object": "payment_intent", + "amount": 262500, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 262500, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3M1YKR2sOmf47Nz91vWs1pmT", + "object": "charge", + "amount": 262500, + "amount_captured": 262500, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3M1YKR2sOmf47Nz91H5LYpIY", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1667839064, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 45, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3M1YKR2sOmf47Nz91drXiltW", + "payment_method": "pm_1M1YKR2sOmf47Nz9zhudFmj4", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNnopJsGMgYH2kV57SE6LBZXrW4wqlWRDUWQvuladk_TMDrnHcxaLwpAjVvyXY2T71ztlsFRB6pt77PJ", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3M1YKR2sOmf47Nz91vWs1pmT/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3M1YKR2sOmf47Nz91drXiltW" + }, + "client_secret": "pi_3M1YKR2sOmf47Nz91drXiltW_secret_0gbfWYm7cOart4zGoozKDSQP0", + "confirmation_method": "manual", + "created": 1667839063, + "currency": "usd", + "customer": "cus_8CyNk3UTi8lvCc", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1M1YKR2sOmf47Nz9zhudFmj4", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Mon, 07 Nov 2022 16:37:45 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/store_order_pay_by_cart_and_wallet_success.yml b/test/vcr_cassettes/store_order_pay_by_cart_and_wallet_success.yml new file mode 100644 index 000000000..f7b9c8045 --- /dev/null +++ b/test/vcr_cassettes/store_order_pay_by_cart_and_wallet_success.yml @@ -0,0 +1,338 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods + body: + encoding: UTF-8 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2023&card[cvc]=314 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 17:46:28 GMT + Content-Type: + - application/json + Content-Length: + - '930' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 1f9773d4-f524-44dc-90f2-dde90fea1744 + Original-Request: + - req_Fbkchw5l1kFzvM + Request-Id: + - req_Fbkchw5l1kFzvM + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1M1ZOy2sOmf47Nz9S0LQW3tB", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1667843188, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Mon, 07 Nov 2022 17:46:28 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents + body: + encoding: UTF-8 + string: payment_method=pm_1M1ZOy2sOmf47Nz9S0LQW3tB&amount=400¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_IhIynmoJbzLpwX + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_Fbkchw5l1kFzvM","request_duration_ms":681}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Thu Sep 29 20:15:11 + PDT 2022; root:xnu-7195.141.42~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 07 Nov 2022 17:46:31 GMT + Content-Type: + - application/json + Content-Length: + - '4464' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - 9452e086-c4bb-4c4e-94fe-ddef5a8f8cc1 + Original-Request: + - req_GIkX2D4yu7OXRO + Request-Id: + - req_GIkX2D4yu7OXRO + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3M1ZOz2sOmf47Nz90tK9gllB", + "object": "payment_intent", + "amount": 400, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 400, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3M1ZOz2sOmf47Nz90wkd0Upl", + "object": "charge", + "amount": 400, + "amount_captured": 400, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3M1ZOz2sOmf47Nz90XN9Q2k6", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1667843190, + "currency": "usd", + "customer": "cus_IhIynmoJbzLpwX", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 49, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3M1ZOz2sOmf47Nz90tK9gllB", + "payment_method": "pm_1M1ZOy2sOmf47Nz9S0LQW3tB", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KPaIpZsGMgYxLskxGmo6LBa54zBFlRI8uQmwFIdZImPLTv2fAl6N6X554JBe_MnNGACgZ-jJ5M4MYK4R", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3M1ZOz2sOmf47Nz90wkd0Upl/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3M1ZOz2sOmf47Nz90tK9gllB" + }, + "client_secret": "pi_3M1ZOz2sOmf47Nz90tK9gllB_secret_8DSGuKQHUpisoFx6hdLYsPtTy", + "confirmation_method": "manual", + "created": 1667843189, + "currency": "usd", + "customer": "cus_IhIynmoJbzLpwX", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1M1ZOy2sOmf47Nz9S0LQW3tB", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Mon, 07 Nov 2022 17:46:31 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/store_order_pay_by_cart_success.yml b/test/vcr_cassettes/store_order_pay_by_cart_success.yml new file mode 100644 index 000000000..0a22021ed --- /dev/null +++ b/test/vcr_cassettes/store_order_pay_by_cart_success.yml @@ -0,0 +1,338 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods + body: + encoding: UTF-8 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2023&card[cvc]=314 + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Wed Nov 10 22:23:07 + PST 2021; root:xnu-7195.141.14~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 25 Oct 2022 17:01:39 GMT + Content-Type: + - application/json + Content-Length: + - '930' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - f35e1243-3373-4c8a-b489-941ab090e32a + Original-Request: + - req_6m9f3nj5nuWQk9 + Request-Id: + - req_6m9f3nj5nuWQk9 + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1LwqVT2sOmf47Nz9JSw0Rtly", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1666717299, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Tue, 25 Oct 2022 17:01:39 GMT +- request: + method: post + uri: https://api.stripe.com/v1/payment_intents + body: + encoding: UTF-8 + string: payment_method=pm_1LwqVT2sOmf47Nz9JSw0Rtly&amount=500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_IhIynmoJbzLpwX + headers: + User-Agent: + - Stripe/v1 RubyBindings/5.29.0 + Authorization: + - Bearer sk_test_testfaketestfaketestfake + Content-Type: + - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_6m9f3nj5nuWQk9","request_duration_ms":667}}' + Stripe-Version: + - '2019-08-14' + X-Stripe-Client-User-Agent: + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin + MacBook-Pro-Sleede-Peng 20.6.0 Darwin Kernel Version 20.6.0: Wed Nov 10 22:23:07 + PST 2021; root:xnu-7195.141.14~1/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 25 Oct 2022 17:01:41 GMT + Content-Type: + - application/json + Content-Length: + - '4464' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, POST, HEAD, OPTIONS, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Idempotency-Key: + - a7152ff2-3b76-46d6-8cfa-60979a27e592 + Original-Request: + - req_8ymsTfLVq6gYs5 + Request-Id: + - req_8ymsTfLVq6gYs5 + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - '2019-08-14' + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pi_3LwqVU2sOmf47Nz91iSEP5Kz", + "object": "payment_intent", + "amount": 500, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 500, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3LwqVU2sOmf47Nz91Twrth0m", + "object": "charge", + "amount": 500, + "amount_captured": 500, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3LwqVU2sOmf47Nz9170kBflD", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1666717300, + "currency": "usd", + "customer": "cus_IhIynmoJbzLpwX", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 22, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3LwqVU2sOmf47Nz91iSEP5Kz", + "payment_method": "pm_1LwqVT2sOmf47Nz9JSw0Rtly", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 4, + "exp_year": 2023, + "fingerprint": "o52jybR7bnmNn6AT", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KPWs4JoGMgbJTvRAJtU6LBbJYFh6FJvH_a5RzUPRqsu57K44IOuXb8Or12WKgAyL7TIX_J3zBZvUJhl1", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3LwqVU2sOmf47Nz91Twrth0m/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3LwqVU2sOmf47Nz91iSEP5Kz" + }, + "client_secret": "pi_3LwqVU2sOmf47Nz91iSEP5Kz_secret_G8gooGGZf0UBUH5BNUxvHG8vt", + "confirmation_method": "manual", + "created": 1666717300, + "currency": "usd", + "customer": "cus_IhIynmoJbzLpwX", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1LwqVT2sOmf47Nz9JSw0Rtly", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + recorded_at: Tue, 25 Oct 2022 17:01:41 GMT +recorded_with: VCR 6.0.0 diff --git a/tsconfig.json b/tsconfig.json index 8a0c68a81..696ff4212 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "declaration": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "lib": ["es6", "dom", "es2015.collection", "es2015.iterable"], + "lib": ["dom", "dom.iterable", "es2019"], "module": "ES2020", "moduleResolution": "node", "sourceMap": true, diff --git a/yarn.lock b/yarn.lock index 7a0ee5cfa..281b9b244 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,45 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/modifiers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078" + integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -3260,20 +3299,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001313: - version "1.0.30001314" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz#65c7f9fb7e4594fca0a333bec1d8939662377596" - integrity sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw== - -caniuse-lite@^1.0.30001219: - version "1.0.30001296" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz" - integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q== - -caniuse-lite@^1.0.30001332: - version "1.0.30001335" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz#899254a0b70579e5a957c32dced79f0727c61f2a" - integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332: + version "1.0.30001397" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz" + integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA== chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" @@ -3317,6 +3346,11 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== +classnames@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + clean-css@^4.2.3: version "4.2.4" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" @@ -5228,6 +5262,11 @@ jquery@>=3.5.0: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== +js-cookie@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" + integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -6539,6 +6578,11 @@ react-cool-onclickoutside@^1.7.0: resolved "https://registry.yarnpkg.com/react-cool-onclickoutside/-/react-cool-onclickoutside-1.7.0.tgz#abc844e14852220fe15f81d7ef44976d15cd9980" integrity sha512-HVZK2155Unee+enpoHKyYP2UdQK69thw90XAOUCjvJBcgRSgfRPgWWt/W1dYzoGp3+nleAa8SJxF1d4FMA4Qmw== +react-custom-events@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/react-custom-events/-/react-custom-events-1.1.1.tgz#792f126e897043a14b9f27a4c5ab7072ff235ceb" + integrity sha512-71iEu3zHsBn3uvF+Sq4Fu5imtRt+cLZO6nG2zqUhdqGVIpZIfeLcl6yieqPghrE+18KFrS5BaHD0NBPP/EZJNw== + react-dom@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" @@ -6600,6 +6644,14 @@ react-select@^5.3.2: prop-types "^15.6.0" react-transition-group "^4.3.0" +react-sortablejs@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/react-sortablejs/-/react-sortablejs-6.1.4.tgz#420ebfab602bbd935035dec24a04c8b3b836dbbf" + integrity sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ== + dependencies: + classnames "2.3.1" + tiny-invariant "1.2.0" + react-switch@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/react-switch/-/react-switch-6.0.0.tgz#bd4a2dea08f211b8a32e55e8314fd44bc1ec947e" @@ -7098,6 +7150,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slugify@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" + integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== + sockjs@^0.3.21: version "0.3.21" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.21.tgz#b34ffb98e796930b60a0cfa11904d6a339a7d417" @@ -7107,6 +7164,11 @@ sockjs@^0.3.21: uuid "^3.4.0" websocket-driver "^0.7.4" +sortablejs@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" + integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -7380,6 +7442,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" + integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== + tippy.js@^6.3.7: version "6.3.7" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" @@ -7424,6 +7491,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"