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:
parent
f81e0910c2
commit
1f8fd47317
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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('&');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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('&');
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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('&');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
32
app/frontend/src/javascript/components/base/fab-popover.tsx
Normal file
32
app/frontend/src/javascript/components/base/fab-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
124
app/frontend/src/javascript/components/pricing/pack-form.tsx
Normal file
124
app/frontend/src/javascript/components/pricing/pack-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import { Reservation } from './reservation';
|
||||
|
||||
export interface MachineIndexFilter {
|
||||
key: 'disabled',
|
||||
value: boolean,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -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";
|
||||
|
54
app/frontend/src/stylesheets/modules/base/fab-popover.scss
Normal file
54
app/frontend/src/stylesheets/modules/base/fab-popover.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
app/frontend/src/stylesheets/modules/pricing/pack-form.scss
Normal file
10
app/frontend/src/stylesheets/modules/pricing/pack-form.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.pack-form {
|
||||
.interval-inputs {
|
||||
display: flex;
|
||||
|
||||
.select-interval {
|
||||
min-width: 49%;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user