diff --git a/CHANGELOG.md b/CHANGELOG.md index 95db3cb6d..f4cd5a7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/controllers/api/prices_controller.rb b/app/controllers/api/prices_controller.rb index 61d7f80ba..de84774a2 100644 --- a/app/controllers/api/prices_controller.rb +++ b/app/controllers/api/prices_controller.rb @@ -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 diff --git a/app/frontend/src/javascript/api/price.ts b/app/frontend/src/javascript/api/price.ts index d5ab87ec4..d85616a1e 100644 --- a/app/frontend/src/javascript/api/price.ts +++ b/app/frontend/src/javascript/api/price.ts @@ -14,11 +14,21 @@ export default class PriceAPI { return res?.data; } + static async create (price: Price): Promise { + const res: AxiosResponse = await apiClient.post('/api/prices', { price }); + return res?.data; + } + static async update (price: Price): Promise { const res: AxiosResponse = await apiClient.patch(`/api/prices/${price.id}`, { price }); return res?.data; } + static async destroy (priceId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/prices/${priceId}`); + return res?.data; + } + private static filtersToQuery (filters?: PriceIndexFilter): string { if (!filters) return ''; diff --git a/app/frontend/src/javascript/api/space.ts b/app/frontend/src/javascript/api/space.ts new file mode 100644 index 000000000..5633bd658 --- /dev/null +++ b/app/frontend/src/javascript/api/space.ts @@ -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> { + const res: AxiosResponse> = await apiClient.get('/api/spaces'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/spaces/${id}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx similarity index 88% rename from app/frontend/src/javascript/components/pricing/configure-packs-button.tsx rename to app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx index fc470f051..c5b1ff43c 100644 --- a/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx @@ -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, @@ -64,8 +64,8 @@ export const ConfigurePacksButton: React.FC = ({ pack }; return ( -
- {showList && @@ -73,7 +73,7 @@ export const ConfigurePacksButton: React.FC = ({ pack {packs?.map(p =>
  • {formatDuration(p.minutes)} - {FormatLib.price(p.amount)} - + diff --git a/app/frontend/src/javascript/components/pricing/create-pack.tsx b/app/frontend/src/javascript/components/pricing/machines/create-pack.tsx similarity index 90% rename from app/frontend/src/javascript/components/pricing/create-pack.tsx rename to app/frontend/src/javascript/components/pricing/machines/create-pack.tsx index a38683315..4d457bcd3 100644 --- a/app/frontend/src/javascript/components/pricing/create-pack.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/create-pack.tsx @@ -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, diff --git a/app/frontend/src/javascript/components/pricing/delete-pack.tsx b/app/frontend/src/javascript/components/pricing/machines/delete-pack.tsx similarity index 80% rename from app/frontend/src/javascript/components/pricing/delete-pack.tsx rename to app/frontend/src/javascript/components/pricing/machines/delete-pack.tsx index 20343629e..dc1d01089 100644 --- a/app/frontend/src/javascript/components/pricing/delete-pack.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/delete-pack.tsx @@ -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 = ({ onSuccess, onError, pa }; return ( -
    - } onClick={toggleDeletionModal} /> +
    + } onClick={toggleDeletionModal} /> = ({ pack, onSuccess, onError }) }; return ( -
    - } onClick={handleRequestEdit} /> +
    + } onClick={handleRequestEdit} /> - {packData && } + onConfirmSendFormId="edit-group"> + {packData && }
    ); diff --git a/app/frontend/src/javascript/components/pricing/machines-pricing.tsx b/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx similarity index 86% rename from app/frontend/src/javascript/components/pricing/machines-pricing.tsx rename to app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx index a099b5896..5b3618b7d 100644 --- a/app/frontend/src/javascript/components/pricing/machines-pricing.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/machines-pricing.tsx @@ -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 = ({ onError, onSuccess }) }; return ( -
    +

    diff --git a/app/frontend/src/javascript/components/pricing/pack-form.tsx b/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx similarity index 95% rename from app/frontend/src/javascript/components/pricing/pack-form.tsx rename to app/frontend/src/javascript/components/pricing/machines/pack-form.tsx index b97257a1f..831e0d899 100644 --- a/app/frontend/src/javascript/components/pricing/pack-form.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/pack-form.tsx @@ -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 = ({ formId, onSubmit, pack }) => }; return ( -
    + , + 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 = ({ prices, onError, onSuccess, groupId, priceableId, priceableType }) => { + const { t } = useTranslation('admin'); + + const [extendedPrices, setExtendedPrices] = useState>(prices); + const [showList, setShowList] = useState(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 ; + }; + + return ( +
    + + {showList && +
      + {extendedPrices?.map(extendedPrice => +
    • + {extendedPrice.duration} {t('app.admin.calendar.minutes')} - {FormatLib.price(extendedPrice.amount)} + + + + +
    • )} +
    + {extendedPrices?.length === 0 && {t('app.admin.configure_extendedPrices_button.no_extendedPrices')}} +
    } +
    + ); +}; diff --git a/app/frontend/src/javascript/components/pricing/spaces/create-extended-price.tsx b/app/frontend/src/javascript/components/pricing/spaces/create-extended-price.tsx new file mode 100644 index 000000000..c01be253a --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/spaces/create-extended-price.tsx @@ -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 = ({ onSuccess, onError, groupId, priceableId, priceableType }) => { + const { t } = useTranslation('admin'); + + const [isOpen, setIsOpen] = useState(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({} 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 ( +
    + + + + {t('app.admin.create_extendedPrice.new_extendedPrice_info', { TYPE: priceableType })} + + + +
    + ); +}; diff --git a/app/frontend/src/javascript/components/pricing/spaces/delete-extended-price.tsx b/app/frontend/src/javascript/components/pricing/spaces/delete-extended-price.tsx new file mode 100644 index 000000000..56af8784e --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/spaces/delete-extended-price.tsx @@ -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 = ({ onSuccess, onError, price }) => { + const { t } = useTranslation('admin'); + + const [deletionModal, setDeletionModal] = useState(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 ( +
    + } onClick={toggleDeletionModal} /> + + {t('app.admin.delete_extendedPrice.delete_confirmation')} + +
    + ); +}; diff --git a/app/frontend/src/javascript/components/pricing/spaces/edit-extended-price.tsx b/app/frontend/src/javascript/components/pricing/spaces/edit-extended-price.tsx new file mode 100644 index 000000000..994432850 --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/spaces/edit-extended-price.tsx @@ -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 = ({ price, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [isOpen, setIsOpen] = useState(false); + const [extendedPriceData, setExtendedPriceData] = useState(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 ( +
    + } onClick={handleRequestEdit} /> + + {extendedPriceData && } + +
    + ); +}; diff --git a/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx b/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx new file mode 100644 index 000000000..31445ee81 --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/spaces/extended-price-form.tsx @@ -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 = ({ formId, onSubmit, price }) => { + const [extendedPriceData, updateExtendedPriceData] = useImmer(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 ( + + + } + required /> + + } + addOn={Fablab.intl_currency} + required /> + + ); +}; diff --git a/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx b/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx new file mode 100644 index 000000000..b7397a028 --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/spaces/spaces-pricing.tsx @@ -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 = ({ onError, onSuccess }) => { + const { t } = useTranslation('admin'); + + const [spaces, setSpaces] = useState>(null); + const [groups, setGroups] = useState>(null); + const [prices, updatePrices] = useImmer>([]); + + // 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 => { + 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 ( +
    + +

    +

    +

    {t('app.admin.pricing.you_can_override')}

    +
    + + + + + {groups?.map(group => )} + + + + {spaces?.map(space => + + {groups?.map(group => )} + )} + +
    {t('app.admin.pricing.spaces')}{group.name}
    {space.name} + {prices.length && } + +
    +
    + ); +}; + +const SpacesPricingWrapper: React.FC = ({ onError, onSuccess }) => { + return ( + + + + ); +}; + +Application.Components.component('spacesPricing', react2angular(SpacesPricingWrapper, ['onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/controllers/admin/pricing.js b/app/frontend/src/javascript/controllers/admin/pricing.js index bf4baf11d..1337cbd22 100644 --- a/app/frontend/src/javascript/controllers/admin/pricing.js +++ b/app/frontend/src/javascript/controllers/admin/pricing.js @@ -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; } } diff --git a/app/frontend/src/javascript/models/price.ts b/app/frontend/src/javascript/models/price.ts index cb90077ca..fcd1b0e8e 100644 --- a/app/frontend/src/javascript/models/price.ts +++ b/app/frontend/src/javascript/models/price.ts @@ -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 { diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index e92dff2b7..08c01cd01 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -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; diff --git a/app/frontend/src/javascript/models/space.ts b/app/frontend/src/javascript/models/space.ts new file mode 100644 index 000000000..a5337c437 --- /dev/null +++ b/app/frontend/src/javascript/models/space.ts @@ -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, + } +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 394a64ce1..0f24a91b5 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -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; }], diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index fb8e6a6f3..32d9cbad9 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -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"; diff --git a/app/frontend/src/stylesheets/modules/pricing/configure-packs-button.scss b/app/frontend/src/stylesheets/modules/pricing/configure-group-button.scss similarity index 93% rename from app/frontend/src/stylesheets/modules/pricing/configure-packs-button.scss rename to app/frontend/src/stylesheets/modules/pricing/configure-group-button.scss index ab2cf5bec..dec3bf005 100644 --- a/app/frontend/src/stylesheets/modules/pricing/configure-packs-button.scss +++ b/app/frontend/src/stylesheets/modules/pricing/configure-group-button.scss @@ -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; diff --git a/app/frontend/src/stylesheets/modules/pricing/delete-pack.scss b/app/frontend/src/stylesheets/modules/pricing/delete-group.scss similarity index 65% rename from app/frontend/src/stylesheets/modules/pricing/delete-pack.scss rename to app/frontend/src/stylesheets/modules/pricing/delete-group.scss index 37d87274c..dd1f2d259 100644 --- a/app/frontend/src/stylesheets/modules/pricing/delete-pack.scss +++ b/app/frontend/src/stylesheets/modules/pricing/delete-group.scss @@ -1,7 +1,7 @@ -.delete-pack { +.delete-group { display: inline; - .remove-pack-button { + &-button { background-color: #cb1117; color: white; } diff --git a/app/frontend/src/stylesheets/modules/pricing/edit-pack.scss b/app/frontend/src/stylesheets/modules/pricing/edit-group.scss similarity index 65% rename from app/frontend/src/stylesheets/modules/pricing/edit-pack.scss rename to app/frontend/src/stylesheets/modules/pricing/edit-group.scss index 1b87b732c..f8c48ff92 100644 --- a/app/frontend/src/stylesheets/modules/pricing/edit-pack.scss +++ b/app/frontend/src/stylesheets/modules/pricing/edit-group.scss @@ -1,3 +1,3 @@ -.edit-pack { +.edit-group { display: inline-block; } diff --git a/app/frontend/src/stylesheets/modules/pricing/pack-form.scss b/app/frontend/src/stylesheets/modules/pricing/group-form.scss similarity index 89% rename from app/frontend/src/stylesheets/modules/pricing/pack-form.scss rename to app/frontend/src/stylesheets/modules/pricing/group-form.scss index 1d10d59aa..3da69b3e4 100644 --- a/app/frontend/src/stylesheets/modules/pricing/pack-form.scss +++ b/app/frontend/src/stylesheets/modules/pricing/group-form.scss @@ -1,4 +1,4 @@ -.pack-form { +.group-form { .interval-inputs { display: flex; diff --git a/app/frontend/src/stylesheets/modules/pricing/machines-pricing.scss b/app/frontend/src/stylesheets/modules/pricing/pricing-list.scss similarity index 95% rename from app/frontend/src/stylesheets/modules/pricing/machines-pricing.scss rename to app/frontend/src/stylesheets/modules/pricing/pricing-list.scss index 99c04a00b..5169794f4 100644 --- a/app/frontend/src/stylesheets/modules/pricing/machines-pricing.scss +++ b/app/frontend/src/stylesheets/modules/pricing/pricing-list.scss @@ -1,4 +1,4 @@ -.machines-pricing { +.pricing-list { .fab-alert { margin: 15px 0; } @@ -28,4 +28,4 @@ border-top: 1px solid #ddd; } } -} +} \ No newline at end of file diff --git a/app/frontend/templates/admin/pricing/spaces.html b/app/frontend/templates/admin/pricing/spaces.html index 41f6d0045..eb4ca3899 100644 --- a/app/frontend/templates/admin/pricing/spaces.html +++ b/app/frontend/templates/admin/pricing/spaces.html @@ -1,29 +1 @@ -
    -

    -

    -

    {{ 'app.admin.pricing.you_can_override' }}

    -
    - - - - - - - - - - - - - -
    {{ 'app.admin.pricing.spaces' }} - {{group.name}} -
    - {{ space.name }} - - - {{ findPriceBy(spacesPrices, space.id, group.id).amount | currency}} - -
    + diff --git a/app/frontend/templates/admin/settings/reservations.html b/app/frontend/templates/admin/settings/reservations.html index 05e599930..e0f2d9ccc 100644 --- a/app/frontend/templates/admin/settings/reservations.html +++ b/app/frontend/templates/admin/settings/reservations.html @@ -117,6 +117,28 @@ required="true">
    + +
    +
    +

    {{ 'app.admin.settings.pack_only_for_subscription_info' }}

    +

    + + +
    + +
    +
    +

    {{ 'app.admin.settings.extended_prices' }}

    +

    + + +
    @@ -170,6 +192,8 @@ label="app.admin.settings.show_event" classes="m-l">
    + +

    {{ 'app.admin.settings.display_invite_to_renew_pack' }}

    @@ -182,14 +206,5 @@ step="0.01">
    -
    -

    {{ 'app.admin.settings.pack_only_for_subscription_info' }}

    -

    - - -
  • diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 2d54022b0..9f97b1a45 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -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) ## diff --git a/app/models/price.rb b/app/models/price.rb index 0a229fdb7..94e2dc5e9 100644 --- a/app/models/price.rb +++ b/app/models/price.rb @@ -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 diff --git a/app/models/setting.rb b/app/models/setting.rb index d0742a62d..e6ed14298 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -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 diff --git a/app/policies/price_policy.rb b/app/policies/price_policy.rb index d403d9ab2..313df745f 100644 --- a/app/policies/price_policy.rb +++ b/app/policies/price_policy.rb @@ -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 diff --git a/app/services/prepaid_pack_service.rb b/app/services/prepaid_pack_service.rb index 58ba51d8a..49ef7772c 100644 --- a/app/services/prepaid_pack_service.rb +++ b/app/services/prepaid_pack_service.rb @@ -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 diff --git a/app/views/api/prices/_price.json.jbuilder b/app/views/api/prices/_price.json.jbuilder index 19a0ffc06..ce2900702 100644 --- a/app/views/api/prices/_price.json.jbuilder +++ b/app/views/api/prices/_price.json.jbuilder @@ -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 diff --git a/app/views/api/prices/compute.json.jbuilder b/app/views/api/prices/compute.json.jbuilder index 6dfd2318b..10e3cc8d8 100644 --- a/app/views/api/prices/compute.json.jbuilder +++ b/app/views/api/prices/compute.json.jbuilder @@ -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] diff --git a/app/views/api/prices/create.json.jbuilder b/app/views/api/prices/create.json.jbuilder new file mode 100644 index 000000000..a44c04cb6 --- /dev/null +++ b/app/views/api/prices/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/prices/price', price: @price diff --git a/app/views/api/prices/index.json.jbuilder b/app/views/api/prices/index.json.jbuilder index 1d35eab98..24b3ea1f8 100644 --- a/app/views/api/prices/index.json.jbuilder +++ b/app/views/api/prices/index.json.jbuilder @@ -1 +1,3 @@ +# frozen_string_literal: true + json.partial! 'api/prices/price', collection: @prices, as: :price diff --git a/app/views/api/prices/update.json.jbuilder b/app/views/api/prices/update.json.jbuilder index a59de38a1..a44c04cb6 100644 --- a/app/views/api/prices/update.json.jbuilder +++ b/app/views/api/prices/update.json.jbuilder @@ -1 +1,3 @@ +# frozen_string_literal: true + json.partial! 'api/prices/price', price: @price diff --git a/app/views/api/spaces/index.json.jbuilder b/app/views/api/spaces/index.json.jbuilder index e0dc6c7d7..97572fe74 100644 --- a/app/views/api/spaces/index.json.jbuilder +++ b/app/views/api/spaces/index.json.jbuilder @@ -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 diff --git a/app/views/api/spaces/show.json.jbuilder b/app/views/api/spaces/show.json.jbuilder index 6ce0b7843..15147739f 100644 --- a/app/views/api/spaces/show.json.jbuilder +++ b/app/views/api/spaces/show.json.jbuilder @@ -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 \ No newline at end of file +# end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 570dd1a24..a2cac5534 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -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, without subscription." prices_calculated_on_hourly_rate_html: "All the prices will be automatically calculated based on the hourly rate defined here.
    For example, if you define an hourly rate at {RATE}: a slot of {DURATION} minutes, will be charged {PRICE}." @@ -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" diff --git a/config/locales/en.yml b/config/locales/en.yml index 688e92473..a6e36f3f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 4fca0d997..fa6c5bb10 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20211220143400_add_duration_to_price.rb b/db/migrate/20211220143400_add_duration_to_price.rb new file mode 100644 index 000000000..3fd1112fe --- /dev/null +++ b/db/migrate/20211220143400_add_duration_to_price.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 406fda14c..647b326ab 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/db/seeds.rb b/db/seeds.rb index 73c9ff89e..67653a44b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -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) diff --git a/test/fixtures/prices.yml b/test/fixtures/prices.yml index 0e7ac37cd..a4e8b0b8f 100644 --- a/test/fixtures/prices.yml +++ b/test/fixtures/prices.yml @@ -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