mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-03-21 12:29:03 +01:00
Merge remote-tracking branch 'origin/spaces_multiprices' into dev
This commit is contained in:
commit
2ec8a63af9
@ -1,5 +1,8 @@
|
||||
# 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
|
||||
- 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
|
||||
|
@ -4,6 +4,20 @@
|
||||
# Prices are used in reservations (Machine, Space)
|
||||
class API::PricesController < API::ApiController
|
||||
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
|
||||
@prices = PriceService.list(params)
|
||||
@ -11,7 +25,6 @@ class API::PricesController < API::ApiController
|
||||
|
||||
def update
|
||||
authorize Price
|
||||
@price = Price.find(params[:id])
|
||||
price_parameters = price_params
|
||||
price_parameters[:amount] = price_parameters[:amount] * 100
|
||||
if @price.update(price_parameters)
|
||||
@ -21,6 +34,12 @@ class API::PricesController < API::ApiController
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @price
|
||||
@price.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def compute
|
||||
cs = CartService.new(current_user)
|
||||
cart = cs.from_hash(params)
|
||||
@ -29,7 +48,15 @@ class API::PricesController < API::ApiController
|
||||
|
||||
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
|
||||
params.require(:price).permit(:amount)
|
||||
params.require(:price).permit(:amount, :duration)
|
||||
end
|
||||
end
|
||||
|
@ -14,11 +14,21 @@ export default class PriceAPI {
|
||||
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> {
|
||||
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
|
||||
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 {
|
||||
if (!filters) return '';
|
||||
|
||||
|
15
app/frontend/src/javascript/api/space.ts
Normal file
15
app/frontend/src/javascript/api/space.ts
Normal file
@ -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 { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabPopover } from '../base/fab-popover';
|
||||
import { FabPopover } from '../../base/fab-popover';
|
||||
import { CreatePack } from './create-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../../api/prepaid-pack';
|
||||
import { DeletePack } from './delete-pack';
|
||||
import { EditPack } from './edit-pack';
|
||||
import FormatLib from '../../lib/format';
|
||||
import FormatLib from '../../../lib/format';
|
||||
|
||||
interface ConfigurePacksButtonProps {
|
||||
packsData: Array<PrepaidPack>,
|
||||
@ -64,8 +64,8 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="configure-packs-button">
|
||||
<button className="packs-button" onClick={toggleShowList}>
|
||||
<div className="configure-group">
|
||||
<button className="configure-group-button" onClick={toggleShowList}>
|
||||
<i className="fas fa-box" />
|
||||
</button>
|
||||
{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 =>
|
||||
<li key={p.id} className={p.disabled ? 'disabled' : ''}>
|
||||
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
|
||||
<span className="pack-actions">
|
||||
<span className="group-actions">
|
||||
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
|
||||
<DeletePack onSuccess={handleSuccess} onError={onError} pack={p} />
|
||||
</span>
|
@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { FabModal } from '../../base/fab-modal';
|
||||
import { PackForm } from './pack-form';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../../api/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { FabAlert } from '../../base/fab-alert';
|
||||
|
||||
interface CreatePackProps {
|
||||
onSuccess: (message: string) => void,
|
@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { Loader } from '../base/loader';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { FabButton } from '../../base/fab-button';
|
||||
import { FabModal } from '../../base/fab-modal';
|
||||
import { Loader } from '../../base/loader';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../../api/prepaid-pack';
|
||||
|
||||
interface DeletePackProps {
|
||||
onSuccess: (message: string) => void,
|
||||
@ -42,8 +42,8 @@ const DeletePackComponent: React.FC<DeletePackProps> = ({ onSuccess, onError, pa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="delete-pack">
|
||||
<FabButton type='button' className="remove-pack-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
|
||||
<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_pack.delete_pack')}
|
||||
isOpen={deletionModal}
|
||||
toggleModal={toggleDeletionModal}
|
@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { FabModal } from '../../base/fab-modal';
|
||||
import { PackForm } from './pack-form';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import PrepaidPackAPI from '../../../api/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabButton } from '../../base/fab-button';
|
||||
|
||||
interface EditPackProps {
|
||||
pack: PrepaidPack,
|
||||
@ -54,16 +54,15 @@ export const EditPack: React.FC<EditPackProps> = ({ pack, onSuccess, onError })
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-pack">
|
||||
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
|
||||
<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_pack.edit_pack')}
|
||||
className="edit-pack-modal"
|
||||
closeButton
|
||||
confirmButton={t('app.admin.edit_pack.confirm_changes')}
|
||||
onConfirmSendFormId="edit-pack">
|
||||
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />}
|
||||
onConfirmSendFormId="edit-group">
|
||||
{packData && <PackForm formId="edit-group" onSubmit={handleUpdate} pack={packData} />}
|
||||
</FabModal>
|
||||
</div>
|
||||
);
|
@ -1,22 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import GroupAPI from '../../api/group';
|
||||
import { Machine } from '../../models/machine';
|
||||
import { Group } from '../../models/group';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { EditablePrice } from './editable-price';
|
||||
import { Loader } from '../../base/loader';
|
||||
import { FabAlert } from '../../base/fab-alert';
|
||||
import { HtmlTranslate } from '../../base/html-translate';
|
||||
import MachineAPI from '../../../api/machine';
|
||||
import GroupAPI from '../../../api/group';
|
||||
import { Machine } from '../../../models/machine';
|
||||
import { Group } from '../../../models/group';
|
||||
import { IApplication } from '../../../models/application';
|
||||
import { EditablePrice } from '../editable-price';
|
||||
import { ConfigurePacksButton } from './configure-packs-button';
|
||||
import PriceAPI from '../../api/price';
|
||||
import { Price } from '../../models/price';
|
||||
import PrepaidPackAPI from '../../api/prepaid-pack';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import PriceAPI from '../../../api/price';
|
||||
import { Price } from '../../../models/price';
|
||||
import PrepaidPackAPI from '../../../api/prepaid-pack';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import { useImmer } from 'use-immer';
|
||||
import FormatLib from '../../lib/format';
|
||||
import FormatLib from '../../../lib/format';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -107,7 +107,7 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="machines-pricing">
|
||||
<div className="pricing-list">
|
||||
<FabAlert level="warning">
|
||||
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
|
||||
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
|
@ -1,11 +1,11 @@
|
||||
import React, { BaseSyntheticEvent } from 'react';
|
||||
import Select from 'react-select';
|
||||
import Switch from 'react-switch';
|
||||
import { PrepaidPack } from '../../models/prepaid-pack';
|
||||
import { PrepaidPack } from '../../../models/prepaid-pack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { FabInput } from '../base/fab-input';
|
||||
import { IFablab } from '../../models/fablab';
|
||||
import { FabInput } from '../../base/fab-input';
|
||||
import { IFablab } from '../../../models/fablab';
|
||||
|
||||
declare let Fablab: IFablab;
|
||||
|
||||
@ -103,7 +103,7 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
|
||||
};
|
||||
|
||||
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>
|
||||
<FabInput id="hours"
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,8 @@ export interface Price {
|
||||
plan_id: number,
|
||||
priceable_type: string,
|
||||
priceable_id: number,
|
||||
amount: number
|
||||
amount: number,
|
||||
duration?: number // in minutes
|
||||
}
|
||||
|
||||
export interface ComputePriceResult {
|
||||
|
@ -111,7 +111,8 @@ export enum SettingName {
|
||||
PublicAgendaModule = 'public_agenda_module',
|
||||
RenewPackThreshold = 'renew_pack_threshold',
|
||||
PackOnlyForSubscription = 'pack_only_for_subscription',
|
||||
OverlappingCategories = 'overlapping_categories'
|
||||
OverlappingCategories = 'overlapping_categories',
|
||||
ExtendedPricesInSameDay = 'extended_prices_in_same_day'
|
||||
}
|
||||
|
||||
export type SettingValue = string|boolean|number;
|
||||
|
15
app/frontend/src/javascript/models/space.ts
Normal file
15
app/frontend/src/javascript/models/space.ts
Normal file
@ -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', " +
|
||||
"'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'," +
|
||||
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories']"
|
||||
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'extended_prices_in_same_day']"
|
||||
}).$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/required-training-modal";
|
||||
@import "modules/user/avatar";
|
||||
@import "modules/pricing/machines-pricing";
|
||||
@import "modules/pricing/pricing-list";
|
||||
@import "modules/pricing/editable-price";
|
||||
@import "modules/pricing/configure-packs-button";
|
||||
@import "modules/pricing/pack-form";
|
||||
@import "modules/pricing/delete-pack";
|
||||
@import "modules/pricing/edit-pack";
|
||||
@import "modules/pricing/configure-group-button";
|
||||
@import "modules/pricing/group-form";
|
||||
@import "modules/pricing/delete-group";
|
||||
@import "modules/pricing/edit-group";
|
||||
@import "modules/settings/check-list-setting";
|
||||
@import "modules/prepaid-packs/propose-packs-modal";
|
||||
@import "modules/prepaid-packs/packs-summary";
|
||||
|
@ -1,9 +1,9 @@
|
||||
.configure-packs-button {
|
||||
.configure-group {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
position: relative;
|
||||
|
||||
.packs-button {
|
||||
&-button {
|
||||
border: 1px solid #d0cccc;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
@ -44,7 +44,7 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.pack-actions button {
|
||||
.group-actions button {
|
||||
font-size: 10px;
|
||||
vertical-align: middle;
|
||||
line-height: 10px;
|
@ -1,7 +1,7 @@
|
||||
.delete-pack {
|
||||
.delete-group {
|
||||
display: inline;
|
||||
|
||||
.remove-pack-button {
|
||||
&-button {
|
||||
background-color: #cb1117;
|
||||
color: white;
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
.edit-pack {
|
||||
.edit-group {
|
||||
display: inline-block;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
.pack-form {
|
||||
.group-form {
|
||||
.interval-inputs {
|
||||
display: flex;
|
||||
|
@ -1,4 +1,4 @@
|
||||
.machines-pricing {
|
||||
.pricing-list {
|
||||
.fab-alert {
|
||||
margin: 15px 0;
|
||||
}
|
||||
@ -28,4 +28,4 @@
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,29 +1 @@
|
||||
<div class="alert alert-warning m-t">
|
||||
<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>
|
||||
<spaces-pricing on-success="onSuccess" on-error="onError">
|
||||
|
@ -117,6 +117,28 @@
|
||||
required="true">
|
||||
</number-setting>
|
||||
</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>
|
||||
@ -170,6 +192,8 @@
|
||||
label="app.admin.settings.show_event"
|
||||
classes="m-l"></boolean-setting>
|
||||
</div>
|
||||
|
||||
<div class="section-separator"></div>
|
||||
<div class="row">
|
||||
<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>
|
||||
@ -182,14 +206,5 @@
|
||||
step="0.01">
|
||||
</number-setting>
|
||||
</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>
|
||||
|
@ -3,7 +3,7 @@
|
||||
MINUTES_PER_HOUR = 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
|
||||
class CartItem::Reservation < CartItem::BaseItem
|
||||
@ -16,19 +16,19 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
end
|
||||
|
||||
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
|
||||
prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) }
|
||||
prices = applicable_prices
|
||||
|
||||
elements = { slots: [] }
|
||||
amount = 0
|
||||
|
||||
hours_available = credits
|
||||
@slots.each_with_index do |slot, index|
|
||||
amount += get_slot_price(base_amount, slot, is_privileged,
|
||||
elements: elements,
|
||||
has_credits: (index < hours_available),
|
||||
prepaid: prepaid)
|
||||
amount += get_slot_price_from_prices(prices, slot, is_privileged,
|
||||
elements: elements,
|
||||
has_credits: (index < hours_available),
|
||||
prepaid: prepaid)
|
||||
end
|
||||
|
||||
{ elements: elements, amount: amount }
|
||||
@ -61,6 +61,27 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
0
|
||||
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
|
||||
# to offer the slot.
|
||||
@ -103,6 +124,35 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
real_price
|
||||
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)
|
||||
##
|
||||
|
@ -7,5 +7,5 @@ class Price < ApplicationRecord
|
||||
belongs_to :priceable, polymorphic: 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
|
||||
|
@ -121,7 +121,8 @@ class Setting < ApplicationRecord
|
||||
public_agenda_module
|
||||
renew_pack_threshold
|
||||
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:
|
||||
# - config/locales/en.yml#settings
|
||||
# - app/frontend/src/javascript/models/setting.ts#SettingName
|
||||
|
@ -2,6 +2,14 @@
|
||||
|
||||
# Check the access policies for API::PricesController
|
||||
class PricePolicy < ApplicationPolicy
|
||||
def create?
|
||||
user.admin? && record.duration != 60
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin? && record.duration != 60
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin?
|
||||
end
|
||||
|
@ -61,10 +61,7 @@ class PrepaidPackService
|
||||
|
||||
## Total number of prepaid minutes available
|
||||
def minutes_available(user, priceable)
|
||||
is_pack_only_for_subscription = Setting.find_by(name: "pack_only_for_subscription")&.value
|
||||
if is_pack_only_for_subscription == 'true' && !user.subscribed_plan
|
||||
return 0
|
||||
end
|
||||
return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan
|
||||
|
||||
user_packs = user_packs(user, priceable)
|
||||
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
|
||||
|
@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.price @amount[:total] / 100.00
|
||||
json.price_without_coupon @amount[:before_coupon] / 100.00
|
||||
if @amount[:elements]
|
||||
|
3
app/views/api/prices/create.json.jbuilder
Normal file
3
app/views/api/prices/create.json.jbuilder
Normal file
@ -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
|
||||
|
@ -1 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! 'api/prices/price', price: @price
|
||||
|
@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.array!(@spaces) do |space|
|
||||
json.extract! space, :id, :name, :description, :slug, :default_places, :disabled
|
||||
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.space_image @space.space_image.attachment.large.url if @space.space_image
|
||||
json.space_files_attributes @space.space_files do |f|
|
||||
@ -9,4 +11,4 @@ end
|
||||
# using the space in the space_show screen
|
||||
# json.space_projects @space.projects do |p|
|
||||
# json.extract! p, :slug, :name
|
||||
# end
|
||||
# end
|
||||
|
@ -368,6 +368,8 @@ en:
|
||||
status_enabled: "Enabled"
|
||||
status_disabled: "Disabled"
|
||||
status_all: "All"
|
||||
spaces_pricing:
|
||||
price_updated: "Price successfully updated"
|
||||
machines_pricing:
|
||||
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>."
|
||||
@ -378,6 +380,11 @@ en:
|
||||
packs: "Prepaid packs"
|
||||
no_packs: "No packs for now"
|
||||
pack_DURATION: "{DURATION} hours"
|
||||
configure_extendedPrices_button:
|
||||
extendedPrices: "Extended prices"
|
||||
no_extendedPrices: "No extended price for now"
|
||||
extended_prices_form:
|
||||
amount: "Price"
|
||||
pack_form:
|
||||
hours: "Hours"
|
||||
amount: "Price"
|
||||
@ -404,6 +411,21 @@ en:
|
||||
edit_pack: "Edit the pack"
|
||||
confirm_changes: "Confirm changes"
|
||||
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
|
||||
coupons_new:
|
||||
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: "Subscription valid for purchase and use of a prepaid pack"
|
||||
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:
|
||||
training_reservations: "Trainings"
|
||||
machine_reservations: "Machines"
|
||||
|
@ -535,3 +535,4 @@ en:
|
||||
renew_pack_threshold: "Threshold for packs renewal"
|
||||
pack_only_for_subscription: "Restrict packs for subscribers"
|
||||
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'
|
||||
put 'pricing' => 'pricing#update'
|
||||
|
||||
resources :prices, only: %i[index update] do
|
||||
resources :prices, only: %i[create index update destroy] do
|
||||
post 'compute', on: :collection
|
||||
end
|
||||
resources :prepaid_packs
|
||||
|
10
db/migrate/20211220143400_add_duration_to_price.rb
Normal file
10
db/migrate/20211220143400_add_duration_to_price.rb
Normal file
@ -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.
|
||||
|
||||
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
|
||||
enable_extension "fuzzystrmatch"
|
||||
@ -492,6 +492,7 @@ ActiveRecord::Schema.define(version: 2021_10_18_121822) do
|
||||
t.integer "weight"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "description"
|
||||
end
|
||||
|
||||
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.datetime "created_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 ["plan_id"], name: "index_prices_on_plan_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')
|
||||
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?
|
||||
# available reservations hours for machines
|
||||
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)
|
||||
|
36
test/fixtures/prices.yml
vendored
36
test/fixtures/prices.yml
vendored
@ -6,6 +6,7 @@ price_1:
|
||||
priceable_id: 1
|
||||
priceable_type: Machine
|
||||
amount: 2400
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 5300
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 4200
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 1100
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 4100
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 5300
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 900
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 5100
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 1600
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 2000
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 3200
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 3400
|
||||
duration: 60
|
||||
created_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_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.038387000 Z
|
||||
updated_at: 2016-04-04 15:15:45.691674000 Z
|
||||
|
||||
@ -136,6 +149,7 @@ price_26:
|
||||
priceable_id: 2
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.048838000 Z
|
||||
updated_at: 2016-04-04 15:15:45.693896000 Z
|
||||
|
||||
@ -146,6 +160,7 @@ price_27:
|
||||
priceable_id: 3
|
||||
priceable_type: Machine
|
||||
amount: 2500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.053412000 Z
|
||||
updated_at: 2016-04-04 15:15:45.697794000 Z
|
||||
|
||||
@ -156,6 +171,7 @@ price_28:
|
||||
priceable_id: 4
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.057117000 Z
|
||||
updated_at: 2016-04-04 15:15:45.700657000 Z
|
||||
|
||||
@ -166,6 +182,7 @@ price_29:
|
||||
priceable_id: 5
|
||||
priceable_type: Machine
|
||||
amount: 1300
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.061171000 Z
|
||||
updated_at: 2016-04-04 15:15:45.707564000 Z
|
||||
|
||||
@ -176,6 +193,7 @@ price_30:
|
||||
priceable_id: 6
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:15:21.065166000 Z
|
||||
updated_at: 2016-04-04 15:15:45.710945000 Z
|
||||
|
||||
@ -186,6 +204,7 @@ price_31:
|
||||
priceable_id: 1
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.920457000 Z
|
||||
updated_at: 2016-04-04 15:17:34.255229000 Z
|
||||
|
||||
@ -196,6 +215,7 @@ price_32:
|
||||
priceable_id: 2
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.926967000 Z
|
||||
updated_at: 2016-04-04 15:17:34.257285000 Z
|
||||
|
||||
@ -206,6 +226,7 @@ price_33:
|
||||
priceable_id: 3
|
||||
priceable_type: Machine
|
||||
amount: 2500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.932723000 Z
|
||||
updated_at: 2016-04-04 15:17:34.258741000 Z
|
||||
|
||||
@ -216,6 +237,7 @@ price_34:
|
||||
priceable_id: 4
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.937168000 Z
|
||||
updated_at: 2016-04-04 15:17:34.260503000 Z
|
||||
|
||||
@ -226,6 +248,7 @@ price_35:
|
||||
priceable_id: 5
|
||||
priceable_type: Machine
|
||||
amount: 1300
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.940520000 Z
|
||||
updated_at: 2016-04-04 15:17:34.263627000 Z
|
||||
|
||||
@ -236,6 +259,7 @@ price_36:
|
||||
priceable_id: 6
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:17:24.944460000 Z
|
||||
updated_at: 2016-04-04 15:17:34.267328000 Z
|
||||
|
||||
@ -246,6 +270,7 @@ price_37:
|
||||
priceable_id: 1
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.836899000 Z
|
||||
updated_at: 2016-04-04 15:18:50.507019000 Z
|
||||
|
||||
@ -256,6 +281,7 @@ price_38:
|
||||
priceable_id: 2
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.842674000 Z
|
||||
updated_at: 2016-04-04 15:18:50.508799000 Z
|
||||
|
||||
@ -266,6 +292,7 @@ price_39:
|
||||
priceable_id: 3
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.847736000 Z
|
||||
updated_at: 2016-04-04 15:18:50.510437000 Z
|
||||
|
||||
@ -276,6 +303,7 @@ price_40:
|
||||
priceable_id: 4
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.852783000 Z
|
||||
updated_at: 2016-04-04 15:18:50.512239000 Z
|
||||
|
||||
@ -286,6 +314,7 @@ price_41:
|
||||
priceable_id: 5
|
||||
priceable_type: Machine
|
||||
amount: 800
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.856602000 Z
|
||||
updated_at: 2016-04-04 15:18:50.514062000 Z
|
||||
|
||||
@ -296,6 +325,7 @@ price_42:
|
||||
priceable_id: 6
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.860220000 Z
|
||||
updated_at: 2016-04-04 15:18:50.517702000 Z
|
||||
|
||||
@ -306,6 +336,7 @@ price_43:
|
||||
priceable_id: 1
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.836899000 Z
|
||||
updated_at: 2016-04-04 15:18:50.507019000 Z
|
||||
|
||||
@ -316,6 +347,7 @@ price_44:
|
||||
priceable_id: 2
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.842674000 Z
|
||||
updated_at: 2016-04-04 15:18:50.508799000 Z
|
||||
|
||||
@ -326,6 +358,7 @@ price_45:
|
||||
priceable_id: 3
|
||||
priceable_type: Machine
|
||||
amount: 1500
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.847736000 Z
|
||||
updated_at: 2016-04-04 15:18:50.510437000 Z
|
||||
|
||||
@ -336,6 +369,7 @@ price_46:
|
||||
priceable_id: 4
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.852783000 Z
|
||||
updated_at: 2016-04-04 15:18:50.512239000 Z
|
||||
|
||||
@ -346,6 +380,7 @@ price_47:
|
||||
priceable_id: 5
|
||||
priceable_type: Machine
|
||||
amount: 800
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.856602000 Z
|
||||
updated_at: 2016-04-04 15:18:50.514062000 Z
|
||||
|
||||
@ -356,5 +391,6 @@ price_48:
|
||||
priceable_id: 6
|
||||
priceable_type: Machine
|
||||
amount: 1000
|
||||
duration: 60
|
||||
created_at: 2016-04-04 15:18:28.860220000 Z
|
||||
updated_at: 2016-04-04 15:18:50.517702000 Z
|
||||
|
Loading…
x
Reference in New Issue
Block a user