1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-30 19:52:20 +01:00

WIP: migrate machine pricing edition interface to react

This commit is contained in:
Sylvain 2021-06-22 11:13:44 +02:00
parent d54f30e048
commit d7ba83f6a0
24 changed files with 352 additions and 168 deletions

View File

@ -6,12 +6,7 @@ class API::GroupsController < API::ApiController
before_action :authenticate_user!, except: :index
def index
@groups = if current_user&.admin?
Group.all
else
Group.where.not(slug: 'admins')
end
@groups = GroupService.list(current_user, params)
end
def create

View File

@ -7,12 +7,7 @@ class API::MachinesController < API::ApiController
respond_to :json
def index
sort_by = Setting.get('machines_sort_by') || 'default'
@machines = if sort_by == 'default'
Machine.includes(:machine_image, :plans)
else
Machine.includes(:machine_image, :plans).order(sort_by)
end
@machines = MachineService.list(params)
end
def show

View File

@ -7,21 +7,8 @@ class API::PricesController < API::ApiController
def index
authorize Price
@prices = Price.all
if params[:priceable_type]
@prices = @prices.where(priceable_type: params[:priceable_type])
@prices = @prices.where(priceable_id: params[:priceable_id]) if params[:priceable_id]
end
if params[:plan_id]
plan_id = if /no|nil|null|undefined/i.match?(params[:plan_id])
nil
else
params[:plan_id]
end
@prices = @prices.where(plan_id: plan_id)
end
@prices = @prices.where(group_id: params[:group_id]) if params[:group_id]
@prices = PriceService.list(params)
end
def update

View File

@ -1,11 +1,17 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Group } from '../models/group';
import { Group, GroupIndexFilter } from '../models/group';
export default class GroupAPI {
static async index (): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get('/api/groups');
static async index (filters?: Array<GroupIndexFilter>): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get(`/api/groups${GroupAPI.filtersToQuery(filters)}`);
return res?.data;
}
private static filtersToQuery(filters?: Array<GroupIndexFilter>): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');
}
}

View File

@ -1,10 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Machine } from '../models/machine';
import { Machine, MachineIndexFilter } from '../models/machine';
export default class MachineAPI {
static async index (): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines`);
static async index (filters?: Array<MachineIndexFilter>): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${MachineAPI.filtersToQuery(filters)}`);
return res?.data;
}
@ -12,5 +12,11 @@ export default class MachineAPI {
const res: AxiosResponse<Machine> = await apiClient.get(`/api/machines/${id}`);
return res?.data;
}
private static filtersToQuery(filters?: Array<MachineIndexFilter>): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');
}
}

View File

@ -1,9 +1,9 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { IndexFilter, PrepaidPack } from '../models/prepaid-pack';
import { PackIndexFilter, PrepaidPack } from '../models/prepaid-pack';
export default class PrepaidPackAPI {
static async index (filters?: Array<IndexFilter>): Promise<Array<PrepaidPack>> {
static async index (filters?: Array<PackIndexFilter>): Promise<Array<PrepaidPack>> {
const res: AxiosResponse<Array<PrepaidPack>> = await apiClient.get(`/api/prepaid_packs${PrepaidPackAPI.filtersToQuery(filters)}`);
return res?.data;
}
@ -28,7 +28,7 @@ export default class PrepaidPackAPI {
return res?.data;
}
private static filtersToQuery(filters?: Array<IndexFilter>): string {
private static filtersToQuery(filters?: Array<PackIndexFilter>): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');

View File

@ -1,12 +1,28 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart } from '../models/payment';
import { ComputePriceResult } from '../models/price';
import { ComputePriceResult, Price, PriceIndexFilter } from '../models/price';
export default class PriceAPI {
static async compute (cart: ShoppingCart): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart);
const res: AxiosResponse<ComputePriceResult> = await apiClient.post(`/api/prices/compute`, cart);
return res?.data;
}
static async index (filters?: Array<PriceIndexFilter>): Promise<Array<Price>> {
const res: AxiosResponse = await apiClient.get(`/api/prices${this.filtersToQuery(filters)}`);
return res?.data;
}
static async update (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.patch(`/api/price/${price.id}`, { price });
return res?.data;
}
private static filtersToQuery(filters?: Array<PriceIndexFilter>): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');
}
}

