1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-03-30 16:36:03 +02:00

Merge remote-tracking branch 'origin/spaces_multiprices' into dev

This commit is contained in:
Du Peng 2021-12-23 09:34:55 +01:00
commit 2ec8a63af9
48 changed files with 813 additions and 120 deletions

@ -1,5 +1,8 @@
# Changelog Fab-manager # Changelog Fab-manager
## v5.1.14 2021 December 21
- Ability to configure prices for spaces by time slots different than the default hourly rate
- Updated portuguese translation - Updated portuguese translation
- Refactored the ReserveButton component to use the same user's data across all the component - Refactored the ReserveButton component to use the same user's data across all the component
- First optimization the load time of the payment schedules list - First optimization the load time of the payment schedules list

@ -4,6 +4,20 @@
# Prices are used in reservations (Machine, Space) # Prices are used in reservations (Machine, Space)
class API::PricesController < API::ApiController class API::PricesController < API::ApiController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_price, only: %i[update destroy]
def create
@price = Price.new(price_create_params)
@price.amount *= 100
authorize @price
if @price.save
render json: @price, status: :created
else
render json: @price.errors, status: :unprocessable_entity
end
end
def index def index
@prices = PriceService.list(params) @prices = PriceService.list(params)
@ -11,7 +25,6 @@ class API::PricesController < API::ApiController
def update def update
authorize Price authorize Price
@price = Price.find(params[:id])
price_parameters = price_params price_parameters = price_params
price_parameters[:amount] = price_parameters[:amount] * 100 price_parameters[:amount] = price_parameters[:amount] * 100
if @price.update(price_parameters) if @price.update(price_parameters)
@ -21,6 +34,12 @@ class API::PricesController < API::ApiController
end end
end end
def destroy
authorize @price
@price.destroy
head :no_content
end
def compute def compute
cs = CartService.new(current_user) cs = CartService.new(current_user)
cart = cs.from_hash(params) cart = cs.from_hash(params)
@ -29,7 +48,15 @@ class API::PricesController < API::ApiController
private private
def set_price
@price = Price.find(params[:id])
end
def price_create_params
params.require(:price).permit(:amount, :duration, :group_id, :plan_id, :priceable_id, :priceable_type)
end
def price_params def price_params
params.require(:price).permit(:amount) params.require(:price).permit(:amount, :duration)
end end
end end

