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:
parent
f56b8eae75
commit
baf41588d3
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 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
|
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,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}
|
||||
|
@ -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']));
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
@ -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,
|
||||
}
|
||||
}
|
@ -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'
|
||||
|
@ -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
|
||||
|
||||
##
|
||||
|
@ -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
|
||||
|
8
app/views/api/user_packs/index.json.jbuilder
Normal file
8
app/views/api/user_packs/index.json.jbuilder
Normal 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
|
@ -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"
|
||||
|
@ -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]
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user