From 1f8fd47317fa33cbc65461c7a6410579a37164d4 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 23 Jun 2021 17:00:15 +0200 Subject: [PATCH] pack creation and listing --- app/frontend/src/javascript/api/group.ts | 2 +- app/frontend/src/javascript/api/machine.ts | 8 +- .../src/javascript/api/prepaid-pack.ts | 8 +- app/frontend/src/javascript/api/price.ts | 6 +- .../javascript/components/base/fab-input.tsx | 8 +- .../javascript/components/base/fab-modal.tsx | 13 +- .../components/base/fab-popover.tsx | 32 +++++ .../payment-schedules-table.tsx | 2 +- .../pricing/configure-packs-button.tsx | 114 ++++++++++++---- .../components/pricing/machines-pricing.tsx | 24 ++-- .../components/pricing/new-pack-modal.tsx | 58 ++++++++ .../components/pricing/pack-form.tsx | 124 ++++++++++++++++++ app/frontend/src/javascript/models/machine.ts | 3 +- .../src/javascript/models/prepaid-pack.ts | 7 +- app/frontend/src/javascript/models/price.ts | 6 +- app/frontend/src/stylesheets/application.scss | 2 + .../stylesheets/modules/base/fab-popover.scss | 54 ++++++++ .../pricing/configure-packs-button.scss | 87 +++++------- .../modules/pricing/pack-form.scss | 10 ++ config/locales/app.admin.en.yml | 16 +++ 20 files changed, 476 insertions(+), 108 deletions(-) create mode 100644 app/frontend/src/javascript/components/base/fab-popover.tsx create mode 100644 app/frontend/src/javascript/components/pricing/new-pack-modal.tsx create mode 100644 app/frontend/src/javascript/components/pricing/pack-form.tsx create mode 100644 app/frontend/src/stylesheets/modules/base/fab-popover.scss create mode 100644 app/frontend/src/stylesheets/modules/pricing/pack-form.scss diff --git a/app/frontend/src/javascript/api/group.ts b/app/frontend/src/javascript/api/group.ts index 65c99df8b..b7e827895 100644 --- a/app/frontend/src/javascript/api/group.ts +++ b/app/frontend/src/javascript/api/group.ts @@ -4,7 +4,7 @@ import { Group, GroupIndexFilter } from '../models/group'; export default class GroupAPI { static async index (filters?: GroupIndexFilter): Promise> { - const res: AxiosResponse> = await apiClient.get(`/api/groups${GroupAPI.filtersToQuery(filters)}`); + const res: AxiosResponse> = await apiClient.get(`/api/groups${this.filtersToQuery(filters)}`); return res?.data; } diff --git a/app/frontend/src/javascript/api/machine.ts b/app/frontend/src/javascript/api/machine.ts index 47f4f3057..b1a338c43 100644 --- a/app/frontend/src/javascript/api/machine.ts +++ b/app/frontend/src/javascript/api/machine.ts @@ -3,8 +3,8 @@ import { AxiosResponse } from 'axios'; import { Machine, MachineIndexFilter } from '../models/machine'; export default class MachineAPI { - static async index (filters?: Array): Promise> { - const res: AxiosResponse> = await apiClient.get(`/api/machines${MachineAPI.filtersToQuery(filters)}`); + static async index (filters?: MachineIndexFilter): Promise> { + const res: AxiosResponse> = 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): 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('&'); } } diff --git a/app/frontend/src/javascript/api/prepaid-pack.ts b/app/frontend/src/javascript/api/prepaid-pack.ts index f48463f65..31ffa969a 100644 --- a/app/frontend/src/javascript/api/prepaid-pack.ts +++ b/app/frontend/src/javascript/api/prepaid-pack.ts @@ -3,8 +3,8 @@ import { AxiosResponse } from 'axios'; import { PackIndexFilter, PrepaidPack } from '../models/prepaid-pack'; export default class PrepaidPackAPI { - static async index (filters?: Array): Promise> { - const res: AxiosResponse> = await apiClient.get(`/api/prepaid_packs${PrepaidPackAPI.filtersToQuery(filters)}`); + static async index (filters?: PackIndexFilter): Promise> { + const res: AxiosResponse> = 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): 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('&'); } } diff --git a/app/frontend/src/javascript/api/price.ts b/app/frontend/src/javascript/api/price.ts index 475ac1ad0..dd7e6cfab 100644 --- a/app/frontend/src/javascript/api/price.ts +++ b/app/frontend/src/javascript/api/price.ts @@ -9,7 +9,7 @@ export default class PriceAPI { return res?.data; } - static async index (filters?: Array): Promise> { + static async index (filters?: PriceIndexFilter): Promise> { 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): 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('&'); } } diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index 48bf60477..515e0c862 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -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 = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step }) => { +export const FabInput: React.FC = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error, step, min, max }) => { const [inputValue, setInputValue] = useState(defaultValue); /** @@ -88,6 +90,8 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, 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 = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation }) => { +export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => { const { t } = useTranslation('shared'); const [blackLogo, setBlackLogo] = useState(null); @@ -55,6 +56,13 @@ export const FabModal: React.FC = ({ 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 = ({ title, isOpen, toggleModal,
{hasCloseButton() &&{t('app.shared.buttons.close')}} - {hasConfirmButton() && {confirmButton}} + {hasConfirmButton() && !confirmationSendForm() && {confirmButton}} + {hasConfirmButton() && confirmationSendForm() && {confirmButton}} {hasCustomFooter() && customFooter}
diff --git a/app/frontend/src/javascript/components/base/fab-popover.tsx b/app/frontend/src/javascript/components/base/fab-popover.tsx new file mode 100644 index 000000000..3438fa746 --- /dev/null +++ b/app/frontend/src/javascript/components/base/fab-popover.tsx @@ -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 = ({ title, className, headerButton, children }) => { + + /** + * Check if the header button should be present + */ + const hasHeaderButton = (): boolean => { + return !!headerButton; + } + + return ( +
+
+

{title}

+ {hasHeaderButton() && headerButton} +
+
+ {children} +
+
+ ); +} diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index d5d176d32..84e294af2 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -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'; diff --git a/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx b/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx index f0375f3d6..cf938939a 100644 --- a/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx +++ b/app/frontend/src/javascript/components/pricing/configure-packs-button.tsx @@ -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, + packsData: Array, 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 = ({ packs, onError }) => { +export const ConfigurePacksButton: React.FC = ({ packsData, onError, onSuccess, groupId, priceableId, priceableType }) => { const { t } = useTranslation('admin'); + + const [packs, setPacks] = useState>(packsData); const [showList, setShowList] = useState(false); const [addPackModal, setAddPackModal] = useState(false); + const [editPackModal, setEditPackModal] = useState(false); + const [deletePackModal, setDeletePackModal] = useState(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 ; } return ( -
- - {showList &&
-
-

{t('app.admin.configure_packs_button.packs')}

- -
-
-
    - {packs?.map(p =>
  • {p.minutes / 60}h - {p.amount}
  • )} -
- {packs?.length === 0 && {t('app.admin.configure_packs_button.no_packs')}} -
-
} - NEW PACK + {showList && +
    + {packs?.map(p => +
  • + {formatDuration(p.minutes)} - {formatPrice(p.amount)} + + + + +
  • )} +
+ {packs?.length === 0 && {t('app.admin.configure_packs_button.no_packs')}} +
} +
); } diff --git a/app/frontend/src/javascript/components/pricing/machines-pricing.tsx b/app/frontend/src/javascript/components/pricing/machines-pricing.tsx index 950fded62..2e7cc699e 100644 --- a/app/frontend/src/javascript/components/pricing/machines-pricing.tsx +++ b/app/frontend/src/javascript/components/pricing/machines-pricing.tsx @@ -39,13 +39,13 @@ const MachinesPricing: React.FC = ({ 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 = ({ 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 => { + return packs.filter(pack => pack.priceable_id === machineId && pack.group_id === groupId); }; /** @@ -123,7 +126,12 @@ const MachinesPricing: React.FC = ({ onError, onSuccess }) {machine.name} {groups?.map(group => {prices && } - {packs && } + {packs && } )} )} diff --git a/app/frontend/src/javascript/components/pricing/new-pack-modal.tsx b/app/frontend/src/javascript/components/pricing/new-pack-modal.tsx new file mode 100644 index 000000000..79e10854c --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/new-pack-modal.tsx @@ -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 = ({ 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({} 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 ( + + + {t('app.admin.new_pack_modal.new_pack_info', { TYPE: priceableType })} + + + + ); +} diff --git a/app/frontend/src/javascript/components/pricing/pack-form.tsx b/app/frontend/src/javascript/components/pricing/pack-form.tsx new file mode 100644 index 000000000..6ee5c0830 --- /dev/null +++ b/app/frontend/src/javascript/components/pricing/pack-form.tsx @@ -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 = ({ formId, onSubmit, packData }) => { + const [pack, updatePack] = useImmer(packData || {} as PrepaidPack); + + const { t } = useTranslation('admin'); + + /** + * Convert all validity intervals to the react-select format + */ + const buildOptions = (): Array => { + 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 ( +
+ + } + required /> + + } + addOn={Fablab.intl_currency} + required /> + +
+ } /> +