View File

@ -1,65 +0,0 @@
import React, { useEffect, useState } from 'react';
import PrepaidPackAPI from '../api/prepaid-pack';
import { IndexFilter, PrepaidPack } from '../models/prepaid-pack';
import { Loader } from './base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../models/application';
declare var Application: IApplication;
interface ConfigurePacksButtonParams {
groupId: number,
priceableId: number,
priceableType: string,
onError: (message: string) => void,
}
/**
* This component is a button that shows the list of prepaid-packs when moving the mouse over it.
* When clicked, it opens a modal dialog to configure (add/delete/edit/remove) prepaid-packs.
*/
const ConfigurePacksButton: React.FC<ConfigurePacksButtonParams> = ({ groupId, priceableId, priceableType, onError }) => {
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
const [showList, setShowList] = useState<boolean>(false);
useEffect(() => {
PrepaidPackAPI.index(buildFilters())
.then(data => setPacks(data))
.catch(error => onError(error))
}, [])
/**
* Build the filters for the current ConfigurePackButton, to query the API and get the concerned packs.
*/
const buildFilters = (): Array<IndexFilter> => {
const res = [];
if (groupId) res.push({ key: 'group_id', value: groupId });
if (priceableId) res.push({ key: 'priceable_id', value: priceableId });
if (priceableType) res.push({ key: 'priceable_type', value: priceableType });
return res;
}
const toggleShowList = (): void => {
setShowList(!showList);
}
return (
<div className="configure-packs-button" onMouseOver={toggleShowList}>
{packs && showList && <div className="packs-overview">
{packs.map(p => <div>{p.minutes / 60}h - {p.amount}</div>)}
</div>}
</div>
);
}
const ConfigurePacksButtonWrapper: React.FC<ConfigurePacksButtonParams> = ({ groupId, priceableId, priceableType, onError }) => {
return (
<Loader>
<ConfigurePacksButton groupId={groupId} priceableId={priceableId} priceableType={priceableType} onError={onError}/>
</Loader>
);
}
Application.Components.component('configurePacksButton', react2angular(ConfigurePacksButtonWrapper, ['groupId', 'priceableId', 'priceableType', 'onError']));

View File

@ -0,0 +1,30 @@
import React, { useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack';
interface ConfigurePacksButtonProps {
packs: Array<PrepaidPack>,
onError: (message: string) => void,
}
/**
* This component is a button that shows the list of prepaid-packs when moving the mouse over it.
* When clicked, it opens a modal dialog to configure (add/delete/edit/remove) prepaid-packs.
*/
export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ packs, onError }) => {
const [showList, setShowList] = useState<boolean>(false);
const toggleShowList = (): void => {
setShowList(!showList);
}
const handleAddPack = (): void => {
//TODO, open a modal to add a new pack
}
return (
<div className="configure-packs-button" onMouseOver={toggleShowList} onClick={handleAddPack}>
{packs && showList && <div className="packs-overview">
{packs.map(p => <div>{p.minutes / 60}h - {p.amount}</div>)}
</div>}
</div>
);
}

View File

