1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

pack creation and listing

This commit is contained in:
Sylvain 2021-06-23 17:00:15 +02:00
parent f81e0910c2
commit 1f8fd47317
20 changed files with 476 additions and 108 deletions

View File

@ -4,7 +4,7 @@ import { Group, GroupIndexFilter } from '../models/group';
export default class GroupAPI {
static async index (filters?: GroupIndexFilter): Promise<Array<Group>> {
const res: AxiosResponse<Array<Group>> = await apiClient.get(`/api/groups${GroupAPI.filtersToQuery(filters)}`);
const res: AxiosResponse<Array<Group>> = await apiClient.get(`/api/groups${this.filtersToQuery(filters)}`);
return res?.data;
}

View File

@ -3,8 +3,8 @@ import { AxiosResponse } from 'axios';
import { Machine, MachineIndexFilter } from '../models/machine';
export default class MachineAPI {
static async index (filters?: Array<MachineIndexFilter>): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${MachineAPI.filtersToQuery(filters)}`);
static async index (filters?: MachineIndexFilter): Promise<Array<Machine>> {
const res: AxiosResponse<Array<Machine>> = await apiClient.get(`/api/machines${this.filtersToQuery(filters)}`);
return res?.data;
}
@ -13,10 +13,10 @@ export default class MachineAPI {
return res?.data;
}
private static filtersToQuery(filters?: Array<MachineIndexFilter>): string {
private static filtersToQuery(filters?: MachineIndexFilter): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

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

View File

@ -9,7 +9,7 @@ export default class PriceAPI {
return res?.data;
}
static async index (filters?: Array<PriceIndexFilter>): Promise<Array<Price>> {
static async index (filters?: PriceIndexFilter): Promise<Array<Price>> {
const res: AxiosResponse = await apiClient.get(`/api/prices${this.filtersToQuery(filters)}`);
return res?.data;
}
@ -19,10 +19,10 @@ export default class PriceAPI {
return res?.data;
}
private static filtersToQuery(filters?: Array<PriceIndexFilter>): string {
private static filtersToQuery(filters?: PriceIndexFilter): string {
if (!filters) return '';
return '?' + filters.map(f => `${f.key}=${f.value}`).join('&');
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -3,7 +3,7 @@ import { debounce as _debounce } from 'lodash';
interface FabInputProps {
id: string,
onChange?: (value: any, validity?: ValidityState) => void,
onChange?: (value: string, validity?: ValidityState) => void,
defaultValue: any,
icon?: ReactNode,
addOn?: ReactNode,
@ -19,12 +19,14 @@ interface FabInputProps {
error?: string,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
step?: number | 'any',
min?: number,
max?: number,
}
/**
* This component is a template for an input component that wraps the application style
*/
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step }) => {
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step, min, max }) => {
const [inputValue, setInputValue] = useState<any>(defaultValue);
/**
@ -88,6 +90,8 @@ export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue,
<input id={id}
type={type}
step={step}
min={min}
max={max}
className="fab-input--input"
value={inputValue}
onChange={handleChange}

View File

@ -27,12 +27,13 @@ interface FabModalProps {
onConfirm?: (event: BaseSyntheticEvent) => void,
preventConfirm?: boolean,
onCreation?: () => void,
onConfirmSendFormId?: string,
}
/**
* This component is a template for a modal dialog that wraps the application style
*/
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
const { t } = useTranslation('shared');
const [blackLogo, setBlackLogo] = useState<CustomAsset>(null);
@ -55,6 +56,13 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
return confirmButton !== undefined;
}
/**
* Check if the behavior of the confirm button is to send a form, using the provided ID
*/
const confirmationSendForm = (): boolean => {
return onConfirmSendFormId !== undefined;
}
/**
* Should we display the close button?
*/
@ -96,7 +104,8 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
<div className="fab-modal-footer">
<Loader>
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
{hasConfirmButton() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasConfirmButton() && !confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasConfirmButton() && confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} type="submit" form={onConfirmSendFormId}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
</Loader>
</div>

View File

@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
interface FabPopoverProps {
title: string,
className?: string,
headerButton?: ReactNode,
}
/**
* This component is a template for a popovers (bottom) that wraps the application style
*/
export const FabPopover: React.FC<FabPopoverProps> = ({ title, className, headerButton, children }) => {
/**
* Check if the header button should be present
*/
const hasHeaderButton = (): boolean => {
return !!headerButton;
}
return (
<div className={`fab-popover ${className ? className : ''}`}>
<div className="popover-title">
<h3>{title}</h3>
{hasHeaderButton() && headerButton}
</div>
<div className="popover-content">
{children}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { ReactEventHandler, ReactNode, useState } from 'react';
import React, { ReactEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import moment from 'moment';

View File

@ -1,52 +1,122 @@
import React, { useState } from 'react';
import React, { ReactNode, useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../base/fab-popover';
import { NewPackModal } from './new-pack-modal';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { IFablab } from '../../models/fablab';
import { duration } from 'moment';
import { FabButton } from '../base/fab-button';
declare var Fablab: IFablab;
interface ConfigurePacksButtonProps {
packs: Array<PrepaidPack>,
packsData: Array<PrepaidPack>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* 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.
* It also triggers modal dialogs to configure (add/delete/edit/remove) prepaid-packs.
*/
export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ packs, onError }) => {
export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ packsData, onError, onSuccess, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [packs, setPacks] = useState<Array<PrepaidPack>>(packsData);
const [showList, setShowList] = useState<boolean>(false);
const [addPackModal, setAddPackModal] = useState<boolean>(false);
const [editPackModal, setEditPackModal] = useState<boolean>(false);
const [deletePackModal, setDeletePackModal] = useState<boolean>(false);
/**
* Return the formatted localized amount for the given price (e.g. 20.5 => "20,50 €")
*/
const formatPrice = (price: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
}
/**
* Return the number of hours, user-friendly formatted
*/
const formatDuration = (minutes: number): string => {
return t('app.admin.configure_packs_button.pack_DURATION', { DURATION: minutes / 60 });
}
/**
* Open/closes the popover listing the existing packs
*/
const toggleShowList = (): void => {
setShowList(!showList);
}
/**
* Open/closes the "new pack" modal
*/
const toggleAddPackModal = (): void => {
setAddPackModal(!addPackModal);
}
const handleAddPack = (): void => {
toggleAddPackModal();
/**
* Open/closes the "edit pack" modal
*/
const toggleEditPackModal = (): void => {
setEditPackModal(!editPackModal);
}
/**
* Open/closes the "confirm delete pack" modal
*/
const toggleRemovePackModal = (): void => {
setDeletePackModal(!deletePackModal);
}
/**
* Callback triggered when the PrepaidPack was successfully created.
* We refresh the list of packs for the current button to get the new one.
*/
const handlePackCreated = (message: string) => {
onSuccess(message);
PrepaidPackAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
.then(data => setPacks(data))
.catch(error => onError(error));
}
/**
* Render the button used to trigger the "new pack" modal
*/
const renderAddButton = (): ReactNode => {
return <button className="add-pack-button" onClick={toggleAddPackModal}><i className="fas fa-plus"/></button>;
}
return (
<div className="configure-packs-button" onClick={toggleShowList}>
<button className="packs-button">
<div className="configure-packs-button">
<button className="packs-button" onClick={toggleShowList}>
<i className="fas fa-box" />
</button>
{showList && <div className="packs-popover">
<div className="popover-title">
<h3>{t('app.admin.configure_packs_button.packs')}</h3>
<button className="add-pack-button" onClick={handleAddPack}><i className="fas fa-plus"/></button>
</div>
<div className="popover-content">
<ul>
{packs?.map(p => <li key={p.id}>{p.minutes / 60}h - {p.amount}</li>)}
</ul>
{packs?.length === 0 && <span>{t('app.admin.configure_packs_button.no_packs')}</span>}
</div>
</div>}
<FabModal isOpen={addPackModal} toggleModal={toggleAddPackModal}>NEW PACK</FabModal>
{showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()}>
<ul>
{packs?.map(p =>
<li key={p.id}>
{formatDuration(p.minutes)} - {formatPrice(p.amount)}
<span className="pack-actions">
<FabButton className="edit-pack-button" onClick={toggleEditPackModal}><i className="fas fa-edit"/></FabButton>
<FabButton className="remove-pack-button" onClick={toggleRemovePackModal}><i className="fas fa-trash"/></FabButton>
</span>
</li>)}
</ul>
{packs?.length === 0 && <span>{t('app.admin.configure_packs_button.no_packs')}</span>}
</FabPopover>}
<NewPackModal isOpen={addPackModal}
toggleModal={toggleAddPackModal}
onSuccess={handlePackCreated}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />
</div>
);
}

View File

@ -39,13 +39,13 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
// retrieve the initial data
useEffect(() => {
MachineAPI.index([{ key: 'disabled', value: false }])
MachineAPI.index({ disabled: false })
.then(data => setMachines(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false , admins: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index([{ key: 'priceable_type', value: 'Machine'}, { key: 'plan_id', value: null }])
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
.then(data => updatePrices(data))
.catch(error => onError(error));
PrepaidPackAPI.index()
@ -74,11 +74,14 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
* 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;
}
}
return prices.find(price => price.priceable_id === machineId && price.group_id === groupId);
};
/**
* Filter the packs matching the given criterion
*/
const filterPacksBy = (machineId, groupId): Array<PrepaidPack> => {
return packs.filter(pack => pack.priceable_id === machineId && pack.group_id === groupId);
};
/**
@ -123,7 +126,12 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
<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} />}
{packs && <ConfigurePacksButton packsData={filterPacksBy(machine.id, group.id)}
onError={onError}
onSuccess={onSuccess}
groupId={group.id}
priceableId={machine.id}
priceableType="Machine" />}
</td>)}
</tr>)}
</tbody>

View File

@ -0,0 +1,58 @@
import React from 'react';
import { FabModal } from '../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
interface NewPackModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component is a modal dialog handing the process of creating a new PrepaidPack
*/
export const NewPackModal: React.FC<NewPackModalProps> = ({ isOpen, toggleModal, onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
/**
* Callback triggered when the user has validated the creation of the new PrepaidPack
*/
const handleSubmit = (pack: PrepaidPack): void => {
// set the already-known attributes of the new pack
const newPack = Object.assign<PrepaidPack, PrepaidPack>({} as PrepaidPack, pack);
newPack.group_id = groupId;
newPack.priceable_id = priceableId;
newPack.priceable_type = priceableType;
// create it on the API
PrepaidPackAPI.create(newPack)
.then(() => {
onSuccess(t('app.admin.new_pack_modal.pack_successfully_created'));
toggleModal();
})
.catch(error => onError(error));
}
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.new_pack_modal.new_pack')}
className="new-pack-modal"
closeButton
confirmButton={t('app.admin.new_pack_modal.create_pack')}
onConfirmSendFormId="new-pack">
<FabAlert level="info">
{t('app.admin.new_pack_modal.new_pack_info', { TYPE: priceableType })}
</FabAlert>
<PackForm formId="new-pack" onSubmit={handleSubmit} />
</FabModal>
);
}

View File

@ -0,0 +1,124 @@
import React, { BaseSyntheticEvent } from 'react';
import Select from 'react-select';
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';
declare var Fablab: IFablab;
interface PackFormProps {
formId: string,
onSubmit: (pack: PrepaidPack) => void,
packData?: PrepaidPack,
}
const ALL_INTERVALS = ['day', 'week', 'month', 'year'] as const;
type interval = typeof ALL_INTERVALS[number];
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: interval, label: string };
/**
* A form component to create/edit a PrepaidPack.
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, packData }) => {
const [pack, updatePack] = useImmer<PrepaidPack>(packData || {} as PrepaidPack);
const { t } = useTranslation('admin');
/**
* Convert all validity intervals to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
return ALL_INTERVALS.map(i => {
return { value: i, label: t(`app.admin.pack_form.intervals.${i}`, { COUNT: pack.validity_count || 0 }) };
});
}
/**
* Callback triggered when the user sends the form.
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(pack);
}
/**
* Callback triggered when the user inputs an amount for the current pack.
*/
const handleUpdateAmount = (amount: string) => {
updatePack(draft => {
draft.amount = parseFloat(amount);
});
}
/**
* Callback triggered when the user inputs a number of hours for the current pack.
*/
const handleUpdateHours = (hours: string) => {
updatePack(draft => {
draft.minutes = parseInt(hours, 10) * 60;
});
}
/**
* Callback triggered when the user inputs a number of periods for the current pack.
*/
const handleUpdateValidityCount = (count: string) => {
updatePack(draft => {
draft.validity_count = parseInt(count, 10);
});
}
/**
* Callback triggered when the user selects a type of interval for the current pack.
*/
const handleUpdateValidityInterval = (option: selectOption) => {
updatePack(draft => {
draft.validity_interval = option.value as interval;
});
}
return (
<form id={formId} onSubmit={handleSubmit} className="pack-form">
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
<FabInput id="hours"
type="number"
defaultValue={pack?.minutes / 60 || ''}
onChange={handleUpdateHours}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.pack_form.amount')} *</label>
<FabInput id="amount"
type="number"
step={0.01}
min={0}
defaultValue={pack?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
<label htmlFor="validity_count">{t('app.admin.pack_form.validity_count')}</label>
<div className="interval-inputs">
<FabInput id="validity_count"
type="number"
min={0}
defaultValue={pack?.validity_count || ''}
onChange={handleUpdateValidityCount}
icon={<i className="fas fa-calendar-week" />} />
<Select placeholder={t('app.admin.pack_form.select_interval')}
className="select-interval"
defaultValue={pack?.validity_interval}
onChange={handleUpdateValidityInterval}
options={buildOptions()} />
</div>
</form>
);
}

View File

@ -1,8 +1,7 @@
import { Reservation } from './reservation';
export interface MachineIndexFilter {
key: 'disabled',
value: boolean,
disabled: boolean,
}
export interface Machine {

View File

@ -1,12 +1,13 @@
export interface PackIndexFilter {
key: 'group_id' | 'priceable_id' | 'priceable_type',
value: number|string,
group_id: number,
priceable_id: number,
priceable_type: string
}
export interface PrepaidPack {
id?: number,
priceable_id: string,
priceable_id: number,
priceable_type: string,
group_id: number,
validity_interval?: 'day' | 'week' | 'month' | 'year',

View File

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

View File

@ -25,6 +25,7 @@
@import "modules/base/fab-input";
@import "modules/base/fab-button";
@import "modules/base/fab-alert";
@import "modules/base/fab-popover";
@import "modules/base/labelled-input";
@import "modules/payment-schedule/payment-schedule-summary";
@import "modules/wallet-info";
@ -58,5 +59,6 @@
@import "modules/pricing/machines-pricing";
@import "modules/pricing/editable-price";
@import "modules/pricing/configure-packs-button";
@import "modules/pricing/pack-form";
@import "app.responsive";

View File

@ -0,0 +1,54 @@
.fab-popover {
& {
position: absolute;
width: 276px;
border: 1px solid rgba(0,0,0,.2);
border-radius: .3rem;
top: 35px;
left: -125px;
z-index: 1;
}
&::before {
content: "";
background-color: transparent;
position: absolute;
left: 132px;
top: -7px;
height: 7px;
width: 12px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ccc;
}
&::after {
content: "";
background-color: transparent;
position: absolute;
left: 133px;
top: -6px;
height: 6px;
width: 10px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #f0f0f0;
}
.popover-title {
padding: .5rem 1rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f0f0f0;
border-bottom: 1px solid rgba(0,0,0,.2);
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px);
& > h3 {
margin: 2px;
}
}
.popover-content {
padding: 1rem 1rem;
color: #212529;
background-color: white;
}
}

View File

@ -18,65 +18,44 @@
color: white;
}
}
.packs-popover {
& {
.popover-title {
.add-pack-button {
position: absolute;
width: 276px;
border: 1px solid rgba(0,0,0,.2);
border-radius: .3rem;
top: 35px;
left: -125px;
z-index: 1;
right: 5px;
top: 10px;
}
}
&::before {
content: "";
background-color: transparent;
position: absolute;
left: 132px;
top: -7px;
height: 7px;
width: 12px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ccc;
}
&::after {
content: "";
background-color: transparent;
position: absolute;
left: 133px;
top: -6px;
height: 6px;
width: 10px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #f0f0f0;
}
.popover-title {
padding: .5rem 1rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f0f0f0;
border-bottom: 1px solid rgba(0,0,0,.2);
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px);
.popover-content {
ul {
padding-left: 19px;
& > h3 {
margin: 2px;
li {
display: flex;
justify-content: space-between;
&::before {
content: '\f466';
font-family: 'Font Awesome 5 Free';
position: absolute;
left: 11px;
font-weight: 800;
font-size: 12px;
vertical-align: middle;
line-height: 24px;
}
.pack-actions > button {
font-size: 10px;
vertical-align: middle;
line-height: 10px;
height: auto;
&.remove-pack-button {
background-color: #cb1117;
color: white;
}
}
}
& > .add-pack-button {
position: absolute;
right: 5px;
top: 10px;
}
}
.popover-content {
padding: 1rem 1rem;
color: #212529;
background-color: white;
}
}
}

View File

@ -0,0 +1,10 @@
.pack-form {
.interval-inputs {
display: flex;
.select-interval {
min-width: 49%;
margin-left: 4px;
}
}
}

View File

@ -377,6 +377,22 @@ en:
configure_packs_button:
packs: "Prepaid packs"
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
pack_form:
hours: "Hours"
amount: "Price"
validity_count: "Maximum validity"
select_interval: "Interval..."
intervals:
day: "{COUNT, plural, one{Day} other{Days}}"
week: "{COUNT, plural, one{Week} other{Weeks}}"
month: "{COUNT, plural, one{Month} other{Months}}"
year: "{COUNT, plural, one{Year} other{Years}}"
new_pack_modal:
new_pack: "New prepaid pack"
new_pack_info: "A prepaid pack allows users to buy {TYPE, select, Machine{machine} Space{space} other{}} hours before booking any slots. These packs can provide discounts on volumes purchases."
create_pack: "Create this pack"
pack_successfully_created: "The new prepaid pack was successfully created."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Add a coupon"