@ -14,11 +14,21 @@ export default class PriceAPI {
return res?.data; return res?.data;
} }
static async create (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.post('/api/prices', { price });
return res?.data;
}
static async update (price: Price): Promise<Price> { static async update (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price }); const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
return res?.data; return res?.data;
} }
static async destroy (priceId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/prices/${priceId}`);
return res?.data;
}
private static filtersToQuery (filters?: PriceIndexFilter): string { private static filtersToQuery (filters?: PriceIndexFilter): string {
if (!filters) return ''; if (!filters) return '';

@ -0,0 +1,15 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Space } from '../models/space';
export default class SpaceAPI {
static async index (): Promise<Array<any>> {
const res: AxiosResponse<Array<Space>> = await apiClient.get('/api/spaces');
return res?.data;
}
static async get (id: number): Promise<Space> {
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
return res?.data;
}
}

@ -1,12 +1,12 @@
import React, { ReactNode, useState } from 'react'; import React, { ReactNode, useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FabPopover } from '../base/fab-popover'; import { FabPopover } from '../../base/fab-popover';
import { CreatePack } from './create-pack'; import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../../api/prepaid-pack';
import { DeletePack } from './delete-pack'; import { DeletePack } from './delete-pack';
import { EditPack } from './edit-pack'; import { EditPack } from './edit-pack';
import FormatLib from '../../lib/format'; import FormatLib from '../../../lib/format';
interface ConfigurePacksButtonProps { interface ConfigurePacksButtonProps {
packsData: Array<PrepaidPack>, packsData: Array<PrepaidPack>,
@ -64,8 +64,8 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
}; };
return ( return (
<div className="configure-packs-button"> <div className="configure-group">
<button className="packs-button" onClick={toggleShowList}> <button className="configure-group-button" onClick={toggleShowList}>
<i className="fas fa-box" /> <i className="fas fa-box" />
</button> </button>
{showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()} className="fab-popover__right"> {showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()} className="fab-popover__right">
@ -73,7 +73,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
{packs?.map(p => {packs?.map(p =>
<li key={p.id} className={p.disabled ? 'disabled' : ''}> <li key={p.id} className={p.disabled ? 'disabled' : ''}>
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)} {formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
<span className="pack-actions"> <span className="group-actions">
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} /> <EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
<DeletePack onSuccess={handleSuccess} onError={onError} pack={p} /> <DeletePack onSuccess={handleSuccess} onError={onError} pack={p} />
</span> </span>

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal'; import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form'; import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert'; import { FabAlert } from '../../base/fab-alert';
interface CreatePackProps { interface CreatePackProps {
onSuccess: (message: string) => void, onSuccess: (message: string) => void,

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button'; import { FabButton } from '../../base/fab-button';
import { FabModal } from '../base/fab-modal'; import { FabModal } from '../../base/fab-modal';
import { Loader } from '../base/loader'; import { Loader } from '../../base/loader';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../../api/prepaid-pack';
interface DeletePackProps { interface DeletePackProps {
onSuccess: (message: string) => void, onSuccess: (message: string) => void,
@ -42,8 +42,8 @@ const DeletePackComponent: React.FC<DeletePackProps> = ({ onSuccess, onError, pa
}; };
return ( return (
<div className="delete-pack"> <div className="delete-group">
<FabButton type='button' className="remove-pack-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} /> <FabButton type='button' className="delete-group-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_pack.delete_pack')} <FabModal title={t('app.admin.delete_pack.delete_pack')}
isOpen={deletionModal} isOpen={deletionModal}
toggleModal={toggleDeletionModal} toggleModal={toggleDeletionModal}

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal'; import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form'; import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button'; import { FabButton } from '../../base/fab-button';
interface EditPackProps { interface EditPackProps {
pack: PrepaidPack, pack: PrepaidPack,
@ -54,16 +54,15 @@ export const EditPack: React.FC<EditPackProps> = ({ pack, onSuccess, onError })
}; };
return ( return (
<div className="edit-pack"> <div className="edit-group">
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} /> <FabButton type='button' icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen} <FabModal isOpen={isOpen}
toggleModal={toggleModal} toggleModal={toggleModal}
title={t('app.admin.edit_pack.edit_pack')} title={t('app.admin.edit_pack.edit_pack')}
className="edit-pack-modal"
closeButton closeButton
confirmButton={t('app.admin.edit_pack.confirm_changes')} confirmButton={t('app.admin.edit_pack.confirm_changes')}
onConfirmSendFormId="edit-pack"> onConfirmSendFormId="edit-group">
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />} {packData && <PackForm formId="edit-group" onSubmit={handleUpdate} pack={packData} />}
</FabModal> </FabModal>
</div> </div>
); );

@ -1,22 +1,22 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular'; import { react2angular } from 'react2angular';
import { Loader } from '../base/loader'; import { Loader } from '../../base/loader';
import { FabAlert } from '../base/fab-alert'; import { FabAlert } from '../../base/fab-alert';
import { HtmlTranslate } from '../base/html-translate'; import { HtmlTranslate } from '../../base/html-translate';
import MachineAPI from '../../api/machine'; import MachineAPI from '../../../api/machine';
import GroupAPI from '../../api/group'; import GroupAPI from '../../../api/group';
import { Machine } from '../../models/machine'; import { Machine } from '../../../models/machine';
import { Group } from '../../models/group'; import { Group } from '../../../models/group';
import { IApplication } from '../../models/application'; import { IApplication } from '../../../models/application';
import { EditablePrice } from './editable-price'; import { EditablePrice } from '../editable-price';
import { ConfigurePacksButton } from './configure-packs-button'; import { ConfigurePacksButton } from './configure-packs-button';
import PriceAPI from '../../api/price'; import PriceAPI from '../../../api/price';
import { Price } from '../../models/price'; import { Price } from '../../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PrepaidPackAPI from '../../../api/prepaid-pack';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
import FormatLib from '../../lib/format'; import FormatLib from '../../../lib/format';
declare const Application: IApplication; declare const Application: IApplication;
@ -107,7 +107,7 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
}; };
return ( return (
<div className="machines-pricing"> <div className="pricing-list">
<FabAlert level="warning"> <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_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><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>

@ -1,11 +1,11 @@
import React, { BaseSyntheticEvent } from 'react'; import React, { BaseSyntheticEvent } from 'react';
import Select from 'react-select'; import Select from 'react-select';
import Switch from 'react-switch'; import Switch from 'react-switch';
import { PrepaidPack } from '../../models/prepaid-pack'; import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
import { FabInput } from '../base/fab-input'; import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../models/fablab'; import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab; declare let Fablab: IFablab;
@ -103,7 +103,7 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
}; };
return ( return (
<form id={formId} onSubmit={handleSubmit} className="pack-form"> <form id={formId} onSubmit={handleSubmit} className="group-form">
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label> <label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
<FabInput id="hours" <FabInput id="hours"
type="number" type="number"

@ -0,0 +1,79 @@
import React, { ReactNode, useState } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../../base/fab-popover';
import { CreateExtendedPrice } from './create-extended-price';
import PriceAPI from '../../../api/price';
import FormatLib from '../../../lib/format';
import { EditExtendedPrice } from './edit-extended-price';
import { DeleteExtendedPrice } from './delete-extended-price';
interface ConfigureExtendedPriceButtonProps {
prices: Array<Price>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component is a button that shows the list of extendedPrices.
* It also triggers modal dialogs to configure (add/edit/remove) extendedPrices.
*/
export const ConfigureExtendedPriceButton: React.FC<ConfigureExtendedPriceButtonProps> = ({ prices, onError, onSuccess, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [extendedPrices, setExtendedPrices] = useState<Array<Price>>(prices);
const [showList, setShowList] = useState<boolean>(false);
/**
* Open/closes the popover listing the existing extended prices
*/
const toggleShowList = (): void => {
setShowList(!showList);
};
/**
* Callback triggered when the extendedPrice was successfully created/deleted/updated.
* We refresh the list of extendedPrices.
*/
const handleSuccess = (message: string) => {
onSuccess(message);
PriceAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
.then(data => setExtendedPrices(data.filter(p => p.duration !== 60)))
.catch(error => onError(error));
};
/**
* Render the button used to trigger the "new extended price" modal
*/
const renderAddButton = (): ReactNode => {
return <CreateExtendedPrice onSuccess={handleSuccess}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
};
return (
<div className="configure-group">
<button className="configure-group-button" onClick={toggleShowList}>
<i className="fas fa-stopwatch" />
</button>
{showList && <FabPopover title={t('app.admin.configure_extendedPrices_button.extendedPrices')} headerButton={renderAddButton()} className="fab-popover__right">
<ul>
{extendedPrices?.map(extendedPrice =>
<li key={extendedPrice.id}>
{extendedPrice.duration} {t('app.admin.calendar.minutes')} - {FormatLib.price(extendedPrice.amount)}
<span className="group-actions">
<EditExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
<DeleteExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
</span>
</li>)}
</ul>
{extendedPrices?.length === 0 && <span>{t('app.admin.configure_extendedPrices_button.no_extendedPrices')}</span>}
</FabPopover>}
</div>
);
};

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../../base/fab-alert';
interface CreateExtendedPriceProps {
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 extended price
*/
export const CreateExtendedPrice: React.FC<CreateExtendedPriceProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Open/closes the "new extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Callback triggered when the user has validated the creation of the new extended price
*/
const handleSubmit = (extendedPrice: Price): void => {
// set the already-known attributes of the new extended price
const newExtendedPrice = Object.assign<Price, Price>({} as Price, extendedPrice);
newExtendedPrice.group_id = groupId;
newExtendedPrice.priceable_id = priceableId;
newExtendedPrice.priceable_type = priceableType;
// create it on the API
PriceAPI.create(newExtendedPrice)
.then(() => {
onSuccess(t('app.admin.create_extendedPrice.extendedPrice_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_extendedPrice.new_extendedPrice')}
className="new-pack-modal"
closeButton
confirmButton={t('app.admin.create_extendedPrice.create_extendedPrice')}
onConfirmSendFormId="new-extended-price">
<FabAlert level="info">
{t('app.admin.create_extendedPrice.new_extendedPrice_info', { TYPE: priceableType })}
</FabAlert>
<ExtendedPriceForm formId="new-extended-price" onSubmit={handleSubmit} />
</FabModal>
</div>
);
};

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
import { FabModal } from '../../base/fab-modal';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
interface DeleteExtendedPriceProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
price: Price,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided extended price.
*/
export const DeleteExtendedPrice: React.FC<DeleteExtendedPriceProps> = ({ onSuccess, onError, price }) => {
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 extended price
*/
const onDeleteConfirmed = (): void => {
PriceAPI.destroy(price.id).then(() => {
onSuccess(t('app.admin.delete_extendedPrice.extendedPrice_deleted'));
}).catch((error) => {
onError(t('app.admin.delete_extendedPrice.unable_to_delete') + error);
});
toggleDeletionModal();
};
return (
<div className="delete-group">
<FabButton type='button' className="delete-group-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_extendedPrice.delete_extendedPrice')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.delete_extendedPrice.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.delete_extendedPrice.delete_confirmation')}</span>
</FabModal>
</div>
);
};

@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
interface EditExtendedPriceProps {
price: Price,
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 extended price
*/
export const EditExtendedPrice: React.FC<EditExtendedPriceProps> = ({ price, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
const [extendedPriceData, setExtendedPriceData] = useState<Price>(price);
/**
* Open/closes the "edit extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* When the user clicks on the edition button open te edition modal
*/
const handleRequestEdit = (): void => {
toggleModal();
};
/**
* Callback triggered when the user has validated the changes of the extended price
*/
const handleUpdate = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.edit_extendedPrice.extendedPrice_successfully_updated'));
setExtendedPriceData(price);
toggleModal();
})
.catch(error => onError(error));
};
return (
<div className="edit-group">
<FabButton type='button' icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_extendedPrice.edit_extendedPrice')}
closeButton
confirmButton={t('app.admin.edit_extendedPrice.confirm_changes')}
onConfirmSendFormId="edit-group">
{extendedPriceData && <ExtendedPriceForm formId="edit-group" onSubmit={handleUpdate} price={extendedPriceData} />}
</FabModal>
</div>
);
};

@ -0,0 +1,74 @@
import React, { BaseSyntheticEvent } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab;
interface ExtendedPriceFormProps {
formId: string,
onSubmit: (pack: Price) => void,
price?: Price,
}
/**
* A form component to create/edit a extended price.
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const ExtendedPriceForm: React.FC<ExtendedPriceFormProps> = ({ formId, onSubmit, price }) => {
const [extendedPriceData, updateExtendedPriceData] = useImmer<Price>(price || {} as Price);
const { t } = useTranslation('admin');
/**
* Callback triggered when the user sends the form.
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(extendedPriceData);
};
/**
* Callback triggered when the user inputs an amount for the current extended price.
*/
const handleUpdateAmount = (amount: string) => {
updateExtendedPriceData(draft => {
draft.amount = parseFloat(amount);
});
};
/**
* Callback triggered when the user inputs a number of minutes for the current extended price.
*/
const handleUpdateHours = (minutes: string) => {
updateExtendedPriceData(draft => {
draft.duration = parseInt(minutes, 10);
});
};
return (
<form id={formId} onSubmit={handleSubmit} className="group-form">
<label htmlFor="duration">{t('app.admin.calendar.minutes')} *</label>
<FabInput id="duration"
type="number"
defaultValue={extendedPriceData?.duration || ''}
onChange={handleUpdateHours}
step={1}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.extended_price_form.amount')} *</label>
<FabInput id="amount"
type="number"
step={0.01}
min={0}
defaultValue={extendedPriceData?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
</form>
);
};

@ -0,0 +1,145 @@
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 SpaceAPI from '../../../api/space';
import GroupAPI from '../../../api/group';
import { Group } from '../../../models/group';
import { IApplication } from '../../../models/application';
import { Space } from '../../../models/space';
import { EditablePrice } from '../editable-price';
import { ConfigureExtendedPriceButton } from './configure-extended-price-button';
import PriceAPI from '../../../api/price';
import { Price } from '../../../models/price';
import { useImmer } from 'use-immer';
import FormatLib from '../../../lib/format';
declare const Application: IApplication;
interface SpacesPricingProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Interface to set and edit the prices of spaces-hours, per group
*/
const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [spaces, setSpaces] = useState<Array<Space>>(null);
const [groups, setGroups] = useState<Array<Group>>(null);
const [prices, updatePrices] = useImmer<Array<Price>>([]);
// retrieve the initial data
useEffect(() => {
SpaceAPI.index()
.then(data => setSpaces(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
.then(data => updatePrices(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 FormatLib.price(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return FormatLib.price(price);
};
/**
* Find the default price (hourly rate) matching the given criterion
*/
const findPriceBy = (spaceId, groupId): Price => {
return prices.find(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration === 60);
};
/**
* Find prices matching the given criterion, except the default hourly rate
*/
const findExtendedPricesBy = (spaceId, groupId): Array<Price> => {
return prices.filter(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration !== 60);
};
/**
* 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.spaces_pricing.price_updated'));
updatePrice(price);
})
.catch(error => onError(error));
};
return (
<div className="pricing-list">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.pricing.these_prices_match_space_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.pricing.you_can_override')}</p>
</FabAlert>
<table>
<thead>
<tr>
<th>{t('app.admin.pricing.spaces')}</th>
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
</tr>
</thead>
<tbody>
{spaces?.map(space => <tr key={space.id}>
<td>{space.name}</td>
{groups?.map(group => <td key={group.id}>
{prices.length && <EditablePrice price={findPriceBy(space.id, group.id)} onSave={handleUpdatePrice} />}
<ConfigureExtendedPriceButton
prices={findExtendedPricesBy(space.id, group.id)}
onError={onError}
onSuccess={onSuccess}
groupId={group.id}
priceableId={space.id}
priceableType='Space' />
</td>)}
</tr>)}
</tbody>
</table>
</div>
);
};
const SpacesPricingWrapper: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
return (
<Loader>
<SpacesPricing onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('spacesPricing', react2angular(SpacesPricingWrapper, ['onError', 'onSuccess']));

@ -461,7 +461,7 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
*/ */
$scope.findPriceBy = function (prices, machineId, groupId) { $scope.findPriceBy = function (prices, machineId, groupId) {
for (const price of Array.from(prices)) { for (const price of Array.from(prices)) {
if ((price.priceable_id === machineId) && (price.group_id === groupId)) { if ((price.priceable_id === machineId) && (price.group_id === groupId) && (price.duration === 60)) {
return price; return price;
} }
} }

@ -11,7 +11,8 @@ export interface Price {
plan_id: number, plan_id: number,
priceable_type: string, priceable_type: string,
priceable_id: number, priceable_id: number,
amount: number amount: number,
duration?: number // in minutes
} }
export interface ComputePriceResult { export interface ComputePriceResult {

@ -111,7 +111,8 @@ export enum SettingName {
PublicAgendaModule = 'public_agenda_module', PublicAgendaModule = 'public_agenda_module',
RenewPackThreshold = 'renew_pack_threshold', RenewPackThreshold = 'renew_pack_threshold',
PackOnlyForSubscription = 'pack_only_for_subscription', PackOnlyForSubscription = 'pack_only_for_subscription',
OverlappingCategories = 'overlapping_categories' OverlappingCategories = 'overlapping_categories',
ExtendedPricesInSameDay = 'extended_prices_in_same_day'
} }
export type SettingValue = string|boolean|number; export type SettingValue = string|boolean|number;

@ -0,0 +1,15 @@
export interface Space {
id: number,
name: string,
description: string,
slug: string,
default_places: number,
disabled: boolean,
space_image: string,
space_file_attributes?: {
id: number,
attachment: string,
attachement_url: string,
}
}

@ -1080,7 +1080,7 @@ angular.module('application.router', ['ui.router'])
"'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " + "'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " +
"'display_name_enable', 'machines_sort_by', 'fab_analytics', 'statistics_module', 'address_required', " + "'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', 'pack_only_for_subscription', 'overlapping_categories']" "'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'extended_prices_in_same_day']"
}).$promise; }).$promise;
}], }],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }], privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

@ -57,12 +57,12 @@
@import "modules/machines/machines-filters"; @import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal"; @import "modules/machines/required-training-modal";
@import "modules/user/avatar"; @import "modules/user/avatar";
@import "modules/pricing/machines-pricing"; @import "modules/pricing/pricing-list";
@import "modules/pricing/editable-price"; @import "modules/pricing/editable-price";
@import "modules/pricing/configure-packs-button"; @import "modules/pricing/configure-group-button";
@import "modules/pricing/pack-form"; @import "modules/pricing/group-form";
@import "modules/pricing/delete-pack"; @import "modules/pricing/delete-group";
@import "modules/pricing/edit-pack"; @import "modules/pricing/edit-group";
@import "modules/settings/check-list-setting"; @import "modules/settings/check-list-setting";
@import "modules/prepaid-packs/propose-packs-modal"; @import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary"; @import "modules/prepaid-packs/packs-summary";

@ -1,9 +1,9 @@
.configure-packs-button { .configure-group {
display: inline-block; display: inline-block;
margin-left: 6px; margin-left: 6px;
position: relative; position: relative;
.packs-button { &-button {
border: 1px solid #d0cccc; border: 1px solid #d0cccc;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
@ -44,7 +44,7 @@
line-height: 24px; line-height: 24px;
} }
.pack-actions button { .group-actions button {
font-size: 10px; font-size: 10px;
vertical-align: middle; vertical-align: middle;
line-height: 10px; line-height: 10px;

@ -1,7 +1,7 @@
.delete-pack { .delete-group {
display: inline; display: inline;
.remove-pack-button { &-button {
background-color: #cb1117; background-color: #cb1117;
color: white; color: white;
} }

@ -1,3 +1,3 @@
.edit-pack { .edit-group {
display: inline-block; display: inline-block;
} }

@ -1,4 +1,4 @@
.pack-form { .group-form {
.interval-inputs { .interval-inputs {
display: flex; display: flex;

@ -1,4 +1,4 @@
.machines-pricing { .pricing-list {
.fab-alert { .fab-alert {
margin: 15px 0; margin: 15px 0;
} }

@ -1,29 +1 @@
<div class="alert alert-warning m-t"> <spaces-pricing on-success="onSuccess" on-error="onError">
<p ng-bind-html="'app.admin.pricing.these_prices_match_space_hours_rates_html' | translate"></p>
<p ng-bind-html="'app.admin.pricing.prices_calculated_on_hourly_rate_html' | translate:{ DURATION:slotDuration, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }"></p>
<p translate>{{ 'app.admin.pricing.you_can_override' }}</p>
</div>
<table class="table">
<thead>
<tr>
<th style="width:20%" translate>{{ 'app.admin.pricing.spaces' }}</th>
<th style="width:20%" ng-repeat="group in enabledGroups">
<span class="text-u-c text-sm">{{group.name}}</span>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="space in enabledSpaces">
<td>
{{ space.name }}
</td>
<td ng-repeat="group in enabledGroups">
<span editable-number="findPriceBy(spacesPrices, space.id, group.id).amount"
e-step="any"
onbeforesave="updatePrice($data, findPriceBy(spacesPrices, space.id, group.id))">
{{ findPriceBy(spacesPrices, space.id, group.id).amount | currency}}
</span>
</td>
</tr>
</tbody>
</table>

@ -117,6 +117,28 @@
required="true"> required="true">
</number-setting> </number-setting>
</div> </div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.extended_prices' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.extended_prices_info_html' | translate"></p>
<boolean-setting name="extended_prices_in_same_day"
settings="allSettings"
label="app.admin.settings.extended_prices_in_same_day"
classes="m-l">
</boolean-setting>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -170,6 +192,8 @@
label="app.admin.settings.show_event" label="app.admin.settings.show_event"
classes="m-l"></boolean-setting> classes="m-l"></boolean-setting>
</div> </div>
<div class="section-separator"></div>
<div class="row"> <div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.display_invite_to_renew_pack' }}</h3> <h3 class="m-l" translate>{{ 'app.admin.settings.display_invite_to_renew_pack' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.packs_threshold_info_html' | translate"></p> <p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.packs_threshold_info_html' | translate"></p>
@ -182,14 +206,5 @@
step="0.01"> step="0.01">
</number-setting> </number-setting>
</div> </div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
</div> </div>
</div> </div>

@ -3,7 +3,7 @@
MINUTES_PER_HOUR = 60.0 MINUTES_PER_HOUR = 60.0
SECONDS_PER_MINUTE = 60.0 SECONDS_PER_MINUTE = 60.0
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 } }.freeze GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 }, custom_duration: nil }.freeze
# A generic reservation added to the shopping cart # A generic reservation added to the shopping cart
class CartItem::Reservation < CartItem::BaseItem class CartItem::Reservation < CartItem::BaseItem
@ -16,16 +16,16 @@ class CartItem::Reservation < CartItem::BaseItem
end end
def price def price
base_amount = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id)).amount
is_privileged = @operator.privileged? && @operator.id != @customer.id is_privileged = @operator.privileged? && @operator.id != @customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) } prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) }
prices = applicable_prices
elements = { slots: [] } elements = { slots: [] }
amount = 0 amount = 0
hours_available = credits hours_available = credits
@slots.each_with_index do |slot, index| @slots.each_with_index do |slot, index|
amount += get_slot_price(base_amount, slot, is_privileged, amount += get_slot_price_from_prices(prices, slot, is_privileged,
elements: elements, elements: elements,
has_credits: (index < hours_available), has_credits: (index < hours_available),
prepaid: prepaid) prepaid: prepaid)
@ -61,6 +61,27 @@ class CartItem::Reservation < CartItem::BaseItem
0 0
end end
##
# Compute the price of a single slot, according to the list of applicable prices.
# @param prices {{ prices: Array<{price: Price, duration: number}> }} list of prices to use with the current reservation
# @see get_slot_price
##
def get_slot_price_from_prices(prices, slot, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_minutes = (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
# apply the base price to the real slot duration
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
price[:duration] -= slot_minutes
real_price
end
## ##
# Compute the price of a single slot, according to the base price and the ability for an admin # Compute the price of a single slot, according to the base price and the ability for an admin
# to offer the slot. # to offer the slot.
@ -103,6 +124,35 @@ class CartItem::Reservation < CartItem::BaseItem
real_price real_price
end end
# We determine the list of prices applicable to current reservation
# The longest available price is always used in priority.
# Eg. If the reservation is for 12 hours, and there are prices for 3 hours, 7 hours,
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
# All these prices are returned to be applied to the reservation.
def applicable_prices
all_slots_in_same_day = @slots.map { |slot| slot[:start_at].to_date }.uniq.size == 1
total_duration = @slots.map { |slot| (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
while remaining_duration.positive?
max_duration = @reservable.prices.where(group_id: @customer.group_id, plan_id: @plan.try(:id))
.where(Price.arel_table[:duration].lteq(remaining_duration))
.maximum(:duration)
max_duration = 60 if max_duration.nil? || Setting.get('extended_prices_in_same_day') && !all_slots_in_same_day
max_duration_price = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id), duration: max_duration)
current_duration = [remaining_duration, max_duration].min
rates[:prices].push(price: max_duration_price, duration: current_duration)
remaining_duration -= current_duration
end
rates[:prices].sort! { |a, b| b[:duration] <=> a[:duration] }
rates
end
## ##
# Compute the number of remaining hours in the users current credits (for machine or space) # Compute the number of remaining hours in the users current credits (for machine or space)
## ##

@ -7,5 +7,5 @@ class Price < ApplicationRecord
belongs_to :priceable, polymorphic: true belongs_to :priceable, polymorphic: true
validates :priceable, :group_id, :amount, presence: true validates :priceable, :group_id, :amount, presence: true
validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id] } validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id duration] }
end end

@ -121,7 +121,8 @@ class Setting < ApplicationRecord
public_agenda_module public_agenda_module
renew_pack_threshold renew_pack_threshold
pack_only_for_subscription pack_only_for_subscription
overlapping_categories] } overlapping_categories
extended_prices_in_same_day] }
# WARNING: when adding a new key, you may also want to add it in: # WARNING: when adding a new key, you may also want to add it in:
# - config/locales/en.yml#settings # - config/locales/en.yml#settings
# - app/frontend/src/javascript/models/setting.ts#SettingName # - app/frontend/src/javascript/models/setting.ts#SettingName

@ -2,6 +2,14 @@
# Check the access policies for API::PricesController # Check the access policies for API::PricesController
class PricePolicy < ApplicationPolicy class PricePolicy < ApplicationPolicy
def create?
user.admin? && record.duration != 60
end
def destroy?
user.admin? && record.duration != 60
end
def update? def update?
user.admin? user.admin?
end end

@ -61,10 +61,7 @@ class PrepaidPackService
## Total number of prepaid minutes available ## Total number of prepaid minutes available
def minutes_available(user, priceable) def minutes_available(user, priceable)
is_pack_only_for_subscription = Setting.find_by(name: "pack_only_for_subscription")&.value return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan
if is_pack_only_for_subscription == 'true' && !user.subscribed_plan
return 0
end
user_packs = user_packs(user, priceable) user_packs = user_packs(user, priceable)
total_available = user_packs.map { |up| up.prepaid_pack.minutes }.reduce(:+) || 0 total_available = user_packs.map { |up| up.prepaid_pack.minutes }.reduce(:+) || 0

@ -1,2 +1,4 @@
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id # frozen_string_literal: true
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id, :duration
json.amount price.amount / 100.0 json.amount price.amount / 100.0

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.price @amount[:total] / 100.00 json.price @amount[:total] / 100.00
json.price_without_coupon @amount[:before_coupon] / 100.00 json.price_without_coupon @amount[:before_coupon] / 100.00
if @amount[:elements] if @amount[:elements]

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', collection: @prices, as: :price json.partial! 'api/prices/price', collection: @prices, as: :price

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price json.partial! 'api/prices/price', price: @price

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.array!(@spaces) do |space| json.array!(@spaces) do |space|
json.extract! space, :id, :name, :description, :slug, :default_places, :disabled json.extract! space, :id, :name, :description, :slug, :default_places, :disabled
json.space_image space.space_image.attachment.medium.url if space.space_image json.space_image space.space_image.attachment.medium.url if space.space_image

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.extract! @space, :id, :name, :description, :characteristics, :created_at, :updated_at, :slug, :default_places, :disabled json.extract! @space, :id, :name, :description, :characteristics, :created_at, :updated_at, :slug, :default_places, :disabled
json.space_image @space.space_image.attachment.large.url if @space.space_image json.space_image @space.space_image.attachment.large.url if @space.space_image
json.space_files_attributes @space.space_files do |f| json.space_files_attributes @space.space_files do |f|

@ -368,6 +368,8 @@ en:
status_enabled: "Enabled" status_enabled: "Enabled"
status_disabled: "Disabled" status_disabled: "Disabled"
status_all: "All" status_all: "All"
spaces_pricing:
price_updated: "Price successfully updated"
machines_pricing: machines_pricing:
prices_match_machine_hours_rates_html: "The prices below match one hour of machine usage, <strong>without subscription</strong>." prices_match_machine_hours_rates_html: "The prices below match one hour of machine usage, <strong>without subscription</strong>."
prices_calculated_on_hourly_rate_html: "All the prices will be automatically calculated based on the hourly rate defined here.<br/><em>For example</em>, if you define an hourly rate at {RATE}: a slot of {DURATION} minutes, will be charged <strong>{PRICE}</strong>." prices_calculated_on_hourly_rate_html: "All the prices will be automatically calculated based on the hourly rate defined here.<br/><em>For example</em>, if you define an hourly rate at {RATE}: a slot of {DURATION} minutes, will be charged <strong>{PRICE}</strong>."
@ -378,6 +380,11 @@ en:
packs: "Prepaid packs" packs: "Prepaid packs"
no_packs: "No packs for now" no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours" pack_DURATION: "{DURATION} hours"
configure_extendedPrices_button:
extendedPrices: "Extended prices"
no_extendedPrices: "No extended price for now"
extended_prices_form:
amount: "Price"
pack_form: pack_form:
hours: "Hours" hours: "Hours"
amount: "Price" amount: "Price"
@ -404,6 +411,21 @@ en:
edit_pack: "Edit the pack" edit_pack: "Edit the pack"
confirm_changes: "Confirm changes" confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated." pack_successfully_updated: "The prepaid pack was successfully updated."
create_extendedPrice:
new_extendedPrice: "New extended price"
new_extendedPrice_info: "Extended prices allows you to define prices based on custom durations, intead on the default hourly rates."
create_extendedPrice: "Create extended price"
extendedPrice_successfully_created: "The new extended price was successfully created."
delete_extendedPrice:
extendedPrice_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the extended price: "
delete_extendedPrice: "Delete the extended price"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this extended price? This won't be possible if it was already bought by users."
edit_extendedPrice:
edit_extendedPrice: "Edit the extended price"
confirm_changes: "Confirm changes"
extendedPrice_successfully_updated: "The extended price was successfully updated."
#ajouter un code promotionnel #ajouter un code promotionnel
coupons_new: coupons_new:
add_a_coupon: "Add a coupon" add_a_coupon: "Add a coupon"
@ -1235,6 +1257,9 @@ en:
pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription." pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription."
pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack" pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack"
pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs" pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs"
extended_prices: "Extended prices"
extended_prices_info_html: "Spaces can have different prices depending on the cumulated duration of the booking. You can choose if this apply to all bookings or only to those starting within the same day."
extended_prices_in_same_day: "Extended prices in the same day"
overlapping_options: overlapping_options:
training_reservations: "Trainings" training_reservations: "Trainings"
machine_reservations: "Machines" machine_reservations: "Machines"

@ -535,3 +535,4 @@ en:
renew_pack_threshold: "Threshold for packs renewal" renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers" pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention" overlapping_categories: "Categories for overlapping booking prevention"
extended_prices_in_same_day: "Extended prices in the same day"

@ -75,7 +75,7 @@ Rails.application.routes.draw do
get 'pricing' => 'pricing#index' get 'pricing' => 'pricing#index'
put 'pricing' => 'pricing#update' put 'pricing' => 'pricing#update'
resources :prices, only: %i[index update] do resources :prices, only: %i[create index update destroy] do
post 'compute', on: :collection post 'compute', on: :collection
end end
resources :prepaid_packs resources :prepaid_packs

@ -0,0 +1,10 @@
# frozen_string_literal: true
# From this migration, we allow Prices to be configured by duration.
# For example, a Price for a 30-minute session could be configured to be twice the price of a 60-minute session.
# This is useful for things like "half-day" sessions, or full-day session when the price is different than the default hour-based price.
class AddDurationToPrice < ActiveRecord::Migration[5.2]
def change
add_column :prices, :duration, :integer, default: 60
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_10_18_121822) do ActiveRecord::Schema.define(version: 2021_12_20_143400) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch" enable_extension "fuzzystrmatch"
@ -492,6 +492,7 @@ ActiveRecord::Schema.define(version: 2021_10_18_121822) do
t.integer "weight" t.integer "weight"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "description"
end end
create_table "plans", id: :serial, force: :cascade do |t| create_table "plans", id: :serial, force: :cascade do |t|
@ -554,6 +555,7 @@ ActiveRecord::Schema.define(version: 2021_10_18_121822) do
t.integer "amount" t.integer "amount"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "duration", default: 60
t.index ["group_id"], name: "index_prices_on_group_id" t.index ["group_id"], name: "index_prices_on_group_id"
t.index ["plan_id"], name: "index_prices_on_plan_id" t.index ["plan_id"], name: "index_prices_on_plan_id"
t.index ["priceable_type", "priceable_id"], name: "index_prices_on_priceable_type_and_priceable_id" t.index ["priceable_type", "priceable_id"], name: "index_prices_on_priceable_type_and_priceable_id"

@ -905,6 +905,8 @@ unless Setting.find_by(name: 'overlapping_categories').try(:value)
Setting.set('overlapping_categories', 'training_reservations,machine_reservations,space_reservations,events_reservations') Setting.set('overlapping_categories', 'training_reservations,machine_reservations,space_reservations,events_reservations')
end end
Setting.set('extended_prices_in_same_day', true) unless Setting.find_by(name: 'extended_prices_in_same_day').try(:value)
if StatisticCustomAggregation.count.zero? if StatisticCustomAggregation.count.zero?
# available reservations hours for machines # available reservations hours for machines
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2) machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)

@ -6,6 +6,7 @@ price_1:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 2400 amount: 2400
duration: 60
created_at: 2016-04-04 14:11:34.242608000 Z created_at: 2016-04-04 14:11:34.242608000 Z
updated_at: 2016-04-04 14:11:34.242608000 Z updated_at: 2016-04-04 14:11:34.242608000 Z
@ -16,6 +17,7 @@ price_2:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 5300 amount: 5300
duration: 60
created_at: 2016-04-04 14:11:34.247363000 Z created_at: 2016-04-04 14:11:34.247363000 Z
updated_at: 2016-04-04 14:11:34.247363000 Z updated_at: 2016-04-04 14:11:34.247363000 Z
@ -26,6 +28,7 @@ price_5:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 4200 amount: 4200
duration: 60
created_at: 2016-04-04 14:11:34.290427000 Z created_at: 2016-04-04 14:11:34.290427000 Z
updated_at: 2016-04-04 14:11:34.290427000 Z updated_at: 2016-04-04 14:11:34.290427000 Z
@ -36,6 +39,7 @@ price_6:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 1100 amount: 1100
duration: 60
created_at: 2016-04-04 14:11:34.293603000 Z created_at: 2016-04-04 14:11:34.293603000 Z
updated_at: 2016-04-04 14:11:34.293603000 Z updated_at: 2016-04-04 14:11:34.293603000 Z
@ -46,6 +50,7 @@ price_9:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 4100 amount: 4100
duration: 60
created_at: 2016-04-04 14:11:34.320809000 Z created_at: 2016-04-04 14:11:34.320809000 Z
updated_at: 2016-04-04 14:11:34.320809000 Z updated_at: 2016-04-04 14:11:34.320809000 Z
@ -56,6 +61,7 @@ price_10:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 5300 amount: 5300
duration: 60
created_at: 2016-04-04 14:11:34.325274000 Z created_at: 2016-04-04 14:11:34.325274000 Z
updated_at: 2016-04-04 14:11:34.325274000 Z updated_at: 2016-04-04 14:11:34.325274000 Z
@ -66,6 +72,7 @@ price_13:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 900 amount: 900
duration: 60
created_at: 2016-04-04 14:11:34.362313000 Z created_at: 2016-04-04 14:11:34.362313000 Z
updated_at: 2016-04-04 14:11:34.362313000 Z updated_at: 2016-04-04 14:11:34.362313000 Z
@ -76,6 +83,7 @@ price_14:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 5100 amount: 5100
duration: 60
created_at: 2016-04-04 14:11:34.366049000 Z created_at: 2016-04-04 14:11:34.366049000 Z
updated_at: 2016-04-04 14:11:34.366049000 Z updated_at: 2016-04-04 14:11:34.366049000 Z
@ -86,6 +94,7 @@ price_17:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 1600 amount: 1600
duration: 60
created_at: 2016-04-04 14:11:34.398206000 Z created_at: 2016-04-04 14:11:34.398206000 Z
updated_at: 2016-04-04 14:11:34.398206000 Z updated_at: 2016-04-04 14:11:34.398206000 Z
@ -96,6 +105,7 @@ price_18:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 2000 amount: 2000
duration: 60
created_at: 2016-04-04 14:11:34.407216000 Z created_at: 2016-04-04 14:11:34.407216000 Z
updated_at: 2016-04-04 14:11:34.407216000 Z updated_at: 2016-04-04 14:11:34.407216000 Z
@ -106,6 +116,7 @@ price_21:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 3200 amount: 3200
duration: 60
created_at: 2016-04-04 14:11:34.442054000 Z created_at: 2016-04-04 14:11:34.442054000 Z
updated_at: 2016-04-04 14:11:34.442054000 Z updated_at: 2016-04-04 14:11:34.442054000 Z
@ -116,6 +127,7 @@ price_22:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 3400 amount: 3400
duration: 60
created_at: 2016-04-04 14:11:34.445147000 Z created_at: 2016-04-04 14:11:34.445147000 Z
updated_at: 2016-04-04 14:11:34.445147000 Z updated_at: 2016-04-04 14:11:34.445147000 Z
@ -126,6 +138,7 @@ price_25:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.038387000 Z created_at: 2016-04-04 15:15:21.038387000 Z
updated_at: 2016-04-04 15:15:45.691674000 Z updated_at: 2016-04-04 15:15:45.691674000 Z
@ -136,6 +149,7 @@ price_26:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.048838000 Z created_at: 2016-04-04 15:15:21.048838000 Z
updated_at: 2016-04-04 15:15:45.693896000 Z updated_at: 2016-04-04 15:15:45.693896000 Z
@ -146,6 +160,7 @@ price_27:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 2500 amount: 2500
duration: 60
created_at: 2016-04-04 15:15:21.053412000 Z created_at: 2016-04-04 15:15:21.053412000 Z
updated_at: 2016-04-04 15:15:45.697794000 Z updated_at: 2016-04-04 15:15:45.697794000 Z
@ -156,6 +171,7 @@ price_28:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.057117000 Z created_at: 2016-04-04 15:15:21.057117000 Z
updated_at: 2016-04-04 15:15:45.700657000 Z updated_at: 2016-04-04 15:15:45.700657000 Z
@ -166,6 +182,7 @@ price_29:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 1300 amount: 1300
duration: 60
created_at: 2016-04-04 15:15:21.061171000 Z created_at: 2016-04-04 15:15:21.061171000 Z
updated_at: 2016-04-04 15:15:45.707564000 Z updated_at: 2016-04-04 15:15:45.707564000 Z
@ -176,6 +193,7 @@ price_30:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.065166000 Z created_at: 2016-04-04 15:15:21.065166000 Z
updated_at: 2016-04-04 15:15:45.710945000 Z updated_at: 2016-04-04 15:15:45.710945000 Z
@ -186,6 +204,7 @@ price_31:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.920457000 Z created_at: 2016-04-04 15:17:24.920457000 Z
updated_at: 2016-04-04 15:17:34.255229000 Z updated_at: 2016-04-04 15:17:34.255229000 Z
@ -196,6 +215,7 @@ price_32:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.926967000 Z created_at: 2016-04-04 15:17:24.926967000 Z
updated_at: 2016-04-04 15:17:34.257285000 Z updated_at: 2016-04-04 15:17:34.257285000 Z
@ -206,6 +226,7 @@ price_33:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 2500 amount: 2500
duration: 60
created_at: 2016-04-04 15:17:24.932723000 Z created_at: 2016-04-04 15:17:24.932723000 Z
updated_at: 2016-04-04 15:17:34.258741000 Z updated_at: 2016-04-04 15:17:34.258741000 Z
@ -216,6 +237,7 @@ price_34:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.937168000 Z created_at: 2016-04-04 15:17:24.937168000 Z
updated_at: 2016-04-04 15:17:34.260503000 Z updated_at: 2016-04-04 15:17:34.260503000 Z
@ -226,6 +248,7 @@ price_35:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 1300 amount: 1300
duration: 60
created_at: 2016-04-04 15:17:24.940520000 Z created_at: 2016-04-04 15:17:24.940520000 Z
updated_at: 2016-04-04 15:17:34.263627000 Z updated_at: 2016-04-04 15:17:34.263627000 Z
@ -236,6 +259,7 @@ price_36:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.944460000 Z created_at: 2016-04-04 15:17:24.944460000 Z
updated_at: 2016-04-04 15:17:34.267328000 Z updated_at: 2016-04-04 15:17:34.267328000 Z
@ -246,6 +270,7 @@ price_37:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.836899000 Z created_at: 2016-04-04 15:18:28.836899000 Z
updated_at: 2016-04-04 15:18:50.507019000 Z updated_at: 2016-04-04 15:18:50.507019000 Z
@ -256,6 +281,7 @@ price_38:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.842674000 Z created_at: 2016-04-04 15:18:28.842674000 Z
updated_at: 2016-04-04 15:18:50.508799000 Z updated_at: 2016-04-04 15:18:50.508799000 Z
@ -266,6 +292,7 @@ price_39:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:18:28.847736000 Z created_at: 2016-04-04 15:18:28.847736000 Z
updated_at: 2016-04-04 15:18:50.510437000 Z updated_at: 2016-04-04 15:18:50.510437000 Z
@ -276,6 +303,7 @@ price_40:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.852783000 Z created_at: 2016-04-04 15:18:28.852783000 Z
updated_at: 2016-04-04 15:18:50.512239000 Z updated_at: 2016-04-04 15:18:50.512239000 Z
@ -286,6 +314,7 @@ price_41:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 800 amount: 800
duration: 60
created_at: 2016-04-04 15:18:28.856602000 Z created_at: 2016-04-04 15:18:28.856602000 Z
updated_at: 2016-04-04 15:18:50.514062000 Z updated_at: 2016-04-04 15:18:50.514062000 Z
@ -296,6 +325,7 @@ price_42:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.860220000 Z created_at: 2016-04-04 15:18:28.860220000 Z
updated_at: 2016-04-04 15:18:50.517702000 Z updated_at: 2016-04-04 15:18:50.517702000 Z
@ -306,6 +336,7 @@ price_43:
priceable_id: 1 priceable_id: 1
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.836899000 Z created_at: 2016-04-04 15:18:28.836899000 Z
updated_at: 2016-04-04 15:18:50.507019000 Z updated_at: 2016-04-04 15:18:50.507019000 Z
@ -316,6 +347,7 @@ price_44:
priceable_id: 2 priceable_id: 2
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.842674000 Z created_at: 2016-04-04 15:18:28.842674000 Z
updated_at: 2016-04-04 15:18:50.508799000 Z updated_at: 2016-04-04 15:18:50.508799000 Z
@ -326,6 +358,7 @@ price_45:
priceable_id: 3 priceable_id: 3
priceable_type: Machine priceable_type: Machine
amount: 1500 amount: 1500
duration: 60
created_at: 2016-04-04 15:18:28.847736000 Z created_at: 2016-04-04 15:18:28.847736000 Z
updated_at: 2016-04-04 15:18:50.510437000 Z updated_at: 2016-04-04 15:18:50.510437000 Z
@ -336,6 +369,7 @@ price_46:
priceable_id: 4 priceable_id: 4
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.852783000 Z created_at: 2016-04-04 15:18:28.852783000 Z
updated_at: 2016-04-04 15:18:50.512239000 Z updated_at: 2016-04-04 15:18:50.512239000 Z
@ -346,6 +380,7 @@ price_47:
priceable_id: 5 priceable_id: 5
priceable_type: Machine priceable_type: Machine
amount: 800 amount: 800
duration: 60
created_at: 2016-04-04 15:18:28.856602000 Z created_at: 2016-04-04 15:18:28.856602000 Z
updated_at: 2016-04-04 15:18:50.514062000 Z updated_at: 2016-04-04 15:18:50.514062000 Z
@ -356,5 +391,6 @@ price_48:
priceable_id: 6 priceable_id: 6
priceable_type: Machine priceable_type: Machine
amount: 1000 amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.860220000 Z created_at: 2016-04-04 15:18:28.860220000 Z
updated_at: 2016-04-04 15:18:50.517702000 Z updated_at: 2016-04-04 15:18:50.517702000 Z