@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { IFablab } from '../../models/fablab';
import { FabInput } from '../base/fab-input';
import { FabButton } from '../base/fab-button';
import { Price } from '../../models/price';
declare var Fablab: IFablab;
interface EditablePriceProps {
price: Price,
onSave: (price: Price) => void,
}
/**
* Display the given price.
* When the user clics on the price, switch to the edition mode to allow him modifying the price.
*/
export const EditablePrice: React.FC<EditablePriceProps> = ({ price, onSave }) => {
const [edit, setEdit] = useState<boolean>(false);
const [tempPrice, setTempPrice] = useState<number>(price.amount);
/**
* Return the formatted localized amount for the price (eg. 20.5 => "20,50 €")
*/
const formatPrice = (): string => {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price.amount);
}
/**
* Saves the new price
*/
const handleValidateEdit = (): void => {
const newPrice: Price = Object.assign({}, price);
newPrice.amount = tempPrice;
onSave(newPrice);
}
/**
* Enable or disable the edit mode
*/
const toggleEdit= (): void => {
setEdit(!edit);
}
return (
<span className="editable-price">
{!edit && <span className="display-price" onClick={toggleEdit}>{formatPrice()}</span>}
{edit && <span>
<FabInput id="price" defaultValue={price.amount} addOn={Fablab.intl_currency} onChange={setTempPrice} required/>
<FabButton icon={<i className="fas fa-check" />} className="approve-button" onClick={handleValidateEdit} />
<FabButton icon={<i className="fas fa-times" />} className="cancel-button" onClick={toggleEdit} />
</span>}
</span>
);
}

View File

