1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-29 18:52:22 +01:00

Add extended price

This commit is contained in:
vincent 2021-12-21 17:13:40 +01:00
parent af4acc895c
commit bd781a14e9
12 changed files with 218 additions and 69 deletions

View File

@ -4,9 +4,10 @@
# 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 def create
@price = Price.new(price_params) @price = Price.new(price_create_params)
@price.amount *= 100 @price.amount *= 100
authorize @price authorize @price
@ -24,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)
@ -34,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)
@ -42,6 +48,10 @@ class API::PricesController < API::ApiController
private private
def set_price
@price = Price.find(params[:id])
end
def price_create_params def price_create_params
params.require(:price).permit(:amount, :duration, :group_id, :plan_id, :priceable_id, :priceable_type) params.require(:price).permit(:amount, :duration, :group_id, :plan_id, :priceable_id, :priceable_type)
end end

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal'; import { FabModal } from '../base/fab-modal';
import { TimeslotForm } from './timeslot-form'; import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../models/price'; import { Price } from '../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack'; import PriceAPI from '../../api/price';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert'; import { FabAlert } from '../base/fab-alert';
interface CreateTimeslotProps { interface CreateExtendedPriceProps {
onSuccess: (message: string) => void, onSuccess: (message: string) => void,
onError: (message: string) => void, onError: (message: string) => void,
groupId: number, groupId: number,
@ -16,9 +16,9 @@ interface CreateTimeslotProps {
/** /**
* This component shows a button. * This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new time slot * When clicked, we show a modal dialog handing the process of creating a new extended price
*/ */
export const CreateTimeslot: React.FC<CreateTimeslotProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => { export const CreateExtendedPrice: React.FC<CreateExtendedPriceProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
@ -31,23 +31,22 @@ export const CreateTimeslot: React.FC<CreateTimeslotProps> = ({ onSuccess, onErr
}; };
/** /**
* Callback triggered when the user has validated the creation of the new time slot * Callback triggered when the user has validated the creation of the new extended price
*/ */
const handleSubmit = (timeslot: Price): void => { const handleSubmit = (extendedPrice: Price): void => {
// set the already-known attributes of the new pack // set the already-known attributes of the new pack
const newTimeslot = Object.assign<Price, Price>({} as Price, timeslot); const newExtendedPrice = Object.assign<Price, Price>({} as Price, extendedPrice);
newTimeslot.group_id = groupId; newExtendedPrice.group_id = groupId;
newTimeslot.priceable_id = priceableId; newExtendedPrice.priceable_id = priceableId;
newTimeslot.priceable_type = priceableType; newExtendedPrice.priceable_type = priceableType;
// create it on the API // create it on the API
console.log('newTimeslot :', newTimeslot); PriceAPI.create(newExtendedPrice)
// PrepaidPackAPI.create(newPack) .then(() => {
// .then(() => { onSuccess(t('app.admin.create_extendedPrice.extendedPrice_successfully_created'));
// onSuccess(t('app.admin.create_timeslot.timeslot_successfully_created')); toggleModal();
// toggleModal(); })
// }) .catch(error => onError(error));
// .catch(error => onError(error));
}; };
return ( return (
@ -55,15 +54,15 @@ export const CreateTimeslot: React.FC<CreateTimeslotProps> = ({ onSuccess, onErr
<button className="add-pack-button" onClick={toggleModal}><i className="fas fa-plus"/></button> <button className="add-pack-button" onClick={toggleModal}><i className="fas fa-plus"/></button>
<FabModal isOpen={isOpen} <FabModal isOpen={isOpen}
toggleModal={toggleModal} toggleModal={toggleModal}
title={t('app.admin.create_timeslot.new_timeslot')} title={t('app.admin.create_extendedPrice.new_extendedPrice')}
className="new-pack-modal" className="new-pack-modal"
closeButton closeButton
confirmButton={t('app.admin.create_timeslot.create_timeslot')} confirmButton={t('app.admin.create_extendedPrice.create_extendedPrice')}
onConfirmSendFormId="new-pack"> onConfirmSendFormId="new-pack">
<FabAlert level="info"> <FabAlert level="info">
{t('app.admin.create_timeslot.new_timeslot_info', { TYPE: priceableType })} {t('app.admin.create_extendedPrice.new_extendedPrice_info', { TYPE: priceableType })}
</FabAlert> </FabAlert>
<TimeslotForm formId="new-pack" onSubmit={handleSubmit} /> <ExtendedPriceForm formId="new-pack" onSubmit={handleSubmit} />
</FabModal> </FabModal>
</div> </div>
); );

View File

@ -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-pack">
<FabButton type='button' className="remove-pack-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>
);
};

View File

@ -0,0 +1,66 @@
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-pack">
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_extendedPrice.edit_extendedPrice')}
className="edit-pack-modal"
closeButton
confirmButton={t('app.admin.edit_extendedPrice.confirm_changes')}
onConfirmSendFormId="edit-pack">
{extendedPriceData && <ExtendedPriceForm formId="edit-pack" onSubmit={handleUpdate} price={extendedPriceData} />}
</FabModal>
</div>
);
};

View File

@ -14,11 +14,11 @@ interface PackFormProps {
} }
/** /**
* A form component to create/edit a time slot. * A form component to create/edit a extended price.
* The form validation must be created elsewhere, using the attribute form={formId}. * The form validation must be created elsewhere, using the attribute form={formId}.
*/ */
export const TimeslotForm: React.FC<PackFormProps> = ({ formId, onSubmit, price }) => { export const ExtendedPriceForm: React.FC<PackFormProps> = ({ formId, onSubmit, price }) => {
const [timeslotData, updateTimeslotData] = useImmer<Price>(price || {} as Price); const [extendedPriceData, updateExtendedPriceData] = useImmer<Price>(price || {} as Price);
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
@ -27,23 +27,23 @@ export const TimeslotForm: React.FC<PackFormProps> = ({ formId, onSubmit, price
*/ */
const handleSubmit = (event: BaseSyntheticEvent): void => { const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault(); event.preventDefault();
onSubmit(timeslotData); onSubmit(extendedPriceData);
}; };
/** /**
* Callback triggered when the user inputs an amount for the current time slot. * Callback triggered when the user inputs an amount for the current extended price.
*/ */
const handleUpdateAmount = (amount: string) => { const handleUpdateAmount = (amount: string) => {
updateTimeslotData(draft => { updateExtendedPriceData(draft => {
draft.amount = parseFloat(amount); draft.amount = parseFloat(amount);
}); });
}; };
/** /**
* Callback triggered when the user inputs a number of minutes for the current time slot. * Callback triggered when the user inputs a number of minutes for the current extended price.
*/ */
const handleUpdateHours = (minutes: string) => { const handleUpdateHours = (minutes: string) => {
updateTimeslotData(draft => { updateExtendedPriceData(draft => {
draft.duration = parseInt(minutes, 10); draft.duration = parseInt(minutes, 10);
}); });
}; };
@ -53,7 +53,7 @@ export const TimeslotForm: React.FC<PackFormProps> = ({ formId, onSubmit, price
<label htmlFor="duration">{t('app.admin.calendar.minutes')} *</label> <label htmlFor="duration">{t('app.admin.calendar.minutes')} *</label>
<FabInput id="duration" <FabInput id="duration"
type="number" type="number"
defaultValue={timeslotData?.duration || ''} defaultValue={extendedPriceData?.duration || ''}
onChange={handleUpdateHours} onChange={handleUpdateHours}
step={1} step={1}
min={1} min={1}
@ -64,7 +64,7 @@ export const TimeslotForm: React.FC<PackFormProps> = ({ formId, onSubmit, price
type="number" type="number"
step={0.01} step={0.01}
min={0} min={0}
defaultValue={timeslotData?.amount || ''} defaultValue={extendedPriceData?.amount || ''}
onChange={handleUpdateAmount} onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />} icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency} addOn={Fablab.intl_currency}

View File

@ -9,7 +9,7 @@ import GroupAPI from '../../api/group';
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 { ConfigureTimeslotButton } from './configure-timeslot-button'; import { ConfigureExtendedPriceButton } from './configure-extended-price-button';
import PriceAPI from '../../api/price'; import PriceAPI from '../../api/price';
import { Price } from '../../models/price'; import { Price } from '../../models/price';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
@ -108,7 +108,7 @@ const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) =>
<td>{space.name}</td> <td>{space.name}</td>
{groups?.map(group => <td key={group.id}> {groups?.map(group => <td key={group.id}>
{prices && <EditablePrice price={findPricesBy(space.id, group.id)[0]} onSave={handleUpdatePrice} />} {prices && <EditablePrice price={findPricesBy(space.id, group.id)[0]} onSave={handleUpdatePrice} />}
<ConfigureTimeslotButton <ConfigureExtendedPriceButton
prices={findPricesBy(space.id, group.id)} prices={findPricesBy(space.id, group.id)}
onError={onError} onError={onError}
onSuccess={onSuccess} onSuccess={onSuccess}

View File

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

View File

@ -6,6 +6,10 @@ class PricePolicy < ApplicationPolicy
user.admin? && record.duration != 60 user.admin? && record.duration != 60
end end
def destroy?
user.admin? && record.duration != 60
end
def update? def update?
user.admin? user.admin?
end end

View File

@ -378,9 +378,9 @@ 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_timeslots_button: configure_extendedPrices_button:
timeslots: "Time slots" extendedPrices: "Extended prices"
no_timeslots: "No time slot for now" no_extendedPrices: "No extended price for now"
pack_form: pack_form:
hours: "Hours" hours: "Hours"
amount: "Price" amount: "Price"
@ -407,21 +407,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_timeslot: create_extendedPrice:
new_timeslot: "New time slot" new_extendedPrice: "New extended price"
new_timeslot_info: "..." new_extendedPrice_info: "..."
create_timeslot: "Create this time slot" create_extendedPrice: "Create extended price"
timeslot_successfully_created: "The new time slot was successfully created." extendedPrice_successfully_created: "The new extended price was successfully created."
delete_timeslot: delete_extendedPrice:
timeslot_deleted: "The time slot was successfully deleted." extendedPrice_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the time slot: " unable_to_delete: "Unable to delete the extended price: "
delete_timeslot: "Delete the time slot" delete_extendedPrice: "Delete the extended price"
confirm_delete: "Delete" confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this time slot? This won't be possible if it was already bought by users." 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_timeslot: edit_extendedPrice:
edit_timeslot: "Edit the time slot" edit_extendedPrice: "Edit the extended price"
confirm_changes: "Confirm changes" confirm_changes: "Confirm changes"
timeslot_successfully_updated: "The time slot was successfully updated." 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"

View File

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