From baf41588d3b7e08c6a63c177d30b725a07135343 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 29 Jun 2021 11:14:36 +0200 Subject: [PATCH] packs summary component --- app/controllers/api/user_packs_controller.rb | 22 +++ app/frontend/src/javascript/api/user-pack.ts | 16 +++ .../components/machines/reserve-button.tsx | 13 +- .../prepaid-packs/packs-summary.tsx | 127 ++++++++++++++++++ .../propose-packs-modal.tsx | 23 ++-- app/frontend/src/javascript/models/setting.ts | 3 +- .../src/javascript/models/user-pack.ts | 14 ++ app/models/user.rb | 8 +- app/policies/setting_policy.rb | 2 +- app/services/prepaid_pack_service.rb | 25 ++-- app/views/api/user_packs/index.json.jbuilder | 8 ++ config/locales/app.logged.en.yml | 3 + config/routes.rb | 1 + 13 files changed, 229 insertions(+), 36 deletions(-) create mode 100644 app/controllers/api/user_packs_controller.rb create mode 100644 app/frontend/src/javascript/api/user-pack.ts create mode 100644 app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx rename app/frontend/src/javascript/components/{machines => prepaid-packs}/propose-packs-modal.tsx (89%) create mode 100644 app/frontend/src/javascript/models/user-pack.ts create mode 100644 app/views/api/user_packs/index.json.jbuilder diff --git a/app/controllers/api/user_packs_controller.rb b/app/controllers/api/user_packs_controller.rb new file mode 100644 index 000000000..479bb1677 --- /dev/null +++ b/app/controllers/api/user_packs_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# API Controller for resources of type StatisticProfilePrepaidPack +class 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 diff --git a/app/frontend/src/javascript/api/user-pack.ts b/app/frontend/src/javascript/api/user-pack.ts new file mode 100644 index 000000000..6df870cf1 --- /dev/null +++ b/app/frontend/src/javascript/api/user-pack.ts @@ -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> { + const res: AxiosResponse> = 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('&'); + } +} diff --git a/app/frontend/src/javascript/components/machines/reserve-button.tsx b/app/frontend/src/javascript/components/machines/reserve-button.tsx index e359d7bfa..4f9cad2a8 100644 --- a/app/frontend/src/javascript/components/machines/reserve-button.tsx +++ b/app/frontend/src/javascript/components/machines/reserve-button.tsx @@ -1,14 +1,14 @@ 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 { RequiredTrainingModal } from './required-training-modal'; -import { react2angular } from 'react2angular'; import { IApplication } from '../../models/application'; -import { useTranslation } from 'react-i18next'; -import { Loader } from '../base/loader'; -import { ProposePacksModal } from './propose-packs-modal'; declare var Application: IApplication; @@ -161,7 +161,8 @@ const ReserveButtonComponent: React.FC = ({ currentUser, mac onEnrollRequested={onEnrollRequested} /> {machine && currentUser && void, + onSuccess: (message: string) => void, +} + +const PacksSummaryComponent: React.FC = ({ item, itemType, customer, operator, onError, onSuccess }) => { + const { t } = useTranslation('logged'); + + const [userPacks, setUserPacks] = useState>(null); + const [threshold, setThreshold] = useState(null); + const [packsModal, setPacksModal] = useState(false); + + useEffect(() => { + UserPackAPI.index({ user_id: customer.id, priceable_type: itemType, priceable_id: item.id }) + .then(data => setUserPacks(data)) + .catch(error => onError(error)); + SettingAPI.get(SettingName.RenewPackThreshold) + .then(data => setThreshold(parseFloat(data.value))) + .catch(error => onError(error)); + }, [item, itemType, customer]) + + /** + * 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); + } + + /** + * 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); + } + + /** + * Total prepaid hours remaining for the current customer + */ + const totalHours = (): number => { + return totalAvailable() - totalUsed() / 60; + } + + /** + * Do we need to display the "renew pack" button? + */ + const shouldDisplayRenew = (): boolean => { + if (threshold < 1) { + return totalAvailable() - totalUsed() >= totalAvailable() * threshold; + } + + return totalAvailable() - totalUsed() >= threshold; + } + + /** + * 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)); + } + + return ( +
+ {t('app.logged.packs_summary.remaining_HOURS', { HOURS: totalHours(), ITEM: itemType })} + {shouldDisplayRenew() &&
+ }> + {t('app.logged.packs_summary.buy_a_new_pack')} + + +
} +
+ ); +} + +export const PacksSummary: React.FC = ({ item, itemType, customer, operator, onError, onSuccess }) => { + return ( + + + + ); +} + +Application.Components.component('packsSummary', react2angular(PacksSummary, ['item', 'itemType', 'customer', 'operator', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/machines/propose-packs-modal.tsx b/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx similarity index 89% rename from app/frontend/src/javascript/components/machines/propose-packs-modal.tsx rename to app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx index ba9a5c61c..15eb29dc1 100644 --- a/app/frontend/src/javascript/components/machines/propose-packs-modal.tsx +++ b/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx @@ -1,4 +1,4 @@ -import React, { BaseSyntheticEvent, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Machine } from '../../models/machine'; import { FabModal, ModalSize } from '../base/fab-modal'; import PrepaidPackAPI from '../../api/prepaid-pack'; @@ -14,21 +14,24 @@ import { PaymentModal } from '../payment/payment-modal'; declare var Fablab: IFablab; +type PackableItem = Machine; + interface ProposePacksModalProps { isOpen: boolean, toggleModal: () => void, - machine: Machine, + item: PackableItem, + itemType: 'Machine', customer: User, operator: User, onError: (message: string) => void, - onDecline: (machine: Machine) => void, - onSuccess: (message:string, machine: Machine) => 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 = ({ isOpen, toggleModal, machine, customer, operator, onError, onDecline, onSuccess }) => { +export const ProposePacksModal: React.FC = ({ isOpen, toggleModal, item, itemType, customer, operator, onError, onDecline, onSuccess }) => { const { t } = useTranslation('logged'); const [price, setPrice] = useState(null); @@ -37,13 +40,13 @@ export const ProposePacksModal: React.FC = ({ isOpen, to const [paymentModal, setPaymentModal] = useState(false); useEffect(() => { - PrepaidPackAPI.index({ priceable_id: machine.id, priceable_type: 'Machine', group_id: customer.group_id, disabled: false }) + 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: machine.id, priceable_type: 'Machine', group_id: customer.group_id, plan_id: null }) + 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)); - }, [machine]); + }, [item]); /** @@ -87,7 +90,7 @@ export const ProposePacksModal: React.FC = ({ isOpen, to * The user has declined to buy a pack */ const handlePacksRefused = (): void => { - onDecline(machine); + onDecline(item); } /** @@ -110,7 +113,7 @@ export const ProposePacksModal: React.FC = ({ isOpen, to * 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'), machine); + onSuccess(t('app.logged.propose_packs_modal.pack_bought_success'), item); } /** diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 3b3a2f52b..5391b00a2 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -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 { diff --git a/app/frontend/src/javascript/models/user-pack.ts b/app/frontend/src/javascript/models/user-pack.ts new file mode 100644 index 000000000..ceaf25e25 --- /dev/null +++ b/app/frontend/src/javascript/models/user-pack.ts @@ -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, + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 4c3139c2c..f4ad820ec 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -128,10 +128,10 @@ class User < ApplicationRecord trainings.map(&:machines).flatten.uniq.include?(machine) end - def packs?(item, threshold = Setting.get('renew_pack_threshold')) + def packs?(item) return true if admin? - PrepaidPackService.user_packs(self, item, threshold).count.positive? + PrepaidPackService.user_packs(self, item).count.positive? end def next_training_reservation_by_machine(machine) @@ -170,6 +170,10 @@ class User < ApplicationRecord has_role? :partner end + def privileged? + admin? || manager? + end + def role if admin? 'admin' diff --git a/app/policies/setting_policy.rb b/app/policies/setting_policy.rb index 420bc7662..a4e1dd86d 100644 --- a/app/policies/setting_policy.rb +++ b/app/policies/setting_policy.rb @@ -39,7 +39,7 @@ class SettingPolicy < ApplicationPolicy tracking_id book_overlapping_slots slot_duration events_in_calendar spaces_module plans_module invoicing_module recaptcha_site_key feature_tour_display disqus_shortname allowed_cad_extensions openlab_app_id openlab_default online_payment_module stripe_public_key confirmation_required wallet_module trainings_module address_required - payment_gateway payzen_endpoint payzen_public_key public_agenda_module] + payment_gateway payzen_endpoint payzen_public_key public_agenda_module renew_pack_threshold] end ## diff --git a/app/services/prepaid_pack_service.rb b/app/services/prepaid_pack_service.rb index 29efbc1b3..c57e2a8a0 100644 --- a/app/services/prepaid_pack_service.rb +++ b/app/services/prepaid_pack_service.rb @@ -18,22 +18,15 @@ class PrepaidPackService packs end - def user_packs(user, priceable, threshold) - query = StatisticProfilePrepaidPack - .includes(:prepaid_pack) - .references(:prepaid_packs) - .where('statistic_profile_id = ?', user.statistic_profile.id) - .where('expires_at > ?', DateTime.current) - .where('prepaid_packs.priceable_id = ?', priceable.id) - .where('prepaid_packs.priceable_type = ?', priceable.class.name) - - if threshold.class == Float - query = query.where('prepaid_packs.minutes - minutes_used >= prepaid_packs.minutes * ?', threshold) - elsif threshold.class == Integer - query = query.where('prepaid_packs.minutes - minutes_used >= ?', threshold) - end - - query + # return the not expired packs for the given item bought by the given user + def user_packs(user, priceable) + StatisticProfilePrepaidPack + .includes(:prepaid_pack) + .references(:prepaid_packs) + .where('statistic_profile_id = ?', user.statistic_profile.id) + .where('expires_at > ?', DateTime.current) + .where('prepaid_packs.priceable_id = ?', priceable.id) + .where('prepaid_packs.priceable_type = ?', priceable.class.name) end end end diff --git a/app/views/api/user_packs/index.json.jbuilder b/app/views/api/user_packs/index.json.jbuilder new file mode 100644 index 000000000..81dbabdc2 --- /dev/null +++ b/app/views/api/user_packs/index.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +json.array!(@user_packs) do |user_pack| + json.extract! user_pack, :minutes_used, :expires_at + json.prepaid_pack do + json.extract! user_pack.prepaid_pack :minutes + end +end diff --git a/config/locales/app.logged.en.yml b/config/locales/app.logged.en.yml index dc8866813..81c5273ad 100644 --- a/config/locales/app.logged.en.yml +++ b/config/locales/app.logged.en.yml @@ -191,6 +191,9 @@ en: week: "{COUNT, plural, one{week} other{weeks}}" month: "{COUNT, plural, one{month} other{months}}" year: "{COUNT, plural, one{year} other{years}}" + packs_summary: + remaining_HOURS: "You have {HOURS} prepaid hours remaining for this {ITEM, select, Machine{machine} Space{space} other{}}." + buy_a_new_pack: "Buy a new pack" #book a training trainings_reserve: trainings_planning: "Trainings planning" diff --git a/config/routes.rb b/config/routes.rb index 69af67b9e..6bee7c76e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -83,6 +83,7 @@ Rails.application.routes.draw do post 'validate', action: 'validate', on: :collection post 'send', action: 'send_to', on: :collection end + resources :user_packs, only: %i[index] resources :trainings_pricings, only: %i[index update]