@ -0,0 +1,130 @@
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 { IFablab } from '../../models/fablab';
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';
declare var Fablab: IFablab;
declare var Application: IApplication;
interface MachinesPricingProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Interface to set and edit the prices of machines-hours, per group
*/
const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [machines, setMachines] = useState<Array<Machine>>(null);
const [groups, setGroups] = useState<Array<Group>>(null);
const [prices, setPrices] = useState<Array<Price>>(null);
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
// retrieve the initial data
useEffect(() => {
MachineAPI.index([{ key: 'disabled', value: false }])
.then(data => setMachines(data))
.catch(error => onError(error));
GroupAPI.index([{ key: 'disabled', value: false }])
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index([{ key: 'priceable_type', value: 'Machine'}, { key: 'plan_id', value: null }])
.then(data => setPrices(data))
.catch(error => onError(error));
PrepaidPackAPI.index()
.then(data => setPacks(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 new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
};
/**
* Find the price matching the given criterion
*/
const findPriceBy = (machineId, groupId): Price => {
for (const price of prices) {
if ((price.priceable_id === machineId) && (price.group_id === groupId)) {
return price;
}
}
};
/**
* Callback triggered when the user has confirmed to update a price
*/
const handleUpdatePrice = (price: Price): void => {
PriceAPI.update(price)
.then(() => onSuccess(t('app.admin.machines_pricing.price_updated')))
.catch(error => onError(error))
}
return (
<div className="machine-pricing">
<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>
<p>{t('app.admin.machines_pricing.you_can_override')}</p>
</FabAlert>
<table>
<thead>
<tr>
<th>{t('app.admin.machines_pricing.machines')}</th>
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
</tr>
</thead>
<tbody>
{machines?.map(machine => <tr key={machine.id}>
<td>{machine.name}</td>
{groups?.map(group => <td key={group.id}>
{prices && <EditablePrice price={findPriceBy(machine.id, group.id)} onSave={handleUpdatePrice} />}
{packs && <ConfigurePacksButton packs={packs} onError={onError} />}
</td>)}
</tr>)}
</tbody>
</table>
</div>
);
}
const MachinesPricingWrapper: React.FC<MachinesPricingProps> = ({ onError, onSuccess }) => {
return (
<Loader>
<MachinesPricing onError={onError} onSuccess={onSuccess} />
</Loader>
);
}
Application.Components.component('machinesPricing', react2angular(MachinesPricingWrapper, ['onError', 'onSuccess']));

View File

@ -18,13 +18,10 @@
/**
* Controller used in the prices edition page
*/
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'machinesPricesPromise', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, machinesPricesPromise, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
Application.Controllers.controller('EditPricingController', ['$scope', '$state', '$uibModal', '$filter', 'TrainingsPricing', 'Credit', 'Pricing', 'Plan', 'Coupon', 'plans', 'groups', 'growl', 'Price', 'dialogs', 'trainingsPricingsPromise', 'trainingsPromise', 'machineCreditsPromise', 'machinesPromise', 'trainingCreditsPromise', 'couponsPromise', 'spacesPromise', 'spacesPricesPromise', 'spacesCreditsPromise', 'settingsPromise', '_t', 'Member', 'uiTourService', 'planCategories',
function ($scope, $state, $uibModal, $filter, TrainingsPricing, Credit, Pricing, Plan, Coupon, plans, groups, growl, Price, dialogs, trainingsPricingsPromise, trainingsPromise, machineCreditsPromise, machinesPromise, trainingCreditsPromise, couponsPromise, spacesPromise, spacesPricesPromise, spacesCreditsPromise, settingsPromise, _t, Member, uiTourService, planCategories) {
/* PUBLIC SCOPE */
// List of machines prices (not considering any plan)
$scope.machinesPrices = machinesPricesPromise;
// List of trainings pricing
$scope.trainingsPricings = trainingsPricingsPromise;
@ -640,6 +637,20 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
return $filter('currency')(price);
};
/**
* Callback triggered by react components
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Setup the feature-tour for the admin/pricing page.
* This is intended as a contextual help (when pressing F1)

View File

@ -1,3 +1,8 @@
export interface GroupIndexFilter {
key: 'disabled',
value: boolean,
}
export interface Group {
id: number,
slug: string,

View File

@ -1,5 +1,10 @@
import { Reservation } from './reservation';
export interface MachineIndexFilter {
key: 'disabled',
value: boolean,
}
export interface Machine {
id: number,
name: string,

View File

@ -1,5 +1,5 @@
export interface IndexFilter {
export interface PackIndexFilter {
key: 'group_id' | 'priceable_id' | 'priceable_type',
value: number|string,
}

View File

@ -1,3 +1,8 @@
export interface PriceIndexFilter {
key: 'priceable_type' | 'priceable_id' | 'group_id' | 'plan_id',
value?: number|string,
}
export interface Price {
id: number,
group_id: number,

View File

@ -771,7 +771,6 @@ angular.module('application.router', ['ui.router'])
resolve: {
plans: ['Plan', function (Plan) { return Plan.query().$promise; }],
groups: ['Group', function (Group) { return Group.query().$promise; }],
machinesPricesPromise: ['Price', function (Price) { return Price.query({ priceable_type: 'Machine', plan_id: 'null' }).$promise; }],
trainingsPricingsPromise: ['TrainingsPricing', function (TrainingsPricing) { return TrainingsPricing.query().$promise; }],
trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
machineCreditsPromise: ['Credit', function (Credit) { return Credit.query({ creditable_type: 'Machine' }).$promise; }],

View File

@ -1,30 +1 @@
<div class="alert alert-warning m-t">
<p ng-bind-html="'app.admin.pricing.these_prices_match_machine_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.machines' }}</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="machine in enabledMachines">
<td>
{{ machine.name }}
</td>
<td ng-repeat="group in enabledGroups">
<span editable-number="findPriceBy(machinesPrices, machine.id, group.id).amount"
e-step="any"
onbeforesave="updatePrice($data, findPriceBy(machinesPrices, machine.id, group.id))">
{{ findPriceBy(machinesPrices, machine.id, group.id).amount | currency}}
</span>
<configure-packs-button group-id="group.id" priceable-id="machine.id" priceable-type="'Machine'" />
</td> <!-- FIXME: too much API calls -->
</tr>
</tbody>
</table>
<machines-pricing on-success="onSuccess" on-error="onError">

View File

@ -1,18 +0,0 @@
<div class="modal-header">
<img ng-src="{{logoBlack.custom_asset_file_attributes.attachment_url}}" alt="{{logo.custom_asset_file_attributes.attachment}}" class="modal-logo"/>
<div class="padder-v">
<span class="avatar text-center">
<fab-user-avatar ng-model="member.user_avatar" avatar-class="thumb-50"></fab-user-avatar>
<span class="user-name m-l-sm">{{member.name}}</span>
</span>
</div>
</div>
<div class="modal-body">
<p class="text-center font-sbold" translate translate-values="{MACHINE:machine.name, TRAINING: humanizeTrainings()}">{{ 'app.shared.request_training_modal.to_book_the_MACHINE_you_must_have_completed_the_TRAINING' }}</p>
<p class="text-center"><button ng-click="ok()" class="btn btn-warning-full rounded width-70" translate>{{ 'app.shared.request_training_modal.register_for_the_training' }}</button></p>
</div>
<div class="modal-footer">
<p class="text-center">{{ 'app.shared.request_training_modal.i_dont_want_to_register_now' | translate }} <br> <a class="text-u-l" href="#" ng-click="cancel($event)" translate>{{ 'app.shared.buttons.close' }}</a></p>
</div>

View File

@ -1,10 +0,0 @@
<div class="modal-header">
<h1 translate>{{ 'app.shared.training_reservation_modal.machine_reservation' }}</h1>
</div>
<div class="modal-body">
<p translate> {{ 'app.shared.training_reservation_modal.you_must_wait_for_your_training_is_being_validated_by_the_fablab_team_to_book_this_machine' }}</p>
<p>{{ 'app.shared.training_reservation_modal.your_training_will_occur_' | translate }} <span class="sbold">{{machine.current_user_next_training_reservation.slots_attributes[0].start_at | amDateFormat: 'LL'}} : {{machine.current_user_next_training_reservation.slots_attributes[0].start_at | amDateFormat:'LT'}} - {{machine.current_user_next_training_reservation.slots_attributes[0].end_at | amDateFormat:'LT'}}</span></p>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="cancel()" translate>{{ 'app.shared.buttons.close' }}</button>
</div>

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Provides methods for Groups
class GroupService
def self.list(operator, filters = {})
groups = if operator&.admin?
Group.where(nil)
else
Group.where.not(slug: 'admins')
end
if filters[:disabled].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
groups = groups.where(disabled: state)
end
groups
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Provides methods for Machines
class MachineService
def self.list(filters)
sort_by = Setting.get('machines_sort_by') || 'default'
machines = if sort_by == 'default'
Machine.includes(:machine_image, :plans)
else
Machine.includes(:machine_image, :plans).order(sort_by)
end
if filters[:disabled].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
machines = machines.where(disabled: state)
end
machines
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
# Provides methods for Prices
class PriceService
def self.list(filters)
prices = Price.where(nil)
prices = prices.where(priceable_type: filters[:priceable_type]) if filters[:priceable_type].present?
prices = prices.where(priceable_id: filters[:priceable_id]) if filters[:priceable_id].present?
prices = prices.where(group_id: filters[:group_id]) if filters[:group_id].present?
if filters[:plan_id].present?
plan_id = /no|nil|null|undefined/i.match?(filters[:plan_id]) ? nil : filters[:plan_id]
prices = prices.where(plan_id: plan_id)
end
prices
end
end

View File

@ -307,7 +307,6 @@ en:
prominence: "Prominence"
price: "Price"
machine_hours: "Machine slots"
these_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 (default), will be charged <strong>{PRICE}</strong>."
you_can_override: "You can override this duration for each availability you create in the agenda. The price will then be adjusted accordingly."
machines: "Machines"
@ -369,6 +368,12 @@ en:
status_enabled: "Enabled"
status_disabled: "Disabled"
status_all: "All"
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 (default), will be charged <strong>{PRICE}</strong>."
you_can_override: "You can override this duration for each availability you create in the agenda. The price will then be adjusted accordingly."
machines: "Machines"
price_updated: "Price successfully updated"
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Add a coupon"