mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-03-14 11:29:15 +01:00
Merge branch 'dev' for release 5.6.0
This commit is contained in:
commit
19cde5f9c4
30
.eslintrc
30
.eslintrc
@ -37,14 +37,11 @@
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "react"],
|
||||
"rules": {
|
||||
"react/prop-types": "off"
|
||||
"react/prop-types": "off",
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -66,7 +63,26 @@
|
||||
"rules": {
|
||||
"camelcase": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["test/**/*"],
|
||||
"plugins": ["jest"],
|
||||
"env": {
|
||||
"jest/globals": true
|
||||
},
|
||||
"globals": {
|
||||
"Range": true,
|
||||
"Document": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./test/frontend/tsconfig.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ CommitMsg:
|
||||
|
||||
MessageFormat:
|
||||
enabled: true
|
||||
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
|
||||
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge) title\n\ndescription
|
||||
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
|
||||
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip) title\n\ndescription
|
||||
sample_message: (bug) no validation on date\n\nThe birthdate was not validated...
|
||||
|
||||
|
@ -3,7 +3,7 @@ require: rubocop-rails
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
Layout/LineLength:
|
||||
Max: 140
|
||||
Max: 145
|
||||
Metrics/MethodLength:
|
||||
Max: 35
|
||||
Metrics/CyclomaticComplexity:
|
||||
|
44
CHANGELOG.md
44
CHANGELOG.md
@ -1,5 +1,49 @@
|
||||
# Changelog Fab-manager
|
||||
|
||||
## v5.6.0 2023 January 5
|
||||
|
||||
- Ability to group machines by categories
|
||||
- Ability to mark a machine as reservable or not
|
||||
- Ability to filter the admin's calendar
|
||||
- Private note on member's profile
|
||||
- Optional external identifier for users
|
||||
- Ability to disable generation of invoices at zero
|
||||
- Accounting data is now built each night and saved in database
|
||||
- Ability to define multiple accounting journal codes
|
||||
- Ability to change the name of the VAT
|
||||
- Ability to cancel a running subscription from the member edition view for admin/managers
|
||||
- OpenAPI endpoint to fetch accounting data
|
||||
- Add reservation deadline parameter (#414)
|
||||
- Verify current password at server side when changing password
|
||||
- Password strengh indicator
|
||||
- Updated OpenAPI documentation
|
||||
- Updated OpenID Connect documentation
|
||||
- OpenAPI users endpoint offer ability to filter by created_after
|
||||
- OpenAPI users endpoint return first name, last name, gender, organization and address
|
||||
- Default accounting codes and labels if not set
|
||||
- Active serving static files from the `/public` folder by default from rails
|
||||
- Display custom error message if the PDF invoice is not found
|
||||
- Report subsription mismatch with user's group
|
||||
- Added sentry for error reporting
|
||||
- Report details of the due for invoices related to a payment schedule
|
||||
- Fix a bug: unable to run test in negative timezones (#425)
|
||||
- Fix a bug: providing an array of attributes to filter OpenApi data, results in error
|
||||
- Fix a bug: unable to manage stocks on new products
|
||||
- Fix a bug: unsupported param[] syntax in OpenAPI
|
||||
- Fix a bug: unable to access in-system notifications if a slot was cancelled
|
||||
- Fix a bug: feature tour in admin/settings is broken
|
||||
- Fix a bug: clearing the new expiration date field in the offer days modal result in errors
|
||||
- Fix a bug: low stock notification is always sent if one of the stocks has reached the threshold
|
||||
- Fix a bug: unable to update title of availability after admin remove a machine/plan in calendar
|
||||
- Fix a bug: unable to access files from the public folder (like example.csv)
|
||||
- Fix a bug: unable to return to the statistics tab
|
||||
- Fix a bug: payment schedule by check result in error if payment mean was not changed
|
||||
- Fix a security issue: updated httparty to 0.21.0 to fix [GHSA-5pq7-52mg-hr42](https://github.com/advisories/GHSA-5pq7-52mg-hr42)
|
||||
- Updated react-modal to 3.16.1
|
||||
- Updated tiptap editor and its dependencies to 2.0.0-beta.204
|
||||
- [TODO DEPLOY] `rails db:seed`
|
||||
- [TODO DEPLOY] `rails fablab:setup:build_accounting_lines`
|
||||
|
||||
## v5.5.8 2022 December 16
|
||||
|
||||
- Fix a bug: wrong reservations count for spaces in availabilities export (#415)
|
||||
|
4
Gemfile
4
Gemfile
@ -143,3 +143,7 @@ gem 'sassc', '= 2.1.0'
|
||||
gem 'redis-session-store'
|
||||
|
||||
gem 'acts_as_list'
|
||||
|
||||
# Error reporting
|
||||
gem 'sentry-rails'
|
||||
gem 'sentry-ruby'
|
||||
|
16
Gemfile.lock
16
Gemfile.lock
@ -166,8 +166,8 @@ GEM
|
||||
hashery (2.1.2)
|
||||
hashie (5.0.0)
|
||||
htmlentities (4.3.4)
|
||||
httparty (0.20.0)
|
||||
mime-types (~> 3.0)
|
||||
httparty (0.21.0)
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.12.0)
|
||||
@ -216,9 +216,6 @@ GEM
|
||||
message_format (0.0.6)
|
||||
twitter_cldr (~> 5.0)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2022.0105)
|
||||
mimemagic (0.4.3)
|
||||
nokogiri (~> 1)
|
||||
rake
|
||||
@ -397,6 +394,11 @@ GEM
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.7.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.7.0)
|
||||
sentry-ruby (5.7.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sha3 (1.0.1)
|
||||
shakapacker (6.2.0)
|
||||
activesupport (>= 5.2)
|
||||
@ -559,6 +561,8 @@ DEPENDENCIES
|
||||
rubyzip (>= 1.3.0)
|
||||
sassc (= 2.1.0)
|
||||
seed_dump
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
sha3
|
||||
shakapacker (= 6.2.0)
|
||||
sidekiq (>= 6.0.7)
|
||||
@ -574,4 +578,4 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.25
|
||||
2.3.26
|
||||
|
@ -2,12 +2,11 @@
|
||||
|
||||
# API Controller for resources of AccountingPeriod
|
||||
class API::AccountingPeriodsController < API::ApiController
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_period, only: %i[show download_archive]
|
||||
|
||||
def index
|
||||
@accounting_periods = AccountingPeriodService.all_periods_with_users
|
||||
@accounting_periods = Accounting::AccountingPeriodService.all_periods_with_users
|
||||
end
|
||||
|
||||
def show; end
|
||||
@ -24,7 +23,7 @@ class API::AccountingPeriodsController < API::ApiController
|
||||
|
||||
def last_closing_end
|
||||
authorize AccountingPeriod
|
||||
last_period = AccountingPeriodService.find_last_period
|
||||
last_period = Accounting::AccountingPeriodService.find_last_period
|
||||
if last_period.nil?
|
||||
invoice = Invoice.order(:created_at).first
|
||||
@last_end = invoice.created_at if invoice
|
||||
@ -35,7 +34,7 @@ class API::AccountingPeriodsController < API::ApiController
|
||||
|
||||
def download_archive
|
||||
authorize AccountingPeriod
|
||||
send_file File.join(Rails.root, @accounting_period.archive_file), type: 'application/json', disposition: 'attachment'
|
||||
send_file Rails.root.join(@accounting_period.archive_file), type: 'application/json', disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -11,12 +11,12 @@ class API::AvailabilitiesController < API::ApiController
|
||||
def index
|
||||
authorize Availability
|
||||
display_window = window
|
||||
@availabilities = Availability.includes(:machines, :tags, :trainings, :spaces)
|
||||
.where('start_at >= ? AND end_at <= ?', display_window[:start], display_window[:end])
|
||||
|
||||
@availabilities = @availabilities.where.not(available_type: 'event') unless Setting.get('events_in_calendar')
|
||||
|
||||
@availabilities = @availabilities.where.not(available_type: 'space') unless Setting.get('spaces_module')
|
||||
service = Availabilities::AvailabilitiesService.new(@current_user, 'availability')
|
||||
machine_ids = params[:m] || []
|
||||
@availabilities = service.index(display_window,
|
||||
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
|
||||
events: (params[:evt] && params[:evt] == 'true'))
|
||||
@availabilities = filter_availabilites(@availabilities)
|
||||
end
|
||||
|
||||
def public
|
||||
@ -27,7 +27,7 @@ class API::AvailabilitiesController < API::ApiController
|
||||
@availabilities = service.public_availabilities(
|
||||
display_window,
|
||||
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
|
||||
(params[:evt] && params[:evt] == 'true')
|
||||
events: (params[:evt] && params[:evt] == 'true')
|
||||
)
|
||||
|
||||
@title_filter = { machine_ids: machine_ids.map(&:to_i) }
|
||||
@ -112,7 +112,7 @@ class API::AvailabilitiesController < API::ApiController
|
||||
render json: @export.errors, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
send_file File.join(Rails.root, export.file),
|
||||
send_file Rails.root.join(export.file),
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
@ -120,7 +120,7 @@ class API::AvailabilitiesController < API::ApiController
|
||||
|
||||
def lock
|
||||
authorize @availability
|
||||
if @availability.update_attributes(lock: lock_params)
|
||||
if @availability.update(lock: lock_params)
|
||||
render :show, status: :ok, location: @availability
|
||||
else
|
||||
render json: @availability.errors, status: :unprocessable_entity
|
||||
@ -163,10 +163,19 @@ class API::AvailabilitiesController < API::ApiController
|
||||
end
|
||||
|
||||
def filter_availabilites(availabilities)
|
||||
availabilities.delete_if(&method(:remove_full?))
|
||||
availabilities_filterd = availabilities
|
||||
availabilities_filterd = availabilities.delete_if(&method(:remove_full?)) if params[:dispo] == 'false'
|
||||
|
||||
availabilities_filterd = availabilities.delete_if(&method(:remove_empty?)) if params[:reserved] == 'true'
|
||||
|
||||
availabilities_filterd
|
||||
end
|
||||
|
||||
def remove_full?(availability)
|
||||
params[:dispo] == 'false' && (availability.is_reserved || (availability.try(:full?) && availability.full?))
|
||||
availability.try(:full?) && availability.full?
|
||||
end
|
||||
|
||||
def remove_empty?(availability)
|
||||
availability.try(:empty?) && availability.empty?
|
||||
end
|
||||
end
|
||||
|
@ -10,9 +10,9 @@ class API::EventsController < API::ApiController
|
||||
@scope = params[:scope]
|
||||
|
||||
# filters
|
||||
@events = @events.joins(:category).where('categories.id = :category', category: params[:category_id]) if params[:category_id]
|
||||
@events = @events.joins(:event_themes).where('event_themes.id = :theme', theme: params[:theme_id]) if params[:theme_id]
|
||||
@events = @events.where('age_range_id = :age_range', age_range: params[:age_range_id]) if params[:age_range_id]
|
||||
@events = @events.joins(:category).where(categories: { id: params[:category_id] }) if params[:category_id]
|
||||
@events = @events.joins(:event_themes).where(event_themes: { id: params[:theme_id] }) if params[:theme_id]
|
||||
@events = @events.where(age_range_id: params[:age_range_id]) if params[:age_range_id]
|
||||
|
||||
if current_user&.admin? || current_user&.manager?
|
||||
@events = case params[:scope]
|
||||
@ -65,7 +65,7 @@ class API::EventsController < API::ApiController
|
||||
|
||||
def update
|
||||
authorize Event
|
||||
res = EventService.update(@event, event_params.permit!, params[:edit_mode])
|
||||
res = Event::UpdateEventService.update(@event, event_params.permit!, params[:edit_mode])
|
||||
render json: { action: 'update', total: res[:events].length, updated: res[:events].select { |r| r[:status] }.length, details: res },
|
||||
status: :ok,
|
||||
location: @event
|
||||
@ -97,7 +97,8 @@ class API::EventsController < API::ApiController
|
||||
event_theme_ids: [],
|
||||
event_image_attributes: [:attachment],
|
||||
event_files_attributes: %i[id attachment _destroy],
|
||||
event_price_categories_attributes: %i[id price_category_id amount _destroy])
|
||||
event_price_categories_attributes: %i[id price_category_id amount _destroy],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
EventService.process_params(event_preparams)
|
||||
end
|
||||
end
|
||||
|
@ -12,9 +12,13 @@ class API::InvoicesController < API::ApiController
|
||||
).all.order('reference DESC')
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def download
|
||||
authorize @invoice
|
||||
send_file File.join(Rails.root, @invoice.file), type: 'application/pdf', disposition: 'attachment'
|
||||
send_file Rails.root.join(@invoice.file), type: 'application/pdf', disposition: 'attachment'
|
||||
rescue ActionController::MissingFile
|
||||
render html: I18n.t('invoices.unable_to_find_pdf'), status: :not_found
|
||||
end
|
||||
|
||||
def list
|
||||
|
52
app/controllers/api/machine_categories_controller.rb
Normal file
52
app/controllers/api/machine_categories_controller.rb
Normal file
@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for resources of type Machine Category
|
||||
# Categories are used to classify Machine
|
||||
class API::MachineCategoriesController < API::ApiController
|
||||
before_action :authenticate_user!, except: [:index]
|
||||
before_action :set_machine_category, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@machine_categories = MachineCategory.all.order(name: :asc)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
authorize MachineCategory
|
||||
@machine_category = MachineCategory.new(machine_category_params)
|
||||
if @machine_category.save
|
||||
render :show, status: :created, location: @category
|
||||
else
|
||||
render json: @machine_category.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize MachineCategory
|
||||
if @machine_category.update(machine_category_params)
|
||||
render :show, status: :ok, location: @category
|
||||
else
|
||||
render json: @machine_category.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize MachineCategory
|
||||
if @machine_category.destroy
|
||||
head :no_content
|
||||
else
|
||||
render json: @machine_category.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_machine_category
|
||||
@machine_category = MachineCategory.find(params[:id])
|
||||
end
|
||||
|
||||
def machine_category_params
|
||||
params.require(:machine_category).permit(:name, machine_ids: [])
|
||||
end
|
||||
end
|
@ -49,8 +49,9 @@ class API::MachinesController < API::ApiController
|
||||
end
|
||||
|
||||
def machine_params
|
||||
params.require(:machine).permit(:name, :description, :spec, :disabled, :plan_ids,
|
||||
params.require(:machine).permit(:name, :description, :spec, :disabled, :machine_category_id, :plan_ids, :reservable,
|
||||
plan_ids: [], machine_image_attributes: [:attachment],
|
||||
machine_files_attributes: %i[id attachment _destroy])
|
||||
machine_files_attributes: %i[id attachment _destroy],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
end
|
||||
end
|
||||
|
@ -4,6 +4,7 @@
|
||||
class API::MembersController < API::ApiController
|
||||
before_action :authenticate_user!, except: [:last_subscribed]
|
||||
before_action :set_member, only: %i[update destroy merge complete_tour update_role validate]
|
||||
before_action :set_operator, only: %i[show update create]
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
@ -46,7 +47,7 @@ class API::MembersController < API::ApiController
|
||||
authorize @member
|
||||
members_service = Members::MembersService.new(@member)
|
||||
|
||||
if members_service.update(user_params)
|
||||
if members_service.update(user_params, current_user, params[:user][:current_password])
|
||||
# Update password without logging out
|
||||
bypass_sign_in(@member) unless current_user.id != params[:id].to_i
|
||||
render :show, status: :ok, location: member_path(@member)
|
||||
@ -213,6 +214,10 @@ class API::MembersController < API::ApiController
|
||||
@member = User.find(params[:id])
|
||||
end
|
||||
|
||||
def set_operator
|
||||
@operator = current_user
|
||||
end
|
||||
|
||||
def user_params
|
||||
if current_user.id == params[:id].to_i
|
||||
params.require(:user).permit(:username, :email, :password, :password_confirmation, :group_id, :is_allow_contact, :is_allow_newsletter,
|
||||
@ -230,15 +235,15 @@ class API::MembersController < API::ApiController
|
||||
],
|
||||
statistic_profile_attributes: %i[id gender birthday])
|
||||
|
||||
elsif current_user.admin? || current_user.manager?
|
||||
elsif current_user.privileged?
|
||||
params.require(:user).permit(:username, :email, :password, :password_confirmation, :is_allow_contact, :is_allow_newsletter, :group_id,
|
||||
tag_ids: [],
|
||||
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
|
||||
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
|
||||
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
|
||||
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, :note,
|
||||
{ user_avatar_attributes: %i[id attachment destroy] }],
|
||||
invoicing_profile_attributes: [
|
||||
:id, :organization,
|
||||
:id, :organization, :external_id,
|
||||
{
|
||||
address_attributes: %i[id address],
|
||||
organization_attributes: [:id, :name, { address_attributes: %i[id address] }],
|
||||
|
@ -4,7 +4,6 @@
|
||||
class API::PaymentsController < API::ApiController
|
||||
before_action :authenticate_user!
|
||||
|
||||
|
||||
# This method must be overridden by the the gateways controllers that inherits API::PaymentsControllers
|
||||
def confirm_payment
|
||||
raise NoMethodError
|
||||
@ -41,5 +40,7 @@ class API::PaymentsController < API::ApiController
|
||||
else
|
||||
{ json: res[:errors].drop_while(&:empty?), status: :unprocessable_entity }
|
||||
end
|
||||
rescue StandardError => e
|
||||
{ json: e, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
|
@ -26,7 +26,7 @@ class API::PlansController < API::ApiController
|
||||
end
|
||||
|
||||
type = plan_params[:type]
|
||||
partner = params[:plan][:partner_id].empty? ? nil : User.find(params[:plan][:partner_id])
|
||||
partner = params[:plan][:partner_id].blank? ? nil : User.find(params[:plan][:partner_id])
|
||||
|
||||
plan = PlansService.create(type, partner, plan_params)
|
||||
if plan.key?(:errors)
|
||||
@ -83,7 +83,8 @@ class API::PlansController < API::ApiController
|
||||
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
|
||||
:training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id,
|
||||
plan_file_attributes: %i[id attachment _destroy],
|
||||
prices_attributes: %i[id amount])
|
||||
prices_attributes: %i[id amount],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -73,6 +73,7 @@ class API::ProductsController < API::ApiController
|
||||
:low_stock_alert, :low_stock_threshold,
|
||||
machine_ids: [],
|
||||
product_files_attributes: %i[id attachment _destroy],
|
||||
product_images_attributes: %i[id attachment is_main _destroy])
|
||||
product_images_attributes: %i[id attachment is_main _destroy],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
end
|
||||
end
|
||||
|
@ -1,13 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for resources of type ProfileCustomField
|
||||
# ProfileCustomFields are used to provide admin config user profile custom fields
|
||||
# ProfileCustomFields are fields configured by an admin, added to the user's profile
|
||||
class API::ProfileCustomFieldsController < API::ApiController
|
||||
before_action :authenticate_user!, except: :index
|
||||
before_action :set_profile_custom_field, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@profile_custom_fields = ProfileCustomField.all.order('id ASC')
|
||||
@profile_custom_fields = @profile_custom_fields.where(actived: params[:actived]) if params[:actived].present?
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
@ -12,10 +12,10 @@ class API::SettingsController < API::ApiController
|
||||
authorize Setting
|
||||
@setting = Setting.find_or_initialize_by(name: params[:name])
|
||||
render status: :not_modified and return if setting_params[:value] == @setting.value
|
||||
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.before_update(@setting)
|
||||
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.update_allowed?(@setting)
|
||||
|
||||
if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
SettingService.after_update(@setting)
|
||||
SettingService.run_after_update([@setting])
|
||||
render status: :ok
|
||||
else
|
||||
render json: @setting.errors.full_messages, status: :unprocessable_entity
|
||||
@ -31,17 +31,19 @@ class API::SettingsController < API::ApiController
|
||||
next if !setting[:name] || !setting[:value]
|
||||
|
||||
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
||||
if !SettingService.before_update(db_setting)
|
||||
if !SettingService.update_allowed?(db_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)
|
||||
unless db_setting.value == setting[:value]
|
||||
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
end
|
||||
end
|
||||
|
||||
@settings.push db_setting
|
||||
may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive?
|
||||
end
|
||||
end
|
||||
SettingService.run_after_update(@settings)
|
||||
end
|
||||
|
||||
def show
|
||||
@ -61,7 +63,7 @@ class API::SettingsController < API::ApiController
|
||||
authorize Setting
|
||||
|
||||
setting = Setting.find_or_create_by(name: params[:name])
|
||||
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.before_update(setting)
|
||||
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.update_allowed?(setting)
|
||||
|
||||
first_val = setting.history_values.order(created_at: :asc).limit(1).first
|
||||
new_val = HistoryValue.create!(
|
||||
@ -69,7 +71,7 @@ class API::SettingsController < API::ApiController
|
||||
value: first_val&.value,
|
||||
invoicing_profile_id: current_user.invoicing_profile.id
|
||||
)
|
||||
SettingService.after_update(setting)
|
||||
SettingService.run_after_update([setting])
|
||||
render json: new_val, status: :ok
|
||||
end
|
||||
|
||||
|
@ -51,6 +51,7 @@ class API::SpacesController < API::ApiController
|
||||
def space_params
|
||||
params.require(:space).permit(:name, :description, :characteristics, :default_places, :disabled,
|
||||
space_image_attributes: [:attachment],
|
||||
space_files_attributes: %i[id attachment _destroy])
|
||||
space_files_attributes: %i[id attachment _destroy],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
end
|
||||
end
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
# API Controller for resources of type Subscription
|
||||
class API::SubscriptionsController < API::ApiController
|
||||
before_action :set_subscription, only: %i[show payment_details]
|
||||
before_action :set_subscription, only: %i[show payment_details cancel]
|
||||
before_action :authenticate_user!
|
||||
|
||||
def show
|
||||
@ -13,6 +13,15 @@ class API::SubscriptionsController < API::ApiController
|
||||
authorize @subscription
|
||||
end
|
||||
|
||||
def cancel
|
||||
authorize @subscription
|
||||
if @subscription.expire(DateTime.current)
|
||||
render :show, status: :ok, location: @subscription
|
||||
else
|
||||
render json: { error: 'already expired' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
|
@ -76,6 +76,7 @@ class API::TrainingsController < API::ApiController
|
||||
def training_params
|
||||
params.require(:training)
|
||||
.permit(:id, :name, :description, :machine_ids, :plan_ids, :nb_total_places, :public_page, :disabled,
|
||||
training_image_attributes: [:attachment], machine_ids: [], plan_ids: [])
|
||||
training_image_attributes: [:attachment], machine_ids: [], plan_ids: [],
|
||||
advanced_accounting_attributes: %i[code analytical_section])
|
||||
end
|
||||
end
|
||||
|
@ -11,7 +11,7 @@ class API::UsersController < API::ApiController
|
||||
if %w[partner manager].include?(params[:role])
|
||||
@users = User.with_role(params[:role].to_sym).includes(:profile)
|
||||
else
|
||||
head 403
|
||||
head :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
|
37
app/controllers/open_api/v1/accounting_controller.rb
Normal file
37
app/controllers/open_api/v1/accounting_controller.rb
Normal file
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# authorized 3rd party softwares can fetch the accounting lines through the OpenAPI
|
||||
class OpenAPI::V1::AccountingController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
def index
|
||||
@codes = {
|
||||
card: Setting.get('accounting_payment_card_code'),
|
||||
wallet: Setting.get('accounting_payment_wallet_code'),
|
||||
other: Setting.get('accounting_payment_other_code')
|
||||
}
|
||||
|
||||
@lines = AccountingLine.order(date: :desc)
|
||||
.includes(:invoicing_profile, invoice: :payment_gateway_object)
|
||||
|
||||
@lines = @lines.where('date >= ?', DateTime.parse(params[:after])) if params[:after].present?
|
||||
@lines = @lines.where('date <= ?', DateTime.parse(params[:before])) if params[:before].present?
|
||||
@lines = @lines.where(invoice_id: may_array(params[:invoice_id])) if params[:invoice_id].present?
|
||||
@lines = @lines.where(line_type: may_array(params[:type])) if params[:type].present?
|
||||
|
||||
@lines = @lines.page(page).per(per_page)
|
||||
paginate @lines, per_page: per_page
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def page
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
end
|
@ -4,7 +4,9 @@
|
||||
module OpenAPI::V1; end
|
||||
|
||||
# Parameters for OpenAPI endpoints
|
||||
class OpenAPI::V1::BaseController < ActionController::Base
|
||||
class OpenAPI::V1::BaseController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :authenticate
|
||||
@ -49,7 +51,7 @@ class OpenAPI::V1::BaseController < ActionController::Base
|
||||
end
|
||||
|
||||
def render_unauthorized
|
||||
render json: { errors: ['Bad credentials'] }, status: 401
|
||||
render json: { errors: ['Bad credentials'] }, status: :unauthorized
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -9,9 +9,7 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
|
||||
raise ActionController::ParameterMissing if params[:user_id].blank?
|
||||
|
||||
@machines = Machine.all
|
||||
|
||||
@machines = @machines.where(id: params[:machine_id]) if params[:machine_id].present?
|
||||
|
||||
@machines = @machines.to_a
|
||||
|
||||
user = User.find(params[:user_id])
|
||||
@ -20,15 +18,11 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
|
||||
(machine.trainings.count != 0) and !user.training_machine?(machine)
|
||||
end
|
||||
|
||||
|
||||
@hours_remaining = Hash[@machines.map { |m| [m.id, 0] }]
|
||||
|
||||
|
||||
@hours_remaining = @machines.to_h { |m| [m.id, 0] }
|
||||
|
||||
return unless user.subscription
|
||||
|
||||
plan_id = user.subscription.plan_id
|
||||
|
||||
@machines.each do |machine|
|
||||
credit = Credit.find_by(plan_id: plan_id, creditable: machine)
|
||||
users_credit = user.users_credits.find_by(credit: credit) if credit
|
||||
|
@ -16,10 +16,9 @@ class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
|
||||
@events.order(created_at: :desc)
|
||||
end
|
||||
|
||||
@events = @events.where(id: may_array(params[:id])) if params[:id].present?
|
||||
|
||||
@events = @events.where(id: params[:id]) if params[:id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
return if params[:page].blank?
|
||||
|
||||
@events = @events.page(params[:page]).per(per_page)
|
||||
paginate @events, per_page: per_page
|
||||
|
@ -11,9 +11,9 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
|
||||
.includes(invoicing_profile: :user)
|
||||
.references(:invoicing_profiles)
|
||||
|
||||
@invoices = @invoices.where(invoicing_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
|
||||
@invoices = @invoices.where(invoicing_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
return if params[:page].blank?
|
||||
|
||||
@invoices = @invoices.page(params[:page]).per(per_page)
|
||||
paginate @invoices, per_page: per_page
|
||||
@ -21,7 +21,7 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
|
||||
|
||||
def download
|
||||
@invoice = Invoice.find(params[:id])
|
||||
send_file File.join(Rails.root, @invoice.file), type: 'application/pdf', disposition: 'inline', filename: @invoice.filename
|
||||
send_file Rails.root.join(@invoice.file), type: 'application/pdf', disposition: 'inline', filename: @invoice.filename
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -11,11 +11,11 @@ class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
|
||||
.includes(statistic_profile: :user)
|
||||
.references(:statistic_profiles)
|
||||
|
||||
@reservations = @reservations.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
|
||||
@reservations = @reservations.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
|
||||
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type])) if params[:reservable_type].present?
|
||||
@reservations = @reservations.where(reservable_id: params[:reservable_id]) if params[:reservable_id].present?
|
||||
@reservations = @reservations.where(reservable_id: may_array(params[:reservable_id])) if params[:reservable_id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
return if params[:page].blank?
|
||||
|
||||
@reservations = @reservations.page(params[:page]).per(per_page)
|
||||
paginate @reservations, per_page: per_page
|
||||
|
@ -12,11 +12,10 @@ class OpenAPI::V1::UserTrainingsController < OpenAPI::V1::BaseController
|
||||
.references(:statistic_profiles)
|
||||
.order(created_at: :desc)
|
||||
|
||||
@user_trainings = @user_trainings.where(statistic_profiles: { user_id: may_array(params[:user_id]) }) if params[:user_id].present?
|
||||
@user_trainings = @user_trainings.where(training_id: may_array(params[:training_id])) if params[:training_id].present?
|
||||
|
||||
@user_trainings = @user_trainings.where(statistic_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
|
||||
@user_trainings = @user_trainings.where(training_id: params[:training_id]) if params[:training_id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
return if params[:page].blank?
|
||||
|
||||
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
|
||||
paginate @user_trainings, per_page: per_page
|
||||
|
@ -7,15 +7,16 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
|
||||
expose_doc
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc).includes(:group, :profile)
|
||||
@users = User.order(created_at: :desc).includes(:group, :profile, :invoicing_profile)
|
||||
|
||||
if params[:email].present?
|
||||
email_param = params[:email].is_a?(String) ? params[:email].downcase : params[:email].map(&:downcase)
|
||||
@users = @users.where(email: email_param)
|
||||
end
|
||||
@users = @users.where(id: params[:user_id]) if params[:user_id].present?
|
||||
@users = @users.where(id: may_array(params[:user_id])) if params[:user_id].present?
|
||||
@users = @users.where('created_at >= ?', DateTime.parse(params[:created_after])) if params[:created_after].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
return if params[:page].blank?
|
||||
|
||||
@users = @users.page(params[:page]).per(per_page)
|
||||
paginate @users, per_page: per_page
|
||||
|
@ -1,19 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Devise controller used for the "forgotten password" feature
|
||||
# Devise controller used for the "forgotten password" feature and to check the current's user password
|
||||
class PasswordsController < Devise::PasswordsController
|
||||
# POST /users/password.json
|
||||
def create
|
||||
self.resource = resource_class.send_reset_password_instructions(resource_params)
|
||||
yield resource if block_given?
|
||||
|
||||
if successfully_sent?(resource)
|
||||
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
|
||||
end
|
||||
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) if successfully_sent?(resource)
|
||||
end
|
||||
|
||||
# POST /password/verify
|
||||
def verify
|
||||
current_user.valid_password?(params[:password]) ? head(200) : head(404)
|
||||
current_user.valid_password?(params[:password]) ? head(:ok) : head(:not_found)
|
||||
end
|
||||
end
|
||||
|
109
app/doc/open_api/v1/accounting_doc.rb
Normal file
109
app/doc/open_api/v1/accounting_doc.rb
Normal file
@ -0,0 +1,109 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# openAPI documentation for accounting endpoints
|
||||
class OpenAPI::V1::AccountingDoc < OpenAPI::V1::BaseDoc
|
||||
resource_description do
|
||||
short 'Accounting lines'
|
||||
desc 'Accounting lines according to the French General Accounting Plan (PCG)'
|
||||
formats FORMATS
|
||||
api_version API_VERSION
|
||||
end
|
||||
|
||||
include OpenAPI::V1::Concerns::ParamGroups
|
||||
|
||||
doc_for :index do
|
||||
api :GET, "/#{API_VERSION}/accounting", 'Accounting lines'
|
||||
description 'All accounting lines, paginated (necessarily becauce there is a lot of data) with optional dates filtering. ' \
|
||||
'Ordered by *date* descendant.<br>' \
|
||||
'The field *status* indicates if the accounting data is being built or if the build is over. ' \
|
||||
'Possible status are: <i>building</i> or <i>built</i>.<br>' \
|
||||
'The field *invoice.payment_details* is available if line_type=payment. It will contain the following data:<br>' \
|
||||
'· *payment_mean*, possible status are: <i>card</i>, <i>wallet</i> or <i>other</i>. *WARNING*: If an invoice was settled ' \
|
||||
'using multiple payment means, this will only report the payment mean applicable to current line.<br>' \
|
||||
'· *gateway_object_id*, if payment_mean=card, report the ID of the payment gateway related object<br>' \
|
||||
'· *gateway_object_type*, if payment_mean=card, report the type of the payment gateway related object<br>' \
|
||||
'· *wallet_transaction_id*, if payment_mean=wallet, report the ID of the wallet transaction<br>'
|
||||
param_group :pagination
|
||||
param :after, DateTime, optional: true, desc: 'Filter accounting lines to lines after the given date.'
|
||||
param :before, DateTime, optional: true, desc: 'Filter accounting lines to lines before the given date.'
|
||||
param :invoice_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various invoices.'
|
||||
param :type, %w[client vat item], optional: true, desc: 'Filter accounting lines by line type.'
|
||||
|
||||
example <<-LINES
|
||||
# /open_api/v1/accounting?after=2022-01-01T00:00:00+02:00&page=1&per_page=3
|
||||
{
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"line_type": "payment",
|
||||
"journal_code": "VT01",
|
||||
"date": "2022-01-02T18:14:21+01:00",
|
||||
"account_code": "5802",
|
||||
"account_label": "Card customers",
|
||||
"analytical_code": "",
|
||||
"invoice": {
|
||||
"reference": "22010009/VL",
|
||||
"id": 274,
|
||||
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
|
||||
"url": "/open_api/v1/invoices/247/download",
|
||||
"payment_details": {
|
||||
"payment_mean": "card",
|
||||
"gateway_object_id": "pi_3MA2PPW4kx8QemzC02ABBEbo",
|
||||
"gateway_object_type": "Stripe::PaymentIntent"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"invoicing_profile_id": 6512,
|
||||
"external_id": "U52-ALC4"
|
||||
},
|
||||
"debit": 14.0,
|
||||
"credit": 0
|
||||
"currency": "EUR",
|
||||
"summary": "Dupont Marcel, 22010009/VL, subscr."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"line_type": "item",
|
||||
"journal_code": "VT01",
|
||||
"date": "2022-01-02T18:14:21+01:00",
|
||||
"account_code": "7071",
|
||||
"account_label": "Subscriptions",
|
||||
"analytical_code": "P3D71",
|
||||
"invoice": {
|
||||
"reference": "22010009/VL",
|
||||
"id": 274,
|
||||
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
|
||||
"url": "/open_api/v1/invoices/247/download"
|
||||
},
|
||||
"user_invoicing_profile_id": 6512,
|
||||
"debit": 0,
|
||||
"credit": 11.67
|
||||
"currency": "EUR",
|
||||
"summary": "Dupont Marcel, 22010009/VL, subscr."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"line_type": "vat",
|
||||
"journal_code": "VT01",
|
||||
"date": "2022-01-02T18:14:21+01:00",
|
||||
"account_code": "4457",
|
||||
"account_label": "Collected VAT",
|
||||
"analytical_code": "P3D71",
|
||||
"invoice": {
|
||||
"reference": "22010009/VL",
|
||||
"id": 274,
|
||||
"label": "Subscription of Dupont Marcel for 1 month starting from 2022, january 2nd",
|
||||
"url": "/open_api/v1/invoices/247/download"
|
||||
},
|
||||
"user_invoicing_profile_id": 6512,
|
||||
"debit": 0,
|
||||
"credit": 2.33
|
||||
"currency": "EUR",
|
||||
"summary": "Dupont Marcel, 22010009/VL, subscr."
|
||||
}
|
||||
],
|
||||
"status": "built"
|
||||
}
|
||||
LINES
|
||||
end
|
||||
end
|
@ -11,8 +11,10 @@ class OpenAPI::V1::BookableMachinesDoc < OpenAPI::V1::BaseDoc
|
||||
|
||||
doc_for :index do
|
||||
api :GET, "/#{API_VERSION}/bookable_machines", 'Bookable machines index'
|
||||
description 'Machines that a given user is allowed to book.'
|
||||
description 'Machines that a given user is allowed to book. If the given user has machine credits due to his current subscription, ' \
|
||||
'it will be reported in *hours_remaining*.'
|
||||
param :user_id, Integer, required: true, desc: 'Id of the given user.'
|
||||
param :machine_id, Integer, optional: true, desc: 'Id of a machine to filter by'
|
||||
example <<-MACHINES
|
||||
# /open_api/v1/bookable_machines?user_id=522
|
||||
{
|
||||
@ -44,7 +46,7 @@ class OpenAPI::V1::BookableMachinesDoc < OpenAPI::V1::BaseDoc
|
||||
"updated_at": "2014-06-30T15:10:14.272+02:00",
|
||||
"created_at": "2014-06-30T03:32:31.977+02:00",
|
||||
"description": "La découpeuse Vinyle, Roland CAMM ...",
|
||||
"spec": "Largeurs de support acceptées: de 50 mm à 70 ... 50 cm/sec ... mécanique: 0,0125 mm/pas\r\n",
|
||||
"spec": "Largeurs de support acceptées: de 50 mm à 70 ... 50 cm/sec ... mécanique: 0,0125 mm/pas",
|
||||
"hours_remaining": 0
|
||||
},
|
||||
{
|
||||
|
@ -17,6 +17,7 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
param_group :pagination
|
||||
param :email, [String, Array], optional: true, desc: 'Filter users by *email* using strict matching.'
|
||||
param :user_id, [Integer, Array], optional: true, desc: 'Filter users by *id* using strict matching.'
|
||||
param :created_after, DateTime, optional: true, desc: 'Filter users to accounts created after the given date.'
|
||||
example <<-USERS
|
||||
# /open_api/v1/users?page=1&per_page=4
|
||||
{
|
||||
@ -25,7 +26,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1746,
|
||||
"email": "xxxxxxx@xxxx.com",
|
||||
"created_at": "2016-05-04T17:21:48.403+02:00",
|
||||
"external_id": "J5821-4"
|
||||
"full_name": "xxxx xxxx",
|
||||
"first_name": "xxxx",
|
||||
"last_name": "xxxx",
|
||||
"gender": "man",
|
||||
"organization": true,
|
||||
"address": "2 impasse xxxxxx, BRUXELLES",
|
||||
"group": {
|
||||
"id": 1,
|
||||
"name": "standard, association",
|
||||
@ -36,7 +43,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1745,
|
||||
"email": "xxxxxxx@gmail.com",
|
||||
"created_at": "2016-05-03T15:21:13.125+02:00",
|
||||
"external_id": "J5846-4"
|
||||
"full_name": "xxxxx xxxxx",
|
||||
"first_name": "xxxxx",
|
||||
"last_name": "xxxxx",
|
||||
"gender": "woman",
|
||||
"organization": true,
|
||||
"address": "Grenoble",
|
||||
"group": {
|
||||
"id": 2,
|
||||
"name": "étudiant, - de 25 ans, enseignant, demandeur d'emploi",
|
||||
@ -47,7 +60,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1744,
|
||||
"email": "xxxxxxx@gmail.com",
|
||||
"created_at": "2016-05-03T13:51:03.223+02:00",
|
||||
"external_id": "J5900-1"
|
||||
"full_name": "xxxxxxx xxxx",
|
||||
"first_name": "xxxxxxx",
|
||||
"last_name": "xxxx",
|
||||
"gender": "man",
|
||||
"organization": false,
|
||||
"address": "21 rue des xxxxxx",
|
||||
"group": {
|
||||
"id": 1,
|
||||
"name": "standard, association",
|
||||
@ -58,7 +77,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1743,
|
||||
"email": "xxxxxxxx@setecastronomy.eu",
|
||||
"created_at": "2016-05-03T12:24:38.724+02:00",
|
||||
"external_id": "P4172-4"
|
||||
"full_name": "xxx xxxxxxx",
|
||||
"first_name": "xxx",
|
||||
"last_name": "xxxxxxx",
|
||||
"gender": "woman",
|
||||
"organization": false,
|
||||
"address": "147 rue xxxxxx, 75000 PARIS, France",
|
||||
"group": {
|
||||
"id": 1,
|
||||
"name": "standard, association",
|
||||
@ -75,7 +100,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1746,
|
||||
"email": "xxxxxxxxxxxx",
|
||||
"created_at": "2016-05-04T17:21:48.403+02:00",
|
||||
"external_id": "J5500-4"
|
||||
"full_name": "xxxx xxxxxx",
|
||||
"first_name": "xxxx",
|
||||
"last_name": "xxxxxx",
|
||||
"gender": "man",
|
||||
"organization": true,
|
||||
"address": "38100",
|
||||
"group": {
|
||||
"id": 1,
|
||||
"name": "standard, association",
|
||||
@ -86,7 +117,13 @@ class OpenAPI::V1::UsersDoc < OpenAPI::V1::BaseDoc
|
||||
"id": 1745,
|
||||
"email": "xxxxxxxxx@gmail.com",
|
||||
"created_at": "2016-05-03T15:21:13.125+02:00",
|
||||
"external_id": null,
|
||||
"full_name": "xxxxx xxxxxx",
|
||||
"first_name": "xxxx",
|
||||
"last_name": "xxxxxx",
|
||||
"gender": "woman",
|
||||
"organization": true,
|
||||
"address": "",
|
||||
"group": {
|
||||
"id": 2,
|
||||
"name": "étudiant, - de 25 ans, enseignant, demandeur d'emploi",
|
||||
|
10
app/frontend/src/javascript/api/age-range.ts
Normal file
10
app/frontend/src/javascript/api/age-range.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { AgeRange } from '../models/event';
|
||||
|
||||
export default class AgeRangeAPI {
|
||||
static async index (): Promise<Array<AgeRange>> {
|
||||
const res: AxiosResponse<Array<AgeRange>> = await apiClient.get('/api/age_ranges');
|
||||
return res?.data;
|
||||
}
|
||||
}
|
10
app/frontend/src/javascript/api/event-category.ts
Normal file
10
app/frontend/src/javascript/api/event-category.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { EventCategory } from '../models/event';
|
||||
|
||||
export default class EventCategoryAPI {
|
||||
static async index (): Promise<Array<EventCategory>> {
|
||||
const res: AxiosResponse<Array<EventCategory>> = await apiClient.get('/api/categories');
|
||||
return res?.data;
|
||||
}
|
||||
}
|
10
app/frontend/src/javascript/api/event-price-category.ts
Normal file
10
app/frontend/src/javascript/api/event-price-category.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { EventPriceCategory } from '../models/event';
|
||||
|
||||
export default class EventPriceCategoryAPI {
|
||||
static async index (): Promise<Array<EventPriceCategory>> {
|
||||
const res: AxiosResponse<Array<EventPriceCategory>> = await apiClient.get('/api/price_categories');
|
||||
return res?.data;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { EventTheme } from '../models/event-theme';
|
||||
import { EventTheme } from '../models/event';
|
||||
|
||||
export default class EventThemeAPI {
|
||||
static async index (): Promise<Array<EventTheme>> {
|
||||
|
27
app/frontend/src/javascript/api/event.ts
Normal file
27
app/frontend/src/javascript/api/event.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Event, EventUpdateResult } from '../models/event';
|
||||
import ApiLib from '../lib/api';
|
||||
|
||||
export default class EventAPI {
|
||||
static async create (event: Event): Promise<Event> {
|
||||
const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']);
|
||||
const res: AxiosResponse<Event> = await apiClient.post('/api/events', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (event: Event, mode: 'single' | 'next' | 'all'): Promise<EventUpdateResult> {
|
||||
const data = ApiLib.serializeAttachments(event, 'event', ['event_files_attributes', 'event_image_attributes']);
|
||||
data.set('edit_mode', mode);
|
||||
const res: AxiosResponse<EventUpdateResult> = await apiClient.put(`/api/events/${event.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
}
|
25
app/frontend/src/javascript/api/machine-category.ts
Normal file
25
app/frontend/src/javascript/api/machine-category.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { MachineCategory } from '../models/machine-category';
|
||||
|
||||
export default class MachineCategoryAPI {
|
||||
static async index (): Promise<Array<MachineCategory>> {
|
||||
const res: AxiosResponse<Array<MachineCategory>> = await apiClient.get('/api/machine_categories');
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (category: MachineCategory): Promise<MachineCategory> {
|
||||
const res: AxiosResponse<MachineCategory> = await apiClient.post('/api/machine_categories', { machine_category: category });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (category: MachineCategory): Promise<MachineCategory> {
|
||||
const res: AxiosResponse<MachineCategory> = await apiClient.patch(`/api/machine_categories/${category.id}`, { machine_category: category });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async destroy (categoryId: number): Promise<void> {
|
||||
const res: AxiosResponse<void> = await apiClient.delete(`/api/machine_categories/${categoryId}`);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
@ -13,4 +13,24 @@ export default class MachineAPI {
|
||||
const res: AxiosResponse<Machine> = await apiClient.get(`/api/machines/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (machine: Machine): Promise<Machine> {
|
||||
const data = ApiLib.serializeAttachments(machine, 'machine', ['machine_files_attributes', 'machine_image_attributes']);
|
||||
const res: AxiosResponse<Machine> = await apiClient.post('/api/machines', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (machine: Machine): Promise<Machine> {
|
||||
const data = ApiLib.serializeAttachments(machine, 'machine', ['machine_files_attributes', 'machine_image_attributes']);
|
||||
const res: AxiosResponse<Machine> = await apiClient.put(`/api/machines/${machine.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { serialize } from 'object-to-formdata';
|
||||
import { User, UserIndexFilter, UserRole } from '../models/user';
|
||||
import { User, MemberIndexFilter, UserRole } from '../models/user';
|
||||
|
||||
export default class MemberAPI {
|
||||
static async list (filters: UserIndexFilter): Promise<Array<User>> {
|
||||
static async list (filters: MemberIndexFilter): Promise<Array<User>> {
|
||||
const res: AxiosResponse<Array<User>> = await apiClient.post('/api/members/list', filters);
|
||||
return res?.data;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Plan, PlansDuration } from '../models/plan';
|
||||
import ApiLib from '../lib/api';
|
||||
|
||||
export default class PlanAPI {
|
||||
static async index (): Promise<Array<Plan>> {
|
||||
@ -12,4 +13,29 @@ export default class PlanAPI {
|
||||
const res: AxiosResponse<Array<PlansDuration>> = await apiClient.get('/api/plans/durations');
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (plan: Plan): Promise<Plan> {
|
||||
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
|
||||
const res: AxiosResponse<Plan> = await apiClient.post('/api/plans', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (plan: Plan): Promise<Plan> {
|
||||
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
|
||||
const res: AxiosResponse<Plan> = await apiClient.put(`/api/plans/${plan.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async get (id: number): Promise<Plan> {
|
||||
const res: AxiosResponse<Plan> = await apiClient.get(`/api/plans/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { serialize } from 'object-to-formdata';
|
||||
import {
|
||||
Product,
|
||||
ProductIndexFilter,
|
||||
@ -22,26 +21,7 @@ export default class ProductAPI {
|
||||
}
|
||||
|
||||
static async create (product: Product): Promise<Product> {
|
||||
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 data = ApiLib.serializeAttachments(product, 'product', ['product_files_attributes', 'product_images_attributes']);
|
||||
const res: AxiosResponse<Product> = await apiClient.post('/api/products', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
@ -51,38 +31,7 @@ export default class ProductAPI {
|
||||
}
|
||||
|
||||
static async update (product: Product): Promise<Product> {
|
||||
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 data = ApiLib.serializeAttachments(product, 'product', ['product_files_attributes', 'product_images_attributes']);
|
||||
const res: AxiosResponse<Product> = await apiClient.patch(`/api/products/${product.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
|
@ -1,10 +1,11 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ProfileCustomField } from '../models/profile-custom-field';
|
||||
import { ProfileCustomField, ProfileCustomFieldIndexFilters } from '../models/profile-custom-field';
|
||||
import ApiLib from '../lib/api';
|
||||
|
||||
export default class ProfileCustomFieldAPI {
|
||||
static async index (): Promise<Array<ProfileCustomField>> {
|
||||
const res: AxiosResponse<Array<ProfileCustomField>> = await apiClient.get('/api/profile_custom_fields');
|
||||
static async index (filters?: ProfileCustomFieldIndexFilters): Promise<Array<ProfileCustomField>> {
|
||||
const res: AxiosResponse<Array<ProfileCustomField>> = await apiClient.get(`/api/profile_custom_fields${ApiLib.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
@ -4,14 +4,15 @@ import {
|
||||
Setting,
|
||||
SettingBulkArray,
|
||||
SettingBulkResult,
|
||||
SettingError,
|
||||
SettingError, SettingGetOptions,
|
||||
SettingName,
|
||||
SettingValue
|
||||
} from '../models/setting';
|
||||
import ApiLib from '../lib/api';
|
||||
|
||||
export default class SettingAPI {
|
||||
static async get (name: SettingName): Promise<Setting> {
|
||||
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}`);
|
||||
static async get (name: SettingName, options?: SettingGetOptions): Promise<Setting> {
|
||||
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}${ApiLib.filtersToQuery(options)}`);
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Space } from '../models/space';
|
||||
import ApiLib from '../lib/api';
|
||||
|
||||
export default class SpaceAPI {
|
||||
static async index (): Promise<Array<Space>> {
|
||||
@ -12,4 +13,24 @@ export default class SpaceAPI {
|
||||
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (space: Space): Promise<Space> {
|
||||
const data = ApiLib.serializeAttachments(space, 'space', ['space_files_attributes', 'space_image_attributes']);
|
||||
const res: AxiosResponse<Space> = await apiClient.post('/api/spaces', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (space: Space): Promise<Space> {
|
||||
const data = ApiLib.serializeAttachments(space, 'space', ['space_files_attributes', 'space_image_attributes']);
|
||||
const res: AxiosResponse<Space> = await apiClient.put(`/api/spaces/${space.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { Subscription, SubscriptionPaymentDetails, UpdateSubscriptionRequest } from '../models/subscription';
|
||||
import { Subscription, SubscriptionPaymentDetails } from '../models/subscription';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export default class SubscriptionAPI {
|
||||
static async update (request: UpdateSubscriptionRequest): Promise<Subscription> {
|
||||
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${request.id}`, { subscription: request });
|
||||
static async cancel (id: number): Promise<Subscription> {
|
||||
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${id}/cancel`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
@ -8,4 +8,24 @@ export default class TrainingAPI {
|
||||
const res: AxiosResponse<Array<Training>> = await apiClient.get(`/api/trainings${ApiLib.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (training: Training): Promise<Training> {
|
||||
const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']);
|
||||
const res: AxiosResponse<Training> = await apiClient.post('/api/trainings', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (training: Training): Promise<Training> {
|
||||
const data = ApiLib.serializeAttachments(training, 'training', ['training_image_attributes']);
|
||||
const res: AxiosResponse<Training> = await apiClient.put(`/api/trainings/${training.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
19
app/frontend/src/javascript/api/user.ts
Normal file
19
app/frontend/src/javascript/api/user.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import ApiLib from '../lib/api';
|
||||
import { UserIndexFilter, User } from '../models/user';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Partner } from '../models/plan';
|
||||
|
||||
export default class UserAPI {
|
||||
static async index (filters: UserIndexFilter): Promise<Array<User>> {
|
||||
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/users${ApiLib.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (user: Partner|User, role: 'partner'|'manager'): Promise<User> {
|
||||
const data = {};
|
||||
data[role === 'partner' ? 'user' : 'manager'] = user;
|
||||
const res: AxiosResponse<User> = await apiClient.post('/api/users', data);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
@ -104,7 +104,7 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout
|
||||
['style', ['style']],
|
||||
['font', ['bold', 'italic', 'underline', 'clear']],
|
||||
['color', ['color']],
|
||||
['para', ['ul', 'ol', 'paragraph']],
|
||||
['para', ['ul', 'ol']],
|
||||
['table', ['table']],
|
||||
['insert', ['link', 'picture', 'hr']],
|
||||
['view', ['fullscreen', 'codeview']],
|
||||
|
@ -0,0 +1,145 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { SettingName, SettingValue, accountingSettings } from '../../models/setting';
|
||||
import SettingAPI from '../../api/setting';
|
||||
import SettingLib from '../../lib/setting';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { FabPanel } from '../base/fab-panel';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface AccountingCodesSettingsProps {
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows customization of accounting codes and other related settings
|
||||
*/
|
||||
export const AccountingCodesSettings: React.FC<AccountingCodesSettingsProps> = ({ onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
const { handleSubmit, register, control, reset } = useForm<Record<SettingName, SettingValue>>();
|
||||
|
||||
useEffect(() => {
|
||||
SettingAPI.query(accountingSettings)
|
||||
.then(settings => {
|
||||
const data = SettingLib.bulkMapToObject(settings);
|
||||
reset(data);
|
||||
})
|
||||
.catch(onError);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on 'save':
|
||||
* Update the settings on the API
|
||||
*/
|
||||
const onSubmit: SubmitHandler<Record<SettingName, SettingValue>> = (data) => {
|
||||
SettingAPI.bulkUpdate(SettingLib.objectToBulkMap(data)).then(() => {
|
||||
onSuccess(t('app.admin.accounting_codes_settings.update_success'));
|
||||
}, reason => {
|
||||
onError(reason);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="accounting-codes-settings" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FabPanel>
|
||||
<h4>{t('app.admin.accounting_codes_settings.advanced_accounting')}</h4>
|
||||
<FormSwitch control={control} id="advanced_accounting"
|
||||
label={t('app.admin.accounting_codes_settings.enable_advanced')}
|
||||
tooltip={t('app.admin.accounting_codes_settings.enable_advanced_help')} />
|
||||
</FabPanel>
|
||||
<FabPanel>
|
||||
<h4>{t('app.admin.accounting_codes_settings.financial')}</h4>
|
||||
<h5>{t('app.admin.accounting_codes_settings.card')}</h5>
|
||||
<div className="cards">
|
||||
<FormInput register={register} id="accounting_payment_card_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<FormInput register={register} id="accounting_payment_card_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_payment_card_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.wallet_debit')}</h5>
|
||||
<div className="wallets">
|
||||
<FormInput register={register} id="accounting_payment_wallet_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<FormInput register={register} id="accounting_payment_wallet_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_payment_wallet_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.other')}</h5>
|
||||
<div className="others">
|
||||
<FormInput register={register} id="accounting_payment_other_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<FormInput register={register} id="accounting_payment_other_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_payment_other_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h4>{t('app.admin.accounting_codes_settings.sales')}</h4>
|
||||
<h5>{t('app.admin.accounting_codes_settings.sales_journal')}</h5>
|
||||
<FormInput register={register} id="accounting_sales_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<h5>{t('app.admin.accounting_codes_settings.subscriptions')}</h5>
|
||||
<div className="subscriptions">
|
||||
<FormInput register={register} id="accounting_subscription_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_subscription_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.machine')}</h5>
|
||||
<div className="machine">
|
||||
<FormInput register={register} id="accounting_Machine_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Machine_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.training')}</h5>
|
||||
<div className="training">
|
||||
<FormInput register={register} id="accounting_Training_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Training_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.event')}</h5>
|
||||
<div className="events">
|
||||
<FormInput register={register} id="accounting_Event_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Event_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.space')}</h5>
|
||||
<div className="space">
|
||||
<FormInput register={register} id="accounting_Space_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Space_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.prepaid_pack')}</h5>
|
||||
<div className="prepaid_pack">
|
||||
<FormInput register={register} id="accounting_Pack_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Pack_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.product')}</h5>
|
||||
<div className="product">
|
||||
<FormInput register={register} id="accounting_Product_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Product_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h5>{t('app.admin.accounting_codes_settings.error')}</h5>
|
||||
<div className="error">
|
||||
<FormInput register={register} id="accounting_Error_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_Error_label"
|
||||
label={t('app.admin.accounting_codes_settings.label')}
|
||||
tooltip={t('app.admin.accounting_codes_settings.error_help')} />
|
||||
</div>
|
||||
<h4>{t('app.admin.accounting_codes_settings.wallet_credit')}</h4>
|
||||
<div className="wallets">
|
||||
<FormInput register={register} id="accounting_wallet_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<FormInput register={register} id="accounting_wallet_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_wallet_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
<h4>{t('app.admin.accounting_codes_settings.VAT')}</h4>
|
||||
<div className="vat">
|
||||
<FormInput register={register} id="accounting_VAT_journal_code" label={t('app.admin.accounting_codes_settings.journal_code')} />
|
||||
<FormInput register={register} id="accounting_VAT_code" label={t('app.admin.accounting_codes_settings.code')} />
|
||||
<FormInput register={register} id="accounting_VAT_label" label={t('app.admin.accounting_codes_settings.label')} />
|
||||
</div>
|
||||
</FabPanel>
|
||||
<FabPanel className="actions">
|
||||
<FabButton type="submit" className="is-info submit-btn">
|
||||
{t('app.admin.accounting_codes_settings.save')}
|
||||
</FabButton>
|
||||
</FabPanel>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('accountingCodesSettings', react2angular(AccountingCodesSettings, ['onSuccess', 'onError']));
|
@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import SettingAPI from '../../api/setting';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AdvancedAccountingFormProps<TFieldValues> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a partial form, to be included in a resource form managed by react-hook-form.
|
||||
* It will add advanced accounting attributes to the parent form, if they are enabled
|
||||
*/
|
||||
export const AdvancedAccountingForm = <TFieldValues extends FieldValues>({ register, onError }: AdvancedAccountingFormProps<TFieldValues>) => {
|
||||
const [isEnabled, setIsEnabled] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
useEffect(() => {
|
||||
SettingAPI.get('advanced_accounting').then(res => setIsEnabled(res.value === 'true')).catch(onError);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="advanced-accounting-form">
|
||||
{isEnabled && <div>
|
||||
<h4>{t('app.admin.advanced_accounting_form.title')}</h4>
|
||||
<FormInput register={register}
|
||||
id="advanced_accounting_attributes.code"
|
||||
label={t('app.admin.advanced_accounting_form.code')} />
|
||||
<FormInput register={register}
|
||||
id="advanced_accounting_attributes.analytical_section"
|
||||
label={t('app.admin.advanced_accounting_form.analytical_section')} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,10 +1,10 @@
|
||||
/**
|
||||
* This is a compatibility wrapper to allow usage of react-switch inside the angular.js app
|
||||
*/
|
||||
import Switch from 'react-switch';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../models/application';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
/**
|
||||
* This is a compatibility wrapper to allow usage of react-switch inside the angular.js app
|
||||
*/
|
||||
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className', 'disabled']));
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path, FieldPathValue } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import AuthProviderAPI from '../../api/auth-provider';
|
||||
@ -114,7 +114,7 @@ export const DataMappingForm = <TFieldValues extends FieldValues, TContext exten
|
||||
* Return a className based on the current mapping-item status
|
||||
*/
|
||||
const itemStatus = (index: number): string => {
|
||||
if (currentFormValues[index]?.id) {
|
||||
if (currentFormValues && currentFormValues[index]?.id) {
|
||||
if (currentFormValues[index]._destroy) return 'destroyed-item';
|
||||
return 'saved-item';
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Path, UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Path, UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useForm, SubmitHandler, useWatch } from 'react-hook-form';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { debounce as _debounce } from 'lodash';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IntegerMappingForm } from './integer-mapping-form';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { CaretDown } from 'phosphor-react';
|
||||
|
||||
interface AccordionItemProps {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Component } from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@ -8,7 +8,7 @@ interface ErrorBoundaryState {
|
||||
* This component will catch javascript errors anywhere in their child component tree and display a message to the user.
|
||||
* @see https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<unknown, ErrorBoundaryState> {
|
||||
export class ErrorBoundary extends Component<unknown, ErrorBoundaryState> {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabAlertProps {
|
||||
level: 'info' | 'warning' | 'danger',
|
||||
@ -10,7 +10,7 @@ interface FabAlertProps {
|
||||
*/
|
||||
export const FabAlert: React.FC<FabAlertProps> = ({ level, className, children }) => {
|
||||
return (
|
||||
<div className={`fab-alert fab-alert--${level} ${className || ''}`}>
|
||||
<div className={`fab-alert fab-alert--${level} ${className || ''}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, BaseSyntheticEvent } from 'react';
|
||||
import { ReactNode, BaseSyntheticEvent } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabButtonProps {
|
||||
onClick?: (event: BaseSyntheticEvent) => void,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { debounce as _debounce } from 'lodash';
|
||||
|
||||
type inputType = string|number|readonly string [];
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
|
||||
import { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabOutputCopyProps {
|
||||
text: string,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import { CaretDoubleLeft, CaretLeft, CaretRight, CaretDoubleRight } from 'phosphor-react';
|
||||
|
||||
interface FabPaginationProps {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabPanelProps {
|
||||
className?: string,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabPopoverProps {
|
||||
title: string,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface FabStateLabelProps {
|
||||
status?: string,
|
||||
|
60
app/frontend/src/javascript/components/base/fab-tabs.tsx
Normal file
60
app/frontend/src/javascript/components/base/fab-tabs.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
|
||||
import * as React from 'react';
|
||||
import _ from 'lodash';
|
||||
import { usePrevious } from '../../lib/use-previous';
|
||||
|
||||
type tabId = string|number;
|
||||
|
||||
interface Tab {
|
||||
id: tabId,
|
||||
title: ReactNode,
|
||||
content: ReactNode,
|
||||
onSelected?: () => void,
|
||||
}
|
||||
|
||||
interface FabTabsProps {
|
||||
tabs: Array<Tab>,
|
||||
defaultTab?: tabId,
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around https://github.com/reactjs/react-tabs that provides the Fab-manager's theme for tabs
|
||||
*/
|
||||
export const FabTabs: React.FC<FabTabsProps> = ({ tabs, defaultTab, className }) => {
|
||||
const [active, setActive] = useState<Tab>(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
|
||||
const previousTabs = usePrevious<Tab[]>(tabs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!_.isEqual(previousTabs?.filter(Boolean).map(t => t.id), tabs?.filter(Boolean).map(t => t?.id))) {
|
||||
setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
/**
|
||||
* Return the index of the currently selected tabs (i.e. the "active" tab)
|
||||
*/
|
||||
const selectedIndex = (): number => {
|
||||
return tabs.findIndex(t => t?.id === active?.id) || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the active tab is changed by the user
|
||||
*/
|
||||
const onIndexSelected = (index: number) => {
|
||||
setActive(tabs[index]);
|
||||
if (typeof tabs[index].onSelected === 'function') {
|
||||
tabs[index].onSelected();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs className={`fab-tabs ${className || ''}`} selectedIndex={selectedIndex()} onSelect={onIndexSelected}>
|
||||
<TabList className="tabs">
|
||||
{tabs.filter(Boolean).map((tab, index) => <Tab key={index}>{tab.title}</Tab>)}
|
||||
</TabList>
|
||||
{tabs.filter(Boolean).map((tab, index) => <TabPanel key={index}>{tab.content}</TabPanel>)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HtmlTranslateProps {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { BaseSyntheticEvent, ReactNode } from 'react';
|
||||
import { BaseSyntheticEvent, ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
type inputType = string|number|readonly string [];
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* This component is a wrapper that display a loader while the children components have their rendering suspended.
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { forwardRef, RefObject, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEditor, EditorContent, Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
@ -24,6 +25,7 @@ interface FabTextEditorProps {
|
||||
placeholder?: string,
|
||||
error?: string,
|
||||
disabled?: boolean
|
||||
ariaLabel?: string,
|
||||
}
|
||||
|
||||
export interface FabTextEditorRef {
|
||||
@ -33,7 +35,7 @@ export interface FabTextEditorRef {
|
||||
/**
|
||||
* This component is a WYSIWYG text editor
|
||||
*/
|
||||
const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
|
||||
const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ heading, bulletList, blockquote, content, limit = 400, video, image, link, onChange, placeholder, error, disabled = false, ariaLabel }, ref: RefObject<FabTextEditorRef>) => {
|
||||
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
|
||||
@ -73,6 +75,12 @@ const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEdi
|
||||
}
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
'aria-label': ariaLabel,
|
||||
role: 'textbox'
|
||||
}
|
||||
},
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
if (editor.isEmpty) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useOnclickOutside from 'react-cool-onclickoutside';
|
||||
import { Editor } from '@tiptap/react';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabInput } from '../base/fab-input';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../../base/loader';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { FabPanel } from '../../base/fab-panel';
|
||||
import { Loader } from '../../base/loader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { IApplication } from '../../../models/application';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { ReservationsPanel } from './reservations-panel';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { FabPanel } from '../../base/fab-panel';
|
||||
import { Reservation, SlotsReservation } from '../../../models/reservation';
|
||||
import ReservationAPI from '../../../api/reservation';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { LabelledInput } from './base/labelled-input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TDateISODate } from '../typings/date-iso';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../models/application';
|
||||
@ -6,6 +6,8 @@ import { Loader } from '../base/loader';
|
||||
import { Event } from '../../models/event';
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
import defaultImage from '../../../../images/default-image.png';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface EventCardProps {
|
||||
@ -53,14 +55,17 @@ export const EventCard: React.FC<EventCardProps> = ({ event, cardType }) => {
|
||||
const formatTime = (): string => {
|
||||
return event.all_day
|
||||
? t('app.public.event_card.all_day')
|
||||
: t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_date), END: FormatLib.time(event.end_date) });
|
||||
: t('app.public.event_card.from_time_to_time', { START: FormatLib.time(event.start_time), END: FormatLib.time(event.end_time) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`event-card event-card--${cardType}`}>
|
||||
{event.event_image
|
||||
{event.event_image_attributes
|
||||
? <div className="event-card-picture">
|
||||
<img src={event.event_image} alt="" />
|
||||
<img src={event.event_image_attributes.attachment_url} alt={event.event_image_attributes.attachment_name} onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = defaultImage;
|
||||
}} />
|
||||
</div>
|
||||
: cardType !== 'sm' &&
|
||||
<div className="event-card-picture">
|
||||
|
318
app/frontend/src/javascript/components/events/event-form.tsx
Normal file
318
app/frontend/src/javascript/components/events/event-form.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption } from '../../models/event';
|
||||
import EventAPI from '../../api/event';
|
||||
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 { FormMultiFileUpload } from '../form/form-multi-file-upload';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { SelectOption } from '../../models/select';
|
||||
import EventCategoryAPI from '../../api/event-category';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import EventThemeAPI from '../../api/event-theme';
|
||||
import { FormMultiSelect } from '../form/form-multi-select';
|
||||
import AgeRangeAPI from '../../api/age-range';
|
||||
import { Plus, Trash } from 'phosphor-react';
|
||||
import FormatLib from '../../lib/format';
|
||||
import EventPriceCategoryAPI from '../../api/event-price-category';
|
||||
import { UpdateRecurrentModal } from './update-recurrent-modal';
|
||||
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface EventFormProps {
|
||||
action: 'create' | 'update',
|
||||
event?: Event,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Form to edit or create events
|
||||
*/
|
||||
export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, onSuccess }) => {
|
||||
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: { ...event } });
|
||||
const output = useWatch<Event>({ control });
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' });
|
||||
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [isAllDay, setIsAllDay] = useState<boolean>(event?.all_day);
|
||||
const [categoriesOptions, setCategoriesOptions] = useState<Array<SelectOption<number>>>([]);
|
||||
const [themesOptions, setThemesOptions] = useState<Array<SelectOption<number>>>(null);
|
||||
const [ageRangeOptions, setAgeRangeOptions] = useState<Array<SelectOption<number>>>(null);
|
||||
const [priceCategoriesOptions, setPriceCategoriesOptions] = useState<Array<SelectOption<number>>>(null);
|
||||
const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState<boolean>(false);
|
||||
const [updatingEvent, setUpdatingEvent] = useState<Event>(null);
|
||||
|
||||
useEffect(() => {
|
||||
EventCategoryAPI.index()
|
||||
.then(data => setCategoriesOptions(data.map(m => decorationToOption(m))))
|
||||
.catch(onError);
|
||||
EventThemeAPI.index()
|
||||
.then(data => setThemesOptions(data.map(t => decorationToOption(t))))
|
||||
.catch(onError);
|
||||
AgeRangeAPI.index()
|
||||
.then(data => setAgeRangeOptions(data.map(r => decorationToOption(r))))
|
||||
.catch(onError);
|
||||
EventPriceCategoryAPI.index()
|
||||
.then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c))))
|
||||
.catch(onError);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the 'remove' button, in the additional prices area
|
||||
*/
|
||||
const handlePriceRemove = (price: EventPriceCategoryAttributes, index: number) => {
|
||||
if (!price.id) return remove(index);
|
||||
|
||||
setValue(`event_price_categories_attributes.${index}._destroy`, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the user validates the machine form: handle create or update
|
||||
*/
|
||||
const onSubmit: SubmitHandler<Event> = (data: Event) => {
|
||||
if (action === 'update') {
|
||||
if (event?.recurrence_events?.length > 0) {
|
||||
setUpdatingEvent(data);
|
||||
toggleRecurrentModal();
|
||||
} else {
|
||||
handleUpdateRecurrentConfirmed(data, 'single');
|
||||
}
|
||||
} else {
|
||||
EventAPI.create(data).then(res => {
|
||||
onSuccess(t(`app.admin.event_form.${action}_success`));
|
||||
window.location.href = `/#!/events/${res.id}`;
|
||||
}).catch(onError);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the confirmation modal for updating recurring events
|
||||
*/
|
||||
const toggleRecurrentModal = () => {
|
||||
setIsOpenRecurrentModal(!isOpenRecurrentModal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if any dates have changed
|
||||
*/
|
||||
const datesHaveChanged = (): boolean => {
|
||||
return ((event?.start_date !== (updatingEvent?.start_date as Date)?.toISOString()?.substring(0, 10)) ||
|
||||
(event?.end_date !== (updatingEvent?.end_date as Date)?.toISOString()?.substring(0, 10)));
|
||||
};
|
||||
|
||||
/**
|
||||
* When the user has confirmed the update of the other occurences (or not), proceed with the API update
|
||||
* and handle the result
|
||||
*/
|
||||
const handleUpdateRecurrentConfirmed = (data: Event, mode: 'single' | 'next' | 'all') => {
|
||||
EventAPI.update(data, mode).then(res => {
|
||||
if (res.total === res.updated) {
|
||||
onSuccess(t('app.admin.event_form.events_updated', { COUNT: res.updated }));
|
||||
} else {
|
||||
onError(t('app.admin.event_form.events_not_updated', { TOTAL: res.total, COUNT: res.total - res.updated }));
|
||||
if (res.details.events.find(d => d.error === 'EventPriceCategory')) {
|
||||
onError(t('app.admin.event_form.error_deleting_reserved_price'));
|
||||
} else {
|
||||
onError(t('app.admin.event_form.other_error'));
|
||||
}
|
||||
}
|
||||
window.location.href = '/#!/events';
|
||||
}).catch(onError);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an event-decoration (category/theme/etc.) to an option usable by react-select
|
||||
*/
|
||||
const decorationToOption = (item: EventDecoration): SelectOption<number> => {
|
||||
return { value: item.id, label: item.name };
|
||||
};
|
||||
|
||||
/**
|
||||
* In 'create' mode, the user can choose if the new event will be recurrent.
|
||||
* This method provides teh various options for recurrence
|
||||
*/
|
||||
const buildRecurrenceOptions = (): Array<SelectOption<RecurrenceOption>> => {
|
||||
return [
|
||||
{ label: t('app.admin.event_form.recurring.none'), value: 'none' },
|
||||
{ label: t('app.admin.event_form.recurring.every_days'), value: 'day' },
|
||||
{ label: t('app.admin.event_form.recurring.every_week'), value: 'week' },
|
||||
{ label: t('app.admin.event_form.recurring.every_month'), value: 'month' },
|
||||
{ label: t('app.admin.event_form.recurring.every_year'), value: 'year' }
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="event-form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormInput register={register}
|
||||
id="title"
|
||||
formState={formState}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.event_form.title')} />
|
||||
<FormImageUpload setValue={setValue}
|
||||
register={register}
|
||||
control={control}
|
||||
formState={formState}
|
||||
rules={{ required: true }}
|
||||
id="event_image_attributes"
|
||||
accept="image/*"
|
||||
defaultImage={output.event_image_attributes}
|
||||
label={t('app.admin.event_form.matching_visual')} />
|
||||
<FormRichText control={control}
|
||||
id="description"
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.event_form.description')}
|
||||
limit={null}
|
||||
heading bulletList blockquote link video image />
|
||||
<FormSelect id="category_id"
|
||||
control={control}
|
||||
formState={formState}
|
||||
label={t('app.admin.event_form.event_category')}
|
||||
options={categoriesOptions}
|
||||
rules={{ required: true }} />
|
||||
{themesOptions && <FormMultiSelect control={control}
|
||||
id="event_theme_ids"
|
||||
formState={formState}
|
||||
options={themesOptions}
|
||||
label={t('app.admin.event_form.event_themes')} />}
|
||||
{ageRangeOptions && <FormSelect control={control}
|
||||
id="age_range_id"
|
||||
formState={formState}
|
||||
options={ageRangeOptions}
|
||||
label={t('app.admin.event_form.age_range')} />}
|
||||
<div className="dates-times">
|
||||
<h4>{t('app.admin.event_form.dates_and_opening_hours')}</h4>
|
||||
<div className="dates">
|
||||
<FormInput id="start_date"
|
||||
type="date"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.event_form.start_date')}
|
||||
rules={{ required: true }} />
|
||||
<FormInput id="end_date"
|
||||
type="date"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.event_form.end_date')}
|
||||
rules={{ required: true }} />
|
||||
</div>
|
||||
<FormSwitch control={control}
|
||||
id="all_day"
|
||||
label={t('app.admin.event_form.all_day')}
|
||||
formState={formState}
|
||||
tooltip={t('app.admin.event_form.all_day_help')}
|
||||
onChange={setIsAllDay} />
|
||||
{!isAllDay && <div className="times">
|
||||
<FormInput id="start_time"
|
||||
type="time"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.event_form.start_time')}
|
||||
rules={{ required: !isAllDay }} />
|
||||
<FormInput id="end_time"
|
||||
type="time"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.event_form.end_time')}
|
||||
rules={{ required: !isAllDay }} />
|
||||
</div> }
|
||||
{action === 'create' && <div className="recurring">
|
||||
<FormSelect options={buildRecurrenceOptions()}
|
||||
control={control}
|
||||
formState={formState}
|
||||
id="recurrence"
|
||||
valueDefault="none"
|
||||
label={t('app.admin.event_form.recurrence')} />
|
||||
<FormInput register={register}
|
||||
id="recurrence_end_at"
|
||||
type="date"
|
||||
formState={formState}
|
||||
nullable
|
||||
defaultValue={null}
|
||||
label={t('app.admin.event_form._and_ends_on')}
|
||||
rules={{ required: !['none', undefined].includes(output.recurrence) }} />
|
||||
</div>}
|
||||
</div>
|
||||
<div className="seats-prices">
|
||||
<h4>{t('app.admin.event_form.prices_and_availabilities')}</h4>
|
||||
<FormInput register={register}
|
||||
id="nb_total_places"
|
||||
label={t('app.admin.event_form.seats_available')}
|
||||
type="number"
|
||||
tooltip={t('app.admin.event_form.seats_help')} />
|
||||
<FormInput register={register}
|
||||
id="amount"
|
||||
formState={formState}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.event_form.standard_rate')}
|
||||
tooltip={t('app.admin.event_form.0_equal_free')}
|
||||
addOn={FormatLib.currencySymbol()} />
|
||||
{priceCategoriesOptions && <div className="additional-prices">
|
||||
{fields.map((price, index) => (
|
||||
<div key={index} className={`price-item ${output.event_price_categories_attributes && output.event_price_categories_attributes[index]?._destroy ? 'destroyed-item' : ''}`}>
|
||||
<FormSelect options={priceCategoriesOptions}
|
||||
control={control}
|
||||
id={`event_price_categories_attributes.${index}.price_category_id`}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.event_form.fare_class')} />
|
||||
<FormInput id={`event_price_categories_attributes.${index}.amount`}
|
||||
register={register}
|
||||
type="number"
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.event_form.price')}
|
||||
addOn={FormatLib.currencySymbol()} />
|
||||
<FabButton className="remove-price is-main" onClick={() => handlePriceRemove(price, index)} icon={<Trash size={20} />} />
|
||||
</div>
|
||||
))}
|
||||
<FabButton className="add-price is-secondary" onClick={() => append({})}>
|
||||
<Plus size={20} />
|
||||
{t('app.admin.event_form.add_price')}
|
||||
</FabButton>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="attachments">
|
||||
<div className='form-item-header event-files-header'>
|
||||
<h4>{t('app.admin.event_form.attachments')}</h4>
|
||||
</div>
|
||||
<FormMultiFileUpload setValue={setValue}
|
||||
addButtonLabel={t('app.admin.event_form.add_a_new_file')}
|
||||
control={control}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
id="event_files_attributes"
|
||||
className="event-files" />
|
||||
</div>
|
||||
<AdvancedAccountingForm register={register} onError={onError} />
|
||||
<FabButton type="submit" className="is-info submit-btn">
|
||||
{t('app.admin.event_form.ACTION_event', { ACTION: action })}
|
||||
</FabButton>
|
||||
<UpdateRecurrentModal isOpen={isOpenRecurrentModal}
|
||||
toggleModal={toggleRecurrentModal}
|
||||
event={updatingEvent}
|
||||
onConfirmed={handleUpdateRecurrentConfirmed}
|
||||
datesChanged={datesHaveChanged()} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const EventFormWrapper: React.FC<EventFormProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<ErrorBoundary>
|
||||
<EventForm {...props} />
|
||||
</ErrorBoundary>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('eventForm', react2angular(EventFormWrapper, ['action', 'event', 'onError', 'onSuccess']));
|
@ -1,96 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Select from 'react-select';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
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;
|
||||
|
||||
interface EventThemesProps {
|
||||
event: Event,
|
||||
onChange: (themes: Array<EventTheme>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a select input to edit the themes associated with the event
|
||||
*/
|
||||
export const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [themes, setThemes] = useState<Array<EventTheme>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
EventThemeAPI.index().then(data => setThemes(data));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if there's any EventTheme in DB, otherwise we won't display the selector
|
||||
*/
|
||||
const hasThemes = (): boolean => {
|
||||
return themes.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the current theme(s) for the given event, formatted to match the react-select format
|
||||
*/
|
||||
const defaultValues = (): Array<SelectOption<number>> => {
|
||||
const res = [];
|
||||
themes.forEach(t => {
|
||||
if (event.event_theme_ids && event.event_theme_ids.indexOf(t.id) > -1) {
|
||||
res.push({ value: t.id, label: t.name });
|
||||
}
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<SelectOption<number>>): void => {
|
||||
const res = [];
|
||||
selectedOptions.forEach(opt => {
|
||||
res.push(themes.find(t => t.id === opt.value));
|
||||
});
|
||||
onChange(res);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert all themes to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<SelectOption<number>> => {
|
||||
return themes.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="event-themes">
|
||||
{hasThemes() && <div className="event-themes--panel">
|
||||
<h3>{ t('app.shared.event_themes.title') }</h3>
|
||||
<div className="content">
|
||||
<Select defaultValue={defaultValues()}
|
||||
placeholder={t('app.shared.event_themes.select_theme')}
|
||||
onChange={handleChange}
|
||||
options={buildOptions()}
|
||||
isMulti />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventThemesWrapper: React.FC<EventThemesProps> = ({ event, onChange }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<EventThemes event={event} onChange={onChange}/>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('eventThemes', react2angular(EventThemesWrapper, ['event', 'onChange']));
|
@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Event } from '../../models/event';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
|
||||
type EditionMode = 'single' | 'next' | 'all';
|
||||
|
||||
interface UpdateRecurrentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
event: Event,
|
||||
onConfirmed: (data: Event, mode: EditionMode) => void,
|
||||
datesChanged: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user for confimation about the update of only the current event or also its recurrences
|
||||
*/
|
||||
export const UpdateRecurrentModal: React.FC<UpdateRecurrentModalProps> = ({ isOpen, toggleModal, event, onConfirmed, datesChanged }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [editMode, setEditMode] = useState<EditionMode>(null);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user confirms the update
|
||||
*/
|
||||
const handleConfirmation = () => {
|
||||
onConfirmed(event, editMode);
|
||||
};
|
||||
|
||||
/**
|
||||
* The user cannot confirm unless he chooses an option
|
||||
*/
|
||||
const preventConfirm = () => {
|
||||
return !editMode;
|
||||
};
|
||||
|
||||
return (
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
title={t('app.admin.update_recurrent_modal.title')}
|
||||
className="update-recurrent-modal"
|
||||
onConfirm={handleConfirmation}
|
||||
preventConfirm={preventConfirm()}
|
||||
confirmButton={t('app.admin.update_recurrent_modal.confirm', { MODE: editMode })}
|
||||
closeButton>
|
||||
<p>{t('app.admin.update_recurrent_modal.edit_recurring_event')}</p>
|
||||
<label>
|
||||
<input name="edit_mode" type="radio" value="single" onClick={() => setEditMode('single')} />
|
||||
<span>{t('app.admin.update_recurrent_modal.edit_this_event')}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input name="edit_mode" type="radio" value="next" onClick={() => setEditMode('next')} />
|
||||
<span>{t('app.admin.update_recurrent_modal.edit_this_and_next')}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input name="edit_mode" type="radio" value="all" onClick={() => setEditMode('all')} />
|
||||
<span>{t('app.admin.update_recurrent_modal.edit_all')}</span>
|
||||
</label>
|
||||
{datesChanged && editMode !== 'single' && <FabAlert level="warning">
|
||||
{t('app.admin.update_recurrent_modal.date_wont_change')}
|
||||
</FabAlert>}
|
||||
</FabModal>
|
||||
);
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { AbstractFormComponent } from '../../models/form-component';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { get as _get } from 'lodash';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as 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';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Path } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
@ -23,7 +24,7 @@ interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>,
|
||||
/**
|
||||
* This component allows to upload file, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
|
||||
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [file, setFile] = useState<FileType>(defaultFile);
|
||||
@ -72,7 +73,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse');
|
||||
|
||||
return (
|
||||
<div className={`form-file-upload ${classNames}`}>
|
||||
<div className={`form-file-upload ${label ? 'with-label' : ''} ${classNames}`}>
|
||||
{hasFile() && (
|
||||
<span>{file.attachment_name}</span>
|
||||
)}
|
||||
@ -86,17 +87,19 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
</a>
|
||||
)}
|
||||
<FormInput type="file"
|
||||
className="image-file-input"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}
|
||||
placeholder={placeholder()}/>
|
||||
ariaLabel={label as string}
|
||||
className="image-file-input"
|
||||
accept={accept}
|
||||
register={register}
|
||||
label={label}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}
|
||||
placeholder={placeholder()}/>
|
||||
{hasFile() &&
|
||||
<FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Path, Controller } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
@ -21,7 +22,7 @@ interface FormImageUploadProps<TFieldValues, TContext extends object> extends Fo
|
||||
mainOption?: boolean,
|
||||
onFileChange?: (value: ImageType) => void,
|
||||
onFileRemove?: () => void,
|
||||
onFileIsMain?: (setIsMain: () => void) => void,
|
||||
onFileIsMain?: (setIsMain: (isMain: boolean) => void) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,7 +32,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [file, setFile] = useState<ImageType>(defaultImage);
|
||||
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage.attachment_url);
|
||||
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage?.attachment_url);
|
||||
|
||||
useEffect(() => {
|
||||
setFile(defaultImage);
|
||||
@ -107,7 +108,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
|
||||
render={({ field: { onChange, value } }) =>
|
||||
<input id={`${id}.is_main`}
|
||||
type="radio"
|
||||
checked={value}
|
||||
checked={value || false}
|
||||
onChange={() => { onFileIsMain(onChange); }} />
|
||||
} />
|
||||
</label>
|
||||
@ -118,7 +119,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues, TContext exten
|
||||
register={register}
|
||||
label={label}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
rules={{ ...rules, required: rules?.required && !hasImage() }}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
import { FieldPathValue } from 'react-hook-form';
|
||||
import { debounce as _debounce } from 'lodash';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
@ -11,6 +12,7 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
|
||||
addOn?: ReactNode,
|
||||
addOnAction?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||
addOnClassName?: string,
|
||||
addOnAriaLabel?: string,
|
||||
debounce?: number,
|
||||
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week' | 'hidden' | 'file',
|
||||
accept?: string,
|
||||
@ -18,13 +20,14 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
|
||||
placeholder?: string,
|
||||
step?: number | 'any',
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
nullable?: boolean
|
||||
nullable?: boolean,
|
||||
ariaLabel?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a template for an input component to use within React Hook Form
|
||||
*/
|
||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps<TFieldValues, TInputType>) => {
|
||||
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel }: FormInputProps<TFieldValues, TInputType>) => {
|
||||
/**
|
||||
* Debounced (ie. temporised) version of the 'on change' callback.
|
||||
*/
|
||||
@ -55,7 +58,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
disabled={disabled}
|
||||
rules={rules} error={error} warning={warning}>
|
||||
{icon && <span className="icon">{icon}</span>}
|
||||
<input id={id}
|
||||
<input id={id} aria-label={ariaLabel}
|
||||
{...register(id as FieldPath<TFieldValues>, {
|
||||
...rules,
|
||||
valueAsDate: type === 'date',
|
||||
@ -69,7 +72,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
||||
placeholder={placeholder}
|
||||
accept={accept} />
|
||||
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
|
||||
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
|
||||
{addOn && addOnAction && <button aria-label={addOnAriaLabel} type="button" onClick={addOnAction} className={`addon ${addOnClassName || ''} is-btn`}>{addOn}</button>}
|
||||
{addOn && !addOnAction && <span aria-label={addOnAriaLabel} className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
|
||||
</AbstractFormItem>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user