mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-01 12:24:28 +01:00
Merge branch 'dev' into l10n_dev
This commit is contained in:
commit
fe1d0f5ae1
26
CHANGELOG.md
26
CHANGELOG.md
@ -1,6 +1,29 @@
|
||||
# Changelog Fab-manager
|
||||
|
||||
- Prepaid packs of hours for the machines
|
||||
- Tests for OpenAPI user trainings
|
||||
- Norwegian language
|
||||
- Fix a bug: unable to filter by multiple user IDs in OpenAPI
|
||||
- Fix a bug: do not display two empty categories in the plan creation form
|
||||
- Fix a bug: do not return to the home page when clicking on previous in the machine description page
|
||||
- Fix a bug: public list of plans fails to render if a group has no plans
|
||||
- [TODO DEPLOY] `rails db:seed`
|
||||
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
|
||||
|
||||
## v5.0.8 2021 June 28
|
||||
|
||||
- Ability to run `fablab:chain:all` non interactively
|
||||
- Full test coverage of the OpenAPI
|
||||
- Fix a bug: unable to get the Events without images from the OpenAPI
|
||||
- Fix a bug: unable to get the Space reservations from the OpenAPI
|
||||
- Fix a bug: unable to get invoices from the OpenAPI
|
||||
|
||||
## v5.0.7 2021 June 24
|
||||
|
||||
- Fix a bug: unable to export members list if no subscriptions was taken
|
||||
- Fix a bug: most OpenAPI endpoints were dysfunctional
|
||||
- Fix a bug: unable to open some modals when the logo was undefined
|
||||
- Fix a bug: stripe subscription generation fails if the user already has a subscription
|
||||
|
||||
## v5.0.6 2021 June 21
|
||||
|
||||
@ -252,6 +275,7 @@
|
||||
- Fix a bug: unable to create a coupon on stripe
|
||||
- Fix a bug: no notifications for refunds generated on wallet credit
|
||||
- Fix a bug: in staging environments, emails are not sent
|
||||
- Fix a bug: unable to configure the app to use a german locale
|
||||
- Fix a security issue: updated carrierwave to 2.1.1 to fix [CVE-2021-21305](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-21305)
|
||||
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
|
||||
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
|
||||
@ -263,8 +287,6 @@
|
||||
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-payment-schedules.sh | bash`
|
||||
- [TODO DEPLOY] -> (only dev) `bundle install`
|
||||
|
||||
- Fix a bug: unable to configure the app to use a german locale
|
||||
|
||||
## v4.6.6 2021 February 02
|
||||
- Full German translation (thanks to [@korrupt](https://crowdin.com/profile/korrupt))
|
||||
- OpenAPI endpoints to create/update/show/delete machines
|
||||
|
@ -6,12 +6,7 @@ class API::GroupsController < API::ApiController
|
||||
before_action :authenticate_user!, except: :index
|
||||
|
||||
def index
|
||||
@groups = if current_user&.admin?
|
||||
Group.all
|
||||
else
|
||||
Group.where.not(slug: 'admins')
|
||||
end
|
||||
|
||||
@groups = GroupService.list(current_user, params)
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -7,12 +7,7 @@ class API::MachinesController < API::ApiController
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
sort_by = Setting.get('machines_sort_by') || 'default'
|
||||
@machines = if sort_by == 'default'
|
||||
Machine.includes(:machine_image, :plans)
|
||||
else
|
||||
Machine.includes(:machine_image, :plans).order(sort_by)
|
||||
end
|
||||
@machines = MachineService.list(params)
|
||||
end
|
||||
|
||||
def show
|
||||
|
53
app/controllers/api/prepaid_packs_controller.rb
Normal file
53
app/controllers/api/prepaid_packs_controller.rb
Normal file
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for resources of type PrepaidPack
|
||||
# PrepaidPacks are used to provide discounts to users that bought many hours at once
|
||||
class API::PrepaidPacksController < API::ApiController
|
||||
before_action :authenticate_user!, except: :index
|
||||
before_action :set_pack, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@packs = PrepaidPackService.list(params).order(minutes: :asc)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
authorize PrepaidPack
|
||||
@pack = PrepaidPack.new(pack_params)
|
||||
if @pack.save
|
||||
render status: :created
|
||||
else
|
||||
render json: @pack.errors.full_messages, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @pack
|
||||
|
||||
if @pack.update(pack_params)
|
||||
render status: :ok
|
||||
else
|
||||
render json: @pack.errors.full_messages, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @pack
|
||||
@pack.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_pack
|
||||
@pack = PrepaidPack.find(params[:id])
|
||||
end
|
||||
|
||||
def pack_params
|
||||
pack_params = params
|
||||
pack_params[:pack][:amount] = pack_params[:pack][:amount].to_f * 100.0 if pack_params[:pack][:amount]
|
||||
params.require(:pack).permit(:priceable_id, :priceable_type, :group_id, :amount, :minutes, :validity_count, :validity_interval,
|
||||
:disabled)
|
||||
end
|
||||
end
|
@ -6,22 +6,7 @@ class API::PricesController < API::ApiController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
authorize Price
|
||||
@prices = Price.all
|
||||
if params[:priceable_type]
|
||||
@prices = @prices.where(priceable_type: params[:priceable_type])
|
||||
|
||||
@prices = @prices.where(priceable_id: params[:priceable_id]) if params[:priceable_id]
|
||||
end
|
||||
if params[:plan_id]
|
||||
plan_id = if /no|nil|null|undefined/i.match?(params[:plan_id])
|
||||
nil
|
||||
else
|
||||
params[:plan_id]
|
||||
end
|
||||
@prices = @prices.where(plan_id: plan_id)
|
||||
end
|
||||
@prices = @prices.where(group_id: params[:group_id]) if params[:group_id]
|
||||
@prices = PriceService.list(params)
|
||||
end
|
||||
|
||||
def update
|
||||
|
@ -30,7 +30,7 @@ class API::StripeController < API::PaymentsController
|
||||
currency: Setting.get('stripe_currency'),
|
||||
confirmation_method: 'manual',
|
||||
confirm: true,
|
||||
customer: current_user.payment_gateway_object.gateway_object_id
|
||||
customer: cart.customer.payment_gateway_object.gateway_object_id
|
||||
}, { api_key: Setting.get('stripe_secret_key') }
|
||||
)
|
||||
elsif params[:payment_intent_id].present?
|
||||
|
22
app/controllers/api/user_packs_controller.rb
Normal file
22
app/controllers/api/user_packs_controller.rb
Normal file
@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# API Controller for resources of type StatisticProfilePrepaidPack
|
||||
class API::UserPacksController < API::ApiController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@user_packs = PrepaidPackService.user_packs(user, item)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user
|
||||
return User.find(params[:user_id]) if current_user.privileged?
|
||||
|
||||
current_user
|
||||
end
|
||||
|
||||
def item
|
||||
params[:priceable_type].classify.constantize.find(params[:priceable_id])
|
||||
end
|
||||
end
|
@ -1,5 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# module definition
|
||||
module OpenAPI::V1; end
|
||||
|
||||
# Parameters for OpenAPI endpoints
|
||||
class OpenAPI::V1::BaseController < ActionController::Base
|
||||
protect_from_forgery with: :null_session
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# authorized 3rd party softwares can list the bookable machines through the OpenAPI
|
||||
class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
expose_doc
|
||||
@ -22,19 +25,15 @@ class OpenAPI::V1::BookableMachinesController < OpenAPI::V1::BaseController
|
||||
|
||||
|
||||
|
||||
if user.subscription
|
||||
plan_id = user.subscription.plan_id
|
||||
return unless user.subscription
|
||||
|
||||
@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
|
||||
plan_id = user.subscription.plan_id
|
||||
|
||||
if credit
|
||||
@hours_remaining[machine.id] = credit.hours - (users_credit.try(:hours_used) || 0)
|
||||
else
|
||||
@hours_remaining[machine.id] = 0
|
||||
end
|
||||
end
|
||||
@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
|
||||
|
||||
@hours_remaining[machine.id] = credit ? credit.hours - (users_credit.try(:hours_used) || 0) : 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1,32 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# authorized 3rd party softwares can manage the events through the OpenAPI
|
||||
class OpenAPI::V1::EventsController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
|
||||
def index
|
||||
|
||||
if upcoming
|
||||
@events = Event.includes(:event_image, :event_files, :availability, :category)
|
||||
.where('availabilities.end_at >= ?', DateTime.current)
|
||||
.order('availabilities.start_at ASC').references(:availabilities)
|
||||
else
|
||||
@events = Event.includes(:event_image, :event_files, :availability, :category).order(created_at: :desc)
|
||||
end
|
||||
@events = Event.includes(:event_image, :event_files, :availability, :category)
|
||||
@events = if upcoming
|
||||
@events.references(:availabilities)
|
||||
.where('availabilities.end_at >= ?', DateTime.current)
|
||||
.order('availabilities.start_at ASC')
|
||||
else
|
||||
@events.order(created_at: :desc)
|
||||
end
|
||||
|
||||
if params[:id].present?
|
||||
@events = @events.where(id: params[:id])
|
||||
end
|
||||
|
||||
if params[:page].present?
|
||||
@events = @events.page(params[:page]).per(per_page)
|
||||
paginate @events, per_page: per_page
|
||||
end
|
||||
@events = @events.where(id: params[:id]) if params[:id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
|
||||
@events = @events.page(params[:page]).per(per_page)
|
||||
paginate @events, per_page: per_page
|
||||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
def upcoming
|
||||
params[:upcoming] || false
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
|
||||
def upcoming
|
||||
params[:upcoming] || false
|
||||
end
|
||||
end
|
||||
|
@ -1,12 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# OpenAPI controller for the invoices
|
||||
class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
def index
|
||||
@invoices = Invoice.order(created_at: :desc)
|
||||
.includes(invoicing_profile: :user)
|
||||
.references(:invoicing_profiles)
|
||||
|
||||
@invoices = @invoices.where(user_id: params[:user_id]) if params[:user_id].present?
|
||||
@invoices = @invoices.where(invoicing_profiles: { user_id: params[:user_id] }) if params[:user_id].present?
|
||||
|
||||
return unless params[:page].present?
|
||||
|
||||
|
@ -1,36 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# public API controller for resources of type Reservation
|
||||
class OpenAPI::V1::ReservationsController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
def index
|
||||
@reservations = Reservation.order(created_at: :desc)
|
||||
.includes(statistic_profile: :user)
|
||||
.references(:statistic_profiles)
|
||||
|
||||
if params[:user_id].present?
|
||||
@reservations = @reservations.where(user_id: params[:user_id])
|
||||
else
|
||||
@reservations = @reservations.includes(user: :profile)
|
||||
end
|
||||
@reservations = @reservations.where(statistic_profiles: { user_id: 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?
|
||||
|
||||
if params[:reservable_type].present?
|
||||
@reservations = @reservations.where(reservable_type: format_type(params[:reservable_type]))
|
||||
end
|
||||
return unless params[:page].present?
|
||||
|
||||
if params[:reservable_id].present?
|
||||
@reservations = @reservations.where(reservable_id: params[:reservable_id])
|
||||
end
|
||||
|
||||
if params[:page].present?
|
||||
@reservations = @reservations.page(params[:page]).per(per_page)
|
||||
paginate @reservations, per_page: per_page
|
||||
end
|
||||
@reservations = @reservations.page(params[:page]).per(per_page)
|
||||
paginate @reservations, per_page: per_page
|
||||
end
|
||||
|
||||
private
|
||||
def format_type(type)
|
||||
type.singularize.classify
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
def format_type(type)
|
||||
type.singularize.classify
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
end
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# public API controller for resources of type Training
|
||||
class OpenAPI::V1::TrainingsController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
expose_doc
|
||||
|
@ -1,30 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# public API controller for user's trainings
|
||||
class OpenAPI::V1::UserTrainingsController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
|
||||
def index
|
||||
@user_trainings = UserTraining.order(created_at: :desc)
|
||||
@user_trainings = StatisticProfileTraining.includes(statistic_profile: :user)
|
||||
.includes(:training)
|
||||
.references(:statistic_profiles)
|
||||
.order(created_at: :desc)
|
||||
|
||||
if params[:user_id].present?
|
||||
@user_trainings = @user_trainings.where(user_id: params[:user_id])
|
||||
else
|
||||
@user_trainings = @user_trainings.includes(user: :profile)
|
||||
end
|
||||
|
||||
if params[:training_id].present?
|
||||
@user_trainings = @user_trainings.where(training_id: params[:training_id])
|
||||
else
|
||||
@user_trainings = @user_trainings.includes(:training)
|
||||
end
|
||||
@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?
|
||||
|
||||
if params[:page].present?
|
||||
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
|
||||
paginate @user_trainings, per_page: per_page
|
||||
end
|
||||
return unless params[:page].present?
|
||||
|
||||
@user_trainings = @user_trainings.page(params[:page]).per(per_page)
|
||||
paginate @user_trainings, per_page: per_page
|
||||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
end
|
||||
|
@ -1,5 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# public API controller for users
|
||||
class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
|
||||
extend OpenAPI::ApiDoc
|
||||
include Rails::Pagination
|
||||
expose_doc
|
||||
|
||||
def index
|
||||
@ -9,19 +13,17 @@ class OpenAPI::V1::UsersController < OpenAPI::V1::BaseController
|
||||
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?
|
||||
|
||||
if params[:user_id].present?
|
||||
@users = @users.where(id: params[:user_id])
|
||||
end
|
||||
return unless params[:page].present?
|
||||
|
||||
if params[:page].present?
|
||||
@users = @users.page(params[:page]).per(per_page)
|
||||
paginate @users, per_page: per_page
|
||||
end
|
||||
@users = @users.page(params[:page]).per(per_page)
|
||||
paginate @users, per_page: per_page
|
||||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
|
||||
def per_page
|
||||
params[:per_page] || 20
|
||||
end
|
||||
end
|
||||
|
@ -16,7 +16,7 @@ class OpenAPI::V1::ReservationsDoc < OpenAPI::V1::BaseDoc
|
||||
description 'Index of reservations made by users, with optional pagination. Order by *created_at* descendant.'
|
||||
param_group :pagination
|
||||
param :user_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various users.'
|
||||
param :reservable_type, %w[Event Machine Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
|
||||
param :reservable_type, %w[Event Machine Space Training], optional: true, desc: 'Scope the request to a specific type of reservable.'
|
||||
param :reservable_id, [Integer, Array], optional: true, desc: 'Scope the request to one or various reservables.'
|
||||
|
||||
example <<-RESERVATIONS
|
||||
|
@ -1,17 +1,11 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { CustomAsset, CustomAssetName } from '../models/custom-asset';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
|
||||
export default class CustomAssetAPI {
|
||||
async get (name: CustomAssetName): Promise<CustomAsset> {
|
||||
static async get (name: CustomAssetName): Promise<CustomAsset> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/custom_assets/${name}`);
|
||||
return res?.data?.custom_asset;
|
||||
}
|
||||
|
||||
static get (name: CustomAssetName): IWrapPromise<CustomAsset> {
|
||||
const api = new CustomAssetAPI();
|
||||
return wrapPromise(api.get(name));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios';
|
||||
import { EventTheme } from '../models/event-theme';
|
||||
|
||||
export default class EventThemeAPI {
|
||||
async index (): Promise<Array<EventTheme>> {
|
||||
static async index (): Promise<Array<EventTheme>> {
|
||||
const res: AxiosResponse<Array<EventTheme>> = await apiClient.get(`/api/event_themes`);
|
||||
return res?.data;
|
||||
}
|
||||
|
@ -1,11 +1,17 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Group } from '../models/group';
|
||||
import { Group, GroupIndexFilter } from '../models/group';
|
||||
|
||||
export default class GroupAPI {
|
||||
static async index (): Promise<Array<Group>> {
|
||||
const res: AxiosResponse<Array<Group>> = await apiClient.get('/api/groups');
|
||||
static async index (filters?: GroupIndexFilter): Promise<Array<Group>> {
|
||||
const res: AxiosResponse<Array<Group>> = await apiClient.get(`/api/groups${this.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
private static filtersToQuery(filters?: GroupIndexFilter): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
||||
}
|
||||
}
|
||||
|
||||
|
13
app/frontend/src/javascript/api/local-payment.ts
Normal file
13
app/frontend/src/javascript/api/local-payment.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ShoppingCart } from '../models/payment';
|
||||
import { PaymentSchedule } from '../models/payment-schedule';
|
||||
import { Invoice } from '../models/invoice';
|
||||
|
||||
export default class LocalPaymentAPI {
|
||||
static async confirmPayment (cart_items: ShoppingCart): Promise<PaymentSchedule|Invoice> {
|
||||
const res: AxiosResponse<PaymentSchedule|Invoice> = await apiClient.post('/api/local_payment/confirm_payment', cart_items);
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
22
app/frontend/src/javascript/api/machine.ts
Normal file
22
app/frontend/src/javascript/api/machine.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Machine, MachineIndexFilter } from '../models/machine';
|
||||
|
||||
export default class MachineAPI {
|
||||
static async index (filters?: MachineIndexFilter): Promise<Array<Machine>> {
|
||||
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${this.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async get (id: number): Promise<Machine> {
|
||||
const res: AxiosResponse<Machine> = await apiClient.get(`/api/machines/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
private static filtersToQuery(filters?: MachineIndexFilter): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
||||
}
|
||||
}
|
||||
|
@ -8,32 +8,32 @@ import {
|
||||
} from '../models/payment-schedule';
|
||||
|
||||
export default class PaymentScheduleAPI {
|
||||
async list (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
static async list (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/list`, query);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async index (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
static async index (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/payment_schedules?page=${query.query.page}&size=${query.query.size}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async cashCheck(paymentScheduleItemId: number): Promise<CashCheckResponse> {
|
||||
static async cashCheck(paymentScheduleItemId: number): Promise<CashCheckResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/cash_check`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
|
||||
static async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async payItem(paymentScheduleItemId: number): Promise<PayItemResponse> {
|
||||
static async payItem(paymentScheduleItemId: number): Promise<PayItemResponse> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/pay_item`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
async cancel (paymentScheduleId: number): Promise<CancelScheduleResponse> {
|
||||
static async cancel (paymentScheduleId: number): Promise<CancelScheduleResponse> {
|
||||
const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`);
|
||||
return res?.data;
|
||||
}
|
||||
|
38
app/frontend/src/javascript/api/prepaid-pack.ts
Normal file
38
app/frontend/src/javascript/api/prepaid-pack.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { PackIndexFilter, PrepaidPack } from '../models/prepaid-pack';
|
||||
|
||||
export default class PrepaidPackAPI {
|
||||
static async index (filters?: PackIndexFilter): Promise<Array<PrepaidPack>> {
|
||||
const res: AxiosResponse<Array<PrepaidPack>> = await apiClient.get(`/api/prepaid_packs${this.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async get (id: number): Promise<PrepaidPack> {
|
||||
const res: AxiosResponse<PrepaidPack> = await apiClient.get(`/api/prepaid_packs/${id}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async create (pack: PrepaidPack): Promise<PrepaidPack> {
|
||||
const res: AxiosResponse<PrepaidPack> = await apiClient.post('/api/prepaid_packs', { pack });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (pack: PrepaidPack): Promise<PrepaidPack> {
|
||||
const res: AxiosResponse<PrepaidPack> = await apiClient.patch(`/api/prepaid_packs/${pack.id}`, { pack });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async destroy (packId: number): Promise<void> {
|
||||
const res: AxiosResponse<void> = await apiClient.delete(`/api/prepaid_packs/${packId}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
private static filtersToQuery(filters?: PackIndexFilter): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,12 +1,28 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ShoppingCart } from '../models/payment';
|
||||
import { ComputePriceResult } from '../models/price';
|
||||
import { ComputePriceResult, Price, PriceIndexFilter } from '../models/price';
|
||||
|
||||
export default class PriceAPI {
|
||||
static async compute (cart: ShoppingCart): Promise<ComputePriceResult> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart);
|
||||
const res: AxiosResponse<ComputePriceResult> = await apiClient.post(`/api/prices/compute`, cart);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async index (filters?: PriceIndexFilter): Promise<Array<Price>> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/prices${this.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (price: Price): Promise<Price> {
|
||||
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
private static filtersToQuery(filters?: PriceIndexFilter): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,14 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Setting, SettingBulkResult, SettingError, SettingName } from '../models/setting';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
|
||||
export default class SettingAPI {
|
||||
async get (name: SettingName): Promise<Setting> {
|
||||
static async get (name: SettingName): Promise<Setting> {
|
||||
const res: AxiosResponse<{setting: Setting}> = await apiClient.get(`/api/settings/${name}`);
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
|
||||
static async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('names', `['${names.join("','")}']`);
|
||||
|
||||
@ -17,37 +16,22 @@ export default class SettingAPI {
|
||||
return SettingAPI.toSettingsMap(names, res?.data);
|
||||
}
|
||||
|
||||
async update (name: SettingName, value: any): Promise<Setting> {
|
||||
static async update (name: SettingName, value: any): Promise<Setting> {
|
||||
const res: AxiosResponse = await apiClient.patch(`/api/settings/${name}`, { setting: { value } });
|
||||
if (res.status === 304) { return { name, value }; }
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
async bulkUpdate (settings: Map<SettingName, any>, transactional: boolean = false): Promise<Map<SettingName, SettingBulkResult>> {
|
||||
static async bulkUpdate (settings: Map<SettingName, any>, transactional: boolean = false): Promise<Map<SettingName, SettingBulkResult>> {
|
||||
const res: AxiosResponse = await apiClient.patch(`/api/settings/bulk_update?transactional=${transactional}`, { settings: SettingAPI.toObjectArray(settings) });
|
||||
return SettingAPI.toBulkMap(res?.data?.settings);
|
||||
}
|
||||
|
||||
async isPresent (name: SettingName): Promise<boolean> {
|
||||
static async isPresent (name: SettingName): Promise<boolean> {
|
||||
const res: AxiosResponse = await apiClient.get(`/api/settings/is_present/${name}`);
|
||||
return res?.data?.isPresent;
|
||||
}
|
||||
|
||||
static get (name: SettingName): IWrapPromise<Setting> {
|
||||
const api = new SettingAPI();
|
||||
return wrapPromise(api.get(name));
|
||||
}
|
||||
|
||||
static query (names: Array<SettingName>): IWrapPromise<Map<SettingName, any>> {
|
||||
const api = new SettingAPI();
|
||||
return wrapPromise(api.query(names));
|
||||
}
|
||||
|
||||
static isPresent (name: SettingName): IWrapPromise<boolean> {
|
||||
const api = new SettingAPI();
|
||||
return wrapPromise(api.isPresent(name));
|
||||
}
|
||||
|
||||
private static toSettingsMap(names: Array<SettingName>, data: Object): Map<SettingName, any> {
|
||||
const map = new Map();
|
||||
names.forEach(name => {
|
||||
|
@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios';
|
||||
import { Theme } from '../models/theme';
|
||||
|
||||
export default class ThemeAPI {
|
||||
async index (): Promise<Array<Theme>> {
|
||||
static async index (): Promise<Array<Theme>> {
|
||||
const res: AxiosResponse<Array<Theme>> = await apiClient.get(`/api/themes`);
|
||||
return res?.data;
|
||||
}
|
||||
|
16
app/frontend/src/javascript/api/user-pack.ts
Normal file
16
app/frontend/src/javascript/api/user-pack.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { UserPack, UserPackIndexFilter } from '../models/user-pack';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export default class UserPackAPI {
|
||||
static async index(filters: UserPackIndexFilter): Promise<Array<UserPack>> {
|
||||
const res: AxiosResponse<Array<UserPack>> = await apiClient.get(`/api/user_packs${this.filtersToQuery(filters)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
private static filtersToQuery(filters?: UserPackIndexFilter): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||
import { Wallet } from '../models/wallet';
|
||||
|
||||
export default class WalletAPI {
|
||||
|
@ -3,7 +3,7 @@ import { debounce as _debounce } from 'lodash';
|
||||
|
||||
interface FabInputProps {
|
||||
id: string,
|
||||
onChange?: (value: any, validity?: ValidityState) => void,
|
||||
onChange?: (value: string, validity?: ValidityState) => void,
|
||||
defaultValue: any,
|
||||
icon?: ReactNode,
|
||||
addOn?: ReactNode,
|
||||
@ -18,12 +18,15 @@ interface FabInputProps {
|
||||
placeholder?: string,
|
||||
error?: string,
|
||||
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
|
||||
step?: number | 'any',
|
||||
min?: number,
|
||||
max?: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a template for an input component that wraps the application style
|
||||
*/
|
||||
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error }) => {
|
||||
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step, min, max }) => {
|
||||
const [inputValue, setInputValue] = useState<any>(defaultValue);
|
||||
|
||||
/**
|
||||
@ -86,6 +89,9 @@ export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue,
|
||||
{hasIcon() && <span className="fab-input--icon">{icon}</span>}
|
||||
<input id={id}
|
||||
type={type}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
className="fab-input--input"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
|
||||
import React, { ReactNode, BaseSyntheticEvent, useEffect, useState } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from './loader';
|
||||
import CustomAssetAPI from '../../api/custom-asset';
|
||||
import { CustomAssetName } from '../../models/custom-asset';
|
||||
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
|
||||
import { FabButton } from './fab-button';
|
||||
|
||||
Modal.setAppElement('body');
|
||||
@ -15,37 +15,40 @@ export enum ModalSize {
|
||||
}
|
||||
|
||||
interface FabModalProps {
|
||||
title: string,
|
||||
title?: string,
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
confirmButton?: ReactNode,
|
||||
closeButton?: boolean,
|
||||
className?: string,
|
||||
width?: ModalSize,
|
||||
customHeader?: ReactNode,
|
||||
customFooter?: ReactNode,
|
||||
onConfirm?: (event: BaseSyntheticEvent) => void,
|
||||
preventConfirm?: boolean,
|
||||
onCreation?: () => void,
|
||||
onConfirmSendFormId?: string,
|
||||
}
|
||||
|
||||
// initial request to the API
|
||||
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
|
||||
|
||||
/**
|
||||
* This component is a template for a modal dialog that wraps the application style
|
||||
*/
|
||||
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm, onCreation }) => {
|
||||
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [blackLogo, setBlackLogo] = useState<CustomAsset>(null);
|
||||
|
||||
// initial request to the API to get the theme's logo, for back backgrounds
|
||||
useEffect(() => {
|
||||
CustomAssetAPI.get(CustomAssetName.LogoBlackFile).then(data => setBlackLogo(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onCreation === 'function' && isOpen) {
|
||||
onCreation();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// the theme's logo, for back backgrounds
|
||||
const blackLogo = blackLogoFile.read();
|
||||
|
||||
/**
|
||||
* Check if the confirm button should be present
|
||||
*/
|
||||
@ -53,6 +56,13 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
||||
return confirmButton !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the behavior of the confirm button is to send a form, using the provided ID
|
||||
*/
|
||||
const confirmationSendForm = (): boolean => {
|
||||
return onConfirmSendFormId !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we display the close button?
|
||||
*/
|
||||
@ -67,6 +77,13 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
||||
return customFooter !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a custom header
|
||||
*/
|
||||
const hasCustomHeader = (): boolean => {
|
||||
return customHeader !== undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}
|
||||
className={`fab-modal fab-modal-${width} ${className}`}
|
||||
@ -74,11 +91,12 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
||||
onRequestClose={toggleModal}>
|
||||
<div className="fab-modal-header">
|
||||
<Loader>
|
||||
<img src={blackLogo.custom_asset_file_attributes.attachment_url}
|
||||
alt={blackLogo.custom_asset_file_attributes.attachment}
|
||||
className="modal-logo" />
|
||||
{blackLogo && <img src={blackLogo.custom_asset_file_attributes.attachment_url}
|
||||
alt={blackLogo.custom_asset_file_attributes.attachment}
|
||||
className="modal-logo" />}
|
||||
</Loader>
|
||||
<h1>{ title }</h1>
|
||||
{!hasCustomHeader() && <h1>{ title }</h1>}
|
||||
{hasCustomHeader() && customHeader}
|
||||
</div>
|
||||
<div className="fab-modal-content">
|
||||
{children}
|
||||
@ -86,7 +104,8 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
|
||||
<div className="fab-modal-footer">
|
||||
<Loader>
|
||||
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
|
||||
{hasConfirmButton() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
|
||||
{hasConfirmButton() && !confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
|
||||
{hasConfirmButton() && confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} type="submit" form={onConfirmSendFormId}>{confirmButton}</FabButton>}
|
||||
{hasCustomFooter() && customFooter}
|
||||
</Loader>
|
||||
</div>
|
||||
|
32
app/frontend/src/javascript/components/base/fab-popover.tsx
Normal file
32
app/frontend/src/javascript/components/base/fab-popover.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface FabPopoverProps {
|
||||
title: string,
|
||||
className?: string,
|
||||
headerButton?: ReactNode,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a template for a popovers (bottom) that wraps the application style
|
||||
*/
|
||||
export const FabPopover: React.FC<FabPopoverProps> = ({ title, className, headerButton, children }) => {
|
||||
|
||||
/**
|
||||
* Check if the header button should be present
|
||||
*/
|
||||
const hasHeaderButton = (): boolean => {
|
||||
return !!headerButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fab-popover ${className ? className : ''}`}>
|
||||
<div className="popover-title">
|
||||
<h3>{title}</h3>
|
||||
{hasHeaderButton() && headerButton}
|
||||
</div>
|
||||
<div className="popover-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -30,7 +30,7 @@ const EventThemes: React.FC<EventThemesProps> = ({ event, onChange }) => {
|
||||
const [themes, setThemes] = useState<Array<EventTheme>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
new EventThemeAPI().index().then(data => setThemes(data));
|
||||
EventThemeAPI.index().then(data => setThemes(data));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,90 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from '../base/loader';
|
||||
import { ReserveButton } from './reserve-button';
|
||||
import { User } from '../../models/user';
|
||||
|
||||
interface MachineCardProps {
|
||||
user?: User,
|
||||
machine: Machine,
|
||||
onShowMachine: (machine: Machine) => void,
|
||||
onReserveMachine: (machine: Machine) => void,
|
||||
onLoginRequested: () => Promise<User>,
|
||||
onEnrollRequested: (trainingId: number) => void,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a box showing the picture of the given machine and two buttons: one to start the reservation process
|
||||
* and another to redirect the user to the machine description page.
|
||||
*/
|
||||
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
// shall we display a loader to prevent double-clicking, while the machine details are loading?
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the 'reserve' button and has passed all the verifications
|
||||
*/
|
||||
const handleReserveMachine = (): void => {
|
||||
onReserveMachine(machine);
|
||||
}
|
||||
/**
|
||||
* Callback triggered when the user clicks on the 'view' button
|
||||
*/
|
||||
const handleShowMachine = (): void => {
|
||||
onShowMachine(machine);
|
||||
}
|
||||
|
||||
const machinePicture = (): ReactNode => {
|
||||
if (!machine.machine_image) {
|
||||
return <div className="machine-picture no-picture" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image})` }} onClick={handleShowMachine} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`machine-card ${loading ? 'loading' : ''} ${machine.disabled ? 'disabled': ''}`}>
|
||||
{machinePicture()}
|
||||
<div className="machine-name">
|
||||
{machine.name}
|
||||
</div>
|
||||
<div className="machine-actions">
|
||||
{!machine.disabled && <ReserveButton currentUser={user}
|
||||
machineId={machine.id}
|
||||
onLoadingStart={() => setLoading(true)}
|
||||
onLoadingEnd={() => setLoading(false)}
|
||||
onError={onError}
|
||||
onSuccess={onSuccess}
|
||||
onReserveMachine={handleReserveMachine}
|
||||
onLoginRequested={onLoginRequested}
|
||||
onEnrollRequested={onEnrollRequested}
|
||||
className="reserve-button">
|
||||
<i className="fas fa-bookmark" />
|
||||
{t('app.public.machine_card.book')}
|
||||
</ReserveButton>}
|
||||
<span>
|
||||
<button onClick={handleShowMachine} className="show-button">
|
||||
<i className="fas fa-eye" />
|
||||
{t('app.public.machine_card.consult')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onSuccess={onSuccess} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import Select from 'react-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface MachinesFiltersProps {
|
||||
onStatusSelected: (enabled: boolean) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: boolean, label: string };
|
||||
|
||||
export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelected }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const defaultValue = { value: true, label: t('app.public.machines_filters.status_enabled') };
|
||||
|
||||
/**
|
||||
* Provides boolean options in the react-select format (yes/no/all)
|
||||
*/
|
||||
const buildBooleanOptions = (): Array<selectOption> => {
|
||||
return [
|
||||
defaultValue,
|
||||
{ value: false, label: t('app.public.machines_filters.status_disabled') },
|
||||
{ value: null, label: t('app.public.machines_filters.status_all') },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a machine status in the dropdown list
|
||||
*/
|
||||
const handleStatusSelected = (option: selectOption): void => {
|
||||
onStatusSelected(option.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="machines-filters">
|
||||
<div className="status-filter">
|
||||
<label htmlFor="status">{t('app.public.machines_filters.show_machines')}</label>
|
||||
<Select defaultValue={defaultValue}
|
||||
id="status"
|
||||
className="status-select"
|
||||
onChange={handleStatusSelected}
|
||||
options={buildBooleanOptions()}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import { MachineCard } from './machine-card';
|
||||
import { MachinesFilters } from './machines-filters';
|
||||
import { User } from '../../models/user';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface MachinesListProps {
|
||||
user?: User,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
onShowMachine: (machine: Machine) => void,
|
||||
onReserveMachine: (machine: Machine) => void,
|
||||
onLoginRequested: () => Promise<User>,
|
||||
onEnrollRequested: (trainingId: number) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a list of all machines and allows filtering on that list.
|
||||
*/
|
||||
const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user }) => {
|
||||
// shown machines
|
||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||
// we keep the full list of machines, for filtering
|
||||
const [allMachines, setAllMachines] = useState<Array<Machine>>(null);
|
||||
|
||||
// retrieve the full list of machines on component mount
|
||||
useEffect(() => {
|
||||
MachineAPI.index()
|
||||
.then(data => setAllMachines(data))
|
||||
.catch(e => onError(e));
|
||||
}, []);
|
||||
|
||||
// filter the machines shown when the full list was retrieved
|
||||
useEffect(() => {
|
||||
handleFilterByStatus(true);
|
||||
}, [allMachines])
|
||||
|
||||
/**
|
||||
* Callback triggered when the user changes the status filter.
|
||||
* Set the 'machines' state to a filtered list, depending on the provided parameter.
|
||||
* @param status, true = enabled machines, false = disabled machines, null = all machines
|
||||
*/
|
||||
const handleFilterByStatus = (status: boolean): void => {
|
||||
if (!allMachines) return;
|
||||
if (status === null) return setMachines(allMachines);
|
||||
|
||||
// enabled machines may have the m.disabled property null (for never disabled machines)
|
||||
// or false (for re-enabled machines)
|
||||
setMachines(allMachines.filter(m => !!m.disabled === !status));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="machines-list">
|
||||
<MachinesFilters onStatusSelected={handleFilterByStatus} />
|
||||
<div className="all-machines">
|
||||
{machines && machines.map(machine => {
|
||||
return <MachineCard key={machine.id}
|
||||
user={user}
|
||||
machine={machine}
|
||||
onShowMachine={onShowMachine}
|
||||
onReserveMachine={onReserveMachine}
|
||||
onError={onError}
|
||||
onSuccess={onSuccess}
|
||||
onLoginRequested={onLoginRequested}
|
||||
onEnrollRequested={onEnrollRequested} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onSuccess', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested']));
|
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PendingTrainingModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
nextReservation: Date,
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog shown if the current user has registered for a training but this training isn't validated
|
||||
* when the user tries to book a machine.
|
||||
*/
|
||||
export const PendingTrainingModal: React.FC<PendingTrainingModalProps> = ({ isOpen, toggleModal, nextReservation }) => {
|
||||
const { t } = useTranslation('logged');
|
||||
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
const formatDateTime = (date: Date): string => {
|
||||
const day = Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
const time = Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
|
||||
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: day, TIME:time });
|
||||
}
|
||||
|
||||
return (
|
||||
<FabModal title={t('app.logged.pending_training_modal.machine_reservation')}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
closeButton={true}>
|
||||
<p>{t('app.logged.pending_training_modal.wait_for_validated')}</p>
|
||||
<p><HtmlTranslate trKey="app.logged.pending_training_modal.training_will_occur_DATE_html" options={{ DATE: formatDateTime(nextReservation) }} /></p>
|
||||
</FabModal>
|
||||
)
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { User } from '../../models/user';
|
||||
import { Avatar } from '../user/avatar';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
interface RequiredTrainingModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
user?: User,
|
||||
machine?: Machine,
|
||||
onEnrollRequested: (trainingId: number) => void,
|
||||
}
|
||||
/**
|
||||
* Modal dialog shown if the current user does not have the required training to book the given machine
|
||||
*/
|
||||
export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ isOpen, toggleModal, user, machine, onEnrollRequested }) => {
|
||||
const { t } = useTranslation('logged');
|
||||
|
||||
/**
|
||||
* Properly format the list of allowed trainings
|
||||
*/
|
||||
const formatTrainings = (): string => {
|
||||
if (!machine) return '';
|
||||
|
||||
return machine.trainings.map(t => t.name).join(t('app.logged.required_training_modal.training_or_training_html'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has clicked on the "enroll" button
|
||||
*/
|
||||
const handleEnroll = (): void => {
|
||||
onEnrollRequested(machine.trainings[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom header of the dialog: we display the username and avatar
|
||||
*/
|
||||
const header = (): ReactNode => {
|
||||
return (
|
||||
<div className="user-info">
|
||||
<Avatar user={user} />
|
||||
<span className="user-name">{user?.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom footer of the dialog: we display a user-friendly message to close the dialog
|
||||
*/
|
||||
const footer = (): ReactNode => {
|
||||
return (
|
||||
<div className="not-now">
|
||||
<p>{t('app.logged.required_training_modal.no_enroll_for_now')}</p>
|
||||
<a onClick={toggleModal}>{t('app.logged.required_training_modal.close')}</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
className="required-training-modal"
|
||||
closeButton={false}
|
||||
customHeader={header()}
|
||||
customFooter={footer()}>
|
||||
<div className="training-info">
|
||||
<p>
|
||||
<HtmlTranslate trKey={'app.logged.required_training_modal.to_book_MACHINE_requires_TRAINING_html'}
|
||||
options={{ MACHINE: machine?.name, TRAINING: formatTrainings() }} />
|
||||
</p>
|
||||
<div className="enroll-container">
|
||||
<FabButton onClick={handleEnroll}>{t('app.logged.required_training_modal.enroll_now')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
</FabModal>
|
||||
)
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { PendingTrainingModal } from './pending-training-modal';
|
||||
import { RequiredTrainingModal } from './required-training-modal';
|
||||
import { Loader } from '../base/loader';
|
||||
import { ProposePacksModal } from '../prepaid-packs/propose-packs-modal';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { User } from '../../models/user';
|
||||
import { IApplication } from '../../models/application';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface ReserveButtonProps {
|
||||
currentUser?: User,
|
||||
machineId: number,
|
||||
onLoadingStart?: () => void,
|
||||
onLoadingEnd?: () => void,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
onReserveMachine: (machine: Machine) => void,
|
||||
onLoginRequested: () => Promise<User>,
|
||||
onEnrollRequested: (trainingId: number) => void,
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component that makes the training verification before redirecting the user to the reservation calendar
|
||||
*/
|
||||
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [machine, setMachine] = useState<Machine>(null);
|
||||
const [user, setUser] = useState<User>(currentUser);
|
||||
const [pendingTraining, setPendingTraining] = useState<boolean>(false);
|
||||
const [trainingRequired, setTrainingRequired] = useState<boolean>(false);
|
||||
const [proposePacks, setProposePacks] = useState<boolean>(false);
|
||||
|
||||
// handle login from an external process
|
||||
useEffect(() => setUser(currentUser), [currentUser]);
|
||||
// check the trainings after we retrieved the machine data
|
||||
useEffect(() => checkTraining(), [machine]);
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the 'reserve' button.
|
||||
*/
|
||||
const handleClick = (): void => {
|
||||
getMachine();
|
||||
};
|
||||
|
||||
/**
|
||||
* We load the full machine data, including data on the current user status for this machine.
|
||||
* Then we check if the user has passed the training for it (if it's needed)
|
||||
*/
|
||||
const getMachine = (): void => {
|
||||
if (onLoadingStart) onLoadingStart();
|
||||
|
||||
MachineAPI.get(machineId)
|
||||
.then(data => {
|
||||
setMachine(data);
|
||||
if (onLoadingEnd) onLoadingEnd();
|
||||
})
|
||||
.catch(error => {
|
||||
onError(error);
|
||||
if (onLoadingEnd) onLoadingEnd();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the alert modal informing the user about his pending training
|
||||
*/
|
||||
const togglePendingTrainingModal = (): void => {
|
||||
setPendingTraining(!pendingTraining);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the alert modal informing the user about his pending training
|
||||
*/
|
||||
const toggleRequiredTrainingModal = (): void => {
|
||||
setTrainingRequired(!trainingRequired);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the modal dialog inviting the user to buy a prepaid-pack
|
||||
*/
|
||||
const toggleProposePacksModal = (): void => {
|
||||
setProposePacks(!proposePacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has successfully bought a pre-paid pack.
|
||||
* Display the success message and redirect him to the booking page.
|
||||
*/
|
||||
const handlePackBought = (message: string, machine: Machine): void => {
|
||||
onSuccess(message);
|
||||
onReserveMachine(machine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the current user has passed the required training before allowing him to book
|
||||
*/
|
||||
const checkTraining = (): void => {
|
||||
// do nothing if the machine is still not loaded
|
||||
if (!machine) return;
|
||||
|
||||
// if there's no user currently logged, trigger the logging process
|
||||
if (!user) {
|
||||
onLoginRequested()
|
||||
.then(() => getMachine())
|
||||
.catch(error => onError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// if the currently logged user has completed the training for this machine, or this machine does not require
|
||||
// a prior training, move forward to the prepaid-packs verification.
|
||||
// Moreover, if there's no enabled associated trainings, also move to the next step.
|
||||
if (machine.current_user_is_trained || machine.trainings.length === 0 ||
|
||||
machine.trainings.map(t => t.disabled).reduce((acc, val) => acc && val, true)) {
|
||||
return checkPrepaidPack();
|
||||
}
|
||||
|
||||
// if the currently logged user booked a training for this machine, tell him that he must wait
|
||||
// for an admin to validate the training before he can book the reservation
|
||||
if (machine.current_user_next_training_reservation) {
|
||||
return setPendingTraining(true);
|
||||
}
|
||||
|
||||
// if the currently logged user doesn't have booked the required training, tell him to register
|
||||
// for a training session first
|
||||
setTrainingRequired(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Once the training condition has been verified, we check if there are prepaid-packs to propose to the customer.
|
||||
*/
|
||||
const checkPrepaidPack = (): void => {
|
||||
// if the customer has already bought a pack or if there's no active packs for this machine,
|
||||
// let the customer reserve
|
||||
if (machine.current_user_has_packs || !machine.has_prepaid_packs_for_current_user) {
|
||||
return onReserveMachine(machine);
|
||||
}
|
||||
|
||||
// otherwise, we show a dialog modal to propose the customer to buy an available pack
|
||||
setProposePacks(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<button onClick={handleClick} className={className ? className : ''}>
|
||||
{children && children}
|
||||
{!children && <span>{t('app.shared.reserve_button.book_this_machine')}</span>}
|
||||
</button>
|
||||
<PendingTrainingModal isOpen={pendingTraining}
|
||||
toggleModal={togglePendingTrainingModal}
|
||||
nextReservation={machine?.current_user_next_training_reservation?.slots_attributes[0]?.start_at} />
|
||||
<RequiredTrainingModal isOpen={trainingRequired}
|
||||
toggleModal={toggleRequiredTrainingModal}
|
||||
user={user}
|
||||
machine={machine}
|
||||
onEnrollRequested={onEnrollRequested} />
|
||||
{machine && currentUser && <ProposePacksModal isOpen={proposePacks}
|
||||
toggleModal={toggleProposePacksModal}
|
||||
item={machine}
|
||||
itemType="Machine"
|
||||
onError={onError}
|
||||
customer={currentUser}
|
||||
onDecline={onReserveMachine}
|
||||
operator={currentUser}
|
||||
onSuccess={handlePackBought} />}
|
||||
</span>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onSuccess={onSuccess} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className}>
|
||||
{children}
|
||||
</ReserveButtonComponent>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onSuccess', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className']));
|
@ -1,16 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import moment from 'moment';
|
||||
import '../../lib/i18n';
|
||||
import { Loader } from '../base/loader';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { PaymentSchedule } from '../../models/payment-schedule';
|
||||
import { IApplication } from '../../models/application';
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PaymentScheduleSummaryProps {
|
||||
schedule: PaymentSchedule
|
||||
@ -25,18 +23,6 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
|
||||
// is open, the modal dialog showing the full details of the payment schedule?
|
||||
const [modal, setModal] = useState(false);
|
||||
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
const formatDate = (date: Date): string => {
|
||||
return Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
/**
|
||||
* Test if all payment deadlines have the same amount
|
||||
*/
|
||||
@ -58,7 +44,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
|
||||
{hasEqualDeadlines() && <ul>
|
||||
<li>
|
||||
<span className="schedule-item-info">
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: formatPrice(schedule.items[0].amount) })}
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: FormatLib.price(schedule.items[0].amount) })}
|
||||
</span>
|
||||
<span className="schedule-item-date">{t('app.shared.cart.first_debit')}</span>
|
||||
</li>
|
||||
@ -66,12 +52,12 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
|
||||
{!hasEqualDeadlines() && <ul>
|
||||
<li>
|
||||
<span className="schedule-item-info">{t('app.shared.cart.monthly_payment_NUMBER', { NUMBER: 1 })}</span>
|
||||
<span className="schedule-item-price">{formatPrice(schedule.items[0].amount)}</span>
|
||||
<span className="schedule-item-price">{FormatLib.price(schedule.items[0].amount)}</span>
|
||||
<span className="schedule-item-date">{t('app.shared.cart.debit')}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="schedule-item-info">
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: formatPrice(schedule.items[1].amount) })}
|
||||
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: FormatLib.price(schedule.items[1].amount) })}
|
||||
</span>
|
||||
</li>
|
||||
</ul>}
|
||||
@ -80,9 +66,9 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
|
||||
<ul className="full-schedule">
|
||||
{schedule.items.map(item => (
|
||||
<li key={String(item.due_date)}>
|
||||
<span className="schedule-item-date">{formatDate(item.due_date)}</span>
|
||||
<span className="schedule-item-date">{FormatLib.date(item.due_date)}</span>
|
||||
<span> </span>
|
||||
<span className="schedule-item-price">{formatPrice(item.amount)}</span>
|
||||
<span className="schedule-item-price">{FormatLib.price(item.amount)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
@ -45,8 +45,7 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
|
||||
const handleLoadMore = (): void => {
|
||||
setPageNumber(pageNumber + 1);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
PaymentScheduleAPI.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
const list = paymentSchedules.concat(res);
|
||||
setPaymentSchedules(list);
|
||||
}).catch((error) => onError(error.message));
|
||||
@ -56,8 +55,7 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
|
||||
* Reload from te API all the currently displayed payment schedules
|
||||
*/
|
||||
const handleRefreshList = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
PaymentScheduleAPI.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
}).catch((err) => {
|
||||
onError(err.message);
|
||||
|
@ -53,8 +53,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
|
||||
setCustomerFilter(customer);
|
||||
setDateFilter(date);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
|
||||
PaymentScheduleAPI.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
}).catch((error) => onError(error.message));
|
||||
};
|
||||
@ -65,8 +64,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
|
||||
const handleLoadMore = (): void => {
|
||||
setPageNumber(pageNumber + 1);
|
||||
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
PaymentScheduleAPI.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
|
||||
const list = paymentSchedules.concat(res);
|
||||
setPaymentSchedules(list);
|
||||
}).catch((error) => onError(error.message));
|
||||
@ -76,8 +74,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
|
||||
* Reload from te API all the currently displayed payment schedules
|
||||
*/
|
||||
const handleRefreshList = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
PaymentScheduleAPI.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
|
||||
setPaymentSchedules(res);
|
||||
}).catch((err) => {
|
||||
onError(err.message);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { ReactEventHandler, ReactNode, useState } from 'react';
|
||||
import React, { ReactEventHandler, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader } from '../base/loader';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
@ -9,13 +8,9 @@ import { UpdateCardModal } from '../payment/update-card-modal';
|
||||
import { StripeElements } from '../payment/stripe/stripe-elements';
|
||||
import { StripeConfirm } from '../payment/stripe/stripe-confirm';
|
||||
import { User, UserRole } from '../../models/user';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
|
||||
import PaymentScheduleAPI from '../../api/payment-schedule';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { SettingName } from '../../models/setting';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
interface PaymentSchedulesTableProps {
|
||||
paymentSchedules: Array<PaymentSchedule>,
|
||||
@ -59,19 +54,6 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
return showExpanded.get(paymentScheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
const formatDate = (date: Date): string => {
|
||||
return Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
}
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value for the CSS property 'display', for the payment schedule deadlines
|
||||
*/
|
||||
@ -222,8 +204,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal.
|
||||
*/
|
||||
const onCheckCashingConfirmed = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.cashCheck(tempDeadline.id).then((res) => {
|
||||
PaymentScheduleAPI.cashCheck(tempDeadline.id).then((res) => {
|
||||
if (res.state === PaymentScheduleItemState.Paid) {
|
||||
refreshSchedulesTable();
|
||||
toggleConfirmCashingModal();
|
||||
@ -267,8 +248,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
*/
|
||||
const afterAction = (): void => {
|
||||
toggleConfirmActionButton();
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.refreshItem(tempDeadline.id).then(() => {
|
||||
PaymentScheduleAPI.refreshItem(tempDeadline.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
toggleResolveActionModal();
|
||||
});
|
||||
@ -304,8 +284,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
*/
|
||||
const handleCardUpdateSuccess = (): void => {
|
||||
if (tempDeadline) {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.payItem(tempDeadline.id).then(() => {
|
||||
PaymentScheduleAPI.payItem(tempDeadline.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
onCardUpdateSuccess();
|
||||
toggleUpdateCardModal();
|
||||
@ -347,8 +326,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
* When the user has confirmed the cancellation, we transfer the request to the API
|
||||
*/
|
||||
const onCancelSubscriptionConfirmed = (): void => {
|
||||
const api = new PaymentScheduleAPI();
|
||||
api.cancel(tempSchedule.id).then(() => {
|
||||
PaymentScheduleAPI.cancel(tempSchedule.id).then(() => {
|
||||
refreshSchedulesTable();
|
||||
toggleCancelSubscriptionModal();
|
||||
});
|
||||
@ -375,8 +353,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
<tr>
|
||||
<td className="w-35 row-header" onClick={togglePaymentScheduleDetails(p.id)}>{expandCollapseIcon(p.id)}</td>
|
||||
<td className="w-200">{p.reference}</td>
|
||||
<td className="w-200">{formatDate(p.created_at)}</td>
|
||||
<td className="w-120">{formatPrice(p.total)}</td>
|
||||
<td className="w-200">{FormatLib.date(p.created_at)}</td>
|
||||
<td className="w-120">{FormatLib.price(p.total)}</td>
|
||||
{showCustomer && <td className="w-200">{p.user.name}</td>}
|
||||
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
|
||||
</tr>
|
||||
@ -395,8 +373,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
|
||||
<td>{formatDate(item.due_date)}</td>
|
||||
<td>{formatPrice(item.amount)}</td>
|
||||
<td>{FormatLib.date(item.due_date)}</td>
|
||||
<td>{FormatLib.price(item.amount)}</td>
|
||||
<td>{formatState(item)}</td>
|
||||
<td>{itemButtons(item, p)}</td>
|
||||
</tr>)}
|
||||
@ -420,8 +398,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
confirmButton={t('app.shared.schedules_table.confirm_button')}>
|
||||
{tempDeadline && <span>
|
||||
{t('app.shared.schedules_table.confirm_check_cashing_body', {
|
||||
AMOUNT: formatPrice(tempDeadline.amount),
|
||||
DATE: formatDate(tempDeadline.due_date)
|
||||
AMOUNT: FormatLib.price(tempDeadline.amount),
|
||||
DATE: FormatLib.date(tempDeadline.due_date)
|
||||
})}
|
||||
</span>}
|
||||
</FabModal>
|
||||
|
@ -4,8 +4,7 @@ import WalletLib from '../../lib/wallet';
|
||||
import { WalletInfo } from '../wallet-info';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { CustomAssetName } from '../../models/custom-asset';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
|
||||
import { ShoppingCart } from '../../models/payment';
|
||||
import { PaymentSchedule } from '../../models/payment-schedule';
|
||||
import { User } from '../../models/user';
|
||||
@ -17,8 +16,7 @@ import SettingAPI from '../../api/setting';
|
||||
import { SettingName } from '../../models/setting';
|
||||
import { ComputePriceResult } from '../../models/price';
|
||||
import { Wallet } from '../../models/wallet';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
|
||||
export interface GatewayFormProps {
|
||||
@ -28,7 +26,7 @@ export interface GatewayFormProps {
|
||||
customer: User,
|
||||
operator: User,
|
||||
className?: string,
|
||||
paymentSchedule?: boolean,
|
||||
paymentSchedule?: PaymentSchedule,
|
||||
cart?: ShoppingCart,
|
||||
formId: string,
|
||||
}
|
||||
@ -39,26 +37,26 @@ interface AbstractPaymentModalProps {
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
cart: ShoppingCart,
|
||||
currentUser: User,
|
||||
schedule: PaymentSchedule,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User,
|
||||
logoFooter: ReactNode,
|
||||
GatewayForm: FunctionComponent<GatewayFormProps>,
|
||||
formId: string,
|
||||
className?: string,
|
||||
formClassName?: string,
|
||||
title?: string,
|
||||
preventCgv?: boolean,
|
||||
preventScheduleInfo?: boolean,
|
||||
modalSize?: ModalSize,
|
||||
}
|
||||
|
||||
|
||||
// initial request to the API
|
||||
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
|
||||
|
||||
/**
|
||||
* This component is an abstract modal that must be extended by each payment gateway to include its payment form.
|
||||
*
|
||||
* This component must not be called directly but must be extended for each implemented payment gateway
|
||||
* @see https://reactjs.org/docs/composition-vs-inheritance.html
|
||||
*/
|
||||
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => {
|
||||
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
|
||||
// customer's wallet
|
||||
const [wallet, setWallet] = useState<Wallet>(null);
|
||||
// server-computed price with all details
|
||||
@ -75,17 +73,18 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
const [tos, setTos] = useState<boolean>(false);
|
||||
// currently active payment gateway
|
||||
const [gateway, setGateway] = useState<string>(null);
|
||||
// the sales conditions
|
||||
const [cgv, setCgv] = useState<CustomAsset>(null);
|
||||
|
||||
const { t } = useTranslation('shared');
|
||||
const cgv = cgvFile.read();
|
||||
|
||||
|
||||
/**
|
||||
* When the component is loaded first, get the name of the currently active payment modal
|
||||
* When the component loads first, get the name of the currently active payment modal
|
||||
*/
|
||||
useEffect(() => {
|
||||
const api = new SettingAPI();
|
||||
api.get(SettingName.PaymentGateway).then((setting) => {
|
||||
CustomAssetAPI.get(CustomAssetName.CgvFile).then(asset => setCgv(asset));
|
||||
SettingAPI.get(SettingName.PaymentGateway).then((setting) => {
|
||||
// we capitalize the first letter of the name
|
||||
setGateway(setting.value.replace(/^\w/, (c) => c.toUpperCase()));
|
||||
})
|
||||
@ -103,8 +102,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
setWallet(wallet);
|
||||
PriceAPI.compute(cart).then((res) => {
|
||||
setPrice(res);
|
||||
const wLib = new WalletLib(wallet);
|
||||
setRemainingPrice(wLib.computeRemainingPrice(res.price));
|
||||
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
|
||||
setReady(true);
|
||||
})
|
||||
})
|
||||
@ -121,7 +119,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
* Check if the user accepts the Terms of Sales document
|
||||
*/
|
||||
const hasCgv = (): boolean => {
|
||||
return cgv != null;
|
||||
return cgv != null && !preventCgv;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -132,17 +130,10 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we are currently creating a payment schedule
|
||||
* Check if we must display the info box about the payment schedule
|
||||
*/
|
||||
const isPaymentSchedule = (): boolean => {
|
||||
return schedule !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (amount: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(amount);
|
||||
const hasPaymentScheduleInfo = (): boolean => {
|
||||
return schedule !== undefined && !preventScheduleInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,12 +169,23 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
return !submitState && terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the modal title. If the provided title is a shared translation key, interpolate it through the
|
||||
* translation service. Otherwise, just display the provided string.
|
||||
*/
|
||||
const getTitle = (): string => {
|
||||
if (title.match(/^app\.shared\./)) {
|
||||
return t(title);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<FabModal title={t('app.shared.payment.online_payment') }
|
||||
<FabModal title={getTitle()}
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
width={ModalSize.medium}
|
||||
width={modalSize}
|
||||
closeButton={false}
|
||||
customFooter={logoFooter}
|
||||
className={`payment-modal ${className ? className : ''}`}>
|
||||
@ -197,11 +199,11 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
customer={customer}
|
||||
paymentSchedule={isPaymentSchedule()}>
|
||||
paymentSchedule={schedule}>
|
||||
{hasErrors() && <div className="payment-errors">
|
||||
{errors}
|
||||
</div>}
|
||||
{isPaymentSchedule() && <div className="payment-schedule-info">
|
||||
{hasPaymentScheduleInfo() && <div className="payment-schedule-info">
|
||||
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: schedule.items.length, GATEWAY: gateway }} />
|
||||
</div>}
|
||||
{hasCgv() && <div className="terms-of-sales">
|
||||
@ -217,7 +219,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
disabled={!canSubmit()}
|
||||
form={formId}
|
||||
className="validate-btn">
|
||||
{t('app.shared.payment.confirm_payment_of_', { AMOUNT: formatPrice(remainingPrice) })}
|
||||
{t('app.shared.payment.confirm_payment_of_', { AMOUNT: FormatLib.price(remainingPrice) })}
|
||||
</button>}
|
||||
{submitState && <div className="payment-pending">
|
||||
<div className="fa-2x">
|
||||
@ -228,3 +230,10 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
|
||||
</FabModal>
|
||||
);
|
||||
}
|
||||
|
||||
AbstractPaymentModal.defaultProps = {
|
||||
title: 'app.shared.payment.online_payment',
|
||||
preventCgv: false,
|
||||
preventScheduleInfo: false,
|
||||
modalSize: ModalSize.medium
|
||||
};
|
||||
|
@ -0,0 +1,140 @@
|
||||
import React, { FormEvent, useState } from 'react';
|
||||
import Select from 'react-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GatewayFormProps } from '../abstract-payment-modal';
|
||||
import LocalPaymentAPI from '../../../api/local-payment';
|
||||
import FormatLib from '../../../lib/format';
|
||||
import SettingAPI from '../../../api/setting';
|
||||
import { SettingName } from '../../../models/setting';
|
||||
import { PaymentModal } from '../payment-modal';
|
||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||
|
||||
|
||||
const ALL_SCHEDULE_METHODS = ['card', 'check'] as const;
|
||||
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
|
||||
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: scheduleMethod, label: string };
|
||||
|
||||
/**
|
||||
* A form component to ask for confirmation before cashing a payment directly at the FabLab's reception.
|
||||
* This is intended for use by privileged users.
|
||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, customer, operator, formId }) => {
|
||||
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [method, setMethod] = useState<scheduleMethod>('check');
|
||||
const [onlinePaymentModal, setOnlinePaymentModal] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Open/closes the online payment modal, used to collect card credentials when paying the payment schedule by card.
|
||||
*/
|
||||
const toggleOnlinePaymentModal = (): void => {
|
||||
setOnlinePaymentModal(!onlinePaymentModal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all payement methods for schedules to the react-select format
|
||||
*/
|
||||
const buildMethodOptions = (): Array<selectOption> => {
|
||||
return ALL_SCHEDULE_METHODS.map(i => methodToOption(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given payment-method to the react-select format
|
||||
*/
|
||||
const methodToOption = (value: scheduleMethod): selectOption => {
|
||||
if (!value) return { value, label: '' };
|
||||
|
||||
return { value, label: t(`app.admin.local_payment.method_${value}`) };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a payment method for the current payment schedule.
|
||||
*/
|
||||
const handleUpdateMethod = (option: selectOption) => {
|
||||
setMethod(option.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submission of the form. It will process the local payment.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
|
||||
if (paymentSchedule && method === 'card') {
|
||||
// check that the online payment is active
|
||||
try {
|
||||
const online = await SettingAPI.get(SettingName.OnlinePaymentModule);
|
||||
if (online.value !== 'true') {
|
||||
return onError(t('app.admin.local_payment.online_payment_disabled'))
|
||||
}
|
||||
return toggleOnlinePaymentModal();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await LocalPaymentAPI.confirmPayment(cart);
|
||||
onSuccess(document);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered after a successful payment by online card for a schedule.
|
||||
*/
|
||||
const afterCreatePaymentSchedule = (document: PaymentSchedule) => {
|
||||
toggleOnlinePaymentModal();
|
||||
onSuccess(document);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} id={formId} className={className ? className : ''}>
|
||||
{!paymentSchedule && <p className="payment">{t('app.admin.local_payment.about_to_cash')}</p>}
|
||||
{paymentSchedule && <div className="payment-schedule">
|
||||
<div className="schedule-method">
|
||||
<label htmlFor="payment-method">{t('app.admin.local_payment.payment_method')}</label>
|
||||
<Select placeholder={ t('app.admin.local_payment.payment_method') }
|
||||
id="payment-method"
|
||||
className="method-select"
|
||||
onChange={handleUpdateMethod}
|
||||
options={buildMethodOptions()}
|
||||
defaultValue={methodToOption(method)} />
|
||||
{method === 'card' && <p>{t('app.admin.local_payment.card_collection_info')}</p>}
|
||||
{method === 'check' && <p>{t('app.admin.local_payment.check_collection_info', { DEADLINES: paymentSchedule.items.length })}</p>}
|
||||
</div>
|
||||
<div className="full-schedule">
|
||||
<ul>
|
||||
{paymentSchedule.items.map(item => {
|
||||
return (
|
||||
<li key={`${item.due_date}`}>
|
||||
<span className="schedule-item-date">{FormatLib.date(item.due_date)}</span>
|
||||
<span> </span>
|
||||
<span className="schedule-item-price">{FormatLib.price(item.amount)}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<PaymentModal isOpen={onlinePaymentModal}
|
||||
toggleModal={toggleOnlinePaymentModal}
|
||||
afterSuccess={afterCreatePaymentSchedule}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
currentUser={operator}
|
||||
customer={customer} />
|
||||
</div>}
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import { AbstractPaymentModal, GatewayFormProps } from '../abstract-payment-modal';
|
||||
import { LocalPaymentForm } from './local-payment-form';
|
||||
import { ShoppingCart } from '../../../models/payment';
|
||||
import { PaymentSchedule } from '../../../models/payment-schedule';
|
||||
import { User } from '../../../models/user';
|
||||
import { Invoice } from '../../../models/invoice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalSize } from '../../base/fab-modal';
|
||||
import { Loader } from '../../base/loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../../models/application';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface LocalPaymentModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
cart: ShoppingCart,
|
||||
currentUser: User,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
}
|
||||
|
||||
/**
|
||||
* This component enables a privileged user to confirm a local payments.
|
||||
*/
|
||||
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
|
||||
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Return the logos, shown in the modal footer.
|
||||
*/
|
||||
const logoFooter = (): ReactNode => {
|
||||
return (
|
||||
<div className="local-modal-icons">
|
||||
<i className="fas fa-lock fa-2x" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
|
||||
*/
|
||||
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
|
||||
return (
|
||||
<LocalPaymentForm onSubmit={onSubmit}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
operator={operator}
|
||||
className={className}
|
||||
formId={formId}
|
||||
cart={cart}
|
||||
customer={customer}
|
||||
paymentSchedule={paymentSchedule}>
|
||||
{children}
|
||||
</LocalPaymentForm>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AbstractPaymentModal className="local-payment-modal"
|
||||
isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
logoFooter={logoFooter()}
|
||||
title={t('app.admin.local_payment.offline_payment')}
|
||||
formId="local-payment-form"
|
||||
formClassName="local-payment-form"
|
||||
currentUser={currentUser}
|
||||
cart={cart}
|
||||
customer={customer}
|
||||
afterSuccess={afterSuccess}
|
||||
schedule={schedule}
|
||||
GatewayForm={renderForm}
|
||||
modalSize={schedule ? ModalSize.large : ModalSize.medium}
|
||||
preventCgv
|
||||
preventScheduleInfo />
|
||||
);
|
||||
}
|
||||
|
||||
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cart, customer }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'currentUser', 'schedule', 'cart', 'customer']));
|
@ -1,4 +1,4 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { StripeModal } from './stripe/stripe-modal';
|
||||
@ -7,7 +7,7 @@ import { IApplication } from '../../models/application';
|
||||
import { ShoppingCart } from '../../models/payment';
|
||||
import { User } from '../../models/user';
|
||||
import { PaymentSchedule } from '../../models/payment-schedule';
|
||||
import { SettingName } from '../../models/setting';
|
||||
import { Setting, SettingName } from '../../models/setting';
|
||||
import { Invoice } from '../../models/invoice';
|
||||
import SettingAPI from '../../api/setting';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -21,20 +21,24 @@ interface PaymentModalProps {
|
||||
onError: (message: string) => void,
|
||||
cart: ShoppingCart,
|
||||
currentUser: User,
|
||||
schedule: PaymentSchedule,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
}
|
||||
|
||||
// initial request to the API
|
||||
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
|
||||
|
||||
/**
|
||||
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
|
||||
* to process an online payment.
|
||||
*/
|
||||
const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
|
||||
const PaymentModalComponent: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const gateway = paymentGateway.read();
|
||||
|
||||
const [gateway, setGateway] = useState<Setting>(null);
|
||||
|
||||
useEffect(() => {
|
||||
SettingAPI.get(SettingName.PaymentGateway)
|
||||
.then(setting => setGateway(setting))
|
||||
.catch(error => onError(error));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Render the Stripe payment modal
|
||||
@ -65,6 +69,8 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
|
||||
/**
|
||||
* Determine which gateway is enabled and return the appropriate payment modal
|
||||
*/
|
||||
if (gateway === null || !isOpen) return <div/>;
|
||||
|
||||
switch (gateway.value) {
|
||||
case 'stripe':
|
||||
return renderStripeModal();
|
||||
@ -82,12 +88,12 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
|
||||
}
|
||||
|
||||
|
||||
const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
|
||||
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
|
||||
<PaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
|
||||
Application.Components.component('paymentModal', react2angular(PaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
|
||||
|
@ -67,11 +67,10 @@ export const PayzenCardUpdateModal: React.FC<PayzenCardUpdateModalProps> = ({ is
|
||||
onSuccess={onSuccess}
|
||||
onError={handleCardUpdateError}
|
||||
className="card-form"
|
||||
paymentSchedule={true}
|
||||
paymentSchedule={schedule}
|
||||
operator={operator}
|
||||
customer={schedule.user as User}
|
||||
updateCard={true}
|
||||
paymentScheduleId={schedule.id}
|
||||
formId={formId} >
|
||||
{errors && <div className="payzen-errors">
|
||||
{errors}
|
||||
|
@ -17,21 +17,19 @@ import { Invoice } from '../../../models/invoice';
|
||||
// we use these two additional parameters to update the card, if provided
|
||||
interface PayzenFormProps extends GatewayFormProps {
|
||||
updateCard?: boolean,
|
||||
paymentScheduleId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* A form component to collect the credit card details and to create the payment method on Stripe.
|
||||
* The form validation button must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, updateCard = false, cart, customer, formId, paymentScheduleId }) => {
|
||||
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => {
|
||||
|
||||
const PayZenKR = useRef<KryptonClient>(null);
|
||||
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
|
||||
|
||||
useEffect(() => {
|
||||
const api = new SettingAPI();
|
||||
api.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => {
|
||||
SettingAPI.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => {
|
||||
createToken().then(formToken => {
|
||||
// Load the remote library
|
||||
KRGlue.loadLibrary(settings.get(SettingName.PayZenEndpoint), settings.get(SettingName.PayZenPublicKey))
|
||||
@ -55,7 +53,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
|
||||
*/
|
||||
const createToken = async (): Promise<CreateTokenResponse> => {
|
||||
if (updateCard) {
|
||||
return await PayzenAPI.updateToken(paymentScheduleId);
|
||||
return await PayzenAPI.updateToken(paymentSchedule?.id);
|
||||
} else if (paymentSchedule) {
|
||||
return await PayzenAPI.chargeCreateToken(cart, customer);
|
||||
} else {
|
||||
|
@ -46,8 +46,7 @@ const PayZenKeysFormComponent: React.FC<PayZenKeysFormProps> = ({ onValidKeys, o
|
||||
* When the component loads for the first time, initialize the keys with the values fetched from the API (if any)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const api = new SettingAPI();
|
||||
api.query(payZenSettings).then(payZenKeys => {
|
||||
SettingAPI.query(payZenSettings).then(payZenKeys => {
|
||||
updateSettings(new Map(payZenKeys));
|
||||
}).catch(error => console.error(error));
|
||||
}, []);
|
||||
|
@ -17,7 +17,7 @@ interface PayZenModalProps {
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
cart: ShoppingCart,
|
||||
currentUser: User,
|
||||
schedule: PaymentSchedule,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
}
|
||||
|
||||
|
@ -54,10 +54,9 @@ export const PayzenSettings: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCu
|
||||
* For the private settings, we initialize them with the placeholder value, if the setting is set.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const api = new SettingAPI();
|
||||
api.query(payZenPublicSettings.concat(payZenOtherSettings)).then(payZenKeys => {
|
||||
api.isPresent(SettingName.PayZenPassword).then(pzPassword => {
|
||||
api.isPresent(SettingName.PayZenHmacKey).then(pzHmac => {
|
||||
SettingAPI.query(payZenPublicSettings.concat(payZenOtherSettings)).then(payZenKeys => {
|
||||
SettingAPI.isPresent(SettingName.PayZenPassword).then(pzPassword => {
|
||||
SettingAPI.isPresent(SettingName.PayZenHmacKey).then(pzHmac => {
|
||||
const map = new Map(payZenKeys);
|
||||
map.set(SettingName.PayZenPassword, pzPassword ? PAYZEN_HIDDEN : '');
|
||||
map.set(SettingName.PayZenHmacKey, pzHmac ? PAYZEN_HIDDEN : '');
|
||||
@ -94,8 +93,7 @@ export const PayzenSettings: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCu
|
||||
* This will update the setting on the server.
|
||||
*/
|
||||
const saveCurrency = (): void => {
|
||||
const api = new SettingAPI();
|
||||
api.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => {
|
||||
SettingAPI.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => {
|
||||
setError('');
|
||||
updateSettings(draft => draft.set(SettingName.PayZenCurrency, result.value));
|
||||
onCurrencyUpdateSuccess(result.value);
|
||||
|
@ -4,9 +4,6 @@ import { loadStripe } from "@stripe/stripe-js";
|
||||
import { SettingName } from '../../../models/setting';
|
||||
import SettingAPI from '../../../api/setting';
|
||||
|
||||
// initial request to the API
|
||||
const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey);
|
||||
|
||||
/**
|
||||
* This component initializes the stripe's Elements tag with the API key
|
||||
*/
|
||||
@ -17,9 +14,10 @@ export const StripeElements: React.FC = memo(({ children }) => {
|
||||
* When this component is mounted, we initialize the <Elements> tag with the Stripe's public key
|
||||
*/
|
||||
useEffect(() => {
|
||||
const key = stripePublicKey.read();
|
||||
const promise = loadStripe(key.value);
|
||||
setStripe(promise);
|
||||
SettingAPI.get(SettingName.StripePublicKey).then(key => {
|
||||
const promise = loadStripe(key.value);
|
||||
setStripe(promise);
|
||||
});
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
@ -43,8 +43,7 @@ const StripeKeysFormComponent: React.FC<StripeKeysFormProps> = ({ onValidKeys, o
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
|
||||
const api = new SettingAPI();
|
||||
api.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => {
|
||||
SettingAPI.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => {
|
||||
setPublicKey(stripeKeys.get(SettingName.StripePublicKey));
|
||||
setSecretKey(stripeKeys.get(SettingName.StripeSecretKey));
|
||||
}).catch(error => console.error(error));
|
||||
|
@ -18,7 +18,7 @@ interface StripeModalProps {
|
||||
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
||||
cart: ShoppingCart,
|
||||
currentUser: User,
|
||||
schedule: PaymentSchedule,
|
||||
schedule?: PaymentSchedule,
|
||||
customer: User
|
||||
}
|
||||
|
||||
|
@ -231,7 +231,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
return (
|
||||
<div key={groupId} className="plans-per-group">
|
||||
<h2 className="group-title">{ groupName(groupId) }</h2>
|
||||
{renderPlansByCategory(plansByGroup)}
|
||||
{plansByGroup && renderPlansByCategory(plansByGroup)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { User } from '../../models/user';
|
||||
import { UserPack } from '../../models/user-pack';
|
||||
import UserPackAPI from '../../api/user-pack';
|
||||
import SettingAPI from '../../api/setting';
|
||||
import { SettingName } from '../../models/setting';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProposePacksModal } from './propose-packs-modal';
|
||||
import { Loader } from '../base/loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
type PackableItem = Machine;
|
||||
|
||||
interface PacksSummaryProps {
|
||||
item: PackableItem,
|
||||
itemType: 'Machine',
|
||||
customer?: User,
|
||||
operator: User,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
refresh?: Promise<void>
|
||||
}
|
||||
|
||||
const PacksSummaryComponent: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess, refresh }) => {
|
||||
const { t } = useTranslation('logged');
|
||||
|
||||
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
|
||||
const [userPacks, setUserPacks] = useState<Array<UserPack>>(null);
|
||||
const [threshold, setThreshold] = useState<number>(null);
|
||||
const [packsModal, setPacksModal] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
SettingAPI.get(SettingName.RenewPackThreshold)
|
||||
.then(data => setThreshold(parseFloat(data.value)))
|
||||
.catch(error => onError(error));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getUserPacksData();
|
||||
}, [customer, item, itemType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refresh instanceof Promise) {
|
||||
refresh.then(getUserPacksData);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Fetch the user packs data from the API
|
||||
*/
|
||||
const getUserPacksData = (): void => {
|
||||
if (_.isEmpty(customer)) return;
|
||||
|
||||
UserPackAPI.index({ user_id: customer.id, priceable_type: itemType, priceable_id: item.id })
|
||||
.then(data => setUserPacks(data))
|
||||
.catch(error => onError(error));
|
||||
PrepaidPackAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, disabled: false })
|
||||
.then(data => setPacks(data))
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total of minutes used by the customer
|
||||
*/
|
||||
const totalUsed = (): number => {
|
||||
if (!userPacks) return 0;
|
||||
|
||||
return userPacks.map(up => up.minutes_used).reduce((acc, curr) => acc + curr, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total of minutes available is the packs bought by the customer
|
||||
*/
|
||||
const totalAvailable = (): number => {
|
||||
if (!userPacks) return 0;
|
||||
|
||||
return userPacks.map(up => up.prepaid_pack.minutes).reduce((acc, curr) => acc + curr, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total prepaid hours remaining for the current customer
|
||||
*/
|
||||
const totalHours = (): number => {
|
||||
return (totalAvailable() - totalUsed()) / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we need to display the "buy new pack" button?
|
||||
*/
|
||||
const shouldDisplayButton = (): boolean => {
|
||||
if (!packs?.length) return false;
|
||||
|
||||
if (threshold < 1) {
|
||||
return totalAvailable() - totalUsed() <= totalAvailable() * threshold;
|
||||
}
|
||||
|
||||
return totalAvailable() - totalUsed() <= threshold * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/closes the prepaid-pack buying modal
|
||||
*/
|
||||
const togglePacksModal = (): void => {
|
||||
setPacksModal(!packsModal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the customer has successfully bought a prepaid-pack
|
||||
*/
|
||||
const handlePackBoughtSuccess = (message: string): void => {
|
||||
onSuccess(message);
|
||||
togglePacksModal();
|
||||
UserPackAPI.index({ user_id: customer.id, priceable_type: itemType, priceable_id: item.id })
|
||||
.then(data => setUserPacks(data))
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
// prevent component rendering if no customer selected
|
||||
if (_.isEmpty(customer)) return <div />;
|
||||
// prevent component rendering if ths customer have no packs and there are no packs available
|
||||
if (totalHours() === 0 && packs?.length === 0) return <div/>;
|
||||
|
||||
return (
|
||||
<div className="packs-summary">
|
||||
<h3>{t('app.logged.packs_summary.prepaid_hours')}</h3>
|
||||
<div className="content">
|
||||
<span className="remaining-hours">
|
||||
{totalHours() > 0 && t('app.logged.packs_summary.remaining_HOURS', { HOURS: totalHours(), ITEM: itemType })}
|
||||
{totalHours() === 0 && t('app.logged.packs_summary.no_hours', { ITEM: itemType })}
|
||||
</span>
|
||||
{shouldDisplayButton() && <div className="button-wrapper">
|
||||
<FabButton className="buy-button" onClick={togglePacksModal} icon={<i className="fa fa-shopping-cart"/>}>
|
||||
{t('app.logged.packs_summary.buy_a_new_pack')}
|
||||
</FabButton>
|
||||
<ProposePacksModal isOpen={packsModal}
|
||||
toggleModal={togglePacksModal}
|
||||
item={item}
|
||||
itemType={itemType}
|
||||
customer={customer}
|
||||
operator={operator}
|
||||
onError={onError}
|
||||
onDecline={togglePacksModal}
|
||||
onSuccess={handlePackBoughtSuccess} />
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PacksSummary: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess, refresh }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<PacksSummaryComponent item={item} itemType={itemType} customer={customer} operator={operator} onError={onError} onSuccess={onSuccess} refresh={refresh} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('packsSummary', react2angular(PacksSummary, ['item', 'itemType', 'customer', 'operator', 'onError', 'onSuccess', 'refresh']));
|
@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { FabModal, ModalSize } from '../base/fab-modal';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { User } from '../../models/user';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import PriceAPI from '../../api/price';
|
||||
import { Price } from '../../models/price';
|
||||
import { PaymentMethod, ShoppingCart } from '../../models/payment';
|
||||
import { PaymentModal } from '../payment/payment-modal';
|
||||
import UserLib from '../../lib/user';
|
||||
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
|
||||
type PackableItem = Machine;
|
||||
|
||||
interface ProposePacksModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
item: PackableItem,
|
||||
itemType: 'Machine',
|
||||
customer: User,
|
||||
operator: User,
|
||||
onError: (message: string) => void,
|
||||
onDecline: (item: PackableItem) => void,
|
||||
onSuccess: (message:string, item: PackableItem) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog shown to offer prepaid-packs for purchase, to the current user.
|
||||
*/
|
||||
export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, toggleModal, item, itemType, customer, operator, onError, onDecline, onSuccess }) => {
|
||||
const { t } = useTranslation('logged');
|
||||
|
||||
const [price, setPrice] = useState<Price>(null);
|
||||
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
|
||||
const [cart, setCart] = useState<ShoppingCart>(null);
|
||||
const [paymentModal, setPaymentModal] = useState<boolean>(false);
|
||||
const [localPaymentModal, setLocalPaymentModal] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
PrepaidPackAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, disabled: false })
|
||||
.then(data => setPacks(data))
|
||||
.catch(error => onError(error));
|
||||
PriceAPI.index({ priceable_id: item.id, priceable_type: itemType, group_id: customer.group_id, plan_id: null })
|
||||
.then(data => setPrice(data[0]))
|
||||
.catch(error => onError(error));
|
||||
}, [item]);
|
||||
|
||||
|
||||
/**
|
||||
* Open/closes the payment modal
|
||||
*/
|
||||
const togglePaymentModal = (): void => {
|
||||
setPaymentModal(!paymentModal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/closes the local payment modal (for admins and managers)
|
||||
*/
|
||||
const toggleLocalPaymentModal = (): void => {
|
||||
setLocalPaymentModal(!localPaymentModal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the hourly-based price of the given prive, to a total price, based on the duration of the given pack
|
||||
*/
|
||||
const hourlyPriceToTotal = (price: Price, pack: PrepaidPack): number => {
|
||||
const hours = pack.minutes / 60;
|
||||
return price.amount * hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the number of hours, user-friendly formatted
|
||||
*/
|
||||
const formatDuration = (minutes: number): string => {
|
||||
return t('app.logged.propose_packs_modal.pack_DURATION', { DURATION: minutes / 60 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a user-friendly string for the validity of the provided pack
|
||||
*/
|
||||
const formatValidity = (pack: PrepaidPack): string => {
|
||||
const period = t(`app.logged.propose_packs_modal.period.${pack.validity_interval}`, { COUNT: pack.validity_count });
|
||||
return t('app.logged.propose_packs_modal.validity', { COUNT: pack.validity_count, PERIODS: period });
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has declined to buy a pack
|
||||
*/
|
||||
const handlePacksRefused = (): void => {
|
||||
onDecline(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has accepted to buy the provided pack, process with the payment
|
||||
*/
|
||||
const handleBuyPack = (pack: PrepaidPack) => {
|
||||
return (): void => {
|
||||
setCart({
|
||||
customer_id: customer.id,
|
||||
payment_method: PaymentMethod.Card,
|
||||
items: [
|
||||
{ prepaid_pack: { id: pack.id }}
|
||||
]
|
||||
});
|
||||
if (new UserLib(operator).isPrivileged(customer)) {
|
||||
return toggleLocalPaymentModal();
|
||||
}
|
||||
togglePaymentModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has bought the pack with a successful payment
|
||||
*/
|
||||
const handlePackBought = (): void => {
|
||||
onSuccess(t('app.logged.propose_packs_modal.pack_bought_success'), item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given prepaid-pack
|
||||
*/
|
||||
const renderPack = (pack: PrepaidPack) => {
|
||||
if (!price) return;
|
||||
|
||||
const normalPrice = hourlyPriceToTotal(price, pack)
|
||||
return (
|
||||
<div key={pack.id} className="pack">
|
||||
<span className="duration">{formatDuration(pack.minutes)}</span>
|
||||
<span className="amount">{FormatLib.price(pack.amount)}</span>
|
||||
{pack.amount < normalPrice && <span className="crossed-out-price">{FormatLib.price(normalPrice)}</span>}
|
||||
<span className="validity">{formatValidity(pack)}</span>
|
||||
<FabButton className="buy-button" onClick={handleBuyPack(pack)} icon={<i className="fas fa-shopping-cart" />}>
|
||||
{t('app.logged.propose_packs_modal.buy_this_pack')}
|
||||
</FabButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
width={ModalSize.large}
|
||||
confirmButton={t('app.logged.propose_packs_modal.no_thanks')}
|
||||
onConfirm={handlePacksRefused}
|
||||
className="propose-packs-modal"
|
||||
title={t('app.logged.propose_packs_modal.available_packs')}>
|
||||
<p>{t('app.logged.propose_packs_modal.packs_proposed')}</p>
|
||||
<div className="list-of-packs">
|
||||
{packs?.map(p => renderPack(p))}
|
||||
</div>
|
||||
{cart && <div>
|
||||
<PaymentModal isOpen={paymentModal}
|
||||
toggleModal={togglePaymentModal}
|
||||
afterSuccess={handlePackBought}
|
||||
onError={onError}
|
||||
cart={cart}
|
||||
currentUser={operator}
|
||||
customer={customer} />
|
||||
<LocalPaymentModal isOpen={localPaymentModal}
|
||||
toggleModal={toggleLocalPaymentModal}
|
||||
afterSuccess={handlePackBought}
|
||||
cart={cart}
|
||||
currentUser={operator}
|
||||
customer={customer} />
|
||||
</div>}
|
||||
</FabModal>
|
||||
);
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabPopover } from '../base/fab-popover';
|
||||
import { CreatePack } from './create-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { DeletePack } from './delete-pack';
|
||||
import { EditPack } from './edit-pack';
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
interface ConfigurePacksButtonProps {
|
||||
packsData: Array<PrepaidPack>,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
groupId: number,
|
||||
priceableId: number,
|
||||
priceableType: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a button that shows the list of prepaid-packs when moving the mouse over it.
|
||||
* It also triggers modal dialogs to configure (add/delete/edit/remove) prepaid-packs.
|
||||
*/
|
||||
export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ packsData, onError, onSuccess, groupId, priceableId, priceableType }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [packs, setPacks] = useState<Array<PrepaidPack>>(packsData);
|
||||
const [showList, setShowList] = useState<boolean>(false);
|
||||
const [editPackModal, setEditPackModal] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Return the number of hours, user-friendly formatted
|
||||
*/
|
||||
const formatDuration = (minutes: number): string => {
|
||||
return t('app.admin.configure_packs_button.pack_DURATION', { DURATION: minutes / 60 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/closes the popover listing the existing packs
|
||||
*/
|
||||
const toggleShowList = (): void => {
|
||||
setShowList(!showList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/closes the "edit pack" modal
|
||||
*/
|
||||
const toggleEditPackModal = (): void => {
|
||||
setEditPackModal(!editPackModal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the PrepaidPack was successfully created/deleted/updated.
|
||||
* We refresh the list of packs for the current tooltip to display the new data.
|
||||
*/
|
||||
const handleSuccess = (message: string) => {
|
||||
onSuccess(message);
|
||||
PrepaidPackAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
|
||||
.then(data => setPacks(data))
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the button used to trigger the "new pack" modal
|
||||
*/
|
||||
const renderAddButton = (): ReactNode => {
|
||||
return <CreatePack onSuccess={handleSuccess}
|
||||
onError={onError}
|
||||
groupId={groupId}
|
||||
priceableId={priceableId}
|
||||
priceableType={priceableType} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="configure-packs-button">
|
||||
<button className="packs-button" onClick={toggleShowList}>
|
||||
<i className="fas fa-box" />
|
||||
</button>
|
||||
{showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()}>
|
||||
<ul>
|
||||
{packs?.map(p =>
|
||||
<li key={p.id} className={p.disabled ? 'disabled' : ''}>
|
||||
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
|
||||
<span className="pack-actions">
|
||||
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
|
||||
<DeletePack onSuccess={handleSuccess} onError={onError} pack={p} />
|
||||
</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
{packs?.length === 0 && <span>{t('app.admin.configure_packs_button.no_packs')}</span>}
|
||||
</FabPopover>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { PackForm } from './pack-form';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
|
||||
interface CreatePackProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
groupId: number,
|
||||
priceableId: number,
|
||||
priceableType: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a button.
|
||||
* When clicked, we show a modal dialog handing the process of creating a new PrepaidPack
|
||||
*/
|
||||
export const CreatePack: React.FC<CreatePackProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Open/closes the "new pack" modal dialog
|
||||
*/
|
||||
const toggleModal = (): void => {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has validated the creation of the new PrepaidPack
|
||||
*/
|
||||
const handleSubmit = (pack: PrepaidPack): void => {
|
||||
// set the already-known attributes of the new pack
|
||||
const newPack = Object.assign<PrepaidPack, PrepaidPack>({} as PrepaidPack, pack);
|
||||
newPack.group_id = groupId;
|
||||
newPack.priceable_id = priceableId;
|
||||
newPack.priceable_type = priceableType;
|
||||
|
||||
// create it on the API
|
||||
PrepaidPackAPI.create(newPack)
|
||||
.then(() => {
|
||||
onSuccess(t('app.admin.create_pack.pack_successfully_created'));
|
||||
toggleModal();
|
||||
})
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="create-pack">
|
||||
<button className="add-pack-button" onClick={toggleModal}><i className="fas fa-plus"/></button>
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
title={t('app.admin.create_pack.new_pack')}
|
||||
className="new-pack-modal"
|
||||
closeButton
|
||||
confirmButton={t('app.admin.create_pack.create_pack')}
|
||||
onConfirmSendFormId="new-pack">
|
||||
<FabAlert level="info">
|
||||
{t('app.admin.create_pack.new_pack_info', { TYPE: priceableType })}
|
||||
</FabAlert>
|
||||
<PackForm formId="new-pack" onSubmit={handleSubmit} />
|
||||
</FabModal>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { Loader } from '../base/loader';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
|
||||
|
||||
interface DeletePackProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
pack: PrepaidPack,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a button.
|
||||
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided pack.
|
||||
*/
|
||||
const DeletePackComponent: React.FC<DeletePackProps> = ({ onSuccess, onError, pack }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [deletionModal, setDeletionModal] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Opens/closes the deletion modal
|
||||
*/
|
||||
const toggleDeletionModal = (): void => {
|
||||
setDeletionModal(!deletionModal);
|
||||
};
|
||||
|
||||
/**
|
||||
* The deletion has been confirmed by the user.
|
||||
* Call the API to trigger the deletion of the temporary set plan-category
|
||||
*/
|
||||
const onDeleteConfirmed = (): void => {
|
||||
PrepaidPackAPI.destroy(pack.id).then(() => {
|
||||
onSuccess(t('app.admin.delete_pack.pack_deleted'));
|
||||
}).catch((error) => {
|
||||
onError(t('app.admin.delete_pack.unable_to_delete') + error);
|
||||
});
|
||||
toggleDeletionModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="delete-pack">
|
||||
<FabButton type='button' className="remove-pack-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
|
||||
<FabModal title={t('app.admin.delete_pack.delete_pack')}
|
||||
isOpen={deletionModal}
|
||||
toggleModal={toggleDeletionModal}
|
||||
closeButton={true}
|
||||
confirmButton={t('app.admin.delete_pack.confirm_delete')}
|
||||
onConfirm={onDeleteConfirmed}>
|
||||
<span>{t('app.admin.delete_pack.delete_confirmation')}</span>
|
||||
</FabModal>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
export const DeletePack: React.FC<DeletePackProps> = ({ onSuccess, onError, pack }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<DeletePackComponent onSuccess={onSuccess} onError={onError} pack={pack} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
70
app/frontend/src/javascript/components/pricing/edit-pack.tsx
Normal file
70
app/frontend/src/javascript/components/pricing/edit-pack.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { PackForm } from './pack-form';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
interface EditPackProps {
|
||||
pack: PrepaidPack,
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a button.
|
||||
* When clicked, we show a modal dialog handing the process of creating a new PrepaidPack
|
||||
*/
|
||||
export const EditPack: React.FC<EditPackProps> = ({ pack, onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [packData, setPackData] = useState<PrepaidPack>(null);
|
||||
|
||||
/**
|
||||
* Open/closes the "edit pack" modal dialog
|
||||
*/
|
||||
const toggleModal = (): void => {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user clicks on the edition button, query the full data of the current pack from the API, then open te edition modal
|
||||
*/
|
||||
const handleRequestEdit = (): void => {
|
||||
PrepaidPackAPI.get(pack.id)
|
||||
.then(data => {
|
||||
setPackData(data);
|
||||
toggleModal();
|
||||
})
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has validated the changes of the PrepaidPack
|
||||
*/
|
||||
const handleUpdate = (pack: PrepaidPack): void => {
|
||||
PrepaidPackAPI.update(pack)
|
||||
.then(() => {
|
||||
onSuccess(t('app.admin.edit_pack.pack_successfully_updated'));
|
||||
toggleModal();
|
||||
})
|
||||
.catch(error => onError(error));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-pack">
|
||||
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
title={t('app.admin.edit_pack.edit_pack')}
|
||||
className="edit-pack-modal"
|
||||
closeButton
|
||||
confirmButton={t('app.admin.edit_pack.confirm_changes')}
|
||||
onConfirmSendFormId="edit-pack">
|
||||
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />}
|
||||
</FabModal>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { FabInput } from '../base/fab-input';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Price } from '../../models/price';
|
||||
import FormatLib from '../../lib/format';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface EditablePriceProps {
|
||||
price: Price,
|
||||
onSave: (price: Price) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the given price.
|
||||
* When the user clics on the price, switch to the edition mode to allow him modifying the price.
|
||||
*/
|
||||
export const EditablePrice: React.FC<EditablePriceProps> = ({ price, onSave }) => {
|
||||
const [edit, setEdit] = useState<boolean>(false);
|
||||
const [tempPrice, setTempPrice] = useState<string>(`${price.amount}`);
|
||||
|
||||
/**
|
||||
* Saves the new price
|
||||
*/
|
||||
const handleValidateEdit = (): void => {
|
||||
const newPrice: Price = Object.assign({}, price);
|
||||
newPrice.amount = parseFloat(tempPrice);
|
||||
onSave(newPrice);
|
||||
toggleEdit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the edit mode
|
||||
*/
|
||||
const toggleEdit = (): void => {
|
||||
setEdit(!edit);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="editable-price">
|
||||
{!edit && <span className="display-price" onClick={toggleEdit}>{FormatLib.price(price.amount)}</span>}
|
||||
{edit && <span>
|
||||
<FabInput id="price" type="number" step={0.01} defaultValue={price.amount} addOn={Fablab.intl_currency} onChange={setTempPrice} required/>
|
||||
<FabButton icon={<i className="fas fa-check" />} className="approve-button" onClick={handleValidateEdit} />
|
||||
<FabButton icon={<i className="fas fa-times" />} className="cancel-button" onClick={toggleEdit} />
|
||||
</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import GroupAPI from '../../api/group';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { Group } from '../../models/group';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { EditablePrice } from './editable-price';
|
||||
import { ConfigurePacksButton } from './configure-packs-button';
|
||||
import PriceAPI from '../../api/price';
|
||||
import { Price } from '../../models/price';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { useImmer } from 'use-immer';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
declare var Application: IApplication;
|
||||
|
||||
interface MachinesPricingProps {
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface to set and edit the prices of machines-hours, per group
|
||||
*/
|
||||
const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [machines, setMachines] = useState<Array<Machine>>(null);
|
||||
const [groups, setGroups] = useState<Array<Group>>(null);
|
||||
const [prices, updatePrices] = useImmer<Array<Price>>(null);
|
||||
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
|
||||
|
||||
// retrieve the initial data
|
||||
useEffect(() => {
|
||||
MachineAPI.index({ disabled: false })
|
||||
.then(data => setMachines(data))
|
||||
.catch(error => onError(error));
|
||||
GroupAPI.index({ disabled: false , admins: false })
|
||||
.then(data => setGroups(data))
|
||||
.catch(error => onError(error));
|
||||
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
|
||||
.then(data => updatePrices(data))
|
||||
.catch(error => onError(error));
|
||||
PrepaidPackAPI.index()
|
||||
.then(data => setPacks(data))
|
||||
.catch(error => onError(error))
|
||||
}, []);
|
||||
|
||||
// duration of the example slot
|
||||
const EXEMPLE_DURATION = 20;
|
||||
|
||||
/**
|
||||
* Return the exemple price, formatted
|
||||
*/
|
||||
const examplePrice = (type: 'hourly_rate' | 'final_price'): string => {
|
||||
const hourlyRate = 10;
|
||||
|
||||
if (type === 'hourly_rate') {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(hourlyRate);
|
||||
}
|
||||
|
||||
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the price matching the given criterion
|
||||
*/
|
||||
const findPriceBy = (machineId, groupId): Price => {
|
||||
return prices.find(price => price.priceable_id === machineId && price.group_id === groupId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter the packs matching the given criterion
|
||||
*/
|
||||
const filterPacksBy = (machineId, groupId): Array<PrepaidPack> => {
|
||||
return packs.filter(pack => pack.priceable_id === machineId && pack.group_id === groupId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the given price in the internal state
|
||||
*/
|
||||
const updatePrice = (price: Price): void => {
|
||||
updatePrices(draft => {
|
||||
const index = draft.findIndex(p => p.id === price.id);
|
||||
draft[index] = price;
|
||||
return draft;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has confirmed to update a price
|
||||
*/
|
||||
const handleUpdatePrice = (price: Price): void => {
|
||||
PriceAPI.update(price)
|
||||
.then(() => {
|
||||
onSuccess(t('app.admin.machines_pricing.price_updated'));
|
||||
updatePrice(price);
|
||||
})
|
||||
.catch(error => onError(error))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="machines-pricing">
|
||||
<FabAlert level="warning">
|
||||
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
|
||||
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: EXEMPLE_DURATION, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
|
||||
<p>{t('app.admin.machines_pricing.you_can_override')}</p>
|
||||
</FabAlert>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('app.admin.machines_pricing.machines')}</th>
|
||||
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{machines?.map(machine => <tr key={machine.id}>
|
||||
<td>{machine.name}</td>
|
||||
{groups?.map(group => <td key={group.id}>
|
||||
{prices && <EditablePrice price={findPriceBy(machine.id, group.id)} onSave={handleUpdatePrice} />}
|
||||
{packs && <ConfigurePacksButton packsData={filterPacksBy(machine.id, group.id)}
|
||||
onError={onError}
|
||||
onSuccess={onSuccess}
|
||||
groupId={group.id}
|
||||
priceableId={machine.id}
|
||||
priceableType="Machine" />}
|
||||
</td>)}
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MachinesPricingWrapper: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<MachinesPricing onError={onError} onSuccess={onSuccess} />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
Application.Components.component('machinesPricing', react2angular(MachinesPricingWrapper, ['onError', 'onSuccess']));
|
||||
|
||||
|
145
app/frontend/src/javascript/components/pricing/pack-form.tsx
Normal file
145
app/frontend/src/javascript/components/pricing/pack-form.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { BaseSyntheticEvent } from 'react';
|
||||
import Select from 'react-select';
|
||||
import Switch from 'react-switch';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { FabInput } from '../base/fab-input';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface PackFormProps {
|
||||
formId: string,
|
||||
onSubmit: (pack: PrepaidPack) => void,
|
||||
pack?: PrepaidPack,
|
||||
}
|
||||
|
||||
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
|
||||
type interval = typeof ALL_INTERVALS[number];
|
||||
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: interval, label: string };
|
||||
|
||||
/**
|
||||
* A form component to create/edit a PrepaidPack.
|
||||
* The form validation must be created elsewhere, using the attribute form={formId}.
|
||||
*/
|
||||
export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) => {
|
||||
const [packData, updatePackData] = useImmer<PrepaidPack>(pack || {} as PrepaidPack);
|
||||
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Convert all validity-intervals to the react-select format
|
||||
*/
|
||||
const buildOptions = (): Array<selectOption> => {
|
||||
return ALL_INTERVALS.map(i => intervalToOption(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given validity-interval to the react-select format
|
||||
*/
|
||||
const intervalToOption = (value: interval): selectOption => {
|
||||
if (!value) return { value, label: '' };
|
||||
|
||||
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user sends the form.
|
||||
*/
|
||||
const handleSubmit = (event: BaseSyntheticEvent): void => {
|
||||
event.preventDefault();
|
||||
onSubmit(packData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user inputs an amount for the current pack.
|
||||
*/
|
||||
const handleUpdateAmount = (amount: string) => {
|
||||
updatePackData(draft => {
|
||||
draft.amount = parseFloat(amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user inputs a number of hours for the current pack.
|
||||
*/
|
||||
const handleUpdateHours = (hours: string) => {
|
||||
updatePackData(draft => {
|
||||
draft.minutes = parseInt(hours, 10) * 60;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user inputs a number of periods for the current pack.
|
||||
*/
|
||||
const handleUpdateValidityCount = (count: string) => {
|
||||
updatePackData(draft => {
|
||||
draft.validity_count = parseInt(count, 10);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a type of interval for the current pack.
|
||||
*/
|
||||
const handleUpdateValidityInterval = (option: selectOption) => {
|
||||
updatePackData(draft => {
|
||||
draft.validity_interval = option.value as interval;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user disables the pack.
|
||||
*/
|
||||
const handleUpdateDisabled = (checked: boolean) => {
|
||||
updatePackData(draft => {
|
||||
draft.disabled = checked;
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="pack-form">
|
||||
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
|
||||
<FabInput id="hours"
|
||||
type="number"
|
||||
defaultValue={packData?.minutes / 60 || ''}
|
||||
onChange={handleUpdateHours}
|
||||
min={1}
|
||||
icon={<i className="fas fa-clock" />}
|
||||
required />
|
||||
<label htmlFor="amount">{t('app.admin.pack_form.amount')} *</label>
|
||||
<FabInput id="amount"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0}
|
||||
defaultValue={packData?.amount || ''}
|
||||
onChange={handleUpdateAmount}
|
||||
icon={<i className="fas fa-money-bill" />}
|
||||
addOn={Fablab.intl_currency}
|
||||
required />
|
||||
<label htmlFor="validity_count">{t('app.admin.pack_form.validity_count')}</label>
|
||||
<div className="interval-inputs">
|
||||
<FabInput id="validity_count"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={packData?.validity_count || ''}
|
||||
onChange={handleUpdateValidityCount}
|
||||
icon={<i className="fas fa-calendar-week" />} />
|
||||
<Select placeholder={t('app.admin.pack_form.select_interval')}
|
||||
className="select-interval"
|
||||
defaultValue={intervalToOption(packData?.validity_interval)}
|
||||
onChange={handleUpdateValidityInterval}
|
||||
options={buildOptions()} />
|
||||
</div>
|
||||
<label htmlFor="disabled">{t('app.admin.pack_form.disabled')}</label>
|
||||
<div>
|
||||
<Switch checked={packData?.disabled || false} onChange={handleUpdateDisabled} id="disabled" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -27,9 +27,6 @@ interface SelectGatewayModalModalProps {
|
||||
onSuccess: (results: Map<SettingName, SettingBulkResult>) => void,
|
||||
}
|
||||
|
||||
// initial request to the API
|
||||
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
|
||||
|
||||
const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, toggleModal, onError, onSuccess }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
@ -37,9 +34,11 @@ const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, to
|
||||
const [selectedGateway, setSelectedGateway] = useState<string>('');
|
||||
const [gatewayConfig, setGatewayConfig] = useState<Map<SettingName, string>>(new Map());
|
||||
|
||||
// request the configured gateway to the API
|
||||
useEffect(() => {
|
||||
const gateway = paymentGateway.read();
|
||||
setSelectedGateway(gateway.value ? gateway.value : '');
|
||||
SettingAPI.get(SettingName.PaymentGateway).then(gateway => {
|
||||
setSelectedGateway(gateway.value ? gateway.value : '');
|
||||
})
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@ -101,8 +100,7 @@ const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, to
|
||||
const settings = new Map<SettingName, string>(gatewayConfig);
|
||||
settings.set(SettingName.PaymentGateway, selectedGateway);
|
||||
|
||||
const api = new SettingAPI();
|
||||
api.bulkUpdate(settings, true).then(result => {
|
||||
SettingAPI.bulkUpdate(settings, true).then(result => {
|
||||
const errorResults = Array.from(result.values()).filter(item => !item.status);
|
||||
if (errorResults.length > 0) {
|
||||
onError(errorResults.map(item => item.error[0]).join(' '));
|
||||
|
29
app/frontend/src/javascript/components/user/avatar.tsx
Normal file
29
app/frontend/src/javascript/components/user/avatar.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { User } from '../../models/user';
|
||||
|
||||
import noAvatar from '../../../../images/no_avatar.png';
|
||||
|
||||
interface AvatarProps {
|
||||
user: User,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component renders the user-profile's picture or a placeholder
|
||||
*/
|
||||
export const Avatar: React.FC<AvatarProps> = ({ user, className }) => {
|
||||
|
||||
/**
|
||||
* Check if the provided user has a configured avatar
|
||||
*/
|
||||
const hasAvatar = (): boolean => {
|
||||
return !!user?.profile?.user_avatar?.attachment_url;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`avatar ${className ? className : ''}`}>
|
||||
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>}
|
||||
{hasAvatar() && <img src={user.profile.user_avatar.attachment_url} alt="user's avatar"/>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -6,12 +6,11 @@ import '../lib/i18n';
|
||||
import { Loader } from './base/loader';
|
||||
import { User } from '../models/user';
|
||||
import { Wallet } from '../models/wallet';
|
||||
import { IFablab } from '../models/fablab';
|
||||
import WalletLib from '../lib/wallet';
|
||||
import { ShoppingCart } from '../models/payment';
|
||||
import FormatLib from '../lib/format';
|
||||
|
||||
declare var Application: IApplication;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
interface WalletInfoProps {
|
||||
cart: ShoppingCart,
|
||||
@ -35,12 +34,6 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, walle
|
||||
setRemainingPrice(wLib.computeRemainingPrice(price));
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (e.g. 20.5 => "20,50 €")
|
||||
*/
|
||||
const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
/**
|
||||
* Check if the currently connected used is also the person making the reservation.
|
||||
* If the currently connected user (i.e. the operator), is an admin or a manager, he may book the reservation for someone else.
|
||||
@ -87,25 +80,25 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, walle
|
||||
<div className="wallet-info">
|
||||
{shouldBeShown() && <div>
|
||||
{isOperatorAndClient() && <div>
|
||||
<h3>{t('app.shared.wallet.wallet_info.you_have_AMOUNT_in_wallet', {AMOUNT: formatPrice(wallet.amount)})}</h3>
|
||||
<h3>{t('app.shared.wallet.wallet_info.you_have_AMOUNT_in_wallet', {AMOUNT: FormatLib.price(wallet.amount)})}</h3>
|
||||
{!hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.wallet_pay_ITEM', {ITEM: getPriceItem()})}
|
||||
</p>}
|
||||
{hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.credit_AMOUNT_for_pay_ITEM', {
|
||||
AMOUNT: formatPrice(remainingPrice),
|
||||
AMOUNT: FormatLib.price(remainingPrice),
|
||||
ITEM: getPriceItem()
|
||||
})}
|
||||
</p>}
|
||||
</div>}
|
||||
{!isOperatorAndClient() && <div>
|
||||
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT: formatPrice(wallet.amount)})}</h3>
|
||||
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT: FormatLib.price(wallet.amount)})}</h3>
|
||||
{!hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.client_wallet_pay_ITEM', {ITEM: getPriceItem()})}
|
||||
</p>}
|
||||
{hasRemainingPrice() && <p>
|
||||
{t('app.shared.wallet.wallet_info.client_credit_AMOUNT_for_pay_ITEM', {
|
||||
AMOUNT: formatPrice(remainingPrice),
|
||||
AMOUNT: FormatLib.price(remainingPrice),
|
||||
ITEM: getPriceItem()
|
||||
})}
|
||||
</p>}
|
||||
|
@ -107,7 +107,8 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
|
||||
partnerId: null,
|
||||
partnerContact: null,
|
||||
ui_weight: 0,
|
||||
monthly_payment: false
|
||||
monthly_payment: false,
|
||||
plan_category_id: null
|
||||
};
|
||||
|
||||
// API URL where the form will be posted
|
||||
|
@ -18,13 +18,10 @@
|
||||
/**
|
||||
* Controller used in the prices edition page
|
||||
*/
|
||||
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'machinesPricesPromise', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
|
||||
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, machinesPricesPromise, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
|
||||
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
|
||||
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
|
||||
/* PUBLIC SCOPE */
|
||||
|
||||
// List of machines prices (not considering any plan)
|
||||
$scope.machinesPrices = machinesPricesPromise;
|
||||
|
||||
// List of trainings pricing
|
||||
$scope.trainingsPricings = trainingsPricingsPromise;
|
||||
|
||||
@ -640,6 +637,20 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
|
||||
return $filter('currency')(price);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered by react components
|
||||
*/
|
||||
$scope.onSuccess = function (message) {
|
||||
growl.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered by react components
|
||||
*/
|
||||
$scope.onError = function (message) {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup the feature-tour for the admin/pricing page.
|
||||
* This is intended as a contextual help (when pressing F1)
|
||||
|
@ -95,123 +95,55 @@ class MachinesController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the transition when a user clicks on the reservation button.
|
||||
* According to the status of user currently logged into the system, redirect him to the reservation page,
|
||||
* or display a modal window asking him to complete a training before he can book a machine reservation.
|
||||
* @param machine {{id:number}} An object containg the id of the machine to book,
|
||||
* the object will be completed before the fonction returns.
|
||||
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
|
||||
*/
|
||||
const _reserveMachine = function (machine, e) {
|
||||
const _this = this;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// retrieve the full machine object
|
||||
return machine = _this.Machine.get({ id: machine.id }, function () {
|
||||
// if the currently logged'in user has completed the training for this machine, or this machine does not require
|
||||
// a prior training, just redirect him to the machine's booking page
|
||||
if (machine.current_user_is_training || (machine.trainings.length === 0)) {
|
||||
return _this.$state.go('app.logged.machines_reserve', { id: machine.slug });
|
||||
} else {
|
||||
// otherwise, if a user is authenticated ...
|
||||
if (_this.$scope.isAuthenticated()) {
|
||||
// ... and have booked a training for this machine, tell him that he must wait for an admin to validate
|
||||
// the training before he can book the reservation
|
||||
if (machine.current_user_training_reservation) {
|
||||
return _this.$uibModal.open({
|
||||
templateUrl: '/machines/training_reservation_modal.html',
|
||||
controller: ['$scope', '$uibModalInstance', function ($scope, $uibModalInstance) {
|
||||
$scope.machine = machine;
|
||||
return $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
}]
|
||||
});
|
||||
// ... but does not have booked the training, tell him to register for a training session first
|
||||
// unless all associated trainings are disabled
|
||||
} else {
|
||||
// if all trainings are disabled, just redirect the user to the reservation calendar
|
||||
if (machine.trainings.map(function (t) { return t.disabled; }).reduce(function (acc, val) { return acc && val; }, true)) {
|
||||
return _this.$state.go('app.logged.machines_reserve', { id: machine.slug });
|
||||
// otherwise open the information modal
|
||||
} else {
|
||||
return _this.$uibModal.open({
|
||||
templateUrl: '/machines/request_training_modal.html',
|
||||
controller: ['$scope', '$uibModalInstance', '$state', function ($scope, $uibModalInstance, $state) {
|
||||
$scope.machine = machine;
|
||||
$scope.member = _this.$scope.currentUser;
|
||||
|
||||
// transform the name of the trainings associated with the machine to integrate them in a sentence
|
||||
$scope.humanizeTrainings = function () {
|
||||
let text = '';
|
||||
angular.forEach($scope.machine.trainings, function (training) {
|
||||
if (text.length > 0) {
|
||||
text += _this._t('app.public.machines_list._or_the_');
|
||||
}
|
||||
return text += training.name.substr(0, 1).toLowerCase() + training.name.substr(1);
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
// modal is closed with validation
|
||||
$scope.ok = function () {
|
||||
$state.go('app.logged.trainings_reserve', { id: $scope.machine.trainings[0].id });
|
||||
return $uibModalInstance.close(machine);
|
||||
};
|
||||
|
||||
// modal is closed with escaping
|
||||
return $scope.cancel = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('cancel');
|
||||
};
|
||||
}
|
||||
] });
|
||||
}
|
||||
}
|
||||
|
||||
// if the user is not logged, open the login modal window
|
||||
} else {
|
||||
return _this.$scope.login();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller used in the public listing page, allowing everyone to see the list of machines
|
||||
*/
|
||||
Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'AuthService', 'Machine', '$uibModal', 'machinesPromise', 'settingsPromise', 'Member', 'uiTourService',
|
||||
function ($scope, $state, _t, AuthService, Machine, $uibModal, machinesPromise, settingsPromise, Member, uiTourService) {
|
||||
Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'AuthService', 'Machine', '$uibModal', 'settingsPromise', 'Member', 'uiTourService', 'machinesPromise', 'growl',
|
||||
function ($scope, $state, _t, AuthService, Machine, $uibModal, settingsPromise, Member, uiTourService, machinesPromise, growl) {
|
||||
/* PUBLIC SCOPE */
|
||||
|
||||
// Retrieve the list of machines
|
||||
$scope.machines = machinesPromise;
|
||||
|
||||
/**
|
||||
* Redirect the user to the machine details page
|
||||
*/
|
||||
* Redirect the user to the machine details page
|
||||
*/
|
||||
$scope.showMachine = function (machine) { $state.go('app.public.machines_show', { id: machine.slug }); };
|
||||
|
||||
/**
|
||||
* Callback to book a reservation for the current machine
|
||||
*/
|
||||
$scope.reserveMachine = _reserveMachine.bind({
|
||||
$scope,
|
||||
$state,
|
||||
_t,
|
||||
$uibModal,
|
||||
Machine
|
||||
});
|
||||
* Shows an error message forwarded from a child component
|
||||
*/
|
||||
$scope.onError = function (message) {
|
||||
growl.error(message);
|
||||
}
|
||||
|
||||
// Default: we show only enabled machines
|
||||
$scope.machineFiltering = 'enabled';
|
||||
/**
|
||||
* Shows a success message forwarded from a child react components
|
||||
*/
|
||||
$scope.onSuccess = function (message) {
|
||||
growl.success(message)
|
||||
}
|
||||
|
||||
// Available options for filtering machines by status
|
||||
$scope.filterDisabled = [
|
||||
'enabled',
|
||||
'disabled',
|
||||
'all'
|
||||
];
|
||||
/**
|
||||
* Open the modal dialog to log the user and resolves the returned promise when the logging process
|
||||
* was successfully completed.
|
||||
*/
|
||||
$scope.onLoginRequest = function (e) {
|
||||
return new Promise((resolve, _reject) => {
|
||||
$scope.login(e, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the training reservation page
|
||||
*/
|
||||
$scope.onEnrollRequest = function (trainingId) {
|
||||
$state.go('app.logged.trainings_reserve', { id: trainingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to book a reservation for the current machine
|
||||
*/
|
||||
$scope.reserveMachine = function (machine) {
|
||||
$state.go('app.logged.machines_reserve', { id: machine.slug });
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the feature-tour for the machines page. (admins only)
|
||||
@ -232,7 +164,7 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
|
||||
placement: 'bottom',
|
||||
orphan: true
|
||||
});
|
||||
if ($scope.machines.length > 0) {
|
||||
if (machinesPromise.length > 0) {
|
||||
uitour.createStep({
|
||||
selector: '.machines-list .show-button',
|
||||
stepId: 'view',
|
||||
@ -253,7 +185,7 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
|
||||
orphan: true
|
||||
});
|
||||
}
|
||||
if ($scope.machines.length > 0) {
|
||||
if (machinesPromise.length > 0) {
|
||||
uitour.createStep({
|
||||
selector: '.machines-list .reserve-button',
|
||||
stepId: 'reserve',
|
||||
@ -286,16 +218,6 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the controller is loaded
|
||||
*/
|
||||
const initialize = function () {}
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
return initialize();
|
||||
}
|
||||
]);
|
||||
|
||||
@ -389,16 +311,44 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows an error message forwarded from a child component
|
||||
*/
|
||||
$scope.onError = function (message) {
|
||||
growl.error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success message forwarded from a child react components
|
||||
*/
|
||||
$scope.onSuccess = function (message) {
|
||||
growl.success(message)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Open the modal dialog to log the user and resolves the returned promise when the logging process
|
||||
* was successfully completed.
|
||||
*/
|
||||
$scope.onLoginRequest = function (e) {
|
||||
return new Promise((resolve, _reject) => {
|
||||
$scope.login(e, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the training reservation page
|
||||
*/
|
||||
$scope.onEnrollRequest = function (trainingId) {
|
||||
$state.go('app.logged.trainings_reserve', { id: trainingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to book a reservation for the current machine
|
||||
*/
|
||||
return $scope.reserveMachine = _reserveMachine.bind({
|
||||
$scope,
|
||||
$state,
|
||||
_t,
|
||||
$uibModal,
|
||||
Machine
|
||||
});
|
||||
$scope.reserveMachine = function (machine) {
|
||||
$state.go('app.logged.machines_reserve', { id: machine.slug });
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@ -407,8 +357,8 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
|
||||
* This controller workflow is pretty similar to the trainings reservation controller.
|
||||
*/
|
||||
|
||||
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation',
|
||||
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation) {
|
||||
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'growl',
|
||||
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation, growl) {
|
||||
/* PRIVATE STATIC CONSTANTS */
|
||||
|
||||
// Slot free to be booked
|
||||
@ -465,6 +415,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
// current machine to reserve
|
||||
$scope.machine = machinePromise;
|
||||
|
||||
// will be set to a Promise and resolved after the payment is sone
|
||||
$scope.afterPaymentPromise = null;
|
||||
|
||||
// fullCalendar (v2) configuration
|
||||
$scope.calendarConfig = CalendarConfig({
|
||||
minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')),
|
||||
@ -668,6 +621,14 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
}
|
||||
|
||||
refetchCalendar();
|
||||
|
||||
// trigger the refresh of react components
|
||||
setTimeout(() => {
|
||||
$scope.afterPaymentPromise = new Promise(resolve => {
|
||||
resolve();
|
||||
});
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
});
|
||||
};
|
||||
|
||||
@ -676,6 +637,21 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
|
||||
*/
|
||||
$scope.filterDisabledPlans = function (plan) { return !plan.disabled; };
|
||||
|
||||
/**
|
||||
* Callback triggered after a successful prepaid-pack purchase
|
||||
*/
|
||||
$scope.onSuccess = function (message) {
|
||||
|
||||
growl.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered by react components
|
||||
*/
|
||||
$scope.onError = function (message) {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
|
@ -93,6 +93,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
|
||||
setTimeout(() => {
|
||||
if (!$scope.isAuthenticated()) {
|
||||
$scope.login();
|
||||
$scope.$apply();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
@ -79,6 +79,12 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
cartItems: undefined
|
||||
};
|
||||
|
||||
// offline payments (at the fablab's reception)
|
||||
$scope.localPayment = {
|
||||
showModal: false,
|
||||
cartItems: undefined
|
||||
};
|
||||
|
||||
// currently logged-in user
|
||||
$scope.currentUser = $rootScope.currentUser;
|
||||
|
||||
@ -325,6 +331,19 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will open/close the local payment modal
|
||||
*/
|
||||
$scope.toggleLocalPaymentModal = (beforeApply) => {
|
||||
setTimeout(() => {
|
||||
$scope.localPayment.showModal = !$scope.localPayment.showModal;
|
||||
if (typeof beforeApply === 'function') {
|
||||
beforeApply();
|
||||
}
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoked atfer a successful card payment
|
||||
* @param invoice {*} may be an Invoice or a paymentSchedule
|
||||
@ -334,6 +353,15 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
afterPayment(invoice);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoked atfer a successful offline payment
|
||||
* @param invoice {*} may be an Invoice or a paymentSchedule
|
||||
*/
|
||||
$scope.afterLocalPaymentSuccess = (invoice) => {
|
||||
$scope.toggleLocalPaymentModal();
|
||||
afterPayment(invoice);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoked when something wrong occurred during the payment dialog initialization
|
||||
* @param message {string}
|
||||
@ -717,195 +745,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
|
||||
*/
|
||||
const payOnSite = function (items) {
|
||||
$uibModal.open({
|
||||
templateUrl: '/shared/valid_reservation_modal.html',
|
||||
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
|
||||
resolve: {
|
||||
price () {
|
||||
return Price.compute(mkCartItems(items, '')).$promise;
|
||||
},
|
||||
cartItems () {
|
||||
return mkCartItems(items, '');
|
||||
},
|
||||
wallet () {
|
||||
return Wallet.getWalletByUser({ user_id: $scope.user.id }).$promise;
|
||||
},
|
||||
coupon () {
|
||||
return $scope.coupon.applied;
|
||||
},
|
||||
selectedPlan () {
|
||||
return $scope.selectedPlan;
|
||||
},
|
||||
schedule () {
|
||||
return $scope.schedule;
|
||||
},
|
||||
user () {
|
||||
return $scope.user;
|
||||
},
|
||||
settings () {
|
||||
return $scope.settings;
|
||||
}
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', '$state', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings',
|
||||
function ($scope, $uibModalInstance, $state, price, Auth, LocalPayment, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user, settings) {
|
||||
// user wallet amount
|
||||
$scope.wallet = wallet;
|
||||
|
||||
// Global price (total of all items)
|
||||
$scope.price = price.price;
|
||||
|
||||
// Price to pay (wallet deducted)
|
||||
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
|
||||
|
||||
// Reservation &| subscription
|
||||
$scope.cartItems = cartItems;
|
||||
|
||||
// Subscription
|
||||
$scope.plan = selectedPlan;
|
||||
|
||||
// Used in wallet info template to interpolate some translations
|
||||
$scope.numberFilter = $filter('number');
|
||||
|
||||
// Shows the schedule info in the modal
|
||||
$scope.schedule = schedule.payment_schedule;
|
||||
|
||||
// how should we collect payments for the payment schedule
|
||||
$scope.method = {
|
||||
payment_method: 'card'
|
||||
};
|
||||
|
||||
// "valid" Button label
|
||||
$scope.validButtonName = '';
|
||||
|
||||
// online payment modal state
|
||||
// this is used to collect card data when a payment-schedule was selected, and paid with a card
|
||||
$scope.isOpenOnlinePaymentModal = false;
|
||||
|
||||
// the customer
|
||||
$scope.user = user;
|
||||
|
||||
/**
|
||||
* Check if the shopping cart contains a reservation
|
||||
* @return {Reservation|boolean}
|
||||
*/
|
||||
$scope.reservation = (function () {
|
||||
const item = cartItems.items.find(i => i.reservation);
|
||||
if (item && item.reservation.slots_attributes.length > 0) {
|
||||
return item.reservation;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Check if the shopping cart contains a subscription
|
||||
* @return {Subscription|boolean}
|
||||
*/
|
||||
$scope.subscription = (function () {
|
||||
const item = cartItems.items.find(i => i.subscription);
|
||||
if (item && item.subscription.plan_id) {
|
||||
return item.subscription;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Callback to process the local payment, triggered on button click
|
||||
*/
|
||||
$scope.ok = function () {
|
||||
if ($scope.schedule && $scope.method.payment_method === 'card') {
|
||||
// check that the online payment is enabled
|
||||
if (settings.online_payment_module !== 'true') {
|
||||
return growl.error(_t('app.shared.cart.online_payment_disabled'));
|
||||
} else {
|
||||
return $scope.toggleOnlinePaymentModal();
|
||||
}
|
||||
}
|
||||
$scope.attempting = true;
|
||||
LocalPayment.confirm(cartItems, function (reservation) {
|
||||
$uibModalInstance.close(reservation);
|
||||
$scope.attempting = true;
|
||||
}, function (response) {
|
||||
$scope.alerts = [];
|
||||
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
|
||||
$scope.attempting = false;
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Callback to close the modal without processing the payment
|
||||
*/
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
|
||||
/**
|
||||
* Asynchronously updates the status of the online payment modal
|
||||
*/
|
||||
$scope.toggleOnlinePaymentModal = function () {
|
||||
setTimeout(() => {
|
||||
$scope.isOpenOnlinePaymentModal = !$scope.isOpenOnlinePaymentModal;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* After creating a payment schedule by card, from an administrator.
|
||||
* @param result {*} PaymentSchedule
|
||||
*/
|
||||
$scope.afterCreatePaymentSchedule = function (result) {
|
||||
$scope.toggleOnlinePaymentModal();
|
||||
$uibModalInstance.close(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoked when something wrong occurred during the payment dialog initialization
|
||||
* @param message {string}
|
||||
*/
|
||||
$scope.onCreatePaymentScheduleError = (message) => {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the directive is loaded
|
||||
*/
|
||||
const initialize = function () {
|
||||
$scope.$watch('method.payment_method', function (newValue) {
|
||||
$scope.validButtonName = computeValidButtonName();
|
||||
$scope.cartItems.payment_method = newValue;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the Label of the confirmation button
|
||||
*/
|
||||
const computeValidButtonName = function () {
|
||||
let method = '';
|
||||
if ($scope.schedule) {
|
||||
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== cartItems.customer_id) {
|
||||
method = $scope.method.payment_method;
|
||||
} else {
|
||||
method = 'card';
|
||||
}
|
||||
}
|
||||
if ($scope.amount > 0) {
|
||||
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')($scope.amount) });
|
||||
} else {
|
||||
if ((price.price > 0) && ($scope.wallet.amount === 0)) {
|
||||
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')(price.price) });
|
||||
} else {
|
||||
return _t('app.shared.buttons.confirm');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// # !!! MUST BE CALLED AT THE END of the controller
|
||||
initialize();
|
||||
}
|
||||
]
|
||||
}).result.finally(null).then(function (paymentSchedule) { afterPayment(paymentSchedule); });
|
||||
$scope.toggleLocalPaymentModal(() => {
|
||||
$scope.localPayment.cartItems = mkCartItems(items);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -10,6 +10,7 @@ Application.Directives.directive('numberSetting', ['Setting', 'growl', '_t',
|
||||
faIcon: '@',
|
||||
helperText: '@',
|
||||
min: '@',
|
||||
step: '@',
|
||||
required: '<'
|
||||
},
|
||||
templateUrl: '/admin/settings/number.html',
|
||||
@ -17,7 +18,7 @@ Application.Directives.directive('numberSetting', ['Setting', 'growl', '_t',
|
||||
// The setting
|
||||
$scope.setting = {
|
||||
name: $scope.name,
|
||||
value: parseInt($scope.settings[$scope.name], 10)
|
||||
value: parseFloat($scope.settings[$scope.name])
|
||||
};
|
||||
|
||||
/**
|
||||
|
20
app/frontend/src/javascript/lib/format.ts
Normal file
20
app/frontend/src/javascript/lib/format.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import moment from 'moment';
|
||||
import { IFablab } from '../models/fablab';
|
||||
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
export default class FormatLib {
|
||||
/**
|
||||
* Return the formatted localized date for the given date
|
||||
*/
|
||||
static date = (date: Date): string => {
|
||||
return Intl.DateTimeFormat().format(moment(date).toDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
|
||||
*/
|
||||
static price = (price: number): string => {
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
}
|
@ -2,8 +2,9 @@ import i18n from 'i18next';
|
||||
import ICU from 'i18next-icu';
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { IFablab } from '../models/fablab';
|
||||
|
||||
declare var Fablab: any;
|
||||
declare var Fablab: IFablab;
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
|
22
app/frontend/src/javascript/lib/user.ts
Normal file
22
app/frontend/src/javascript/lib/user.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { User, UserRole } from '../models/user';
|
||||
|
||||
export default class UserLib {
|
||||
private user: User;
|
||||
|
||||
constructor (user: User) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has privileged access for resources concerning the provided customer
|
||||
*/
|
||||
isPrivileged = (customer: User): boolean => {
|
||||
if (this.user.role === UserRole.Admin) return true;
|
||||
|
||||
if (this.user.role === UserRole.Manager) {
|
||||
return (this.user.id !== customer.id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* This function wraps a Promise to make it compatible with react Suspense
|
||||
*/
|
||||
export interface IWrapPromise<T> {
|
||||
read: () => T
|
||||
}
|
||||
|
||||
function wrapPromise(promise: Promise<any>): IWrapPromise<any> {
|
||||
let status: string = 'pending';
|
||||
let response: any;
|
||||
|
||||
const suspender: Promise<any> = promise.then(
|
||||
(res) => {
|
||||
status = 'success'
|
||||
response = res
|
||||
},
|
||||
(err) => {
|
||||
status = 'error'
|
||||
response = err
|
||||
},
|
||||
);
|
||||
|
||||
const read = (): any => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
throw suspender
|
||||
case 'error':
|
||||
throw response
|
||||
default:
|
||||
return response
|
||||
}
|
||||
};
|
||||
|
||||
return { read };
|
||||
}
|
||||
|
||||
export default wrapPromise;
|
@ -1,3 +1,8 @@
|
||||
export interface GroupIndexFilter {
|
||||
disabled?: boolean,
|
||||
admins?: boolean,
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number,
|
||||
slug: string,
|
||||
|
34
app/frontend/src/javascript/models/machine.ts
Normal file
34
app/frontend/src/javascript/models/machine.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Reservation } from './reservation';
|
||||
|
||||
export interface MachineIndexFilter {
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
id: number,
|
||||
name: string,
|
||||
description?: string,
|
||||
spec?: string,
|
||||
disabled: boolean,
|
||||
slug: string,
|
||||
machine_image: string,
|
||||
machine_files_attributes?: Array<{
|
||||
id: number,
|
||||
attachment: string,
|
||||
attachment_url: string
|
||||
}>,
|
||||
trainings?: Array<{
|
||||
id: number,
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
}>,
|
||||
current_user_is_trained?: boolean,
|
||||
current_user_next_training_reservation?: Reservation,
|
||||
current_user_has_packs?: boolean,
|
||||
has_prepaid_packs_for_current_user?: boolean,
|
||||
machine_projects?: Array<{
|
||||
id: number,
|
||||
name: string,
|
||||
slug: string,
|
||||
}>
|
||||
}
|
@ -19,7 +19,7 @@ export enum PaymentMethod {
|
||||
Other = ''
|
||||
}
|
||||
|
||||
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ card_update: { date: Date } };
|
||||
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ prepaid_pack: { id: number } };
|
||||
|
||||
export interface ShoppingCart {
|
||||
customer_id: number,
|
||||
|
19
app/frontend/src/javascript/models/prepaid-pack.ts
Normal file
19
app/frontend/src/javascript/models/prepaid-pack.ts
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
export interface PackIndexFilter {
|
||||
group_id?: number,
|
||||
priceable_id?: number,
|
||||
priceable_type?: string,
|
||||
disabled?: boolean,
|
||||
}
|
||||
|
||||
export interface PrepaidPack {
|
||||
id?: number,
|
||||
priceable_id?: number,
|
||||
priceable_type?: string,
|
||||
group_id: number,
|
||||
validity_interval?: 'day' | 'week' | 'month' | 'year',
|
||||
validity_count?: number,
|
||||
minutes: number,
|
||||
amount: number,
|
||||
disabled?: boolean,
|
||||
}
|
@ -1,3 +1,10 @@
|
||||
export interface PriceIndexFilter {
|
||||
priceable_type?: string,
|
||||
priceable_id?: number,
|
||||
group_id?: number,
|
||||
plan_id?: number|null,
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
id: number,
|
||||
group_id: number,
|
||||
|
@ -108,7 +108,8 @@ export enum SettingName {
|
||||
PayZenPublicKey = 'payzen_public_key',
|
||||
PayZenHmacKey = 'payzen_hmac',
|
||||
PayZenCurrency = 'payzen_currency',
|
||||
PublicAgendaModule = 'public_agenda_module'
|
||||
PublicAgendaModule = 'public_agenda_module',
|
||||
RenewPackThreshold = 'renew_pack_threshold',
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
|
14
app/frontend/src/javascript/models/user-pack.ts
Normal file
14
app/frontend/src/javascript/models/user-pack.ts
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
export interface UserPackIndexFilter {
|
||||
user_id?: number,
|
||||
priceable_type: string,
|
||||
priceable_id: number
|
||||
}
|
||||
|
||||
export interface UserPack {
|
||||
minutes_used: number,
|
||||
expires_at: Date,
|
||||
prepaid_pack: {
|
||||
minutes: number,
|
||||
}
|
||||
}
|
@ -772,7 +772,6 @@ angular.module('application.router', ['ui.router'])
|
||||
resolve: {
|
||||
plans: ['Plan', function (Plan) { return Plan.query().$promise; }],
|
||||
groups: ['Group', function (Group) { return Group.query().$promise; }],
|
||||
machinesPricesPromise: ['Price', function (Price) { return Price.query({ priceable_type: 'Machine', plan_id: 'null' }).$promise; }],
|
||||
trainingsPricingsPromise: ['TrainingsPricing', function (TrainingsPricing) { return TrainingsPricing.query().$promise; }],
|
||||
trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
|
||||
machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }],
|
||||
@ -1080,7 +1079,8 @@ angular.module('application.router', ['ui.router'])
|
||||
"'fablab_name', 'name_genre', 'reminder_enable', 'plans_module', 'confirmation_required', " +
|
||||
"'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " +
|
||||
"'display_name_enable', 'machines_sort_by', 'fab_analytics', 'statistics_module', 'address_required', " +
|
||||
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module']"
|
||||
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," +
|
||||
"'renew_pack_threshold']"
|
||||
}).$promise;
|
||||
}],
|
||||
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],
|
||||
|
@ -21,34 +21,49 @@
|
||||
@import "modules/signup";
|
||||
@import "modules/stripe";
|
||||
@import "modules/tour";
|
||||
@import "modules/fab-modal";
|
||||
@import "modules/fab-input";
|
||||
@import "modules/fab-button";
|
||||
@import "modules/fab-alert";
|
||||
@import "modules/payment-schedule-summary";
|
||||
@import "modules/base/fab-modal";
|
||||
@import "modules/base/fab-input";
|
||||
@import "modules/base/fab-button";
|
||||
@import "modules/base/fab-alert";
|
||||
@import "modules/base/fab-popover";
|
||||
@import "modules/base/labelled-input";
|
||||
@import "modules/payment-schedule/payment-schedule-summary";
|
||||
@import "modules/wallet-info";
|
||||
@import "modules/stripe-modal";
|
||||
@import "modules/labelled-input";
|
||||
@import "modules/document-filters";
|
||||
@import "modules/payment-schedules-table";
|
||||
@import "modules/payment-schedules-list";
|
||||
@import "modules/stripe-confirm";
|
||||
@import "modules/payment-schedule-dashboard";
|
||||
@import "modules/plans-list";
|
||||
@import "modules/plan-card";
|
||||
@import "modules/payment-schedule/payment-schedules-table";
|
||||
@import "modules/payment-schedule/payment-schedules-list";
|
||||
@import "modules/payment-schedule/payment-schedule-dashboard";
|
||||
@import "modules/plans/plans-list";
|
||||
@import "modules/plans/plan-card";
|
||||
@import "modules/plans/plans-filter";
|
||||
@import "modules/event-themes";
|
||||
@import "modules/select-gateway-modal";
|
||||
@import "modules/stripe-keys-form";
|
||||
@import "modules/payzen-keys-form";
|
||||
@import "modules/payzen-settings";
|
||||
@import "modules/payment-modal";
|
||||
@import "modules/payzen-modal";
|
||||
@import "modules/stripe-update-card-modal";
|
||||
@import "modules/payzen-update-card-modal";
|
||||
@import "modules/plan-categories-list";
|
||||
@import "modules/create-plan-category";
|
||||
@import "modules/edit-plan-category";
|
||||
@import "modules/delete-plan-category";
|
||||
@import "modules/plans-filter";
|
||||
@import "modules/payment/payment-modal";
|
||||
@import "modules/payment/stripe/stripe-keys-form";
|
||||
@import "modules/payment/stripe/stripe-confirm";
|
||||
@import "modules/payment/stripe/stripe-modal";
|
||||
@import "modules/payment/stripe/stripe-update-card-modal";
|
||||
@import "modules/payment/payzen/payzen-keys-form";
|
||||
@import "modules/payment/payzen/payzen-settings";
|
||||
@import "modules/payment/payzen/payzen-modal";
|
||||
@import "modules/payment/payzen/payzen-update-card-modal";
|
||||
@import "modules/payment/local-payment/local-payment-modal";
|
||||
@import "modules/plan-categories/plan-categories-list";
|
||||
@import "modules/plan-categories/create-plan-category";
|
||||
@import "modules/plan-categories/edit-plan-category";
|
||||
@import "modules/plan-categories/delete-plan-category";
|
||||
@import "modules/machines/machine-card";
|
||||
@import "modules/machines/machines-list";
|
||||
@import "modules/machines/machines-filters";
|
||||
@import "modules/machines/required-training-modal";
|
||||
@import "modules/user/avatar";
|
||||
@import "modules/pricing/machines-pricing";
|
||||
@import "modules/pricing/editable-price";
|
||||
@import "modules/pricing/configure-packs-button";
|
||||
@import "modules/pricing/pack-form";
|
||||
@import "modules/pricing/delete-pack";
|
||||
@import "modules/pricing/edit-pack";
|
||||
@import "modules/prepaid-packs/propose-packs-modal";
|
||||
@import "modules/prepaid-packs/packs-summary";
|
||||
|
||||
@import "app.responsive";
|
||||
|
54
app/frontend/src/stylesheets/modules/base/fab-popover.scss
Normal file
54
app/frontend/src/stylesheets/modules/base/fab-popover.scss
Normal file
@ -0,0 +1,54 @@
|
||||
.fab-popover {
|
||||
& {
|
||||
position: absolute;
|
||||
width: 276px;
|
||||
border: 1px solid rgba(0,0,0,.2);
|
||||
border-radius: .3rem;
|
||||
top: 35px;
|
||||
left: -125px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
left: 132px;
|
||||
top: -7px;
|
||||
height: 7px;
|
||||
width: 12px;
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-bottom: 7px solid #ccc;
|
||||
}
|
||||
&::after {
|
||||
content: "";
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
left: 133px;
|
||||
top: -6px;
|
||||
height: 6px;
|
||||
width: 10px;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #f0f0f0;
|
||||
}
|
||||
.popover-title {
|
||||
padding: .5rem 1rem;
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
background-color: #f0f0f0;
|
||||
border-bottom: 1px solid rgba(0,0,0,.2);
|
||||
border-top-left-radius: calc(.3rem - 1px);
|
||||
border-top-right-radius: calc(.3rem - 1px);
|
||||
|
||||
& > h3 {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
.popover-content {
|
||||
padding: 1rem 1rem;
|
||||
color: #212529;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
142
app/frontend/src/stylesheets/modules/machines/machine-card.scss
Normal file
142
app/frontend/src/stylesheets/modules/machines/machine-card.scss
Normal file
@ -0,0 +1,142 @@
|
||||
.machine-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin: 0 15px 30px;
|
||||
width: 30%;
|
||||
min-width: 263px;
|
||||
position: relative;
|
||||
|
||||
&.loading::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(29, 29, 29, 0.5);
|
||||
border-radius: 6px;
|
||||
margin: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.loading::after {
|
||||
content: '\f1ce';
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
font-size: 4em;
|
||||
top: 110px;
|
||||
color: white;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% { transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1219px) {
|
||||
width: 45%;
|
||||
min-width: 195px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 674px) {
|
||||
width: 95%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
.machine-picture {
|
||||
height: 250px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: opacity 0.5s ease;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #333333;
|
||||
background-color: #f5f5f5;
|
||||
border-color: #ddd;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
position: relative;
|
||||
|
||||
&.no-picture::before {
|
||||
position: absolute;
|
||||
content: '\f03e';
|
||||
font-family: 'Font Awesome 5 Free' !important;
|
||||
font-weight: 900;
|
||||
font-size: 4em;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding-top: 84px;
|
||||
}
|
||||
}
|
||||
|
||||
.machine-name {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
font-size: 1.6rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
height: 4em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.machine-actions {
|
||||
padding: 0;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom-right-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
|
||||
& > span {
|
||||
width: 50%;
|
||||
|
||||
& > button {
|
||||
border: none !important;
|
||||
padding: 15px 12px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background-color: #fbfbfb;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
|
||||
& > i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&.reserve-button {
|
||||
border-right: 1px solid #dddddd !important;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
.machine-actions > span {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
.machines-filters {
|
||||
margin: 1.5em;
|
||||
|
||||
.status-filter {
|
||||
& {
|
||||
display: inline-flex;
|
||||
width: 50%;
|
||||
}
|
||||
& > label {
|
||||
white-space: nowrap;
|
||||
line-height: 2em;
|
||||
}
|
||||
& > * {
|
||||
display: inline-block;
|
||||
}
|
||||
.status-select {
|
||||
width: 100%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px){
|
||||
.machines-filters {
|
||||
.status-filter {
|
||||
padding-right: 0;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
.machines-list {
|
||||
.all-machines {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
.required-training-modal {
|
||||
.user-info {
|
||||
text-align: center;
|
||||
padding: 15px 0;
|
||||
.avatar img { width: 50px; }
|
||||
.user-name { margin-left: 10px; }
|
||||
}
|
||||
|
||||
.training-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.not-now {
|
||||
text-align: center;
|
||||
|
||||
& > a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user