1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

packs summary component

This commit is contained in:
Sylvain 2021-06-29 11:14:36 +02:00
parent f56b8eae75
commit baf41588d3
13 changed files with 229 additions and 36 deletions

View File

@ -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

View 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('&');
}
}

View File

@ -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<ReserveButtonProps> = ({ currentUser, mac
onEnrollRequested={onEnrollRequested} />
{machine && currentUser && <ProposePacksModal isOpen={proposePacks}
toggleModal={toggleProposePacksModal}
machine={machine}
item={machine}
itemType="Machine"
onError={onError}
customer={currentUser}
onDecline={onReserveMachine}

View File

@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
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';
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,
}
const PacksSummaryComponent: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess }) => {
const { t } = useTranslation('logged');
const [userPacks, setUserPacks] = useState<Array<UserPack>>(null);
const [threshold, setThreshold] = useState<number>(null);
const [packsModal, setPacksModal] = useState<boolean>(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 (
<div className="packs-summary">
<span className="remaining-hours">{t('app.logged.packs_summary.remaining_HOURS', { HOURS: totalHours(), ITEM: itemType })}</span>
{shouldDisplayRenew() && <div>
<FabButton className="renew-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>
);
}
export const PacksSummary: React.FC<PacksSummaryProps> = ({ item, itemType, customer, operator, onError, onSuccess }) => {
return (
<Loader>
<PacksSummaryComponent item={item} itemType={itemType} customer={customer} operator={operator} onError={onError} onSuccess={onSuccess} />
</Loader>
);
}
Application.Components.component('packsSummary', react2angular(PacksSummary, ['item', 'itemType', 'customer', 'operator', 'onError', 'onSuccess']));

View File

@ -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<ProposePacksModalProps> = ({ isOpen, toggleModal, machine, customer, operator, onError, onDecline, onSuccess }) => {
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);
@ -37,13 +40,13 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
const [paymentModal, setPaymentModal] = useState<boolean>(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<ProposePacksModalProps> = ({ 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<ProposePacksModalProps> = ({ 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);
}
/**

View File

@ -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 {

View 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,
}
}

View File

@ -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'

View File

@ -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
##

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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]