From 587dcda5d5289851e985a91007eb161a0ec09071 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 2 Feb 2023 12:48:39 +0100 Subject: [PATCH 01/23] (i18n) updated translations --- config/locales/app.logged.pt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/app.logged.pt.yml b/config/locales/app.logged.pt.yml index e89af82d5..d2546c3b7 100644 --- a/config/locales/app.logged.pt.yml +++ b/config/locales/app.logged.pt.yml @@ -156,7 +156,7 @@ pt: cancelled_slot: "Cancelled" credits_panel: title: "My credits" - info: "Your subscription comes with free credits you can use when reserving" + info: "Your subscription comes with free credits you can use on reservations" remaining_credits_html: "You can book {REMAINING} {REMAINING, plural, one{slot} other{slots}} for free." used_credits_html: "You have already used {USED} {USED, plural, =0{credit} one{credit} other{credits}}." no_credits: "You don't have any credits yet. Some subscriptions may allow you to book some slots for free." From 9294bc4c88e052aec8d6556f43a2f46d0bf85ed8 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 24 Jan 2023 09:47:01 +0100 Subject: [PATCH 02/23] (ui) Plan form layout --- .../javascript/components/plans/plan-form.tsx | 290 +++++++++++------- .../stylesheets/modules/base/fab-tabs.scss | 14 +- .../modules/base/fab-text-editor.scss | 2 +- .../modules/events/event-form.scss | 9 +- .../stylesheets/modules/plans/plan-form.scss | 71 ++++- .../src/stylesheets/variables/layout.scss | 4 + app/frontend/templates/admin/plans/edit.html | 19 +- app/frontend/templates/admin/plans/new.html | 12 +- config/locales/app.admin.en.yml | 10 +- 9 files changed, 257 insertions(+), 174 deletions(-) diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index 44c4cbe6d..e7c18d891 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -24,6 +24,7 @@ import { UserPlus } from 'phosphor-react'; import { PartnerModal } from './partner-modal'; import { PlanPricingForm } from './plan-pricing-form'; import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form'; +import { FabTabs } from '../base/fab-tabs'; declare const Application: IApplication; @@ -130,140 +131,195 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc setValue('partner_id', user.id); }; - return ( -
-
-

{t('app.admin.plan_form.general_information')}

- - {action === 'create' && } - {!allGroups && groups && } - {categories?.length > 0 && } - {action === 'update' && - {t('app.admin.plan_form.edit_amount_info')} - } - - - - - - - -

{t('app.admin.plan_form.duration')}

-
+ /** + * Render the content of the 'subscriptions settings' tab + */ + const renderSettingsTab = () => ( +
+
+
+

{t('app.admin.plan_form.description')}

+
+
- + rules={{ + required: true, + maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') } + }} + label={t('app.admin.plan_form.name')} /> + +
-

{t('app.admin.plan_form.partnership')}

-
+
+ +
+
+

{t('app.admin.plan_form.general_settings')}

+

{t('app.admin.plan_form.general_settings_info')}

+
+
+ {action === 'create' && } + {!allGroups && groups && } +
+ + +
+ +
+
+ +
+
+

{t('app.admin.plan_form.activation_and_payment')}

+
+
+ + + +
+
+ +
+
+

{t('app.admin.plan_form.partnership')}

+

{t('app.admin.plan_form.partner_plan_help')}

+
+
{output.type === 'PartnerPlan' &&
- } onClick={tooglePartnerModal}> + {partners && } + } onClick={tooglePartnerModal}> {t('app.admin.plan_form.new_user')} - {partners && } - {output.partner_id && - {t('app.admin.plan_form.alert_partner_notification')} - }
}
- - {action === 'update' && } - - {t('app.admin.plan_form.ACTION_plan', { ACTION: action })} - +
+ + {categories?.length > 0 && } + {action === 'update' && + {t('app.admin.plan_form.edit_amount_info')} + } + + + + + {action === 'update' && } +
+ ); + + return ( +
+
+

{t('app.admin.plan_form.ACTION_title', { ACTION: action })}

+
+ + {t('app.admin.plan_form.save')} + +
+
+ + + plop + } + ]} /> + header { + padding-bottom: 0; + @include header($sticky: true); + gap: 2.4rem; } - .duration { - display: flex; - flex-direction: row; - .form-item:first-child { - margin-right: 32px; - } - } - .partner { + &-content { display: flex; flex-direction: column; - align-items: flex-end; + gap: 3.2rem; - .fab-alert { - width: 100%; + section { @include layout-settings; } + .grp { + display: flex; + flex-direction: column; + @media (min-width: 640px) {flex-direction: row; } + + .form-item { margin: 0; } + .form-item:first-child { margin-right: 2.4rem; } + } + + .partner { + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: 0 2.4rem; + + @media (min-width: 640px) { + flex-direction: row; + button { margin-bottom: 1.6rem; } + } } } - .submit-btn { - float: right; - } + + //.plan-sheet { + // margin-top: 4rem; + //} + //.duration { + // display: flex; + // flex-direction: row; + + // .form-item:first-child { + // margin-right: 32px; + // } + //} + //.partner { + // display: flex; + // flex-direction: column; + // align-items: flex-end; + + // .fab-alert { + // width: 100%; + // } + //} + //.submit-btn { + // float: right; + //} } diff --git a/app/frontend/src/stylesheets/variables/layout.scss b/app/frontend/src/stylesheets/variables/layout.scss index 1717818c5..f5a51adad 100644 --- a/app/frontend/src/stylesheets/variables/layout.scss +++ b/app/frontend/src/stylesheets/variables/layout.scss @@ -29,10 +29,14 @@ } & > .content { + display: flex; + flex-direction: column; padding: 1.6rem; background-color: var(--gray-soft-light); border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); + & > * { margin-bottom: 0; } + & > *:not(:last-child) { margin-bottom: 3.2rem; } } @media (min-width: 1024px) { diff --git a/app/frontend/templates/admin/plans/edit.html b/app/frontend/templates/admin/plans/edit.html index d0b2a1555..161d1bd59 100644 --- a/app/frontend/templates/admin/plans/edit.html +++ b/app/frontend/templates/admin/plans/edit.html @@ -10,24 +10,7 @@

{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ suscriptionPlan.base_name }}

- -
-
-
- -
-
- -
-
- -
-
+ diff --git a/app/frontend/templates/admin/plans/new.html b/app/frontend/templates/admin/plans/new.html index 9a2b14257..a5cddf0f6 100644 --- a/app/frontend/templates/admin/plans/new.html +++ b/app/frontend/templates/admin/plans/new.html @@ -14,14 +14,4 @@
-
-
- -
-
- -
-
- -
-
+ diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 7ca09e20c..f00a72928 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -152,7 +152,13 @@ en: every_month: "Every month" every_year: "Every year" plan_form: - general_information: "General information" + ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" + tab_settings: "Settings" + tab_usage_limits: "Usage limits" + description: "Description" + general_settings: "General settings" + general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." + activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." group: "Group" @@ -185,7 +191,7 @@ en: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" - ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan" + save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" partner_modal: From c964ec8a6db708d65decef31179cc3acbf101fdd Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 24 Jan 2023 18:59:01 +0100 Subject: [PATCH 03/23] (ui) Plan limit form and modal --- .../javascript/components/plans/plan-form.tsx | 45 ++++--- .../components/plans/plan-limit-form.tsx | 110 ++++++++++++++++++ .../components/plans/plan-limit-modal.tsx | 98 ++++++++++++++++ .../components/plans/plan-pricing-form.tsx | 58 ++++----- .../components/store/product-stock-form.tsx | 2 +- app/frontend/src/stylesheets/application.scss | 3 + .../stylesheets/modules/base/fab-tabs.scss | 1 + .../modules/events/event-form.scss | 2 - .../modules/machines/machine-form.scss | 2 - .../stylesheets/modules/plans/plan-form.scss | 24 ---- .../modules/plans/plan-limit-form.scss | 64 ++++++++++ .../modules/plans/plan-limit-modal.scss | 30 +++++ .../modules/plans/plan-pricing-form.scss | 12 ++ .../modules/spaces/space-form.scss | 2 - .../modules/trainings/training-form.scss | 2 - .../src/stylesheets/variables/layout.scss | 1 + config/locales/app.admin.en.yml | 25 +++- 17 files changed, 403 insertions(+), 78 deletions(-) create mode 100644 app/frontend/src/javascript/components/plans/plan-limit-form.tsx create mode 100644 app/frontend/src/javascript/components/plans/plan-limit-modal.tsx create mode 100644 app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss create mode 100644 app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss create mode 100644 app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index e7c18d891..77b596868 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -25,6 +25,7 @@ import { PartnerModal } from './partner-modal'; import { PlanPricingForm } from './plan-pricing-form'; import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form'; import { FabTabs } from '../base/fab-tabs'; +import { PlanLimitForm } from './plan-limit-form'; declare const Application: IApplication; @@ -202,6 +203,9 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc formState={formState} rules={{ required: true }} /> + {action === 'update' && + {t('app.admin.plan_form.edit_amount_info')} + } = ({ action, plan, onError, onSuc - {categories?.length > 0 && } - {action === 'update' && - {t('app.admin.plan_form.edit_amount_info')} - } +
+
+

{t('app.admin.plan_form.display')}

+
+
+ {categories?.length > 0 && } + +
+
- +
+ +
- {action === 'update' && = ({ action, plan, onError, onSuc { id: 'usageLimits', title: t('app.admin.plan_form.tab_usage_limits'), - content:
plop
+ content: } ]} /> diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx new file mode 100644 index 000000000..d5e41b05e --- /dev/null +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Control, FormState } from 'react-hook-form/dist/types/form'; +import { FormSwitch } from '../form/form-switch'; +import { useTranslation } from 'react-i18next'; +import { FabButton } from '../base/fab-button'; +import { PencilSimple, Trash } from 'phosphor-react'; +import { PlanLimitModal } from './plan-limit-modal'; + +interface PlanLimitFormProps { + control: Control, + formState: FormState +} + +/** + * Form tab to manage a subscription's usage limit + */ +export const PlanLimitForm = ({ control, formState }: PlanLimitFormProps) => { + const { t } = useTranslation('admin'); + + const [isOpen, setIsOpen] = useState(false); + + /** + * Opens/closes the product stock edition modal + */ + const toggleModal = (): void => { + setIsOpen(!isOpen); + }; + + return ( +
+
+
+

{t('app.admin.plan_limit_form.usage_limitation')}

+

{t('app.admin.plan_limit_form.usage_limitation_info')}

+
+
+ +
+
+ +
+
+

{t('app.admin.plan_limit_form.all_limitations')}

+
+ + {t('app.admin.plan_limit_form.new_usage_limitation')} + +
+
+ +
+

{t('app.admin.plan_limit_form.by_categories')}

+
+
+ {t('app.admin.plan_limit_form.category')} +

Plop

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

5

+
+ +
+
+ + + + + + +
+
+
+
+ +
+

{t('app.admin.plan_limit_form.by_machine')}

+
+
+ {t('app.admin.plan_limit_form.machine')} +

Pouet

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

5

+
+ +
+
+ + + + + + +
+
+
+
+
+ + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx new file mode 100644 index 000000000..42b54b2d9 --- /dev/null +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabAlert } from '../base/fab-alert'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { useForm } from 'react-hook-form'; +import { FormSelect } from '../form/form-select'; +import { FormInput } from '../form/form-input'; + +type typeSelectOption = { value: any, label: string }; +interface PlanLimitModalProps { + isOpen: boolean, + toggleModal: () => void, +} + +/** + * Form to manage subscriptions limitations of use + */ +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal }) => { + const { t } = useTranslation('admin'); + + const { register, control, formState } = useForm(); + const [limitType, setLimitType] = React.useState<'categories' | 'machine'>('categories'); + + /** + * Toggle the form between 'categories' and 'machine' + */ + const toggleLimitType = (evt: React.MouseEvent, type: 'categories' | 'machine') => { + evt.preventDefault(); + setLimitType(type); + }; + + /** + * Creates options to the react-select format + */ + const buildMachinesCategoriesOptions = (): Array => { + return [ + { value: '0', label: 'yep' }, + { value: '1', label: 'nope' } + ]; + }; + /** + * Creates options to the react-select format + */ + const buildMachinesOptions = (): Array => { + return [ + { value: '0', label: 'pif' }, + { value: '1', label: 'paf' }, + { value: '2', label: 'pouf' } + ]; + }; + + return ( + +
+

{t('app.admin.plan_limit_modal.limit_reservations')}

+
+ + +
+ {limitType === 'categories' && <> + {t('app.admin.plan_limit_modal.categories_info')} + + } + {limitType === 'machine' && <> + {t('app.admin.plan_limit_modal.machine_info')} + + } + + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx index 637ecfe76..13d31f4e7 100644 --- a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx @@ -92,32 +92,36 @@ export const PlanPricingForm = ({ register, control, fo }; return ( -
-

{t('app.admin.plan_pricing_form.prices')}

- {plans && } - { { - if (price.priceable_type !== 'Machine') return false; - return renderPriceElement(price, index); - }).filter(Boolean) - }, - spaces && { - id: 'spaces', - title: t('app.admin.plan_pricing_form.spaces'), - content: fields.map((price, index) => { - if (price.priceable_type !== 'Space') return false; - return renderPriceElement(price, index); - }).filter(Boolean) - } - ]} />} -
+
+
+

{t('app.admin.plan_pricing_form.prices')}

+

{t('app.admin.plan_pricing_form.copy_prices_from_help')}

+
+
+ {plans && } + { { + if (price.priceable_type !== 'Machine') return false; + return renderPriceElement(price, index); + }).filter(Boolean) + }, + spaces && { + id: 'spaces', + title: t('app.admin.plan_pricing_form.spaces'), + content: fields.map((price, index) => { + if (price.priceable_type !== 'Space') return false; + return renderPriceElement(price, index); + }).filter(Boolean) + } + ]} />} +
+
); }; diff --git a/app/frontend/src/javascript/components/store/product-stock-form.tsx b/app/frontend/src/javascript/components/store/product-stock-form.tsx index bf12a121f..40cfaf423 100644 --- a/app/frontend/src/javascript/components/store/product-stock-form.tsx +++ b/app/frontend/src/javascript/components/store/product-stock-form.tsx @@ -178,7 +178,7 @@ export const ProductStockForm = ({ currentFormValues, {t('app.admin.store.product_stock_form.external')}

{currentFormValues?.stock?.external}

- } className="is-black">Modifier + } className="is-black">{t('app.admin.store.product_stock_form.edit')} {fields.length > 0 &&
diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 22fc9555c..f89015592 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -97,6 +97,9 @@ @import "modules/plan-categories/plan-categories-list"; @import "modules/plans/plan-card"; @import "modules/plans/plan-form"; +@import "modules/plans/plan-limit-form"; +@import "modules/plans/plan-limit-modal"; +@import "modules/plans/plan-pricing-form"; @import "modules/plans/plans-filter"; @import "modules/plans/plans-list"; @import "modules/prepaid-packs/packs-summary"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-tabs.scss b/app/frontend/src/stylesheets/modules/base/fab-tabs.scss index 0b1019752..ecffcfd65 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-tabs.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-tabs.scss @@ -26,6 +26,7 @@ background-color: var(--gray-soft-lightest); cursor: default; } + &:focus { outline: none; } } } } diff --git a/app/frontend/src/stylesheets/modules/events/event-form.scss b/app/frontend/src/stylesheets/modules/events/event-form.scss index e113f927e..92d91a1ae 100644 --- a/app/frontend/src/stylesheets/modules/events/event-form.scss +++ b/app/frontend/src/stylesheets/modules/events/event-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/modules/machines/machine-form.scss b/app/frontend/src/stylesheets/modules/machines/machine-form.scss index 90c099238..28364b434 100644 --- a/app/frontend/src/stylesheets/modules/machines/machine-form.scss +++ b/app/frontend/src/stylesheets/modules/machines/machine-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/modules/plans/plan-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-form.scss index 4b7841d72..7dde64762 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-form.scss @@ -37,28 +37,4 @@ } } } - - //.plan-sheet { - // margin-top: 4rem; - //} - //.duration { - // display: flex; - // flex-direction: row; - - // .form-item:first-child { - // margin-right: 32px; - // } - //} - //.partner { - // display: flex; - // flex-direction: column; - // align-items: flex-end; - - // .fab-alert { - // width: 100%; - // } - //} - //.submit-btn { - // float: right; - //} } diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss new file mode 100644 index 000000000..117e66eb2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -0,0 +1,64 @@ +.plan-limit-form { + display: flex; + flex-direction: column; + gap: 3.2rem 0; + + section { @include layout-settings; } + + .plan-limit-grp { + header { + @include header(); + p { + @include title-base; + margin: 0; + } + } + .plan-limit-list { + max-height: 65vh; + margin-bottom: 6.4rem; + display: flex; + flex-direction: column; + overflow-y: auto; + + .title { @include text-base(500); } + .plan-limit-item { + width: 100%; + margin-bottom: 2.4rem; + padding: 1.6rem; + display: flex; + justify-content: space-between; + gap: 3.2rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { + margin: 0; + @include text-base(600); + } + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .grpBtn { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--main) } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss new file mode 100644 index 000000000..08d11d0eb --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-modal.scss @@ -0,0 +1,30 @@ +.plan-limit-modal { + .grp { + margin-bottom: 3.2rem; + display: flex; + justify-content: space-between; + align-items: center; + button { + flex: 1; + padding: 1.6rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + color: var(--gray-soft-darkest); + @include text-base; + &.is-active { + border: 1px solid var(--gray-soft-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + } + } + button:first-of-type { + border-radius: var(--border-radius) 0 0 var(--border-radius); + } + button:last-of-type { + border-radius: 0 var(--border-radius) var(--border-radius) 0; + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss new file mode 100644 index 000000000..68a9e5756 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/plans/plan-pricing-form.scss @@ -0,0 +1,12 @@ +.plan-pricing-form { + .fab-tabs .tabs li { + margin-bottom: 1.6rem; + &:hover { background-color: var(--gray-soft); } + &.react-tabs__tab--selected:hover { background-color: transparent; } + } + + .react-tabs__tab-panel { + max-height: 50vh; + overflow-y: auto; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/spaces/space-form.scss b/app/frontend/src/stylesheets/modules/spaces/space-form.scss index 0db00f748..f8caba094 100644 --- a/app/frontend/src/stylesheets/modules/spaces/space-form.scss +++ b/app/frontend/src/stylesheets/modules/spaces/space-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/modules/trainings/training-form.scss b/app/frontend/src/stylesheets/modules/trainings/training-form.scss index adf701132..1eeb90524 100644 --- a/app/frontend/src/stylesheets/modules/trainings/training-form.scss +++ b/app/frontend/src/stylesheets/modules/trainings/training-form.scss @@ -15,8 +15,6 @@ flex-direction: column; gap: 3.2rem; - .fab-alert { margin: 0; } - section { @include layout-settings; } .save-btn { align-self: flex-start; } } diff --git a/app/frontend/src/stylesheets/variables/layout.scss b/app/frontend/src/stylesheets/variables/layout.scss index f5a51adad..b61ccb975 100644 --- a/app/frontend/src/stylesheets/variables/layout.scss +++ b/app/frontend/src/stylesheets/variables/layout.scss @@ -37,6 +37,7 @@ border-radius: var(--border-radius); & > * { margin-bottom: 0; } & > *:not(:last-child) { margin-bottom: 3.2rem; } + .fab-alert { margin: 0 0 1.6rem; } } @media (min-width: 1024px) { diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index f00a72928..6cd746770 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -164,6 +164,7 @@ en: group: "Group" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." + display: "Display" category: "Category" category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions." number_of_periods: "Number of periods" @@ -182,7 +183,7 @@ en: description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" - new_user: "New user ..." + new_user: "New user" alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user." disabled: "Disable subscription" disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it." @@ -194,6 +195,27 @@ en: save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" + plan_limit_form: + usage_limitation: "Limitation of use" + usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation." + usage_limitation_switch: "Restrict machine reservations to a number of hours per day." + new_usage_limitation: "Add a limitation of use" + all_limitations: "All limitations" + by_categories: "By machines categories" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + max_hours_per_day: "Max. hours/day" + plan_limit_modal: + title: "Manage limitation of use" + limit_reservations: "Limit reservations" + by_categories: "By machines categories" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + categories_info: "If you select all machine categories, the limits will apply across the board. Please note that if you have already created limitations for specific categories, these will be permanently overwritten." + machine_info: "If you select all machines, the limits will apply across the board. Please note that if you have already created limitations for machines, these will be permanently overwritten." + max_hours_per_day: "Maximum number of reservation hours per day" partner_modal: title: "Create a new partner" create_partner: "Create the partner" @@ -2300,6 +2322,7 @@ en: stocks: "Stock:" internal: "Private stock" external: "Public stock" + edit: "Edit" all: "All types" remaining_stock: "Remaining stock" type_in: "Add" From 38aec6b1fd76b5a57c50b76ee92db2cec248d592 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 25 Jan 2023 10:09:24 +0100 Subject: [PATCH 04/23] (ui) Fix responsive --- .../components/plans/plan-limit-form.tsx | 32 +++++++++++-------- .../modules/plans/plan-limit-form.scss | 11 +++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index d5e41b05e..9d794a456 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -55,13 +55,15 @@ export const PlanLimitForm = ({ control, formState }:

{t('app.admin.plan_limit_form.by_categories')}

-
- {t('app.admin.plan_limit_form.category')} -

Plop

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

5

+
+
+ {t('app.admin.plan_limit_form.category')} +

Plop

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

5

+
@@ -80,13 +82,15 @@ export const PlanLimitForm = ({ control, formState }:

{t('app.admin.plan_limit_form.by_machine')}

-
- {t('app.admin.plan_limit_form.machine')} -

Pouet

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

5

+
+
+ {t('app.admin.plan_limit_form.machine')} +

Pouet

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

5

+
diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index 117e66eb2..7ba197435 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -58,6 +58,17 @@ .delete-btn {background: var(--main) } } } + + @media (min-width: 540px) { + .grp { + flex: 1; + display: flex; + justify-content: space-between; + & > * { + flex: 1; + } + } + } } } } From ffe6e5968151927bb79c331985d2864a90f8c97d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 6 Mar 2023 16:56:42 +0100 Subject: [PATCH 05/23] (test) fix running test Also: (quality) updated browserbd on caniuse (ui) improved help text --- .../components/plans/plan-pricing-form.tsx | 3 ++- config/locales/app.admin.en.yml | 1 + test/frontend/components/plans/plan-form.test.tsx | 8 ++++---- yarn.lock | 14 +++++--------- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx index 13d31f4e7..247b9b60b 100644 --- a/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-pricing-form.tsx @@ -95,12 +95,13 @@ export const PlanPricingForm = ({ register, control, fo

{t('app.admin.plan_pricing_form.prices')}

-

{t('app.admin.plan_pricing_form.copy_prices_from_help')}

+

{t('app.admin.plan_pricing_form.about_prices')}

{plans && } { { expect(screen.getByLabelText(/app.admin.plan_form.period/)).toBeInTheDocument(); expect(screen.getByLabelText(/app.admin.plan_form.partner_plan/)).toBeInTheDocument(); expect(screen.queryByTestId('plan-pricing-form')).toBeNull(); - expect(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /app.admin.plan_form.save/ })).toBeInTheDocument(); }); test('create new plan', async () => { @@ -66,7 +66,7 @@ describe('PlanForm', () => { // advanced_accounting_attributes.analytical_section fireEvent.change(screen.getByLabelText(/app.admin.advanced_accounting_form.analytical_section/), { target: { value: '9B20A' } }); // send the form - fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })); + fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.save/ })); await waitFor(() => { const expected: Plan = { base_name: 'Test Plan', @@ -119,7 +119,7 @@ describe('PlanForm', () => { expect(screen.getByLabelText(/app.admin.plan_form.notified_partner/)).toBeInTheDocument(); expect(screen.getByText(/app.admin.plan_form.alert_partner_notification/)).toBeInTheDocument(); expect(screen.getByTestId('plan-pricing-form')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /app.admin.plan_form.save/ })).toBeInTheDocument(); }); test('selecting transversal plan disables group select', async () => { @@ -162,7 +162,7 @@ describe('PlanForm', () => { // update machine price fireEvent.change(screen.getByLabelText(new RegExp(machine.name)), { target: { value: 42.42 } }); // send the form - fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })); + fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.save/ })); await waitFor(() => { const expected = { prices_attributes: expect.arrayContaining([{ diff --git a/yarn.lock b/yarn.lock index 2afdac520..0cdeb9026 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4664,15 +4664,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332: - version "1.0.30001397" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz" - integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA== - -caniuse-lite@^1.0.30001400: - version "1.0.30001439" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz#ab7371faeb4adff4b74dad1718a6fd122e45d9cb" - integrity sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001400: + version "1.0.30001460" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001460.tgz" + integrity sha512-Bud7abqjvEjipUkpLs4D7gR0l8hBYBHoa+tGtKJHvT2AYzLp1z7EmVkUT4ERpVUfca8S2HGIVs883D8pUH1ZzQ== chalk@4.1.1: version "4.1.1" @@ -8242,6 +8237,7 @@ object-keys@^1.0.12, object-keys@^1.1.1: integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== object-to-formdata-tz@4.4.3, "object-to-formdata@npm:object-to-formdata-tz@4.4.3": + name object-to-formdata version "4.4.3" resolved "https://registry.yarnpkg.com/object-to-formdata-tz/-/object-to-formdata-tz-4.4.3.tgz#3059059d0f02ce90c7fdd9d83f491e8af34707ae" integrity sha512-3XK2hDLCUAfpwatU6Jr3WzzF3ncmzScXPUiIOWgXdYwnxijCojqH41w3DdHRLoPs3MgUHzHBAtLVOFmSlaDWlQ== From 3f1f267e42eb263ceb4274416b25b3171585f1e2 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 7 Mar 2023 11:39:36 +0100 Subject: [PATCH 06/23] (bug) unable to select a new machine for an existing category --- app/frontend/src/javascript/components/plans/plan-limit-form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 9d794a456..70a3814e5 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -68,6 +68,7 @@ export const PlanLimitForm = ({ control, formState }:
+ {/* TODO, use */} From 65afcbe2a99c268d023d3e1d0d34b09036030b7e Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 7 Mar 2023 11:51:11 +0100 Subject: [PATCH 07/23] (quality) use EditDestroyButtons in product list --- .../components/store/product-item.tsx | 33 +++++++------------ .../javascript/components/store/products.tsx | 12 +++---- config/locales/app.admin.en.yml | 3 +- 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/app/frontend/src/javascript/components/store/product-item.tsx b/app/frontend/src/javascript/components/store/product-item.tsx index a8e252304..3f2d38bab 100644 --- a/app/frontend/src/javascript/components/store/product-item.tsx +++ b/app/frontend/src/javascript/components/store/product-item.tsx @@ -1,22 +1,23 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { FabButton } from '../base/fab-button'; import { Product } from '../../models/product'; -import { PencilSimple, Trash } from 'phosphor-react'; import noImage from '../../../../images/no_image.png'; import { FabStateLabel } from '../base/fab-state-label'; import { ProductPrice } from './product-price'; +import { EditDestroyButtons } from '../base/edit-destroy-buttons'; +import ProductAPI from '../../api/product'; interface ProductItemProps { product: Product, onEdit: (product: Product) => void, - onDelete: (productId: number) => void, + onDelete: (message: string) => void, + onError: (message: string) => void, } /** * This component shows a product item in the admin view */ -export const ProductItem: React.FC = ({ product, onEdit, onDelete }) => { +export const ProductItem: React.FC = ({ product, onEdit, onDelete, onError }) => { const { t } = useTranslation('admin'); /** @@ -34,15 +35,6 @@ export const ProductItem: React.FC = ({ product, onEdit, onDel }; }; - /** - * Init the process of delete the given product - */ - const deleteProduct = (productId: number): () => void => { - return (): void => { - onDelete(productId); - }; - }; - /** * Returns CSS class from stock status */ @@ -80,14 +72,13 @@ export const ProductItem: React.FC = ({ product, onEdit, onDel
-
- - - - - - -
+
); diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 2f94b9367..1f55aa2c5 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -111,14 +111,9 @@ const Products: React.FC = ({ onSuccess, onError, uiRouter }) => }; /** Delete a product */ - const deleteProduct = async (productId: number): Promise => { - try { - await ProductAPI.destroy(productId); - await fetchProducts(); - onSuccess(t('app.admin.store.products.successfully_deleted')); - } catch (e) { - onError(t('app.admin.store.products.unable_to_delete') + e); - } + const deleteProduct = async (message: string): Promise => { + await fetchProducts(); + onSuccess(message); }; /** Goto new product page */ @@ -244,6 +239,7 @@ const Products: React.FC = ({ onSuccess, onError, uiRouter }) => diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 6a3bd13e8..17bb94082 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -2244,8 +2244,6 @@ en: unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" - successfully_deleted: "The product has been successfully deleted" - unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" filter_apply: "Apply" @@ -2267,6 +2265,7 @@ en: sort: "Sort:" visible_only: "Visible products only" product_item: + product: "product" visible: "visible" hidden: "hidden" stock: From 0f142680b8b37f02f4b74169cba69f3e8e172bac Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 7 Mar 2023 17:35:21 +0100 Subject: [PATCH 08/23] (wip) configure usage limits for plans --- app/controllers/api/plans_controller.rb | 5 +- .../components/form/form-select.tsx | 6 +- .../components/form/form-unsaved-list.tsx | 57 ++++++++++++ .../javascript/components/plans/plan-form.tsx | 4 +- .../components/plans/plan-limit-form.tsx | 92 +++++++++++++++++-- .../components/plans/plan-limit-modal.tsx | 87 ++++++++++-------- app/frontend/src/javascript/models/plan.ts | 11 ++- .../modules/plans/plan-limit-form.scss | 28 +++++- app/models/machine.rb | 2 + app/models/machine_category.rb | 1 + app/models/plan.rb | 3 + app/models/plan_limitation.rb | 12 +++ app/views/api/plans/_plan.json.jbuilder | 4 + app/views/api/plans/show.json.jbuilder | 2 + config/locales/app.admin.en.yml | 4 + .../20230307123611_add_limiting_to_plan.rb | 8 ++ .../20230307123841_create_plan_limitations.rb | 14 +++ 17 files changed, 286 insertions(+), 54 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-unsaved-list.tsx create mode 100644 app/models/plan_limitation.rb create mode 100644 db/migrate/20230307123611_add_limiting_to_plan.rb create mode 100644 db/migrate/20230307123841_create_plan_limitations.rb diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index f5895b07d..927c136fc 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -80,11 +80,12 @@ class API::PlansController < API::ApiController end @parameters = @parameters.require(:plan) - .permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling, + .permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling, :limiting, :training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id, plan_file_attributes: %i[id attachment _destroy], prices_attributes: %i[id amount], - advanced_accounting_attributes: %i[code analytical_section]) + advanced_accounting_attributes: %i[code analytical_section], + plan_limitations_attributes: %i[limitable_id limitable_type limit]) end end end diff --git a/app/frontend/src/javascript/components/form/form-select.tsx b/app/frontend/src/javascript/components/form/form-select.tsx index ed62fb163..76512dd29 100644 --- a/app/frontend/src/javascript/components/form/form-select.tsx +++ b/app/frontend/src/javascript/components/form/form-select.tsx @@ -58,10 +58,10 @@ export const FormSelect = c.value === value)} + value={value === null ? null : options.find(c => c.value === value)} onChange={val => { - onChangeCb(val.value); - onChange(val.value); + onChangeCb(val?.value); + onChange(val?.value); }} placeholder={placeholder} isDisabled={isDisabled} diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx new file mode 100644 index 000000000..65fe050d7 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -0,0 +1,57 @@ +import { FieldArrayWithId, UseFieldArrayRemove } from 'react-hook-form/dist/types/fieldArray'; +import { UseFormRegister } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { useTranslation } from 'react-i18next'; +import { ReactNode } from 'react'; +import { X } from 'phosphor-react'; +import { FormInput } from './form-input'; +import { FieldArrayPath } from 'react-hook-form/dist/types/path'; + +interface FormUnsavedListProps, TKeyName extends string> { + fields: Array>, + remove: UseFieldArrayRemove, + register: UseFormRegister, + className?: string, + title: string, + shouldRenderField?: (field: FieldArrayWithId) => boolean, + formAttributeName: string, + renderFieldAttribute: (field: FieldArrayWithId, attribute: string) => ReactNode, +} + +/** + * This component render a list of unsaved attributes, created elsewhere than in the form (e.g. in a modal dialog) + * and pending for the form to be saved. + */ +export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField, formAttributeName, renderFieldAttribute }: FormUnsavedListProps) => { + const { t } = useTranslation('shared'); + + /** + * Render an unsaved field + */ + const renderUnsavedField = (field: FieldArrayWithId, index: number): ReactNode => { + return ( +
+ {Object.keys(field).map(attribute => ( +
+ {renderFieldAttribute(field, attribute)} + +
+ ))} +

remove(index)}> + {t('app.shared.form_unsaved_list.cancel')} + +

+
+ ); + }; + return ( +
+ {title} + {t('app.shared.form_unsaved_list.save_reminder')} + {fields.map((field, index) => { + if (typeof shouldRenderField === 'function' && !shouldRenderField(field)) return false; + return renderUnsavedField(field, index); + }).filter(Boolean)} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index 77b596868..082dcefce 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -326,7 +326,9 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc id: 'usageLimits', title: t('app.admin.plan_form.tab_usage_limits'), content: + register={register} + formState={formState} + onError={onError} /> } ]} /> diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 70a3814e5..4d32c1183 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -1,23 +1,45 @@ -import { useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { Control, FormState } from 'react-hook-form/dist/types/form'; import { FormSwitch } from '../form/form-switch'; import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; -import { PencilSimple, Trash } from 'phosphor-react'; +import { PencilSimple, Trash, X } from 'phosphor-react'; import { PlanLimitModal } from './plan-limit-modal'; +import { Plan, PlanLimitation } from '../../models/plan'; +import { useFieldArray, UseFormRegister } from 'react-hook-form'; +import { FormInput } from '../form/form-input'; +import { Machine } from '../../models/machine'; +import { MachineCategory } from '../../models/machine-category'; +import MachineAPI from '../../api/machine'; +import MachineCategoryAPI from '../../api/machine-category'; +import { FormUnsavedList } from '../form/form-unsaved-list'; interface PlanLimitFormProps { - control: Control, - formState: FormState + register: UseFormRegister, + control: Control, + formState: FormState, + onError: (message: string) => void, } /** * Form tab to manage a subscription's usage limit */ -export const PlanLimitForm = ({ control, formState }: PlanLimitFormProps) => { +export const PlanLimitForm = ({ register, control, formState, onError }: PlanLimitFormProps) => { const { t } = useTranslation('admin'); + const { fields, append, remove } = useFieldArray({ control, name: 'plan_limitations_attributes' }); const [isOpen, setIsOpen] = useState(false); + const [machines, setMachines] = useState>([]); + const [categories, setCategories] = useState>([]); + + useEffect(() => { + MachineAPI.index({ disabled: false }) + .then(setMachines) + .catch(onError); + MachineCategoryAPI.index() + .then(setCategories) + .catch(onError); + }, []); /** * Opens/closes the product stock edition modal @@ -26,6 +48,43 @@ export const PlanLimitForm = ({ control, formState }: setIsOpen(!isOpen); }; + /** + * Triggered when a new limit was added or an existing limit was modified + */ + const onPlanLimitSuccess = (planLimit: PlanLimitation): void => { + append({ ...planLimit }); + }; + + /** + * Render an attribute of an unsaved limitation of use + */ + const renderOngoingLimitAttribute = (limit: PlanLimitation, attribute: string): ReactNode => { + switch (attribute) { + case 'limitable_id': + if (limit.limitable_type === 'MachineCategory') { + return ( + <> + {t('app.admin.plan_limit_form.category')} +

{categories?.find(c => c.id === limit.limitable_id)?.name}

+ + ); + } + return ( + <> + {t('app.admin.plan_limit_form.machine')} +

{machines?.find(m => m.id === limit.limitable_id)?.name}

+ + ); + case 'limit': + return ( + <> + {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limit.limit}

+ + ); + } + }; + return (
@@ -38,7 +97,7 @@ export const PlanLimitForm = ({ control, formState }: formState={formState} defaultValue={false} label={t('app.admin.plan_limit_form.usage_limitation_switch')} - id="active_limitation" /> + id="limiting" />
@@ -80,6 +139,14 @@ export const PlanLimitForm = ({ control, formState }:
+ limit.limitable_type === 'MachineCategory'} + formAttributeName="plan_limitations_attributes" + renderFieldAttribute={renderOngoingLimitAttribute} /> +

{t('app.admin.plan_limit_form.by_machine')}

@@ -108,8 +175,19 @@ export const PlanLimitForm = ({ control, formState }:
+ limit.limitable_type === 'Machine'} + formAttributeName="plan_limitations_attributes" + renderFieldAttribute={renderOngoingLimitAttribute} /> + + machines={machines} + categories={categories} + toggleModal={toggleModal} + onSuccess={onPlanLimitSuccess} />
); }; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx index 42b54b2d9..d0be25345 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -2,51 +2,68 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { FabAlert } from '../base/fab-alert'; import { FabModal, ModalSize } from '../base/fab-modal'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { FormSelect } from '../form/form-select'; import { FormInput } from '../form/form-input'; +import { LimitableType, PlanLimitation } from '../../models/plan'; +import { Machine } from '../../models/machine'; +import { MachineCategory } from '../../models/machine-category'; +import { SelectOption } from '../../models/select'; +import { FabButton } from '../base/fab-button'; -type typeSelectOption = { value: any, label: string }; interface PlanLimitModalProps { isOpen: boolean, toggleModal: () => void, + onSuccess: (limit: PlanLimitation) => void, + machines: Array + categories: Array } /** * Form to manage subscriptions limitations of use */ -export const PlanLimitModal: React.FC = ({ isOpen, toggleModal }) => { +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess }) => { const { t } = useTranslation('admin'); - const { register, control, formState } = useForm(); - const [limitType, setLimitType] = React.useState<'categories' | 'machine'>('categories'); + const { register, control, formState, setValue, handleSubmit } = useForm({ defaultValues: { limitable_type: 'MachineCategory' } }); + const limitType = useWatch({ control, name: 'limitable_type' }); /** * Toggle the form between 'categories' and 'machine' */ - const toggleLimitType = (evt: React.MouseEvent, type: 'categories' | 'machine') => { + const toggleLimitType = (evt: React.MouseEvent, type: LimitableType) => { evt.preventDefault(); - setLimitType(type); + setValue('limitable_type', type); + setValue('limitable_id', null); }; /** - * Creates options to the react-select format + * Callback triggered when the user validates the new limit. + * We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form */ - const buildMachinesCategoriesOptions = (): Array => { - return [ - { value: '0', label: 'yep' }, - { value: '1', label: 'nope' } - ]; + const onSubmit = (event: React.FormEvent) => { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + return handleSubmit((data: PlanLimitation) => { + onSuccess(data); + toggleModal(); + })(event); }; /** * Creates options to the react-select format */ - const buildMachinesOptions = (): Array => { - return [ - { value: '0', label: 'pif' }, - { value: '1', label: 'paf' }, - { value: '2', label: 'pouf' } - ]; + const buildOptions = (): Array> => { + if (limitType === 'MachineCategory') { + return categories.map(cat => { + return { value: cat.id, label: cat.name }; + }); + } else { + return machines.map(machine => { + return { value: machine.id, label: machine.name }; + }); + } }; return ( @@ -55,43 +72,35 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo isOpen={isOpen} toggleModal={toggleModal} closeButton> -
+

{t('app.admin.plan_limit_modal.limit_reservations')}

- -
- {limitType === 'categories' && <> - {t('app.admin.plan_limit_modal.categories_info')} - - } - {limitType === 'machine' && <> {t('app.admin.plan_limit_modal.machine_info')} - + - } - + {t('app.admin.plan_limit_modal.confirm')} ); diff --git a/app/frontend/src/javascript/models/plan.ts b/app/frontend/src/javascript/models/plan.ts index 6b7656521..8c469610a 100644 --- a/app/frontend/src/javascript/models/plan.ts +++ b/app/frontend/src/javascript/models/plan.ts @@ -12,6 +12,14 @@ export interface Partner { email: string } +export type LimitableType = 'Machine'|'MachineCategory'; +export interface PlanLimitation { + id?: number, + limitable_id: number, + limitable_type: LimitableType, + limit: number +} + export interface Plan { id?: number, base_name: string, @@ -35,7 +43,8 @@ export interface Plan { partner_id?: number, partnership?: boolean, partners?: Array, - advanced_accounting_attributes?: AdvancedAccounting + advanced_accounting_attributes?: AdvancedAccounting, + plan_limitations_attributes: Array } export interface PlansDuration { diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index 7ba197435..e0e65746c 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -5,6 +5,32 @@ section { @include layout-settings; } + .ongoing-limits { + margin: 2.4rem 0; + .save-notice { + @include text-xs; + margin-left: 1rem; + color: var(--alert); + } + .unsaved-plan-limit { + background-color: var(--gray-soft-light); + border: 0; + padding: 1.2rem; + margin-top: 1rem; + + .cancel-action { + &:hover { + text-decoration: underline; + cursor: pointer; + } + svg { + margin-left: 1rem; + vertical-align: middle; + } + } + } + } + .plan-limit-grp { header { @include header(); @@ -72,4 +98,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/models/machine.rb b/app/models/machine.rb index bd4e71854..38577b474 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -42,6 +42,8 @@ class Machine < ApplicationRecord belongs_to :machine_category + has_many :plan_limitations, dependent: :destroy + after_create :create_statistic_subtype after_create :create_machine_prices after_create :update_gateway_product diff --git a/app/models/machine_category.rb b/app/models/machine_category.rb index 9fe2e9de0..78ebba508 100644 --- a/app/models/machine_category.rb +++ b/app/models/machine_category.rb @@ -4,4 +4,5 @@ class MachineCategory < ApplicationRecord has_many :machines, dependent: :nullify accepts_nested_attributes_for :machines, allow_destroy: true + has_many :plan_limitations, dependent: :destroy end diff --git a/app/models/plan.rb b/app/models/plan.rb index d3b8b6e39..0a508f8e2 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -21,6 +21,9 @@ class Plan < ApplicationRecord has_many :cart_item_subscriptions, class_name: 'CartItem::Subscription', dependent: :destroy has_many :cart_item_payment_schedules, class_name: 'CartItem::PaymentSchedule', dependent: :destroy + has_many :plan_limitations, dependent: :destroy + accepts_nested_attributes_for :plan_limitations, allow_destroy: true + extend FriendlyId friendly_id :base_name, use: :slugged diff --git a/app/models/plan_limitation.rb b/app/models/plan_limitation.rb new file mode 100644 index 000000000..29556e611 --- /dev/null +++ b/app/models/plan_limitation.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Allows to set booking limits on some resources, per plan. +class PlanLimitation < ApplicationRecord + belongs_to :plan + + belongs_to :limitable, polymorphic: true + belongs_to :machine, foreign_type: 'Machine', foreign_key: 'limitable_id', inverse_of: :plan_limitations + belongs_to :machine_category, foreign_type: 'MachineCategory', foreign_key: 'limitable_id', inverse_of: :plan_limitations + + validates :limitable_id, :limitable_type, :limit, presence: true +end diff --git a/app/views/api/plans/_plan.json.jbuilder b/app/views/api/plans/_plan.json.jbuilder index ddffd567c..5eea29ed3 100644 --- a/app/views/api/plans/_plan.json.jbuilder +++ b/app/views/api/plans/_plan.json.jbuilder @@ -27,3 +27,7 @@ if plan.advanced_accounting end end +json.plan_limitations_attributes plan.plan_limitations do |limitation| + json.extract! limitation, :id, :limitable_id, :limitable_type, :limit +end + diff --git a/app/views/api/plans/show.json.jbuilder b/app/views/api/plans/show.json.jbuilder index 799730b4d..edba9aaf4 100644 --- a/app/views/api/plans/show.json.jbuilder +++ b/app/views/api/plans/show.json.jbuilder @@ -1 +1,3 @@ +# frozen_string_literal: true + json.partial! 'api/plans/plan', plan: @plan diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 17bb94082..fbf3d0ac5 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -206,6 +206,9 @@ en: category: "Machines category" machine: "Machine name" max_hours_per_day: "Max. hours/day" + ongoing_limit: "Ongoing settings" + save_reminder: "Don't forget to save your settings" + cancel: "Cancel this limitation" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" @@ -216,6 +219,7 @@ en: categories_info: "If you select all machine categories, the limits will apply across the board. Please note that if you have already created limitations for specific categories, these will be permanently overwritten." machine_info: "If you select all machines, the limits will apply across the board. Please note that if you have already created limitations for machines, these will be permanently overwritten." max_hours_per_day: "Maximum number of reservation hours per day" + confirm: "Confirm" partner_modal: title: "Create a new partner" create_partner: "Create the partner" diff --git a/db/migrate/20230307123611_add_limiting_to_plan.rb b/db/migrate/20230307123611_add_limiting_to_plan.rb new file mode 100644 index 000000000..5fd95b875 --- /dev/null +++ b/db/migrate/20230307123611_add_limiting_to_plan.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# From this migration, any subscription plan can define restrictions on the reservation of resources +class AddLimitingToPlan < ActiveRecord::Migration[5.2] + def change + add_column :plans, :limiting, :boolean + end +end diff --git a/db/migrate/20230307123841_create_plan_limitations.rb b/db/migrate/20230307123841_create_plan_limitations.rb new file mode 100644 index 000000000..be02a1546 --- /dev/null +++ b/db/migrate/20230307123841_create_plan_limitations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# This table saves the restrictions settings, per plan and resource +class CreatePlanLimitations < ActiveRecord::Migration[5.2] + def change + create_table :plan_limitations do |t| + t.references :plan, foreign_key: true, index: true + t.references :limitable, polymorphic: true + t.integer :limit, null: false, default: 0 + + t.timestamps + end + end +end From 2b8a7008bda3670685f11a2aa205f075b2f4a53b Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 9 Mar 2023 15:47:12 +0100 Subject: [PATCH 09/23] (quality) use a single component for unsaved list --- .../components/form/form-unsaved-list.tsx | 30 +++++---- .../components/plans/plan-limit-form.tsx | 58 ++++++++--------- .../components/store/product-stock-form.tsx | 63 ++++++++++--------- app/frontend/src/stylesheets/application.scss | 1 + .../modules/form/form-unsaved-list.scss | 47 ++++++++++++++ .../modules/plans/plan-limit-form.scss | 30 +-------- .../modules/store/product-stock-form.scss | 22 ------- config/locales/app.admin.en.yml | 3 +- config/locales/app.shared.en.yml | 3 + 9 files changed, 131 insertions(+), 126 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx index 65fe050d7..e96675f60 100644 --- a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -2,7 +2,7 @@ import { FieldArrayWithId, UseFieldArrayRemove } from 'react-hook-form/dist/type import { UseFormRegister } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { useTranslation } from 'react-i18next'; -import { ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { X } from 'phosphor-react'; import { FormInput } from './form-input'; import { FieldArrayPath } from 'react-hook-form/dist/types/path'; @@ -14,15 +14,18 @@ interface FormUnsavedListProps) => boolean, - formAttributeName: string, - renderFieldAttribute: (field: FieldArrayWithId, attribute: string) => ReactNode, + renderField: (field: FieldArrayWithId) => ReactNode, + formAttributeName: `${string}_attributes`, + formAttributes: Array>, + saveReminderLabel?: string | ReactNode, + cancelLabel?: string | ReactNode } /** * This component render a list of unsaved attributes, created elsewhere than in the form (e.g. in a modal dialog) * and pending for the form to be saved. */ -export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField, formAttributeName, renderFieldAttribute }: FormUnsavedListProps) => { +export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { const { t } = useTranslation('shared'); /** @@ -31,25 +34,26 @@ export const FormUnsavedList = , index: number): ReactNode => { return (
- {Object.keys(field).map(attribute => ( -
- {renderFieldAttribute(field, attribute)} - -
- ))} + {renderField(field)}

remove(index)}> - {t('app.shared.form_unsaved_list.cancel')} + {cancelLabel || t('app.shared.form_unsaved_list.cancel')}

+ {formAttributes.map((attribute, attrIndex) => ( + + ))}
); }; + + if (fields.filter(shouldRenderField).length === 0) return null; + return (
{title} - {t('app.shared.form_unsaved_list.save_reminder')} + {saveReminderLabel || t('app.shared.form_unsaved_list.save_reminder')} {fields.map((field, index) => { - if (typeof shouldRenderField === 'function' && !shouldRenderField(field)) return false; + if (!shouldRenderField(field)) return false; return renderUnsavedField(field, index); }).filter(Boolean)}
diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 4d32c1183..be5e1220a 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -3,11 +3,10 @@ import { Control, FormState } from 'react-hook-form/dist/types/form'; import { FormSwitch } from '../form/form-switch'; import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; -import { PencilSimple, Trash, X } from 'phosphor-react'; +import { PencilSimple, Trash } from 'phosphor-react'; import { PlanLimitModal } from './plan-limit-modal'; import { Plan, PlanLimitation } from '../../models/plan'; import { useFieldArray, UseFormRegister } from 'react-hook-form'; -import { FormInput } from '../form/form-input'; import { Machine } from '../../models/machine'; import { MachineCategory } from '../../models/machine-category'; import MachineAPI from '../../api/machine'; @@ -56,34 +55,24 @@ export const PlanLimitForm = ({ register, control, for }; /** - * Render an attribute of an unsaved limitation of use + * Render an unsaved limitation of use */ - const renderOngoingLimitAttribute = (limit: PlanLimitation, attribute: string): ReactNode => { - switch (attribute) { - case 'limitable_id': - if (limit.limitable_type === 'MachineCategory') { - return ( - <> - {t('app.admin.plan_limit_form.category')} -

{categories?.find(c => c.id === limit.limitable_id)?.name}

- - ); - } - return ( - <> - {t('app.admin.plan_limit_form.machine')} -

{machines?.find(m => m.id === limit.limitable_id)?.name}

- - ); - case 'limit': - return ( - <> - {t('app.admin.plan_limit_form.max_hours_per_day')} -

{limit.limit}

- - ); - } - }; + const renderOngoingLimit = (limit: PlanLimitation): ReactNode => ( + <> + {(limit.limitable_type === 'MachineCategory' &&
+ {t('app.admin.plan_limit_form.category')} +

{categories?.find(c => c.id === limit.limitable_id)?.name}

+
) || +
+ {t('app.admin.plan_limit_form.machine')} +

{machines?.find(m => m.id === limit.limitable_id)?.name}

+
} +
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limit.limit}

+
+ + ); return (
@@ -142,10 +131,12 @@ export const PlanLimitForm = ({ register, control, for limit.limitable_type === 'MachineCategory'} formAttributeName="plan_limitations_attributes" - renderFieldAttribute={renderOngoingLimitAttribute} /> + formAttributes={['limitable_id', 'limit']} + renderField={renderOngoingLimit} + cancelLabel={t('app.admin.plan_limit_form.cancel')} />

{t('app.admin.plan_limit_form.by_machine')}

@@ -181,7 +172,10 @@ export const PlanLimitForm = ({ register, control, for title={t('app.admin.plan_limit_form.ongoing_limit')} shouldRenderField={(limit: PlanLimitation) => limit.limitable_type === 'Machine'} formAttributeName="plan_limitations_attributes" - renderFieldAttribute={renderOngoingLimitAttribute} /> + formAttributes={['limitable_id', 'limit']} + renderField={renderOngoingLimit} + saveReminderLabel={t('app.admin.plan_limit_form.save_reminder')} + cancelLabel={t('app.admin.plan_limit_form.cancel')} /> { currentFormValues: Product, @@ -159,6 +160,25 @@ export const ProductStockForm = ({ currentFormValues, } }; + /** + * Render an attribute of an unsaved stock movement + */ + const renderOngoingStockMovement = (movement: ProductStockMovement): ReactNode => ( + <> +
+

{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(movement.reason)}`)}

+
+
+ {t(`app.admin.store.product_stock_form.${movement.stock_type}`)} +

{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}

+
+
+ {t('app.admin.store.product_stock_form.reason')} +

{t(ProductLib.stockMovementReasonTrKey(movement.reason))}

+
+ + ); + return (

{t('app.admin.store.product_stock_form.stock_up_to_date')}  @@ -181,33 +201,16 @@ export const ProductStockForm = ({ currentFormValues, } className="is-black">{t('app.admin.store.product_stock_form.edit')}

- {fields.length > 0 &&
- {t('app.admin.store.product_stock_form.ongoing_operations')} - {t('app.admin.store.product_stock_form.save_reminder')} - {fields.map((newMovement, index) => ( -
-
-

{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}

-
-
- {t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)} -

{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}

-
-
- {t('app.admin.store.product_stock_form.reason')} -

{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}

-
-

remove(index)}> - {t('app.admin.store.product_stock_form.cancel')} - -

- - - -
- ))} -
} +
diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index f89015592..b70e769c6 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -62,6 +62,7 @@ @import "modules/form/form-checklist"; @import "modules/form/form-file-upload"; @import "modules/form/form-image-upload"; +@import "modules/form/form-unsaved-list"; @import "modules/group/change-group"; @import "modules/invoices/invoices-settings-panel"; @import "modules/invoices/vat-settings-modal"; diff --git a/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss b/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss new file mode 100644 index 000000000..f7b1a37dc --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-unsaved-list.scss @@ -0,0 +1,47 @@ +.form-unsaved-list { + .save-notice { + @include text-xs; + margin-left: 1rem; + color: var(--alert); + } + .unsaved-field { + background-color: var(--gray-soft-light); + border: 0; + padding: 1.2rem; + margin-top: 1rem;width: 100%; + display: flex; + gap: 4.8rem; + justify-items: flex-start; + align-items: center; + border-radius: var(--border-radius); + + & > * { flex: 1 1 45%; } + + p { + margin: 0; + @include text-base; + } + .title { + @include text-base(600); + flex: 1 1 100%; + } + .group { + span { + @include text-xs; + color: var(--gray-hard-light); + } + p { @include text-base(600); } + } + + .cancel-action { + &:hover { + text-decoration: underline; + cursor: pointer; + } + svg { + margin-left: 1rem; + vertical-align: middle; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index e0e65746c..273f8093b 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -5,32 +5,6 @@ section { @include layout-settings; } - .ongoing-limits { - margin: 2.4rem 0; - .save-notice { - @include text-xs; - margin-left: 1rem; - color: var(--alert); - } - .unsaved-plan-limit { - background-color: var(--gray-soft-light); - border: 0; - padding: 1.2rem; - margin-top: 1rem; - - .cancel-action { - &:hover { - text-decoration: underline; - cursor: pointer; - } - svg { - margin-left: 1rem; - vertical-align: middle; - } - } - } - } - .plan-limit-grp { header { @include header(); @@ -39,9 +13,11 @@ margin: 0; } } + .form-unsaved-list { + margin-bottom: 6.4rem; + } .plan-limit-list { max-height: 65vh; - margin-bottom: 6.4rem; display: flex; flex-direction: column; overflow-y: auto; diff --git a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss index 5290c7a80..1e9a2bd78 100644 --- a/app/frontend/src/stylesheets/modules/store/product-stock-form.scss +++ b/app/frontend/src/stylesheets/modules/store/product-stock-form.scss @@ -3,28 +3,6 @@ .ongoing-stocks { margin: 2.4rem 0; - .save-notice { - @include text-xs; - margin-left: 1rem; - color: var(--alert); - } - .unsaved-stock-movement { - background-color: var(--gray-soft-light); - border: 0; - padding: 1.2rem; - margin-top: 1rem; - - .cancel-action { - &:hover { - text-decoration: underline; - cursor: pointer; - } - svg { - margin-left: 1rem; - vertical-align: middle; - } - } - } } .store-list { margin-top: 2.4rem; diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index fbf3d0ac5..c523ca492 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -206,8 +206,7 @@ en: category: "Machines category" machine: "Machine name" max_hours_per_day: "Max. hours/day" - ongoing_limit: "Ongoing settings" - save_reminder: "Don't forget to save your settings" + ongoing_limitations: "Ongoing limitations" cancel: "Cancel this limitation" plan_limit_modal: title: "Manage limitation of use" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 18a3da175..92ecc9b37 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -540,3 +540,6 @@ en: show_reserved_uniq: "Show only slots with reservations" machine: machine_uncategorized: "Uncategorized machines" + form_unsaved_list: + save_reminder: "Do not forget to save your changes" + cancel: "Cancel" From dad3babbe46ac61695206bda46d515b92a527483 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 9 Mar 2023 15:57:46 +0100 Subject: [PATCH 10/23] (ui) add alert on leaving plan form with unsaved changes --- .../src/javascript/components/plans/plan-form.tsx | 8 ++++++-- .../src/javascript/controllers/admin/plans.js | 14 ++++++++++---- app/frontend/src/javascript/models/plan.ts | 2 +- app/frontend/templates/admin/plans/edit.html | 2 +- app/frontend/templates/admin/plans/new.html | 2 +- test/frontend/components/plans/plan-form.test.tsx | 15 ++++++++------- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index 082dcefce..a41731269 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -26,6 +26,8 @@ import { PlanPricingForm } from './plan-pricing-form'; import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form'; import { FabTabs } from '../base/fab-tabs'; import { PlanLimitForm } from './plan-limit-form'; +import { UnsavedFormAlert } from '../form/unsaved-form-alert'; +import { UIRouter } from '@uirouter/angularjs'; declare const Application: IApplication; @@ -35,12 +37,13 @@ interface PlanFormProps { onError: (message: string) => void, onSuccess: (message: string) => void, beforeSubmit?: (data: Plan) => void, + uiRouter: UIRouter } /** * Form to edit or create subscription plans */ -export const PlanForm: React.FC = ({ action, plan, onError, onSuccess, beforeSubmit }) => { +export const PlanForm: React.FC = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => { const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...plan } }); const output = useWatch({ control }); // eslint-disable-line const { t } = useTranslation('admin'); @@ -316,6 +319,7 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc
+ = (props) => { ); }; -Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess'])); +Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess', 'uiRouter'])); diff --git a/app/frontend/src/javascript/controllers/admin/plans.js b/app/frontend/src/javascript/controllers/admin/plans.js index ab80d0824..5ef815c29 100644 --- a/app/frontend/src/javascript/controllers/admin/plans.js +++ b/app/frontend/src/javascript/controllers/admin/plans.js @@ -21,11 +21,14 @@ /** * Controller used in the plan creation form */ -Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', 'planCategories', - function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, planCategories) { +Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', '$uiRouter', + function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, $uiRouter) { // protection against request forgery CSRF.setMetaTags(); + // the following item is used by the UnsavedFormAlert component to detect a page change + $scope.uiRouter = $uiRouter; + /** * Shows an error message forwarded from a child component */ @@ -46,13 +49,16 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', /** * Controller used in the plan edition form */ -Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', 'Plan', 'planCategories', - function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, Plan, planCategories) { +Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', '$uiRouter', + function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, $uiRouter) { // protection against request forgery CSRF.setMetaTags(); $scope.suscriptionPlan = cleanPlan(planPromise); + // the following item is used by the UnsavedFormAlert component to detect a page change + $scope.uiRouter = $uiRouter; + /** * Shows an error message forwarded from a child component */ diff --git a/app/frontend/src/javascript/models/plan.ts b/app/frontend/src/javascript/models/plan.ts index 8c469610a..a93b07059 100644 --- a/app/frontend/src/javascript/models/plan.ts +++ b/app/frontend/src/javascript/models/plan.ts @@ -44,7 +44,7 @@ export interface Plan { partnership?: boolean, partners?: Array, advanced_accounting_attributes?: AdvancedAccounting, - plan_limitations_attributes: Array + plan_limitations_attributes?: Array } export interface PlansDuration { diff --git a/app/frontend/templates/admin/plans/edit.html b/app/frontend/templates/admin/plans/edit.html index 161d1bd59..9ad6077a8 100644 --- a/app/frontend/templates/admin/plans/edit.html +++ b/app/frontend/templates/admin/plans/edit.html @@ -13,4 +13,4 @@
- + diff --git a/app/frontend/templates/admin/plans/new.html b/app/frontend/templates/admin/plans/new.html index a5cddf0f6..7315fe38b 100644 --- a/app/frontend/templates/admin/plans/new.html +++ b/app/frontend/templates/admin/plans/new.html @@ -14,4 +14,4 @@
- + diff --git a/test/frontend/components/plans/plan-form.test.tsx b/test/frontend/components/plans/plan-form.test.tsx index 66af009ff..1ef115d8d 100644 --- a/test/frontend/components/plans/plan-form.test.tsx +++ b/test/frontend/components/plans/plan-form.test.tsx @@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event'; import plans from '../../__fixtures__/plans'; import machines from '../../__fixtures__/machines'; import { tiptapEvent } from '../../__lib__/tiptap'; +import { uiRouter } from '../../__lib__/ui-router'; describe('PlanForm', () => { const onError = jest.fn(); @@ -14,7 +15,7 @@ describe('PlanForm', () => { const beforeSubmit = jest.fn(); test('render create PlanForm', async () => { - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ })); expect(screen.getByLabelText(/app.admin.plan_form.name/)).toBeInTheDocument(); expect(screen.getByLabelText(/app.admin.plan_form.transversal/)).toBeInTheDocument(); @@ -35,7 +36,7 @@ describe('PlanForm', () => { }); test('create new plan', async () => { - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ })); const user = userEvent.setup(); // base_name @@ -98,7 +99,7 @@ describe('PlanForm', () => { test('render update PlanForm with partner', async () => { const plan = plans[1]; - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_pricing_form.copy_prices_from/ })); expect(screen.getByLabelText(/app.admin.plan_form.name/)).toBeInTheDocument(); expect(screen.queryByLabelText(/app.admin.plan_form.transversal/)).toBeNull(); @@ -123,14 +124,14 @@ describe('PlanForm', () => { }); test('selecting transversal plan disables group select', async () => { - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ })); fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.transversal/ })); expect(screen.queryByRole('combobox', { name: /app.admin.plan_form.group/ })).toBeNull(); }); test('selecting partner plan shows partner selection', async () => { - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ })); fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.partner_plan/ })); expect(screen.getByLabelText(/app.admin.plan_form.notified_partner/)); @@ -138,7 +139,7 @@ describe('PlanForm', () => { }); test('creating a new partner selects him by default', async () => { - render(); + render(); await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ })); fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.partner_plan/ })); fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.new_user/ })); @@ -157,7 +158,7 @@ describe('PlanForm', () => { test('update plan prices', async () => { const plan = plans[1]; const machine = machines[1]; - render(); + render(); await waitFor(() => screen.getByLabelText(new RegExp(machine.name))); // update machine price fireEvent.change(screen.getByLabelText(new RegExp(machine.name)), { target: { value: 42.42 } }); From 622a14909a9100e1a99578f546dea77b9d671f96 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 9 Mar 2023 16:57:03 +0100 Subject: [PATCH 11/23] (feat) save new limitations --- app/controllers/api/plans_controller.rb | 2 +- .../components/plans/plan-limit-form.tsx | 147 +++++++++--------- .../components/plans/plan-limit-modal.tsx | 2 +- app/frontend/src/javascript/models/plan.ts | 4 +- .../modules/plans/plan-limit-form.scss | 2 +- app/models/plan_limitation.rb | 3 +- app/views/api/plans/_plan.json.jbuilder | 2 +- .../20230307123841_create_plan_limitations.rb | 6 +- db/schema.rb | 7 +- 9 files changed, 93 insertions(+), 82 deletions(-) diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index 927c136fc..4e1a04c0a 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -85,7 +85,7 @@ class API::PlansController < API::ApiController plan_file_attributes: %i[id attachment _destroy], prices_attributes: %i[id amount], advanced_accounting_attributes: %i[code analytical_section], - plan_limitations_attributes: %i[limitable_id limitable_type limit]) + plan_limitations_attributes: %i[id limitable_id limitable_type limit]) end end end diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index be5e1220a..c2f288ad6 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -6,7 +6,7 @@ import { FabButton } from '../base/fab-button'; import { PencilSimple, Trash } from 'phosphor-react'; import { PlanLimitModal } from './plan-limit-modal'; import { Plan, PlanLimitation } from '../../models/plan'; -import { useFieldArray, UseFormRegister } from 'react-hook-form'; +import { useFieldArray, UseFormRegister, useWatch } from 'react-hook-form'; import { Machine } from '../../models/machine'; import { MachineCategory } from '../../models/machine-category'; import MachineAPI from '../../api/machine'; @@ -26,6 +26,7 @@ interface PlanLimitFormProps { export const PlanLimitForm = ({ register, control, formState, onError }: PlanLimitFormProps) => { const { t } = useTranslation('admin'); const { fields, append, remove } = useFieldArray({ control, name: 'plan_limitations_attributes' }); + const limiting = useWatch({ control, name: 'limiting' }); const [isOpen, setIsOpen] = useState(false); const [machines, setMachines] = useState>([]); @@ -90,7 +91,7 @@ export const PlanLimitForm = ({ register, control, for
-
+ {limiting &&

{t('app.admin.plan_limit_form.all_limitations')}

@@ -100,82 +101,86 @@ export const PlanLimitForm = ({ register, control, for
-
-

{t('app.admin.plan_limit_form.by_categories')}

-
-
-
- {t('app.admin.plan_limit_form.category')} -

Plop

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

5

-
-
+ {fields.filter(f => f.limitable_type === 'MachineCategory').length > 0 && +
+

{t('app.admin.plan_limit_form.by_categories')}

+ {fields.filter(f => f.limitable_type === 'MachineCategory' && !f.modified).map(limitation => ( +
+
+
+ {t('app.admin.plan_limit_form.category')} +

{categories.find(c => c.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
-
-
- {/* TODO, use */} - - - - - - +
+
+ + + + + + +
+
-
+ ))} + limit.limitable_type === 'MachineCategory' && limit.modified} + formAttributeName="plan_limitations_attributes" + formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} + renderField={renderOngoingLimit} + cancelLabel={t('app.admin.plan_limit_form.cancel')} />
-
+ } - limit.limitable_type === 'MachineCategory'} - formAttributeName="plan_limitations_attributes" - formAttributes={['limitable_id', 'limit']} - renderField={renderOngoingLimit} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + {fields.filter(f => f.limitable_type === 'Machine').length > 0 && +
+

{t('app.admin.plan_limit_form.by_machine')}

+ {fields.filter(f => f.limitable_type === 'Machine' && !f.modified).map(limitation => ( +
+
+
+ {t('app.admin.plan_limit_form.machine')} +

{machines.find(m => m.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
-
-

{t('app.admin.plan_limit_form.by_machine')}

-
-
-
- {t('app.admin.plan_limit_form.machine')} -

Pouet

+
+
+ + + + + + +
+
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

5

-
-
- -
-
- - - - - - -
-
+ ))} + limit.limitable_type === 'Machine' && limit.modified} + formAttributeName="plan_limitations_attributes" + formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} + renderField={renderOngoingLimit} + cancelLabel={t('app.admin.plan_limit_form.cancel')} />
-
-
- - limit.limitable_type === 'Machine'} - formAttributeName="plan_limitations_attributes" - formAttributes={['limitable_id', 'limit']} - renderField={renderOngoingLimit} - saveReminderLabel={t('app.admin.plan_limit_form.save_reminder')} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + } +
} = ({ isOpen, toggleMo event.preventDefault(); } return handleSubmit((data: PlanLimitation) => { - onSuccess(data); + onSuccess({ ...data, modified: true }); toggleModal(); })(event); }; diff --git a/app/frontend/src/javascript/models/plan.ts b/app/frontend/src/javascript/models/plan.ts index a93b07059..8a0723dbb 100644 --- a/app/frontend/src/javascript/models/plan.ts +++ b/app/frontend/src/javascript/models/plan.ts @@ -17,7 +17,8 @@ export interface PlanLimitation { id?: number, limitable_id: number, limitable_type: LimitableType, - limit: number + limit: number, + modified?: boolean, } export interface Plan { @@ -42,6 +43,7 @@ export interface Plan { plan_file_url?: string, partner_id?: number, partnership?: boolean, + limiting?: boolean, partners?: Array, advanced_accounting_attributes?: AdvancedAccounting, plan_limitations_attributes?: Array diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index 273f8093b..aff0b4cff 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -22,7 +22,7 @@ flex-direction: column; overflow-y: auto; - .title { @include text-base(500); } + & > .title { @include text-base(500); } .plan-limit-item { width: 100%; margin-bottom: 2.4rem; diff --git a/app/models/plan_limitation.rb b/app/models/plan_limitation.rb index 29556e611..be3e68b30 100644 --- a/app/models/plan_limitation.rb +++ b/app/models/plan_limitation.rb @@ -8,5 +8,6 @@ class PlanLimitation < ApplicationRecord belongs_to :machine, foreign_type: 'Machine', foreign_key: 'limitable_id', inverse_of: :plan_limitations belongs_to :machine_category, foreign_type: 'MachineCategory', foreign_key: 'limitable_id', inverse_of: :plan_limitations - validates :limitable_id, :limitable_type, :limit, presence: true + validates :limitable_id, :limitable_type, :limit, :plan_id, presence: true + validates :plan_id, :limitable_id, :limitable_type, uniqueness: true end diff --git a/app/views/api/plans/_plan.json.jbuilder b/app/views/api/plans/_plan.json.jbuilder index 5eea29ed3..095eff843 100644 --- a/app/views/api/plans/_plan.json.jbuilder +++ b/app/views/api/plans/_plan.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type, - :ui_weight, :disabled, :monthly_payment, :plan_category_id + :ui_weight, :disabled, :monthly_payment, :plan_category_id, :limiting json.amount plan.amount / 100.00 json.prices_attributes plan.prices, partial: 'api/prices/price', as: :price if plan.plan_file diff --git a/db/migrate/20230307123841_create_plan_limitations.rb b/db/migrate/20230307123841_create_plan_limitations.rb index be02a1546..9b7e2a3d2 100644 --- a/db/migrate/20230307123841_create_plan_limitations.rb +++ b/db/migrate/20230307123841_create_plan_limitations.rb @@ -4,11 +4,13 @@ class CreatePlanLimitations < ActiveRecord::Migration[5.2] def change create_table :plan_limitations do |t| - t.references :plan, foreign_key: true, index: true - t.references :limitable, polymorphic: true + t.references :plan, foreign_key: true, index: true, null: false + t.references :limitable, polymorphic: true, null: false t.integer :limit, null: false, default: 0 t.timestamps end + + add_index :plan_limitations, %i[plan_id limitable_id limitable_type], unique: true, name: 'index_plan_limitations_on_plan_and_limitable' end end diff --git a/db/schema.rb b/db/schema.rb index a0ac87761..7555d7cf7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -740,13 +740,14 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do end create_table "plan_limitations", force: :cascade do |t| - t.bigint "plan_id" - t.string "limitable_type" - t.bigint "limitable_id" + t.bigint "plan_id", null: false + t.string "limitable_type", null: false + t.bigint "limitable_id", null: false t.integer "limit", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["limitable_type", "limitable_id"], name: "index_plan_limitations_on_limitable_type_and_limitable_id" + t.index ["plan_id", "limitable_id", "limitable_type"], name: "index_plan_limitations_on_plan_and_limitable", unique: true t.index ["plan_id"], name: "index_plan_limitations_on_plan_id" end From 6abea031823e3984c44a08fb83ddcc55bea86fd4 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 10 Mar 2023 14:13:00 +0100 Subject: [PATCH 12/23] (feat) update limitations --- .../api/plan_limitations_controller.rb | 12 ++ app/controllers/api/plans_controller.rb | 2 +- .../src/javascript/api/plan-limitation.ts | 9 + .../components/base/edit-destroy-buttons.tsx | 9 +- .../components/form/form-unsaved-list.tsx | 8 +- .../javascript/components/plans/plan-form.tsx | 7 +- .../components/plans/plan-limit-form.tsx | 188 +++++++++++------- .../components/plans/plan-limit-modal.tsx | 16 +- .../components/store/product-stock-form.tsx | 2 +- app/frontend/src/javascript/models/plan.ts | 3 +- .../modules/plans/plan-limit-form.scss | 13 -- app/models/plan_limitation.rb | 2 +- app/policies/plan_limitation_policy.rb | 8 + config/locales/app.admin.en.yml | 2 + config/routes.rb | 1 + package.json | 2 +- yarn.lock | 8 +- 17 files changed, 185 insertions(+), 107 deletions(-) create mode 100644 app/controllers/api/plan_limitations_controller.rb create mode 100644 app/frontend/src/javascript/api/plan-limitation.ts create mode 100644 app/policies/plan_limitation_policy.rb diff --git a/app/controllers/api/plan_limitations_controller.rb b/app/controllers/api/plan_limitations_controller.rb new file mode 100644 index 000000000..91cc11385 --- /dev/null +++ b/app/controllers/api/plan_limitations_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# API Controller for resources of type PlanLimitation +# PlanLimitation allows to restrict bookings of resources for the subscribers of that plan. +class PlanLimitationsController < API::ApiController + def destroy + @limitation = PlanLimitation.find(params[:id]) + authorize @limitation + @limitation.destroy + head :no_content + end +end diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index 4e1a04c0a..e2faf9e77 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -85,7 +85,7 @@ class API::PlansController < API::ApiController plan_file_attributes: %i[id attachment _destroy], prices_attributes: %i[id amount], advanced_accounting_attributes: %i[code analytical_section], - plan_limitations_attributes: %i[id limitable_id limitable_type limit]) + plan_limitations_attributes: %i[id limitable_id limitable_type limit _destroy]) end end end diff --git a/app/frontend/src/javascript/api/plan-limitation.ts b/app/frontend/src/javascript/api/plan-limitation.ts new file mode 100644 index 000000000..b167a104c --- /dev/null +++ b/app/frontend/src/javascript/api/plan-limitation.ts @@ -0,0 +1,9 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; + +export default class PlanLimitationAPI { + static async destroy (id: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/plan_limitations/${id}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index 16ca23496..95fb784ac 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -14,7 +14,8 @@ interface EditDestroyButtonsProps { apiDestroy: (itemId: number) => Promise, confirmationMessage?: string|ReactNode, className?: string, - iconSize?: number + iconSize?: number, + showEditButton?: boolean, } /** @@ -22,7 +23,7 @@ interface EditDestroyButtonsProps { * Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item. * Edit : triggers the provided function. */ -export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20, showEditButton = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -50,9 +51,9 @@ export const EditDestroyButtons: React.FC = ({ onDelete return ( <>
- + {showEditButton && - + } diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx index e96675f60..b791bd002 100644 --- a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -1,4 +1,4 @@ -import { FieldArrayWithId, UseFieldArrayRemove } from 'react-hook-form/dist/types/fieldArray'; +import { FieldArrayWithId } from 'react-hook-form/dist/types/fieldArray'; import { UseFormRegister } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { useTranslation } from 'react-i18next'; @@ -9,7 +9,7 @@ import { FieldArrayPath } from 'react-hook-form/dist/types/path'; interface FormUnsavedListProps, TKeyName extends string> { fields: Array>, - remove: UseFieldArrayRemove, + onRemove?: (index: number) => void, register: UseFormRegister, className?: string, title: string, @@ -25,7 +25,7 @@ interface FormUnsavedListProps = FieldArrayPath, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { +export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { const { t } = useTranslation('shared'); /** @@ -35,7 +35,7 @@ export const FormUnsavedList = {renderField(field)} -

remove(index)}> +

onRemove(index)}> {cancelLabel || t('app.shared.form_unsaved_list.cancel')}

diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index a41731269..d66dd3882 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -44,7 +44,7 @@ interface PlanFormProps { * Form to edit or create subscription plans */ export const PlanForm: React.FC = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => { - const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...plan } }); + const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm({ defaultValues: { ...plan } }); const output = useWatch({ control }); // eslint-disable-line const { t } = useTranslation('admin'); @@ -332,7 +332,10 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc content: + onError={onError} + onSuccess={onSuccess} + getValues={getValues} + resetField={resetField} /> } ]} /> diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index c2f288ad6..cccb25784 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -1,9 +1,8 @@ import { ReactNode, useEffect, useState } from 'react'; -import { Control, FormState } from 'react-hook-form/dist/types/form'; +import { Control, FormState, UseFormGetValues, UseFormResetField } from 'react-hook-form/dist/types/form'; import { FormSwitch } from '../form/form-switch'; import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; -import { PencilSimple, Trash } from 'phosphor-react'; import { PlanLimitModal } from './plan-limit-modal'; import { Plan, PlanLimitation } from '../../models/plan'; import { useFieldArray, UseFormRegister, useWatch } from 'react-hook-form'; @@ -12,25 +11,31 @@ import { MachineCategory } from '../../models/machine-category'; import MachineAPI from '../../api/machine'; import MachineCategoryAPI from '../../api/machine-category'; import { FormUnsavedList } from '../form/form-unsaved-list'; +import { EditDestroyButtons } from '../base/edit-destroy-buttons'; +import PlanLimitationAPI from '../../api/plan-limitation'; interface PlanLimitFormProps { register: UseFormRegister, control: Control, formState: FormState, onError: (message: string) => void, + onSuccess: (message: string) => void, + getValues: UseFormGetValues, + resetField: UseFormResetField } /** * Form tab to manage a subscription's usage limit */ -export const PlanLimitForm = ({ register, control, formState, onError }: PlanLimitFormProps) => { +export const PlanLimitForm = ({ register, control, formState, onError, onSuccess, getValues, resetField }: PlanLimitFormProps) => { const { t } = useTranslation('admin'); - const { fields, append, remove } = useFieldArray({ control, name: 'plan_limitations_attributes' }); + const { fields, append, remove, update } = useFieldArray({ control, name: 'plan_limitations_attributes' }); const limiting = useWatch({ control, name: 'limiting' }); const [isOpen, setIsOpen] = useState(false); const [machines, setMachines] = useState>([]); const [categories, setCategories] = useState>([]); + const [edited, setEdited] = useState<{index: number, limitation: PlanLimitation}>(null); useEffect(() => { MachineAPI.index({ disabled: false }) @@ -51,8 +56,49 @@ export const PlanLimitForm = ({ register, control, for /** * Triggered when a new limit was added or an existing limit was modified */ - const onPlanLimitSuccess = (planLimit: PlanLimitation): void => { - append({ ...planLimit }); + const onLimitationSuccess = (limitation: PlanLimitation): void => { + const id = getValues(`plan_limitations_attributes.${edited?.index}.id`); + if (id) { + update(edited.index, { ...limitation, id }); + setEdited(null); + } else { + append({ ...limitation }); + } + }; + + /** + * Triggered when an unsaved limit was removed from the "pending" list. + */ + const onRemoveUnsaved = (index: number): void => { + const id = getValues(`plan_limitations_attributes.${index}.id`); + if (id) { + // will reset the field to its default values + resetField(`plan_limitations_attributes.${index}`); + // unmount and remount the field + update(index, getValues(`plan_limitations_attributes.${index}`)); + } else { + remove(index); + } + }; + + /** + * Callback triggered when the limitation was deleted. Return a callback accepting a message + */ + const onLimitationDeleted = (index: number): (message: string) => void => { + return (message: string) => { + onSuccess(message); + remove(index); + }; + }; + + /** + * Callback triggered when the user wants to modify a limitation. Return a callback + */ + const onEditLimitation = (limitation: PlanLimitation, index: number): () => void => { + return () => { + setEdited({ index, limitation }); + toggleModal(); + }; }; /** @@ -100,84 +146,83 @@ export const PlanLimitForm = ({ register, control, for
+ limit._modified} + formAttributeName="plan_limitations_attributes" + formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} + renderField={renderOngoingLimit} + cancelLabel={t('app.admin.plan_limit_form.cancel')} /> - {fields.filter(f => f.limitable_type === 'MachineCategory').length > 0 && + {fields.filter(f => f._modified).length > 0 && +

{t('app.admin.plan_limit_form.saved_limitations')}

+ } + + {fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&

{t('app.admin.plan_limit_form.by_categories')}

- {fields.filter(f => f.limitable_type === 'MachineCategory' && !f.modified).map(limitation => ( -
-
-
- {t('app.admin.plan_limit_form.category')} -

{categories.find(c => c.id === limitation.limitable_id)?.name}

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

{limitation.limit}

-
-
+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false; -
-
- - - - - - + return ( +
+
+
+ {t('app.admin.plan_limit_form.category')} +

{categories.find(c => c.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
+ +
+
-
- ))} - limit.limitable_type === 'MachineCategory' && limit.modified} - formAttributeName="plan_limitations_attributes" - formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} - renderField={renderOngoingLimit} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + ); + }).filter(Boolean)}
} - {fields.filter(f => f.limitable_type === 'Machine').length > 0 && + {fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 &&

{t('app.admin.plan_limit_form.by_machine')}

- {fields.filter(f => f.limitable_type === 'Machine' && !f.modified).map(limitation => ( -
-
-
- {t('app.admin.plan_limit_form.machine')} -

{machines.find(m => m.id === limitation.limitable_id)?.name}

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

{limitation.limit}

-
-
+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'Machine' || limitation._modified) return false; -
-
- - - - - - + return ( +
+
+
+ {t('app.admin.plan_limit_form.machine')} +

{machines.find(m => m.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
+ +
+
-
- ))} - limit.limitable_type === 'Machine' && limit.modified} - formAttributeName="plan_limitations_attributes" - formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} - renderField={renderOngoingLimit} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + ); + }).filter(Boolean)}
}
} @@ -186,7 +231,8 @@ export const PlanLimitForm = ({ register, control, for machines={machines} categories={categories} toggleModal={toggleModal} - onSuccess={onPlanLimitSuccess} /> + onSuccess={onLimitationSuccess} + limitation={edited?.limitation} />
); }; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx index 7c9ad6255..50c1349c7 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -10,24 +10,30 @@ import { Machine } from '../../models/machine'; import { MachineCategory } from '../../models/machine-category'; import { SelectOption } from '../../models/select'; import { FabButton } from '../base/fab-button'; +import { useEffect } from 'react'; interface PlanLimitModalProps { isOpen: boolean, toggleModal: () => void, onSuccess: (limit: PlanLimitation) => void, machines: Array - categories: Array + categories: Array, + limitation?: PlanLimitation, } /** * Form to manage subscriptions limitations of use */ -export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess }) => { +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation }) => { const { t } = useTranslation('admin'); - const { register, control, formState, setValue, handleSubmit } = useForm({ defaultValues: { limitable_type: 'MachineCategory' } }); + const { register, control, formState, setValue, handleSubmit, reset } = useForm({ defaultValues: limitation || { limitable_type: 'MachineCategory' } }); const limitType = useWatch({ control, name: 'limitable_type' }); + useEffect(() => { + reset(limitation); + }, [limitation]); + /** * Toggle the form between 'categories' and 'machine' */ @@ -47,7 +53,8 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo event.preventDefault(); } return handleSubmit((data: PlanLimitation) => { - onSuccess({ ...data, modified: true }); + onSuccess({ ...data, _modified: true }); + reset({}); toggleModal(); })(event); }; @@ -85,6 +92,7 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo
{t('app.admin.plan_limit_modal.machine_info')} + ({ currentFormValues, Date: Fri, 10 Mar 2023 15:06:18 +0100 Subject: [PATCH 13/23] (ui) improved limitation add/edit/destroy --- .../components/base/edit-destroy-buttons.tsx | 5 +-- .../components/plans/plan-limit-form.tsx | 16 +++++++-- .../components/plans/plan-limit-modal.tsx | 33 ++++++++++++------- config/locales/app.admin.en.yml | 2 ++ package.json | 2 +- yarn.lock | 8 ++--- 6 files changed, 45 insertions(+), 21 deletions(-) diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index 95fb784ac..3795f9023 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -12,6 +12,7 @@ interface EditDestroyButtonsProps { itemId: number, itemType: string, apiDestroy: (itemId: number) => Promise, + confirmationTitle?: string, confirmationMessage?: string|ReactNode, className?: string, iconSize?: number, @@ -23,7 +24,7 @@ interface EditDestroyButtonsProps { * Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item. * Edit : triggers the provided function. */ -export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20, showEditButton = true }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationTitle, confirmationMessage, className, iconSize = 20, showEditButton = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -58,7 +59,7 @@ export const EditDestroyButtons: React.FC = ({ onDelete
- ({ register, control, for setIsOpen(!isOpen); }; + /** + * Triggered when the user clicks on 'add a limitation' + */ + const onAddLimitation = (): void => { + setEdited(null); + toggleModal(); + }; /** * Triggered when a new limit was added or an existing limit was modified */ @@ -141,7 +148,7 @@ export const PlanLimitForm = ({ register, control, for

{t('app.admin.plan_limit_form.all_limitations')}

- + {t('app.admin.plan_limit_form.new_usage_limitation')}
@@ -185,6 +192,8 @@ export const PlanLimitForm = ({ register, control, for onEdit={onEditLimitation(limitation, index)} itemId={limitation.id} itemType={t('app.admin.plan_limit_form.limitation')} + confirmationTitle={t('app.admin.plan_limit_form.confirmation_title')} + confirmationMessage={t('app.admin.plan_limit_form.confirmation_message')} apiDestroy={PlanLimitationAPI.destroy} />
@@ -218,6 +227,8 @@ export const PlanLimitForm = ({ register, control, for onEdit={onEditLimitation(limitation, index)} itemId={limitation.id} itemType={t('app.admin.plan_limit_form.limitation')} + confirmationTitle={t('app.admin.plan_limit_form.confirmation_title')} + confirmationMessage={t('app.admin.plan_limit_form.confirmation_message')} apiDestroy={PlanLimitationAPI.destroy} />
@@ -232,7 +243,8 @@ export const PlanLimitForm = ({ register, control, for categories={categories} toggleModal={toggleModal} onSuccess={onLimitationSuccess} - limitation={edited?.limitation} /> + limitation={edited?.limitation} + existingLimitations={fields} />
); }; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx index 50c1349c7..208c5820f 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -19,12 +19,13 @@ interface PlanLimitModalProps { machines: Array categories: Array, limitation?: PlanLimitation, + existingLimitations: Array; } /** * Form to manage subscriptions limitations of use */ -export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation }) => { +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation, existingLimitations = [] }) => { const { t } = useTranslation('admin'); const { register, control, formState, setValue, handleSubmit, reset } = useForm({ defaultValues: limitation || { limitable_type: 'MachineCategory' } }); @@ -54,7 +55,7 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo } return handleSubmit((data: PlanLimitation) => { onSuccess({ ...data, _modified: true }); - reset({}); + reset({ limitable_type: 'MachineCategory', limitable_id: null, limit: null }); toggleModal(); })(event); }; @@ -63,13 +64,17 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo */ const buildOptions = (): Array> => { if (limitType === 'MachineCategory') { - return categories.map(cat => { - return { value: cat.id, label: cat.name }; - }); + return categories + .filter(c => limitation || !existingLimitations.filter(l => l.limitable_type === 'MachineCategory').map(l => l.limitable_id).includes(c.id)) + .map(cat => { + return { value: cat.id, label: cat.name }; + }); } else { - return machines.map(machine => { - return { value: machine.id, label: machine.name }; - }); + return machines + .filter(m => limitation || !existingLimitations.filter(l => l.limitable_type === 'Machine').map(l => l.limitable_id).includes(m.id)) + .map(machine => { + return { value: machine.id, label: machine.name }; + }); } }; @@ -78,23 +83,27 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo width={ModalSize.large} isOpen={isOpen} toggleModal={toggleModal} + onClose={() => reset({ limitable_type: 'MachineCategory' })} closeButton>

{t('app.admin.plan_limit_modal.limit_reservations')}

{t('app.admin.plan_limit_modal.machine_info')} Date: Fri, 10 Mar 2023 16:00:04 +0100 Subject: [PATCH 14/23] (feat) destroy a limitation --- .../api/plan_limitations_controller.rb | 2 +- .../components/base/edit-destroy-buttons.tsx | 17 ++++++++++------- .../components/plans/plan-limit-form.tsx | 14 +++++++++----- config/locales/app.admin.en.yml | 4 ++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/controllers/api/plan_limitations_controller.rb b/app/controllers/api/plan_limitations_controller.rb index 91cc11385..ea2171d61 100644 --- a/app/controllers/api/plan_limitations_controller.rb +++ b/app/controllers/api/plan_limitations_controller.rb @@ -2,7 +2,7 @@ # API Controller for resources of type PlanLimitation # PlanLimitation allows to restrict bookings of resources for the subscribers of that plan. -class PlanLimitationsController < API::ApiController +class API::PlanLimitationsController < API::ApiController def destroy @limitation = PlanLimitation.find(params[:id]) authorize @limitation diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index 3795f9023..d61de771d 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -5,26 +5,29 @@ import { useTranslation } from 'react-i18next'; import { FabButton } from './fab-button'; import { FabModal } from './fab-modal'; -interface EditDestroyButtonsProps { +type EditDestroyButtonsCommon = { onDeleteSuccess: (message: string) => void, onError: (message: string) => void, onEdit: () => void, itemId: number, - itemType: string, apiDestroy: (itemId: number) => Promise, - confirmationTitle?: string, - confirmationMessage?: string|ReactNode, className?: string, iconSize?: number, showEditButton?: boolean, } +type EditDestroyButtonsMessages = + { itemType: string, confirmationTitle?: string, confirmationMessage?: string|ReactNode, deleteSuccessMessage?: string } | + { itemType?: never, confirmationTitle: string, confirmationMessage: string|ReactNode, deleteSuccessMessage: string} + +type EditDestroyButtonsProps = EditDestroyButtonsCommon & EditDestroyButtonsMessages; + /** * This component shows a group of two buttons. * Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item. * Edit : triggers the provided function. */ -export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationTitle, confirmationMessage, className, iconSize = 20, showEditButton = true }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -42,9 +45,9 @@ export const EditDestroyButtons: React.FC = ({ onDelete */ const onDeleteConfirmed = (): void => { apiDestroy(itemId).then(() => { - onDeleteSuccess(t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType })); + onDeleteSuccess(deleteSuccessMessage || t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType })); }).catch((error) => { - onError(t('app.admin.edit_destroy_buttons.unable_to_delete', { TYPE: itemType }) + error); + onError(t('app.admin.edit_destroy_buttons.unable_to_delete') + error); }); toggleDeletionModal(); }; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 5715c9d5b..2c1888934 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -89,12 +89,16 @@ export const PlanLimitForm = ({ register, control, for }; /** - * Callback triggered when the limitation was deleted. Return a callback accepting a message + * Callback triggered when a previously-saved limitation was deleted. Return a callback accepting a message. */ const onLimitationDeleted = (index: number): (message: string) => void => { return (message: string) => { onSuccess(message); remove(index); + // This have a little drowback: remove(index) will set the form as "dirty", and trigger the "unsaved form alert", even if clicking on save or not + // won't change anything to the deleted item. To improve this we could do the following: do not destroy the limitation through the API and instead + // set {_destroy: true} and destroy the limitation when saving the form but we need some UI for items about to be deleted + // update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true }); }; }; @@ -190,8 +194,8 @@ export const PlanLimitForm = ({ register, control, for @@ -225,10 +229,10 @@ export const PlanLimitForm = ({ register, control, for
diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 6c0cfe661..1c7adcb8c 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -3,7 +3,7 @@ en: admin: edit_destroy_buttons: deleted: "The {TYPE} was successfully deleted." - unable_to_delete: "Unable to delete the {TYPE}: " + unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" delete_confirmation: "Are you sure you want to delete this {TYPE}?" @@ -209,9 +209,9 @@ en: ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - limitation: "Limitation" confirmation_title: "Delete the limitation" confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." + delete_success: "The limitation was successfully deleted." plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" From c3d6206dbac4c1e3b46838b16f74aac5fe97d522 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 10 Mar 2023 16:14:38 +0100 Subject: [PATCH 15/23] (quality) typed CartItem::Reservation --- app/models/cart_item/reservation.rb | 18 +++++++++++++----- app/services/prepaid_pack_service.rb | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 372369fab..805845aaf 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -13,18 +13,22 @@ class CartItem::Reservation < CartItem::BaseItem nil end + # @return [Plan,NilClass] def plan nil end + # @return [User] def operator operator_profile.user end + # @return [User] def customer customer_profile.user end + # @return [Hash{Symbol=>Integer,Hash{Symbol=>ArrayInteger,Float,Boolean,Time}>}}] def price is_privileged = operator.privileged? && operator.id != customer.id prepaid = { minutes: PrepaidPackService.minutes_available(customer, reservable) } @@ -48,10 +52,13 @@ class CartItem::Reservation < CartItem::BaseItem { elements: elements, amount: amount } end + # @return [String,NilClass] def name reservable&.name end + # @param all_items [Array] + # @return [Boolean] def valid?(all_items = []) pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) } @@ -91,6 +98,7 @@ class CartItem::Reservation < CartItem::BaseItem true end + # @return [Reservation] def to_object ::Reservation.new( reservable_id: reservable_id, @@ -107,7 +115,7 @@ class CartItem::Reservation < CartItem::BaseItem end # Group the slots by date, if the extended_prices_in_same_day option is set to true - # @return Hash{Symbol => Array} + # @return [Hash{Symbol => Array}] def grouped_slots return { all: cart_item_reservation_slots } unless Setting.get('extended_prices_in_same_day') @@ -125,7 +133,7 @@ class CartItem::Reservation < CartItem::BaseItem # @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided # @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default) # @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer - # @return [Float] + # @return [Float,Integer] def get_slot_price_from_prices(prices, slot_reservation, is_privileged, options = {}) options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options) @@ -152,7 +160,7 @@ class CartItem::Reservation < CartItem::BaseItem # @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided # @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default) # @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer - # @return [Float] price of the slot + # @return [Float,Integer] price of the slot def get_slot_price(hourly_rate, slot_reservation, is_privileged, options = {}) options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options) @@ -245,10 +253,10 @@ class CartItem::Reservation < CartItem::BaseItem cart_item_reservation_slots.map { |sr| { id: sr.slots_reservation_id, slot_id: sr.slot_id, offered: sr.offered } } end - ## # Check if the given availability requires a valid subscription. If so, check if the current customer # has the required susbcription, otherwise, check if the operator is privileged - ## + # @param availability [Availability] + # @param pending_subscription [CartItem::Subscription, NilClass] def required_subscription?(availability, pending_subscription) (customer.subscribed_plan && availability.plan_ids.include?(customer.subscribed_plan.id)) || (pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) || diff --git a/app/services/prepaid_pack_service.rb b/app/services/prepaid_pack_service.rb index 9e0437e96..7a03ecaef 100644 --- a/app/services/prepaid_pack_service.rb +++ b/app/services/prepaid_pack_service.rb @@ -73,7 +73,7 @@ class PrepaidPackService # Total number of prepaid minutes available # @param user [User] - # @param priceable [Machine,Space] + # @param priceable [Machine,Space,NilClass] def minutes_available(user, priceable) return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan From 8504864c206553d6558eb12f7472b6ff84c82b7d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 13 Mar 2023 17:29:06 +0100 Subject: [PATCH 16/23] (feat) limit reservation depending on current suscription --- app/models/cart_item/reservation.rb | 70 ++++++---- app/models/machine.rb | 2 +- app/models/machine_category.rb | 2 +- app/models/plan_limitation.rb | 7 + app/services/reservation_limit_service.rb | 63 +++++++++ config/locales/en.yml | 1 + .../reservation_limit_service_test.rb | 127 ++++++++++++++++++ 7 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 app/services/reservation_limit_service.rb create mode 100644 test/services/reservation_limit_service_test.rb diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 805845aaf..e3a136361 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -61,40 +61,20 @@ class CartItem::Reservation < CartItem::BaseItem # @return [Boolean] def valid?(all_items = []) pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) } + plan = pending_subscription&.plan || customer&.subscribed_plan reservation_deadline_minutes = Setting.get('reservation_deadline').to_i reservation_deadline = reservation_deadline_minutes.minutes.since - cart_item_reservation_slots.each do |sr| - slot = sr.slot - if slot.nil? - errors.add(:slot, I18n.t('cart_item_validation.slot')) - return false - end - - availability = slot.availability - if availability.nil? - errors.add(:availability, I18n.t('cart_item_validation.availability')) - return false - end - - if slot.full? - errors.add(:slot, I18n.t('cart_item_validation.full')) - return false - end - - if slot.start_at < reservation_deadline && !operator.privileged? - errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes })) - return false - end - - next if availability.plan_ids.empty? - next if required_subscription?(availability, pending_subscription) - - errors.add(:availability, I18n.t('cart_item_validation.restricted')) + unless ReservationLimitService.authorized?(plan, customer, self, all_items) + errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', { HOURS: ReservationLimitService.limit(plan, reservable) })) return false end + cart_item_reservation_slots.each do |sr| + return false unless validate_slot_reservation(sr, pending_subscription, reservation_deadline, errors) + end + true end @@ -263,4 +243,40 @@ class CartItem::Reservation < CartItem::BaseItem (operator.manager? && customer.id != operator.id) || operator.admin? end + + # @param reservation_slot [CartItem::ReservationSlot] + # @param pending_subscription [CartItem::Subscription, NilClass] + # @param reservation_deadline [Date,Time] + # @param errors [ActiveModel::Errors] + # @return [Boolean] + def validate_slot_reservation(reservation_slot, pending_subscription, reservation_deadline, errors) + slot = reservation_slot.slot + if slot.nil? + errors.add(:slot, I18n.t('cart_item_validation.slot')) + return false + end + + availability = slot.availability + if availability.nil? + errors.add(:availability, I18n.t('cart_item_validation.availability')) + return false + end + + if slot.full? + errors.add(:slot, I18n.t('cart_item_validation.full')) + return false + end + + if slot.start_at < reservation_deadline && !operator.privileged? + errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes })) + return false + end + + unless availability.plan_ids.empty? && required_subscription?(availability, pending_subscription) + errors.add(:availability, I18n.t('cart_item_validation.restricted')) + return false + end + + true + end end diff --git a/app/models/machine.rb b/app/models/machine.rb index 38577b474..a3f81cfa1 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -42,7 +42,7 @@ class Machine < ApplicationRecord belongs_to :machine_category - has_many :plan_limitations, dependent: :destroy + has_many :plan_limitations, dependent: :destroy, inverse_of: :machine, foreign_type: 'limitable_type', foreign_key: 'limitable_id' after_create :create_statistic_subtype after_create :create_machine_prices diff --git a/app/models/machine_category.rb b/app/models/machine_category.rb index 78ebba508..47c670640 100644 --- a/app/models/machine_category.rb +++ b/app/models/machine_category.rb @@ -4,5 +4,5 @@ class MachineCategory < ApplicationRecord has_many :machines, dependent: :nullify accepts_nested_attributes_for :machines, allow_destroy: true - has_many :plan_limitations, dependent: :destroy + has_many :plan_limitations, dependent: :destroy, inverse_of: :machine_category, foreign_type: 'limitable_type', foreign_key: 'limitable_id' end diff --git a/app/models/plan_limitation.rb b/app/models/plan_limitation.rb index fe11fd36b..9bdabecbf 100644 --- a/app/models/plan_limitation.rb +++ b/app/models/plan_limitation.rb @@ -10,4 +10,11 @@ class PlanLimitation < ApplicationRecord validates :limitable_id, :limitable_type, :limit, :plan_id, presence: true validates :limitable_id, uniqueness: { scope: %i[limitable_type plan_id] } + + # @return [Array] + def reservables + return limitable.machines if limitable_type == 'MachineCategory' + + [limitable] + end end diff --git a/app/services/reservation_limit_service.rb b/app/services/reservation_limit_service.rb new file mode 100644 index 000000000..325f80e98 --- /dev/null +++ b/app/services/reservation_limit_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Check if a user if allowed to book a reservation without exceeding the limits set by his plan +class ReservationLimitService + class << self + # @param plan [Plan,NilClass] + # @param customer [User] + # @param reservation [CartItem::Reservation] + # @param cart_items [Array] + # @return [Boolean] + def authorized?(plan, customer, reservation, cart_items) + return true if plan.nil? || !plan.limiting + + return true if reservation.nil? || !reservation.is_a?(CartItem::Reservation) + + plan.plan_limitations.filter { |limit| limit.reservables.include?(reservation.reservable) }.each do |limit| + reservation.cart_item_reservation_slots.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots| + daily_duration = reservations_duration(customer, date, reservation, cart_items) + + (reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0) + return false if Rational(daily_duration / 3600).to_f > limit.limit + end + end + + true + end + + # @param plan [Plan,NilClass] + # @param reservable [Machine,Event,Space,Training] + # @return [Integer,NilClass] in hours + def limit(plan, reservable) + return nil unless plan&.limiting + + plan&.plan_limitations&.find { |limit| limit.reservables.include?(reservable) }&.limit + end + + private + + # @param customer [User] + # @param date [Date] + # @param reservation [CartItem::Reservation] + # @param cart_items [Array] + # @return [Integer] in seconds + def reservations_duration(customer, date, reservation, cart_items) + daily_reservations = customer.reservations + .includes(slots_reservations: :slot) + .where(reservable: reservation.reservable) + .where(slots_reservations: { canceled_at: nil }) + .where("date_trunc('day', slots.start_at) = :date", date: date) + + cart_daily_reservations = cart_items.filter do |item| + item.is_a?(CartItem::Reservation) && + item != reservation && + item.reservable == reservation.reservable && + item.cart_item_reservation_slots + .includes(:slot) + .where("date_trunc('day', slots.start_at) = :date", date: date) + end + + (daily_reservations.map { |r| r.slots_reservations.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0) + + (cart_daily_reservations.map { |r| r.cart_item_reservation_slots.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0) + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 44b0a59ca..1f3360329 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -519,6 +519,7 @@ en: availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" diff --git a/test/services/reservation_limit_service_test.rb b/test/services/reservation_limit_service_test.rb new file mode 100644 index 000000000..ab5ae26c7 --- /dev/null +++ b/test/services/reservation_limit_service_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReservationLimitServiceTest < ActiveSupport::TestCase + setup do + @acamus = User.find_by(username: 'acamus') + @admin = User.find_by(username: 'admin') + @machine = Machine.first + @plan = Plan.find(1) + end + + test 'simple reservation without plan' do + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }] + ) + assert ReservationLimitService.authorized?(nil, @acamus, reservation, []) + end + + test 'simple reservation with not limiting plan' do + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }] + ) + assert ReservationLimitService.authorized?(@plan, @acamus, reservation, []) + end + + test 'simple reservation with limiting plan' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }] + ) + assert ReservationLimitService.authorized?(@plan, @acamus, reservation, []) + end + + test 'reservation exceeds plan limit' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + slots = Availabilities::AvailabilitiesService.new(@acamus) + .machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now }) + + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[0] }, { slot: slots[1] }, { slot: slots[2] }] + ) + assert_not ReservationLimitService.authorized?(@plan, @acamus, reservation, []) + end + + test 'second reservation at plan limit' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + slots = Availabilities::AvailabilitiesService.new(@acamus) + .machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now }) + + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[0] }] + ) + reservation2 = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[1] }] + ) + assert ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation]) + end + + test 'second reservation exceeds plan limit' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + slots = Availabilities::AvailabilitiesService.new(@acamus) + .machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now }) + + reservation = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[0] }, { slot: slots[1] }] + ) + reservation2 = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[2] }] + ) + assert_not ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation]) + end + + test 'reservation of other resource should not conflict' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + slots = Availabilities::AvailabilitiesService.new(@acamus) + .machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now }) + + reservation = CartItem::SpaceReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: Space.first, + cart_item_reservation_slots_attributes: [{ slot: Space.first.availabilities.first.slots.first }, + { slot: Space.first.availabilities.first.slots.last }] + ) + reservation2 = CartItem::MachineReservation.new( + customer_profile: @acamus.invoicing_profile, + operator_profile: @acamus.invoicing_profile, + reservable: @machine, + cart_item_reservation_slots_attributes: [{ slot: slots[0] }] + ) + assert ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation]) + end + + test 'get plan limit' do + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) + assert_equal 2, ReservationLimitService.limit(@plan, @machine) + end + + test 'get plan without limit' do + assert_nil ReservationLimitService.limit(@plan, @machine) + end +end From 0ac99e3b6b8004ae316a340fd4477c8341a700f4 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 14 Mar 2023 11:54:41 +0100 Subject: [PATCH 17/23] (quality) improved error message --- app/controllers/api/local_payment_controller.rb | 2 ++ app/controllers/api/payzen_controller.rb | 2 +- app/controllers/api/stripe_controller.rb | 4 ++-- .../src/javascript/components/plans/plan-limit-form.tsx | 4 ++-- app/models/cart_item/reservation.rb | 5 ++++- app/models/shopping_cart.rb | 1 + app/services/cart_service.rb | 3 +-- config/locales/en.yml | 2 +- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/local_payment_controller.rb b/app/controllers/api/local_payment_controller.rb index 126510dd4..a5c18200f 100644 --- a/app/controllers/api/local_payment_controller.rb +++ b/app/controllers/api/local_payment_controller.rb @@ -8,6 +8,8 @@ class API::LocalPaymentController < API::PaymentsController authorize LocalPaymentContext.new(cart, price[:amount]) + render json: cart.errors, status: :unprocessable_entity and return unless cart.valid? + render on_payment_success(nil, nil, cart) end diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb index 69fbaa70d..14eaf310b 100644 --- a/app/controllers/api/payzen_controller.rb +++ b/app/controllers/api/payzen_controller.rb @@ -55,7 +55,7 @@ class API::PayzenController < API::PaymentsController def check_cart cart = shopping_cart - render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid? + render json: cart.errors, status: :unprocessable_entity and return unless cart.valid? render json: { cart: 'ok' }, status: :ok end diff --git a/app/controllers/api/stripe_controller.rb b/app/controllers/api/stripe_controller.rb index 05b3a6e2b..e885c6fd4 100644 --- a/app/controllers/api/stripe_controller.rb +++ b/app/controllers/api/stripe_controller.rb @@ -19,7 +19,7 @@ class API::StripeController < API::PaymentsController res = nil # json of the API answer cart = shopping_cart - render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid? + render json: cart.errors, status: :unprocessable_entity and return unless cart.valid? begin amount = debit_amount(cart) # will contains the amount and the details of each invoice lines @@ -73,7 +73,7 @@ class API::StripeController < API::PaymentsController def setup_subscription cart = shopping_cart - render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid? + render json: cart.errors, status: :unprocessable_entity and return unless cart.valid? service = Stripe::Service.new method = service.attach_method_as_default( diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 2c1888934..acf66d416 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -69,7 +69,7 @@ export const PlanLimitForm = ({ register, control, for update(edited.index, { ...limitation, id }); setEdited(null); } else { - append({ ...limitation }); + append({ ...limitation, id }); } }; @@ -97,7 +97,7 @@ export const PlanLimitForm = ({ register, control, for remove(index); // This have a little drowback: remove(index) will set the form as "dirty", and trigger the "unsaved form alert", even if clicking on save or not // won't change anything to the deleted item. To improve this we could do the following: do not destroy the limitation through the API and instead - // set {_destroy: true} and destroy the limitation when saving the form but we need some UI for items about to be deleted + // set {_destroy: true} and destroy the limitation when saving the form, but we need some UI for items about to be deleted // update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true }); }; }; diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index e3a136361..4a8a80b36 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -67,7 +67,10 @@ class CartItem::Reservation < CartItem::BaseItem reservation_deadline = reservation_deadline_minutes.minutes.since unless ReservationLimitService.authorized?(plan, customer, self, all_items) - errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', { HOURS: ReservationLimitService.limit(plan, reservable) })) + errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', { + HOURS: ReservationLimitService.limit(plan, reservable), + RESERVABLE: reservable.name + })) return false end diff --git a/app/models/shopping_cart.rb b/app/models/shopping_cart.rb index 01b2c6046..31ed97e43 100644 --- a/app/models/shopping_cart.rb +++ b/app/models/shopping_cart.rb @@ -160,6 +160,7 @@ class ShoppingCart # Check if the current cart needs the user to have been validated, and if the condition is satisfied. # Return an array of errors, if any; false otherwise + # @return [Array,FalseClass] def check_user_validation(items) user_validation_required = Setting.get('user_validation_required') user_validation_required_list = Setting.get('user_validation_required_list') diff --git a/app/services/cart_service.rb b/app/services/cart_service.rb index aea1c834d..6c6588e4b 100644 --- a/app/services/cart_service.rb +++ b/app/services/cart_service.rb @@ -6,10 +6,9 @@ class CartService @operator = operator end - ## # For details about the expected hash format # @see app/frontend/src/javascript/models/payment.ts > interface ShoppingCart - ## + # @return [ShoppingCart] def from_hash(cart_items) cart_items.permit! if cart_items.is_a? ActionController::Parameters diff --git a/config/locales/en.yml b/config/locales/en.yml index 1f3360329..d8a3b9064 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -519,7 +519,7 @@ en: availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" - limit_reached: "You have reached the booking limit of %{HOURS}H per day for your current subscription" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" From 8a32c029d3b4e4da2eb764f98ba690ad16e3d260 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 14 Mar 2023 12:35:58 +0100 Subject: [PATCH 18/23] (feat) machine limit override category limit --- .../components/plans/plan-limit-form.tsx | 2 +- .../components/plans/plan-limit-modal.tsx | 4 ++-- app/services/reservation_limit_service.rb | 16 +++++++++------- config/locales/app.admin.en.yml | 8 ++++---- test/services/reservation_limit_service_test.rb | 14 ++++++++++++++ 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index acf66d416..7f33ad0ba 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -173,7 +173,7 @@ export const PlanLimitForm = ({ register, control, for {fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&
-

{t('app.admin.plan_limit_form.by_categories')}

+

{t('app.admin.plan_limit_form.by_category')}

{fields.map((limitation, index) => { if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx index 208c5820f..2ae2dc62a 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -91,7 +91,7 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo
- {t('app.admin.plan_limit_modal.machine_info')} + {limitType === 'Machine' ? t('app.admin.plan_limit_modal.machine_info') : t('app.admin.plan_limit_modal.categories_info')} limit.limit - end + limit = limit(plan, reservation.reservable) + return true if limit.nil? + + reservation.cart_item_reservation_slots.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots| + daily_duration = reservations_duration(customer, date, reservation, cart_items) + + (reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0) + return false if Rational(daily_duration / 3600).to_f > limit end true @@ -30,7 +31,8 @@ class ReservationLimitService def limit(plan, reservable) return nil unless plan&.limiting - plan&.plan_limitations&.find { |limit| limit.reservables.include?(reservable) }&.limit + limitations = plan&.plan_limitations&.filter { |limit| limit.reservables.include?(reservable) } + limitations&.find { |limit| limit.limitable_type != 'MachineCategory' }&.limit || limitations&.first&.limit end private diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 1c7adcb8c..38ea2c6d7 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -201,7 +201,7 @@ en: usage_limitation_switch: "Restrict machine reservations to a number of hours per day." new_usage_limitation: "Add a limitation of use" all_limitations: "All limitations" - by_categories: "By machines categories" + by_category: "By machine category" by_machine: "By machine" category: "Machines category" machine: "Machine name" @@ -215,12 +215,12 @@ en: plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" - by_categories: "By machines categories" + by_category: "By machines category" by_machine: "By machine" category: "Machines category" machine: "Machine name" - categories_info: "If you select all machine categories, the limits will apply across the board. Please note that if you have already created limitations for specific categories, these will be permanently overwritten." - machine_info: "If you select all machines, the limits will apply across the board. Please note that if you have already created limitations for machines, these will be permanently overwritten." + categories_info: "If you select all machine categories, the limits will apply across the board." + machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten." max_hours_per_day: "Maximum number of reservation hours per day" confirm: "Confirm" partner_modal: diff --git a/test/services/reservation_limit_service_test.rb b/test/services/reservation_limit_service_test.rb index ab5ae26c7..8976f0515 100644 --- a/test/services/reservation_limit_service_test.rb +++ b/test/services/reservation_limit_service_test.rb @@ -124,4 +124,18 @@ class ReservationLimitServiceTest < ActiveSupport::TestCase test 'get plan without limit' do assert_nil ReservationLimitService.limit(@plan, @machine) end + + test 'get category limit' do + category = MachineCategory.find(1) + category.update(machine_ids: [@machine.id]) + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable: category, limit: 4 }]) + assert_equal 4, ReservationLimitService.limit(@plan, @machine) + end + + test 'machine limit should override the category limit' do + category = MachineCategory.find(1) + category.update(machine_ids: [@machine.id]) + @plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 2 }, { limitable: category, limit: 4 }]) + assert_equal 2, ReservationLimitService.limit(@plan, @machine) + end end From 6026189e5945c600f17c201891735fee1e855cf7 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 14 Mar 2023 12:41:08 +0100 Subject: [PATCH 19/23] (i18n) updated translations --- config/locales/app.admin.de.yml | 49 +++++++++++++++++++++++++++----- config/locales/app.admin.en.yml | 2 +- config/locales/app.admin.es.yml | 49 +++++++++++++++++++++++++++----- config/locales/app.admin.fr.yml | 45 +++++++++++++++++++++++++---- config/locales/app.admin.no.yml | 49 +++++++++++++++++++++++++++----- config/locales/app.admin.pt.yml | 49 +++++++++++++++++++++++++++----- config/locales/app.admin.zu.yml | 47 ++++++++++++++++++++++++++---- config/locales/app.logged.pt.yml | 2 +- config/locales/app.shared.de.yml | 3 ++ config/locales/app.shared.es.yml | 3 ++ config/locales/app.shared.fr.yml | 3 ++ config/locales/app.shared.no.yml | 3 ++ config/locales/app.shared.pt.yml | 3 ++ config/locales/app.shared.zu.yml | 3 ++ config/locales/de.yml | 1 + config/locales/es.yml | 1 + config/locales/fr.yml | 1 + config/locales/no.yml | 1 + config/locales/pt.yml | 1 + config/locales/zu.yml | 1 + 20 files changed, 275 insertions(+), 41 deletions(-) diff --git a/config/locales/app.admin.de.yml b/config/locales/app.admin.de.yml index d8892dab7..b793ae586 100644 --- a/config/locales/app.admin.de.yml +++ b/config/locales/app.admin.de.yml @@ -3,7 +3,7 @@ de: admin: edit_destroy_buttons: deleted: "The {TYPE} was successfully deleted." - unable_to_delete: "Unable to delete the {TYPE}: " + unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" delete_confirmation: "Are you sure you want to delete this {TYPE}?" @@ -152,12 +152,19 @@ de: every_month: "Every month" every_year: "Every year" plan_form: - general_information: "General information" + ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" + tab_settings: "Settings" + tab_usage_limits: "Usage limits" + description: "Description" + general_settings: "General settings" + general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." + activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." group: "Group" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." + display: "Display" category: "Category" category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions." number_of_periods: "Number of periods" @@ -173,10 +180,9 @@ de: rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought." monthly_payment: "Monthly payment?" monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months." - description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" - new_user: "New user ..." + new_user: "New user" alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user." disabled: "Disable subscription" disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it." @@ -185,9 +191,37 @@ de: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" - ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan" + save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" + plan_limit_form: + usage_limitation: "Limitation of use" + usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation." + usage_limitation_switch: "Restrict machine reservations to a number of hours per day." + new_usage_limitation: "Add a limitation of use" + all_limitations: "All limitations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + max_hours_per_day: "Max. hours/day" + ongoing_limitations: "Ongoing limitations" + saved_limitations: "Saved limitations" + cancel: "Cancel this limitation" + confirmation_title: "Delete the limitation" + confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." + delete_success: "The limitation was successfully deleted." + plan_limit_modal: + title: "Manage limitation of use" + limit_reservations: "Limit reservations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + categories_info: "If you select all machine categories, the limits will apply across the board." + machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten." + max_hours_per_day: "Maximum number of reservation hours per day" + confirm: "Confirm" partner_modal: title: "Create a new partner" create_partner: "Create the partner" @@ -196,6 +230,7 @@ de: email: "Email address" plan_pricing_form: prices: "Prices" + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -2215,8 +2250,6 @@ de: unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" - successfully_deleted: "The product has been successfully deleted" - unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" filter_apply: "Apply" @@ -2238,6 +2271,7 @@ de: sort: "Sort:" visible_only: "Visible products only" product_item: + product: "product" visible: "visible" hidden: "hidden" stock: @@ -2294,6 +2328,7 @@ de: stocks: "Stock:" internal: "Private stock" external: "Public stock" + edit: "Edit" all: "All types" remaining_stock: "Remaining stock" type_in: "Add" diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 38ea2c6d7..4cc346e05 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -201,7 +201,7 @@ en: usage_limitation_switch: "Restrict machine reservations to a number of hours per day." new_usage_limitation: "Add a limitation of use" all_limitations: "All limitations" - by_category: "By machine category" + by_category: "By machines category" by_machine: "By machine" category: "Machines category" machine: "Machine name" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index 3314a1b67..63bff355b 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -3,7 +3,7 @@ es: admin: edit_destroy_buttons: deleted: "The {TYPE} was successfully deleted." - unable_to_delete: "Unable to delete the {TYPE}: " + unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" delete_confirmation: "Are you sure you want to delete this {TYPE}?" @@ -152,12 +152,19 @@ es: every_month: "Every month" every_year: "Every year" plan_form: - general_information: "General information" + ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" + tab_settings: "Settings" + tab_usage_limits: "Usage limits" + description: "Description" + general_settings: "General settings" + general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." + activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." group: "Group" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." + display: "Display" category: "Category" category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions." number_of_periods: "Number of periods" @@ -173,10 +180,9 @@ es: rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought." monthly_payment: "Monthly payment?" monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months." - description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" - new_user: "New user ..." + new_user: "New user" alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user." disabled: "Disable subscription" disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it." @@ -185,9 +191,37 @@ es: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" - ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan" + save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" + plan_limit_form: + usage_limitation: "Limitation of use" + usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation." + usage_limitation_switch: "Restrict machine reservations to a number of hours per day." + new_usage_limitation: "Add a limitation of use" + all_limitations: "All limitations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + max_hours_per_day: "Max. hours/day" + ongoing_limitations: "Ongoing limitations" + saved_limitations: "Saved limitations" + cancel: "Cancel this limitation" + confirmation_title: "Delete the limitation" + confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." + delete_success: "The limitation was successfully deleted." + plan_limit_modal: + title: "Manage limitation of use" + limit_reservations: "Limit reservations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + categories_info: "If you select all machine categories, the limits will apply across the board." + machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten." + max_hours_per_day: "Maximum number of reservation hours per day" + confirm: "Confirm" partner_modal: title: "Create a new partner" create_partner: "Create the partner" @@ -196,6 +230,7 @@ es: email: "Email address" plan_pricing_form: prices: "Prices" + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -2215,8 +2250,6 @@ es: unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" - successfully_deleted: "The product has been successfully deleted" - unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" filter_apply: "Apply" @@ -2238,6 +2271,7 @@ es: sort: "Sort:" visible_only: "Visible products only" product_item: + product: "product" visible: "visible" hidden: "hidden" stock: @@ -2294,6 +2328,7 @@ es: stocks: "Stock:" internal: "Private stock" external: "Public stock" + edit: "Edit" all: "All types" remaining_stock: "Remaining stock" type_in: "Add" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 7fbe35394..dc08d80fc 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -152,12 +152,19 @@ fr: every_month: "Chaque mois" every_year: "Chaque année" plan_form: - general_information: "Informations générales" + ACTION_title: "{ACTION, select, create{Nouvelle} other{Mettre à jour la}} formule d'abonnement" + tab_settings: "Paramètres" + tab_usage_limits: "Limites d'utilisation" + description: "Description" + general_settings: "Paramètres généraux" + general_settings_info: "Déterminez à quel groupe cet abonnement est dédié. Définissez également son prix et sa durée en périodes." + activation_and_payment: "Activation et paiement de l'abonnement" name: "Nom" name_max_length: "Le nom doit faire moins de 24 caractères." group: "Groupe" transversal: "Abonnement transversal" transversal_help: "Si cette option est cochée, une copie de cette formule d'abonnement sera créée pour chaque groupe actuellement activé." + display: "Affichage" category: "Catégorie" category_help: "Les catégories vous permettent de regrouper les formules d'abonnement, sur la vue publique des abonnements." number_of_periods: "Nombre de périodes" @@ -173,7 +180,6 @@ fr: rolling_subscription_help: "Un abonnement glissant commencera le jour de la première formation. Sinon, il commencera dès qu'il est acheté." monthly_payment: "Paiement mensuel ?" monthly_payment_help: "Si le paiement mensuel est activé, les membres pourront choisir entre un paiement unique ou un échéancier de paiement échelonné chaque mois." - description: "Description" information_sheet: "Fiche descriptive" notified_partner: "Partenaire notifié" new_user: "Nouvel utilisateur ..." @@ -185,9 +191,37 @@ fr: partner_plan: "Abonnement partenaire" partner_plan_help: "Vous pouvez vendre des abonnements en partenariat avec un autre organisme. Ce faisant, l'autre entité sera informée lorsqu'un membre s'abonne à cette formule d'abonnement." partner_created: "Le partenaire a bien été créé" - ACTION_plan: "{ACTION, select, create{Créer} other{Mettre à jour}} la formule d'abonnement" + save: "Enregistrer" create_success: "Création du/des formule(s) d'abonnement réussie(s). N'oubliez pas de redéfinir les tarifs." update_success: "La formule d'abonnement a bien été mise à jour" + plan_limit_form: + usage_limitation: "Limite d'usage" + usage_limitation_info: "Définissez un nombre maximum d'heures de réservation par jour et par catégorie de machine. Les catégories de machines qui n'ont aucun paramètre configuré ne seront soumises à aucune limitation." + usage_limitation_switch: "Restreindre les réservations de machines à un certain nombre d'heures par jour." + new_usage_limitation: "Ajouter une limite d'usage" + all_limitations: "Toutes les limitations" + by_category: "Par catégorie de machines" + by_machine: "Par machine" + category: "Catégorie de machines" + machine: "Nom machine" + max_hours_per_day: "Max. heures/jour" + ongoing_limitations: "Limites en cours" + saved_limitations: "Limites enregistrées" + cancel: "Annuler cette limite" + confirmation_title: "Supprimer la limite" + confirmation_message: "Êtes-vous sûr de vouloir supprimer cette limite ? Cela prendra effet immédiatement et ne sera pas résersible." + delete_success: "La limite a bien été supprimée." + plan_limit_modal: + title: "Gérer la limite d'usage" + limit_reservations: "Limiter les réservations" + by_category: "Par catégorie de machines" + by_machine: "Par machine" + category: "Catégorie de machines" + machine: "Nom machine" + categories_info: "Si vous sélectionnez toutes les catégories de machines, les limites s'appliqueront à tous les niveaux." + machine_info: "Veuillez noter que si vous avez déjà créé une limite pour la catégorie de machines incluant la machine sélectionnée, elle sera définitivement écrasée." + max_hours_per_day: "Nombre maximum d'heures de réservation par jour" + confirm: "Confirmer" partner_modal: title: "Créer un nouveau partenaire" create_partner: "Créer le partenaire" @@ -196,6 +230,7 @@ fr: email: "Adresse électronique" plan_pricing_form: prices: "Tarifs" + about_prices: "Les prix définis ici s'appliqueront aux membres qui s'abonneront à cette formule d'abonnement, pour les machines et les espaces." copy_prices_from: "Copier les prix depuis" copy_prices_from_help: "Cela remplacera tous les prix de cette formule d'abonnement par les prix de la formule sélectionnée" machines: "Machines" @@ -2215,8 +2250,6 @@ fr: unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement." all_products: "Tous les produits" create_a_product: "Créer un produit" - successfully_deleted: "Le produit a bien été supprimé" - unable_to_delete: "Impossible de supprimer le produit : " filter: "Filter" filter_clear: "Tout effacer" filter_apply: "Appliquer" @@ -2238,6 +2271,7 @@ fr: sort: "Trier :" visible_only: "Produits visibles uniquement" product_item: + product: "produit" visible: "visible" hidden: "caché" stock: @@ -2294,6 +2328,7 @@ fr: stocks: "Stock :" internal: "Stock interne" external: "Stock externe" + edit: "Modifier" all: "Tous types" remaining_stock: "Stock restant" type_in: "Ajouter" diff --git a/config/locales/app.admin.no.yml b/config/locales/app.admin.no.yml index 5526bf3e5..7b44369c1 100644 --- a/config/locales/app.admin.no.yml +++ b/config/locales/app.admin.no.yml @@ -3,7 +3,7 @@ admin: edit_destroy_buttons: deleted: "The {TYPE} was successfully deleted." - unable_to_delete: "Unable to delete the {TYPE}: " + unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" delete_confirmation: "Are you sure you want to delete this {TYPE}?" @@ -152,12 +152,19 @@ every_month: "Every month" every_year: "Every year" plan_form: - general_information: "General information" + ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" + tab_settings: "Settings" + tab_usage_limits: "Usage limits" + description: "Description" + general_settings: "General settings" + general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." + activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." group: "Group" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." + display: "Display" category: "Category" category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions." number_of_periods: "Number of periods" @@ -173,10 +180,9 @@ rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought." monthly_payment: "Monthly payment?" monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months." - description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" - new_user: "New user ..." + new_user: "New user" alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user." disabled: "Disable subscription" disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it." @@ -185,9 +191,37 @@ partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" - ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan" + save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" + plan_limit_form: + usage_limitation: "Limitation of use" + usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation." + usage_limitation_switch: "Restrict machine reservations to a number of hours per day." + new_usage_limitation: "Add a limitation of use" + all_limitations: "All limitations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + max_hours_per_day: "Max. hours/day" + ongoing_limitations: "Ongoing limitations" + saved_limitations: "Saved limitations" + cancel: "Cancel this limitation" + confirmation_title: "Delete the limitation" + confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." + delete_success: "The limitation was successfully deleted." + plan_limit_modal: + title: "Manage limitation of use" + limit_reservations: "Limit reservations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + categories_info: "If you select all machine categories, the limits will apply across the board." + machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten." + max_hours_per_day: "Maximum number of reservation hours per day" + confirm: "Confirm" partner_modal: title: "Create a new partner" create_partner: "Create the partner" @@ -196,6 +230,7 @@ email: "Email address" plan_pricing_form: prices: "Prices" + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -2215,8 +2250,6 @@ unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" - successfully_deleted: "The product has been successfully deleted" - unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" filter_apply: "Apply" @@ -2238,6 +2271,7 @@ sort: "Sort:" visible_only: "Visible products only" product_item: + product: "product" visible: "visible" hidden: "hidden" stock: @@ -2294,6 +2328,7 @@ stocks: "Stock:" internal: "Private stock" external: "Public stock" + edit: "Edit" all: "All types" remaining_stock: "Remaining stock" type_in: "Add" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index eb5517437..8edf494f5 100644 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -3,7 +3,7 @@ pt: admin: edit_destroy_buttons: deleted: "The {TYPE} was successfully deleted." - unable_to_delete: "Unable to delete the {TYPE}: " + unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" delete_confirmation: "Are you sure you want to delete this {TYPE}?" @@ -152,12 +152,19 @@ pt: every_month: "Every month" every_year: "Every year" plan_form: - general_information: "General information" + ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" + tab_settings: "Settings" + tab_usage_limits: "Usage limits" + description: "Description" + general_settings: "General settings" + general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." + activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." group: "Group" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." + display: "Display" category: "Category" category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions." number_of_periods: "Number of periods" @@ -173,10 +180,9 @@ pt: rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought." monthly_payment: "Monthly payment?" monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months." - description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" - new_user: "New user ..." + new_user: "New user" alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user." disabled: "Disable subscription" disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it." @@ -185,9 +191,37 @@ pt: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" - ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan" + save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" + plan_limit_form: + usage_limitation: "Limitation of use" + usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation." + usage_limitation_switch: "Restrict machine reservations to a number of hours per day." + new_usage_limitation: "Add a limitation of use" + all_limitations: "All limitations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + max_hours_per_day: "Max. hours/day" + ongoing_limitations: "Ongoing limitations" + saved_limitations: "Saved limitations" + cancel: "Cancel this limitation" + confirmation_title: "Delete the limitation" + confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." + delete_success: "The limitation was successfully deleted." + plan_limit_modal: + title: "Manage limitation of use" + limit_reservations: "Limit reservations" + by_category: "By machines category" + by_machine: "By machine" + category: "Machines category" + machine: "Machine name" + categories_info: "If you select all machine categories, the limits will apply across the board." + machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten." + max_hours_per_day: "Maximum number of reservation hours per day" + confirm: "Confirm" partner_modal: title: "Create a new partner" create_partner: "Create the partner" @@ -196,6 +230,7 @@ pt: email: "Email address" plan_pricing_form: prices: "Prices" + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -2215,8 +2250,6 @@ pt: unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" - successfully_deleted: "The product has been successfully deleted" - unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" filter_apply: "Apply" @@ -2238,6 +2271,7 @@ pt: sort: "Sort:" visible_only: "Visible products only" product_item: + product: "product" visible: "visible" hidden: "hidden" stock: @@ -2294,6 +2328,7 @@ pt: stocks: "Stock:" internal: "Private stock" external: "Public stock" + edit: "Edit" all: "All types" remaining_stock: "Remaining stock" type_in: "Add" diff --git a/config/locales/app.admin.zu.yml b/config/locales/app.admin.zu.yml index bb75b5f93..edc8acae7 100644 --- a/config/locales/app.admin.zu.yml +++ b/config/locales/app.admin.zu.yml @@ -3,7 +3,7 @@ zu: admin: edit_destroy_buttons: deleted: "crwdns36793:0{TYPE}crwdne36793:0" - unable_to_delete: "crwdns36795:0{TYPE}crwdne36795:0" + unable_to_delete: "crwdns36795:0crwdne36795:0" delete_item: "crwdns36797:0{TYPE}crwdne36797:0" confirm_delete: "crwdns36799:0crwdne36799:0" delete_confirmation: "crwdns36801:0{TYPE}crwdne36801:0" @@ -152,12 +152,19 @@ zu: every_month: "crwdns31885:0crwdne31885:0" every_year: "crwdns31887:0crwdne31887:0" plan_form: - general_information: "crwdns31889:0crwdne31889:0" + ACTION_title: "crwdns37397:0ACTION={ACTION}crwdne37397:0" + tab_settings: "crwdns37399:0crwdne37399:0" + tab_usage_limits: "crwdns37401:0crwdne37401:0" + description: "crwdns31931:0crwdne31931:0" + general_settings: "crwdns37403:0crwdne37403:0" + general_settings_info: "crwdns37405:0crwdne37405:0" + activation_and_payment: "crwdns37407:0crwdne37407:0" name: "crwdns31891:0crwdne31891:0" name_max_length: "crwdns31893:0crwdne31893:0" group: "crwdns31895:0crwdne31895:0" transversal: "crwdns31897:0crwdne31897:0" transversal_help: "crwdns31899:0crwdne31899:0" + display: "crwdns37409:0crwdne37409:0" category: "crwdns31901:0crwdne31901:0" category_help: "crwdns31903:0crwdne31903:0" number_of_periods: "crwdns31905:0crwdne31905:0" @@ -173,7 +180,6 @@ zu: rolling_subscription_help: "crwdns31925:0crwdne31925:0" monthly_payment: "crwdns31927:0crwdne31927:0" monthly_payment_help: "crwdns31929:0crwdne31929:0" - description: "crwdns31931:0crwdne31931:0" information_sheet: "crwdns31933:0crwdne31933:0" notified_partner: "crwdns31935:0crwdne31935:0" new_user: "crwdns31937:0crwdne31937:0" @@ -185,9 +191,37 @@ zu: partner_plan: "crwdns31949:0crwdne31949:0" partner_plan_help: "crwdns31951:0crwdne31951:0" partner_created: "crwdns31953:0crwdne31953:0" - ACTION_plan: "crwdns31955:0ACTION={ACTION}crwdne31955:0" + save: "crwdns37411:0crwdne37411:0" create_success: "crwdns31957:0crwdne31957:0" update_success: "crwdns31959:0crwdne31959:0" + plan_limit_form: + usage_limitation: "crwdns37413:0crwdne37413:0" + usage_limitation_info: "crwdns37415:0crwdne37415:0" + usage_limitation_switch: "crwdns37417:0crwdne37417:0" + new_usage_limitation: "crwdns37419:0crwdne37419:0" + all_limitations: "crwdns37421:0crwdne37421:0" + by_category: "crwdns37477:0crwdne37477:0" + by_machine: "crwdns37425:0crwdne37425:0" + category: "crwdns37427:0crwdne37427:0" + machine: "crwdns37429:0crwdne37429:0" + max_hours_per_day: "crwdns37431:0crwdne37431:0" + ongoing_limitations: "crwdns37433:0crwdne37433:0" + saved_limitations: "crwdns37435:0crwdne37435:0" + cancel: "crwdns37437:0crwdne37437:0" + confirmation_title: "crwdns37439:0crwdne37439:0" + confirmation_message: "crwdns37441:0crwdne37441:0" + delete_success: "crwdns37443:0crwdne37443:0" + plan_limit_modal: + title: "crwdns37445:0crwdne37445:0" + limit_reservations: "crwdns37447:0crwdne37447:0" + by_category: "crwdns37479:0crwdne37479:0" + by_machine: "crwdns37451:0crwdne37451:0" + category: "crwdns37453:0crwdne37453:0" + machine: "crwdns37455:0crwdne37455:0" + categories_info: "crwdns37457:0crwdne37457:0" + machine_info: "crwdns37459:0crwdne37459:0" + max_hours_per_day: "crwdns37461:0crwdne37461:0" + confirm: "crwdns37463:0crwdne37463:0" partner_modal: title: "crwdns31961:0crwdne31961:0" create_partner: "crwdns31963:0crwdne31963:0" @@ -196,6 +230,7 @@ zu: email: "crwdns31969:0crwdne31969:0" plan_pricing_form: prices: "crwdns31971:0crwdne31971:0" + about_prices: "crwdns37465:0crwdne37465:0" copy_prices_from: "crwdns31973:0crwdne31973:0" copy_prices_from_help: "crwdns31975:0crwdne31975:0" machines: "crwdns31977:0crwdne31977:0" @@ -2215,8 +2250,6 @@ zu: unexpected_error_occurred: "crwdns31338:0crwdne31338:0" all_products: "crwdns31340:0crwdne31340:0" create_a_product: "crwdns31342:0crwdne31342:0" - successfully_deleted: "crwdns31344:0crwdne31344:0" - unable_to_delete: "crwdns31346:0crwdne31346:0" filter: "crwdns31348:0crwdne31348:0" filter_clear: "crwdns31350:0crwdne31350:0" filter_apply: "crwdns31352:0crwdne31352:0" @@ -2238,6 +2271,7 @@ zu: sort: "crwdns31380:0crwdne31380:0" visible_only: "crwdns31382:0crwdne31382:0" product_item: + product: "crwdns37467:0crwdne37467:0" visible: "crwdns31384:0crwdne31384:0" hidden: "crwdns31386:0crwdne31386:0" stock: @@ -2294,6 +2328,7 @@ zu: stocks: "crwdns31480:0crwdne31480:0" internal: "crwdns31482:0crwdne31482:0" external: "crwdns31484:0crwdne31484:0" + edit: "crwdns37469:0crwdne37469:0" all: "crwdns31486:0crwdne31486:0" remaining_stock: "crwdns31488:0crwdne31488:0" type_in: "crwdns31490:0crwdne31490:0" diff --git a/config/locales/app.logged.pt.yml b/config/locales/app.logged.pt.yml index d2546c3b7..e89af82d5 100644 --- a/config/locales/app.logged.pt.yml +++ b/config/locales/app.logged.pt.yml @@ -156,7 +156,7 @@ pt: cancelled_slot: "Cancelled" credits_panel: title: "My credits" - info: "Your subscription comes with free credits you can use on reservations" + info: "Your subscription comes with free credits you can use when reserving" remaining_credits_html: "You can book {REMAINING} {REMAINING, plural, one{slot} other{slots}} for free." used_credits_html: "You have already used {USED} {USED, plural, =0{credit} one{credit} other{credits}}." no_credits: "You don't have any credits yet. Some subscriptions may allow you to book some slots for free." diff --git a/config/locales/app.shared.de.yml b/config/locales/app.shared.de.yml index a144ffe52..e6729c1d2 100644 --- a/config/locales/app.shared.de.yml +++ b/config/locales/app.shared.de.yml @@ -540,3 +540,6 @@ de: show_reserved_uniq: "Show only slots with reservations" machine: machine_uncategorized: "Uncategorized machines" + form_unsaved_list: + save_reminder: "Do not forget to save your changes" + cancel: "Cancel" diff --git a/config/locales/app.shared.es.yml b/config/locales/app.shared.es.yml index 163ac6409..84b402efe 100644 --- a/config/locales/app.shared.es.yml +++ b/config/locales/app.shared.es.yml @@ -540,3 +540,6 @@ es: show_reserved_uniq: "Show only slots with reservations" machine: machine_uncategorized: "Uncategorized machines" + form_unsaved_list: + save_reminder: "Do not forget to save your changes" + cancel: "Cancel" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 014695efe..7a1bed07c 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -540,3 +540,6 @@ fr: show_reserved_uniq: "Afficher uniquement les créneaux avec réservation" machine: machine_uncategorized: "Machines non classés" + form_unsaved_list: + save_reminder: "N'oubliez pas d'enregistrer vos modifications" + cancel: "Annuler" diff --git a/config/locales/app.shared.no.yml b/config/locales/app.shared.no.yml index 37432f921..c4cfe28ae 100644 --- a/config/locales/app.shared.no.yml +++ b/config/locales/app.shared.no.yml @@ -540,3 +540,6 @@ show_reserved_uniq: "Show only slots with reservations" machine: machine_uncategorized: "Uncategorized machines" + form_unsaved_list: + save_reminder: "Do not forget to save your changes" + cancel: "Cancel" diff --git a/config/locales/app.shared.pt.yml b/config/locales/app.shared.pt.yml index d10597589..979387e4f 100644 --- a/config/locales/app.shared.pt.yml +++ b/config/locales/app.shared.pt.yml @@ -540,3 +540,6 @@ pt: show_reserved_uniq: "Show only slots with reservations" machine: machine_uncategorized: "Uncategorized machines" + form_unsaved_list: + save_reminder: "Do not forget to save your changes" + cancel: "Cancel" diff --git a/config/locales/app.shared.zu.yml b/config/locales/app.shared.zu.yml index 160d0576c..16d393cda 100644 --- a/config/locales/app.shared.zu.yml +++ b/config/locales/app.shared.zu.yml @@ -540,3 +540,6 @@ zu: show_reserved_uniq: "crwdns36249:0crwdne36249:0" machine: machine_uncategorized: "crwdns36219:0crwdne36219:0" + form_unsaved_list: + save_reminder: "crwdns37471:0crwdne37471:0" + cancel: "crwdns37473:0crwdne37473:0" diff --git a/config/locales/de.yml b/config/locales/de.yml index 1e4c10649..7d5dc76af 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -519,6 +519,7 @@ de: availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" diff --git a/config/locales/es.yml b/config/locales/es.yml index c5b568b26..758835ce4 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -519,6 +519,7 @@ es: availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 9190d103d..23862982e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -519,6 +519,7 @@ fr: availability: "La disponibilité n'existe pas" full: "Le créneau est déjà entièrement réservé" deadline: "Vous ne pouvez pas réserver un créneau %{MINUTES} minutes avant son début" + limit_reached: "Vous avez atteint la limite de réservation de %{HOURS}H par jour pour la %{RESERVABLE}, pour votre abonnement actuel" restricted: "Cette disponibilité n'est disponible que pour les abonnés" plan: "Cette formule d'abonnement est désactivé" plan_group: "Cette formule d'abonnement est réservée aux membres du groupe %{GROUP}" diff --git a/config/locales/no.yml b/config/locales/no.yml index ea878e0b5..d35e5fca3 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -519,6 +519,7 @@ availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index ad4cc56b1..70a9519a3 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -519,6 +519,7 @@ pt: availability: "The availaility doesn't exist" full: "The slot is already fully reserved" deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start" + limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription" restricted: "This availability is restricted for subscribers" plan: "This subscription plan is disabled" plan_group: "This subscription plan is reserved for members of group %{GROUP}" diff --git a/config/locales/zu.yml b/config/locales/zu.yml index 1b77b8e2f..f4d695292 100644 --- a/config/locales/zu.yml +++ b/config/locales/zu.yml @@ -519,6 +519,7 @@ zu: availability: "crwdns36269:0crwdne36269:0" full: "crwdns36271:0crwdne36271:0" deadline: "crwdns36273:0%{MINUTES}crwdne36273:0" + limit_reached: "crwdns37475:0%{HOURS}crwdnd37475:0%{RESERVABLE}crwdne37475:0" restricted: "crwdns36275:0crwdne36275:0" plan: "crwdns36277:0crwdne36277:0" plan_group: "crwdns37207:0%{GROUP}crwdne37207:0" From 25cbf63b48070f53fc08fdb2f0351dfdb5ad450d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 14 Mar 2023 16:45:01 +0100 Subject: [PATCH 20/23] (feat) notify member on limit reached --- CHANGELOG.md | 2 + app/models/reservation.rb | 11 ++++ app/services/reservation_limit_service.rb | 50 +++++++++++++++---- ...er_reservation_limit_reached.json.jbuilder | 7 +++ ..._member_reservation_limit_reached.html.erb | 9 ++++ config/locales/app.admin.de.yml | 12 ++--- config/locales/app.admin.en.yml | 13 +++-- config/locales/app.admin.es.yml | 12 ++--- config/locales/app.admin.fr.yml | 10 ++-- config/locales/app.admin.no.yml | 12 ++--- config/locales/app.admin.pt.yml | 12 ++--- config/locales/app.admin.zu.yml | 10 ++-- config/locales/de.yml | 2 + config/locales/en.yml | 2 + config/locales/es.yml | 2 + config/locales/fr.yml | 2 + config/locales/mails.de.yml | 4 ++ config/locales/mails.en.yml | 4 ++ config/locales/mails.es.yml | 4 ++ config/locales/mails.fr.yml | 4 ++ config/locales/mails.no.yml | 4 ++ config/locales/mails.pt.yml | 4 ++ config/locales/mails.zu.yml | 4 ++ config/locales/no.yml | 2 + config/locales/pt.yml | 2 + config/locales/zu.yml | 2 + db/seeds/notification_types.rb | 8 +++ .../reservation_limit_service_test.rb | 40 +++++++++++++-- 28 files changed, 196 insertions(+), 54 deletions(-) create mode 100644 app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder create mode 100644 app/views/notifications_mailer/notify_member_reservation_limit_reached.html.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3296b7405..5eaf4fc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog Fab-manager +- [TODO DEPLOY] `rails db:seed` + ## v5.8.2 2023 March 13 - Improved upgrade script diff --git a/app/models/reservation.rb b/app/models/reservation.rb index 50f240627..d0472354b 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -32,6 +32,7 @@ class Reservation < ApplicationRecord after_commit :notify_member_create_reservation, on: :create after_commit :notify_admin_member_create_reservation, on: :create after_commit :extend_subscription, on: :create + after_commit :notify_member_limitation_reached, on: :create delegate :user, to: :statistic_profile @@ -137,4 +138,14 @@ class Reservation < ApplicationRecord receiver: User.admins_and_managers, attached_object: self end + + def notify_member_limitation_reached + date = ReservationLimitService.reached_limit_date(self) + return if date.nil? + + NotificationCenter.call type: 'notify_member_reservation_limit_reached', + receiver: user, + attached_object: ReservationLimitService.limit(user.subscribed_plan, reservable), + meta_data: { date: date } + end end diff --git a/app/services/reservation_limit_service.rb b/app/services/reservation_limit_service.rb index d4cf57b51..64ae9b7dc 100644 --- a/app/services/reservation_limit_service.rb +++ b/app/services/reservation_limit_service.rb @@ -19,20 +19,39 @@ class ReservationLimitService reservation.cart_item_reservation_slots.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots| daily_duration = reservations_duration(customer, date, reservation, cart_items) + (reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0) - return false if Rational(daily_duration / 3600).to_f > limit + return false if Rational(daily_duration / 3600).to_f > limit.limit end true end + # @param reservation [Reservation] + # @return [Date,NilClass] + def reached_limit_date(reservation) + user = reservation.user + plan = user.subscribed_plan + return nil if plan.nil? || !plan.limiting + + limit = limit(plan, reservation.reservable) + return nil if limit.nil? + + reservation.slots_reservations.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots| + daily_duration = saved_reservations_durations(user, reservation.reservable, date, reservation) + + (reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0) + return date if Rational(daily_duration / 3600).to_f >= limit.limit + end + + nil + end + # @param plan [Plan,NilClass] # @param reservable [Machine,Event,Space,Training] - # @return [Integer,NilClass] in hours + # @return [PlanLimitation] in hours def limit(plan, reservable) return nil unless plan&.limiting limitations = plan&.plan_limitations&.filter { |limit| limit.reservables.include?(reservable) } - limitations&.find { |limit| limit.limitable_type != 'MachineCategory' }&.limit || limitations&.first&.limit + limitations&.find { |limit| limit.limitable_type != 'MachineCategory' } || limitations&.first end private @@ -43,11 +62,7 @@ class ReservationLimitService # @param cart_items [Array] # @return [Integer] in seconds def reservations_duration(customer, date, reservation, cart_items) - daily_reservations = customer.reservations - .includes(slots_reservations: :slot) - .where(reservable: reservation.reservable) - .where(slots_reservations: { canceled_at: nil }) - .where("date_trunc('day', slots.start_at) = :date", date: date) + daily_reservations_hours = saved_reservations_durations(customer, reservation.reservable, date) cart_daily_reservations = cart_items.filter do |item| item.is_a?(CartItem::Reservation) && @@ -58,8 +73,25 @@ class ReservationLimitService .where("date_trunc('day', slots.start_at) = :date", date: date) end - (daily_reservations.map { |r| r.slots_reservations.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0) + + daily_reservations_hours + (cart_daily_reservations.map { |r| r.cart_item_reservation_slots.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0) end + + # @param customer [User] + # @param reservable [Machine,Event,Space,Training] + # @param date [Date] + # @param reservation [Reservation] + # @return [Integer] in seconds + def saved_reservations_durations(customer, reservable, date, reservation = nil) + daily_reservations = customer.reservations + .includes(slots_reservations: :slot) + .where(reservable: reservable) + .where(slots_reservations: { canceled_at: nil }) + .where("date_trunc('day', slots.start_at) = :date", date: date) + + daily_reservations = daily_reservations.where.not(id: reservation.id) unless reservation.nil? + + (daily_reservations.map { |r| r.slots_reservations.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0) + end end end diff --git a/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder b/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder new file mode 100644 index 000000000..f90b16e39 --- /dev/null +++ b/app/views/api/notifications/_notify_member_reservation_limit_reached.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.limit_reached', + HOURS: notification.attached_object.limit, + ITEM: notification.attached_object.limitable.name, + DATE: I18n.l(notification.get_meta_data(:date).to_date)) diff --git a/app/views/notifications_mailer/notify_member_reservation_limit_reached.html.erb b/app/views/notifications_mailer/notify_member_reservation_limit_reached.html.erb new file mode 100644 index 000000000..e8949e66d --- /dev/null +++ b/app/views/notifications_mailer/notify_member_reservation_limit_reached.html.erb @@ -0,0 +1,9 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.limit_reached', + HOURS: @attached_object.limit, + ITEM: @attached_object.limitable.name, + DATE: I18n.l(@notification.get_meta_data(:date).to_date)) %> +

+ diff --git a/config/locales/app.admin.de.yml b/config/locales/app.admin.de.yml index b793ae586..3bf5b5482 100644 --- a/config/locales/app.admin.de.yml +++ b/config/locales/app.admin.de.yml @@ -230,7 +230,7 @@ de: email: "Email address" plan_pricing_form: prices: "Prices" - about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -321,7 +321,7 @@ de: manage_trainings: "Klicke hier, um Schulungen hinzuzufügen oder zu entfernen." number_of_tickets: "Anzahl der Tickets: " adjust_the_opening_hours: "Öffnungszeiten anpassen" - to_time: "bis" #eg. from 18:00 to 21:00 + to_time: "bis" #e.g. from 18:00 to 21:00 restrict_options: "Einschränkungsoptionen" restrict_with_labels: "Diesen Slot mit Labels einschränken" restrict_for_subscriptions: "Diesen Slot auf Abonnenten einschränken" @@ -543,8 +543,8 @@ de: on_DATE: "am {DATE}" from_DATE: "von {DATE}" from_TIME: "ab {TIME}" - to_date: "bis" #eg: from 01/01 to 01/05 - to_time: "bis" #eg. from 18:00 to 21:00 + to_date: "bis" #e.g.: from 01/01 to 01/05 + to_time: "bis" #e.g. from 18:00 to 21:00 title: "Titel" dates: "Datum" booking: "Buchung" @@ -1488,8 +1488,8 @@ de: statistics: "Statistiken" evolution: "Entwicklung" age_filter: "Altersfilter" - from_age: "Von" #eg. from 8 to 40 years old - to_age: "bis" #eg. from 8 to 40 years old + from_age: "Von" #e.g. from 8 to 40 years old + to_age: "bis" #e.g. from 8 to 40 years old start: "Start:" end: "Ende:" custom_filter: "Benutzerderfinierter Filter" diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 4cc346e05..fd3c7234a 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -180,7 +180,6 @@ en: rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought." monthly_payment: "Monthly payment?" monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months." - description: "Description" information_sheet: "Information sheet" notified_partner: "Notified partner" new_user: "New user" @@ -231,7 +230,7 @@ en: email: "Email address" plan_pricing_form: prices: "Prices" - about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -322,7 +321,7 @@ en: manage_trainings: "Click here to add or remove trainings." number_of_tickets: "Number of tickets: " adjust_the_opening_hours: "Adjust the opening hours" - to_time: "to" #eg. from 18:00 to 21:00 + to_time: "to" #e.g. from 18:00 to 21:00 restrict_options: "Restriction options" restrict_with_labels: "Restrict this slot with labels" restrict_for_subscriptions: "Restrict this slot for subscription users" @@ -544,8 +543,8 @@ en: on_DATE: "on {DATE}" from_DATE: "from {DATE}" from_TIME: "from {TIME}" - to_date: "to" #eg: from 01/01 to 01/05 - to_time: "to" #eg. from 18:00 to 21:00 + to_date: "to" #e.g.: from 01/01 to 01/05 + to_time: "to" #e.g. from 18:00 to 21:00 title: "Title" dates: "Dates" booking: "Booking" @@ -1489,8 +1488,8 @@ en: statistics: "Statistics" evolution: "Evolution" age_filter: "Age filter" - from_age: "From" #eg. from 8 to 40 years old - to_age: "to" #eg. from 8 to 40 years old + from_age: "From" #e.g. from 8 to 40 years old + to_age: "to" #e.g. from 8 to 40 years old start: "Start:" end: "End:" custom_filter: "Custom filter" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index 63bff355b..709f3535a 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -230,7 +230,7 @@ es: email: "Email address" plan_pricing_form: prices: "Prices" - about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -321,7 +321,7 @@ es: manage_trainings: "Click here to add or remove trainings." number_of_tickets: "Número de tickets: " adjust_the_opening_hours: "Ajustar el horario de apertura" - to_time: "a" #eg. from 18:00 to 21:00 + to_time: "a" #e.g. from 18:00 to 21:00 restrict_options: "Restriction options" restrict_with_labels: "Restringir este horario con etiquetas" restrict_for_subscriptions: "Restrict this slot for subscription users" @@ -543,8 +543,8 @@ es: on_DATE: "on {DATE}" from_DATE: "Desde {DATE}" from_TIME: "Desde {TIME}" - to_date: "to" #eg: from 01/01 to 01/05 - to_time: "to" #eg. from 18:00 to 21:00 + to_date: "to" #e.g.: from 01/01 to 01/05 + to_time: "to" #e.g. from 18:00 to 21:00 title: "Title" dates: "Dates" booking: "Booking" @@ -1488,8 +1488,8 @@ es: statistics: "Statistics" evolution: "Evolución" age_filter: "Filtro de edad" - from_age: "Desde" #eg. from 8 to 40 years old - to_age: "a" #eg. from 8 to 40 years old + from_age: "Desde" #e.g. from 8 to 40 years old + to_age: "a" #e.g. from 8 to 40 years old start: "Principio:" end: "Final:" custom_filter: "Filtro personalizado" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index dc08d80fc..ef93f982c 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -321,7 +321,7 @@ fr: manage_trainings: "Cliquez-ici pour ajouter ou supprimer des formations." number_of_tickets: "Nombre de places : " adjust_the_opening_hours: "Ajuster l'horaire" - to_time: "à" #eg. from 18:00 to 21:00 + to_time: "à" #e.g. from 18:00 to 21:00 restrict_options: "Options de restriction" restrict_with_labels: "Restreindre ce créneau avec des étiquettes" restrict_for_subscriptions: "Restreindre ce créneau pour les abonnements" @@ -543,8 +543,8 @@ fr: on_DATE: "le {DATE}" from_DATE: "du {DATE}" from_TIME: "de {TIME}" - to_date: "au" #eg: from 01/01 to 01/05 - to_time: "à" #eg. from 18:00 to 21:00 + to_date: "au" #e.g.: from 01/01 to 01/05 + to_time: "à" #e.g. from 18:00 to 21:00 title: "Titre" dates: "Dates" booking: "Réservations" @@ -1488,8 +1488,8 @@ fr: statistics: "Statistiques" evolution: "Évolution" age_filter: "Filtre d'âge" - from_age: "De" #eg. from 8 to 40 years old - to_age: "à" #eg. from 8 to 40 years old + from_age: "De" #e.g. from 8 to 40 years old + to_age: "à" #e.g. from 8 to 40 years old start: "Début :" end: "Fin :" custom_filter: "Filtre personnalisé" diff --git a/config/locales/app.admin.no.yml b/config/locales/app.admin.no.yml index 7b44369c1..f8a9e446a 100644 --- a/config/locales/app.admin.no.yml +++ b/config/locales/app.admin.no.yml @@ -230,7 +230,7 @@ email: "Email address" plan_pricing_form: prices: "Prices" - about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -321,7 +321,7 @@ manage_trainings: "Klikk her for å legge til eller endre opplæring." number_of_tickets: "Antall billetter: " adjust_the_opening_hours: "Endre åpningstid" - to_time: "til" #eg. from 18:00 to 21:00 + to_time: "til" #e.g. from 18:00 to 21:00 restrict_options: "Alternativer for begrensning" restrict_with_labels: "Begrens denne reservasjonen med etiketter" restrict_for_subscriptions: "Begrens denne reservasjoen til medlemmer" @@ -543,8 +543,8 @@ on_DATE: "{DATE}" from_DATE: "fra {DATE}" from_TIME: "fra {TIME}" - to_date: "til" #eg: from 01/01 to 01/05 - to_time: "til" #eg. from 18:00 to 21:00 + to_date: "til" #e.g.: from 01/01 to 01/05 + to_time: "til" #e.g. from 18:00 to 21:00 title: "Tittel" dates: "Datoer" booking: "Reservasjon" @@ -1488,8 +1488,8 @@ statistics: "Statistikk" evolution: "Utvikling" age_filter: "Aldersfilter" - from_age: "Fra" #eg. from 8 to 40 years old - to_age: "til" #eg. from 8 to 40 years old + from_age: "Fra" #e.g. from 8 to 40 years old + to_age: "til" #e.g. from 8 to 40 years old start: "Start:" end: "Slutt:" custom_filter: "Egendefinerte filtre" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index 8edf494f5..35c786b86 100644 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -230,7 +230,7 @@ pt: email: "Email address" plan_pricing_form: prices: "Prices" - about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces." + about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour." copy_prices_from: "Copy prices from" copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan" machines: "Machines" @@ -321,7 +321,7 @@ pt: manage_trainings: "Clique aqui para adicionar ou remover treinamentos." number_of_tickets: "Número de vagas: " adjust_the_opening_hours: "Ajustar o horário de funcionamento" - to_time: "ás" #eg. from 18:00 to 21:00 + to_time: "ás" #e.g. from 18:00 to 21:00 restrict_options: "Opções de restrição" restrict_with_labels: "Restrinja este slot com etiquetas" restrict_for_subscriptions: "Restringir este slot para os usuários da assinatura" @@ -543,8 +543,8 @@ pt: on_DATE: "No {DATE}" from_DATE: "Em {DATE}" from_TIME: "Ás {TIME}" - to_date: "ás" #eg: from 01/01 to 01/05 - to_time: "ás" #eg. from 18:00 to 21:00 + to_date: "ás" #e.g.: from 01/01 to 01/05 + to_time: "ás" #e.g. from 18:00 to 21:00 title: "Título" dates: "Datas" booking: "Reserva" @@ -1488,8 +1488,8 @@ pt: statistics: "Estatísticas" evolution: "Evolução" age_filter: "Filtro de idade" - from_age: "Dos" #eg. from 8 to 40 years old - to_age: "aos" #eg. from 8 to 40 years old + from_age: "Dos" #e.g. from 8 to 40 years old + to_age: "aos" #e.g. from 8 to 40 years old start: "Início:" end: "Fim:" custom_filter: "Filtro customizado" diff --git a/config/locales/app.admin.zu.yml b/config/locales/app.admin.zu.yml index edc8acae7..9e785700c 100644 --- a/config/locales/app.admin.zu.yml +++ b/config/locales/app.admin.zu.yml @@ -321,7 +321,7 @@ zu: manage_trainings: "crwdns24132:0crwdne24132:0" number_of_tickets: "crwdns24134:0crwdne24134:0" adjust_the_opening_hours: "crwdns24136:0crwdne24136:0" - to_time: "crwdns24138:0crwdne24138:0" #eg. from 18:00 to 21:00 + to_time: "crwdns24138:0crwdne24138:0" #e.g. from 18:00 to 21:00 restrict_options: "crwdns24140:0crwdne24140:0" restrict_with_labels: "crwdns24142:0crwdne24142:0" restrict_for_subscriptions: "crwdns24144:0crwdne24144:0" @@ -543,8 +543,8 @@ zu: on_DATE: "crwdns24452:0{DATE}crwdne24452:0" from_DATE: "crwdns24454:0{DATE}crwdne24454:0" from_TIME: "crwdns24456:0{TIME}crwdne24456:0" - to_date: "crwdns24458:0crwdne24458:0" #eg: from 01/01 to 01/05 - to_time: "crwdns24460:0crwdne24460:0" #eg. from 18:00 to 21:00 + to_date: "crwdns24458:0crwdne24458:0" #e.g.: from 01/01 to 01/05 + to_time: "crwdns24460:0crwdne24460:0" #e.g. from 18:00 to 21:00 title: "crwdns24462:0crwdne24462:0" dates: "crwdns24464:0crwdne24464:0" booking: "crwdns24466:0crwdne24466:0" @@ -1488,8 +1488,8 @@ zu: statistics: "crwdns26224:0crwdne26224:0" evolution: "crwdns26226:0crwdne26226:0" age_filter: "crwdns26228:0crwdne26228:0" - from_age: "crwdns26230:0crwdne26230:0" #eg. from 8 to 40 years old - to_age: "crwdns26232:0crwdne26232:0" #eg. from 8 to 40 years old + from_age: "crwdns26230:0crwdne26230:0" #e.g. from 8 to 40 years old + to_age: "crwdns26232:0crwdne26232:0" #e.g. from 8 to 40 years old start: "crwdns26234:0crwdne26234:0" end: "crwdns26236:0crwdne26236:0" custom_filter: "crwdns26238:0crwdne26238:0" diff --git a/config/locales/de.yml b/config/locales/de.yml index 7d5dc76af..c5fc2ebb7 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -433,6 +433,8 @@ de: schedule_deadline: "Sie müssen den Scheck zur %{DATE} -Frist einlösen, für den Zeitplan %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "Sie müssen das Lastschriftverfahren für die %{DATE} -Frist bestätigen, für Zeitplan %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Supporting document uploaded by member %{NAME}." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/en.yml b/config/locales/en.yml index d8a3b9064..7efcadac9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -433,6 +433,8 @@ en: schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Supporting document uploaded by member %{NAME}." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/es.yml b/config/locales/es.yml index 758835ce4..5814ffea2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -433,6 +433,8 @@ es: schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Supporting document uploaded by member %{NAME}." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 23862982e..268f297da 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -433,6 +433,8 @@ fr: schedule_deadline: "Vous devez encaisser le chèque de l'échéance du %{DATE}, pour l'échéancier %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "Vous devez confirmer le prélèvement bancaire pour l'échéance du %{DATE} , pour l'échéancier %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "Pour le %{DATE}, vous avez atteint votre limite quotidienne de %{HOURS} heures de réservation de la %{ITEM}." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Le membre %{NAME} a téléversé un nouveau justificatif." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/mails.de.yml b/config/locales/mails.de.yml index bf6cd16b9..1de187d70 100644 --- a/config/locales/mails.de.yml +++ b/config/locales/mails.de.yml @@ -375,6 +375,10 @@ de: remember: "Gemäß Ihrem Zahlungsplan von %{REFERENCE} wurde zum %{DATE} eine Belastung der Karte in Höhe von %{AMOUNT} geplant." date: "Dies ist eine Erinnerung zur Prüfung, ob das Bankkonto erfolgreich belastet werden konnte." confirm: "Bitte bestätigen Sie den Erhalt des Guthabens in Ihrer Zahlungsverwaltung, damit die entsprechende Rechnung generiert werden kann." + notify_member_reservation_limit_reached: + subject: "Daily reservation limit reached" + body: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: subject: "Supporting documents uploaded by a member" body: diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 9cf5f2f1e..6151da3c6 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -375,6 +375,10 @@ en: remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}." date: "This is a reminder to verify that the direct bank debit was successfull." confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated." + notify_member_reservation_limit_reached: + subject: "Daily reservation limit reached" + body: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: subject: "Supporting documents uploaded by a member" body: diff --git a/config/locales/mails.es.yml b/config/locales/mails.es.yml index de3a39772..338ae9812 100644 --- a/config/locales/mails.es.yml +++ b/config/locales/mails.es.yml @@ -375,6 +375,10 @@ es: remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}." date: "This is a reminder to verify that the direct bank debit was successfull." confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated." + notify_member_reservation_limit_reached: + subject: "Daily reservation limit reached" + body: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: subject: "Supporting documents uploaded by a member" body: diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index b6d51198b..0517ad9e2 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -375,6 +375,10 @@ fr: remember: "Conformément à l'échéancier de paiement %{REFERENCE}, une échéance de %{AMOUNT} était prévu pour être prélevée le %{DATE}." date: "Ceci est un rappel pour vérifier que le prélèvement bancaire a bien été effectué." confirm: "Veuillez confirmer la réception des fonds dans votre interface de gestion des échéanciers de paiement, afin que la facture correspondante soit générée." + notify_member_reservation_limit_reached: + subject: "Limite de réservation quotidienne atteinte" + body: + limit_reached: "Pour le %{DATE}, vous avez atteint votre limite quotidienne de %{HOURS} heures de réservation de la %{ITEM}." notify_admin_user_supporting_document_files_created: subject: "Justificatif téléversé par un membre" body: diff --git a/config/locales/mails.no.yml b/config/locales/mails.no.yml index dfa2d7078..a4ed1ec5e 100644 --- a/config/locales/mails.no.yml +++ b/config/locales/mails.no.yml @@ -375,6 +375,10 @@ remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}." date: "This is a reminder to verify that the direct bank debit was successfull." confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated." + notify_member_reservation_limit_reached: + subject: "Daily reservation limit reached" + body: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: subject: "Supporting documents uploaded by a member" body: diff --git a/config/locales/mails.pt.yml b/config/locales/mails.pt.yml index 2440db3c6..2462370ec 100644 --- a/config/locales/mails.pt.yml +++ b/config/locales/mails.pt.yml @@ -375,6 +375,10 @@ pt: remember: "De acordo com a agenda de pagamento %{REFERENCE}, %{AMOUNT} deveria ser debitado em %{DATE}." date: "Este é um lembrete para verificar se o débito bancário foi bem sucedido." confirm: "Não se esqueça de confirmar o recibo na interface de gestão de pagamento, para que a fatura correspondente seja gerada." + notify_member_reservation_limit_reached: + subject: "Daily reservation limit reached" + body: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: subject: "Supporting documents uploaded by a member" body: diff --git a/config/locales/mails.zu.yml b/config/locales/mails.zu.yml index 5b0661463..6aca672d6 100644 --- a/config/locales/mails.zu.yml +++ b/config/locales/mails.zu.yml @@ -375,6 +375,10 @@ zu: remember: "crwdns29938:0%{REFERENCE}crwdnd29938:0%{AMOUNT}crwdnd29938:0%{DATE}crwdne29938:0" date: "crwdns29940:0crwdne29940:0" confirm: "crwdns29942:0crwdne29942:0" + notify_member_reservation_limit_reached: + subject: "crwdns37483:0crwdne37483:0" + body: + limit_reached: "crwdns37485:0%{DATE}crwdnd37485:0%{HOURS}crwdnd37485:0%{ITEM}crwdne37485:0" notify_admin_user_supporting_document_files_created: subject: "crwdns37349:0crwdne37349:0" body: diff --git a/config/locales/no.yml b/config/locales/no.yml index d35e5fca3..88c958a75 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -433,6 +433,8 @@ schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Supporting document uploaded by member %{NAME}." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 70a9519a3..a191da464 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -433,6 +433,8 @@ pt: schedule_deadline: "Você deve realizar a verificação para a data limite de %{DATE} para agendar %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "Você deve realizar a verificação do débito para a data limite de %{DATE}, para o agendamento %{REFERENCE}" + notify_member_reservation_limit_reached: + limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation." notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "Supporting document uploaded by member %{NAME}." notify_admin_user_supporting_document_files_updated: diff --git a/config/locales/zu.yml b/config/locales/zu.yml index f4d695292..d85098d04 100644 --- a/config/locales/zu.yml +++ b/config/locales/zu.yml @@ -433,6 +433,8 @@ zu: schedule_deadline: "crwdns21120:0%{DATE}crwdnd21120:0%{REFERENCE}crwdne21120:0" notify_admin_payment_schedule_transfer_deadline: schedule_deadline: "crwdns22305:0%{DATE}crwdnd22305:0%{REFERENCE}crwdne22305:0" + notify_member_reservation_limit_reached: + limit_reached: "crwdns37481:0%{DATE}crwdnd37481:0%{HOURS}crwdnd37481:0%{ITEM}crwdne37481:0" notify_admin_user_supporting_document_files_created: supporting_document_files_uploaded: "crwdns37341:0%{NAME}crwdne37341:0" notify_admin_user_supporting_document_files_updated: diff --git a/db/seeds/notification_types.rb b/db/seeds/notification_types.rb index 43d213005..8dbe8dc68 100644 --- a/db/seeds/notification_types.rb +++ b/db/seeds/notification_types.rb @@ -23,3 +23,11 @@ unless NotificationType.find_by(name: 'notify_admin_order_is_paid') is_configurable: true ) end + +unless NotificationType.find_by(name: 'notify_member_reservation_limit_reached') + NotificationType.create!( + name: 'notify_member_reservation_limit_reached', + category: 'agenda', + is_configurable: false + ) +end diff --git a/test/services/reservation_limit_service_test.rb b/test/services/reservation_limit_service_test.rb index 8976f0515..5c9f432cf 100644 --- a/test/services/reservation_limit_service_test.rb +++ b/test/services/reservation_limit_service_test.rb @@ -50,7 +50,7 @@ class ReservationLimitServiceTest < ActiveSupport::TestCase customer_profile: @acamus.invoicing_profile, operator_profile: @acamus.invoicing_profile, reservable: @machine, - cart_item_reservation_slots_attributes: [{ slot: slots[0] }, { slot: slots[1] }, { slot: slots[2] }] + cart_item_reservation_slots_attributes: [{ slot: slots[2] }, { slot: slots[3] }, { slot: slots[4] }] ) assert_not ReservationLimitService.authorized?(@plan, @acamus, reservation, []) end @@ -118,7 +118,7 @@ class ReservationLimitServiceTest < ActiveSupport::TestCase test 'get plan limit' do @plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }]) - assert_equal 2, ReservationLimitService.limit(@plan, @machine) + assert_equal 2, ReservationLimitService.limit(@plan, @machine).limit end test 'get plan without limit' do @@ -129,13 +129,45 @@ class ReservationLimitServiceTest < ActiveSupport::TestCase category = MachineCategory.find(1) category.update(machine_ids: [@machine.id]) @plan.update(limiting: true, plan_limitations_attributes: [{ limitable: category, limit: 4 }]) - assert_equal 4, ReservationLimitService.limit(@plan, @machine) + assert_equal 4, ReservationLimitService.limit(@plan, @machine).limit end test 'machine limit should override the category limit' do category = MachineCategory.find(1) category.update(machine_ids: [@machine.id]) @plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 2 }, { limitable: category, limit: 4 }]) - assert_equal 2, ReservationLimitService.limit(@plan, @machine) + limit = ReservationLimitService.limit(@plan, @machine) + assert_equal 2, limit.limit + assert_equal @machine, limit.limitable + end + + test 'reservation reaches the limit' do + user = User.find_by(username: 'kdumas') + plan = user.subscribed_plan + plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 1 }]) + slots = Availabilities::AvailabilitiesService.new(user) + .machines([@machine], user, { start: Time.current, end: 10.days.from_now }) + reservation = Reservation.create!( + statistic_profile: user.statistic_profile, + reservable: @machine, + slots_reservations_attributes: [{ slot: slots.last }] + ) + reservation.reload + assert_equal slots.last.start_at.to_date, ReservationLimitService.reached_limit_date(reservation) + end + + test 'reservation does not reaches the limit' do + user = User.find_by(username: 'kdumas') + plan = user.subscribed_plan + plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 2 }]) + slots = Availabilities::AvailabilitiesService.new(user) + .machines([@machine], user, { start: Time.current, end: 10.days.from_now }) + reservation = Reservation.create!( + statistic_profile: user.statistic_profile, + reservable: @machine, + slots_reservations_attributes: [{ slot: slots.last }] + ) + reservation.reload + assert_nil ReservationLimitService.reached_limit_date(reservation) end end From 7ad1f8da3f61d7cda0b74475a7aeeae23c7369f9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 14 Mar 2023 17:37:45 +0100 Subject: [PATCH 21/23] (quality) delete a plan limitation --- CHANGELOG.md | 1 + .../api/plan_limitations_controller.rb | 12 -- .../src/javascript/api/plan-limitation.ts | 9 -- .../components/base/edit-destroy-buttons.tsx | 38 +++++-- .../machines/machine-categories-list.tsx | 2 +- .../javascript/components/plans/plan-form.tsx | 1 - .../components/plans/plan-limit-form.tsx | 61 +++++----- .../machines/configure-packs-button.tsx | 2 +- .../components/store/product-item.tsx | 2 +- .../components/trainings/trainings.tsx | 2 +- .../modules/plans/plan-limit-form.scss | 21 ++++ app/policies/plan_limitation_policy.rb | 8 -- config/locales/app.admin.de.yml | 107 +++++++++--------- config/locales/app.admin.en.yml | 7 +- config/locales/app.admin.es.yml | 7 +- config/locales/app.admin.fr.yml | 5 +- config/locales/app.admin.no.yml | 7 +- config/locales/app.admin.pt.yml | 7 +- config/locales/app.admin.zu.yml | 7 +- config/routes.rb | 1 - 20 files changed, 157 insertions(+), 150 deletions(-) delete mode 100644 app/controllers/api/plan_limitations_controller.rb delete mode 100644 app/frontend/src/javascript/api/plan-limitation.ts delete mode 100644 app/policies/plan_limitation_policy.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eaf4fc0a..087ea6005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog Fab-manager - [TODO DEPLOY] `rails db:seed` +- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet` ## v5.8.2 2023 March 13 diff --git a/app/controllers/api/plan_limitations_controller.rb b/app/controllers/api/plan_limitations_controller.rb deleted file mode 100644 index ea2171d61..000000000 --- a/app/controllers/api/plan_limitations_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# API Controller for resources of type PlanLimitation -# PlanLimitation allows to restrict bookings of resources for the subscribers of that plan. -class API::PlanLimitationsController < API::ApiController - def destroy - @limitation = PlanLimitation.find(params[:id]) - authorize @limitation - @limitation.destroy - head :no_content - end -end diff --git a/app/frontend/src/javascript/api/plan-limitation.ts b/app/frontend/src/javascript/api/plan-limitation.ts deleted file mode 100644 index b167a104c..000000000 --- a/app/frontend/src/javascript/api/plan-limitation.ts +++ /dev/null @@ -1,9 +0,0 @@ -import apiClient from './clients/api-client'; -import { AxiosResponse } from 'axios'; - -export default class PlanLimitationAPI { - static async destroy (id: number): Promise { - const res: AxiosResponse = await apiClient.delete(`/api/plan_limitations/${id}`); - return res?.data; - } -} diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index d61de771d..3add0e256 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -6,28 +6,33 @@ import { FabButton } from './fab-button'; import { FabModal } from './fab-modal'; type EditDestroyButtonsCommon = { - onDeleteSuccess: (message: string) => void, onError: (message: string) => void, onEdit: () => void, itemId: number, - apiDestroy: (itemId: number) => Promise, + destroy: (itemId: number) => Promise, className?: string, iconSize?: number, showEditButton?: boolean, } -type EditDestroyButtonsMessages = - { itemType: string, confirmationTitle?: string, confirmationMessage?: string|ReactNode, deleteSuccessMessage?: string } | - { itemType?: never, confirmationTitle: string, confirmationMessage: string|ReactNode, deleteSuccessMessage: string} +type DeleteSuccess = + { onDeleteSuccess: (message: string) => void, deleteSuccessMessage: string } | + { onDeleteSuccess?: never, deleteSuccessMessage?: never } -type EditDestroyButtonsProps = EditDestroyButtonsCommon & EditDestroyButtonsMessages; +type DestroyMessages = + ({ showDestroyConfirmation?: true } & + ({ itemType: string, confirmationTitle?: string, confirmationMessage?: string|ReactNode } | + { itemType?: never, confirmationTitle: string, confirmationMessage: string|ReactNode })) | + { showDestroyConfirmation: false, itemType?: never, confirmationTitle?: never, confirmationMessage?: never }; + +type EditDestroyButtonsProps = EditDestroyButtonsCommon & DeleteSuccess & DestroyMessages; /** * This component shows a group of two buttons. * Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item. * Edit : triggers the provided function. */ -export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, destroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true, showDestroyConfirmation = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -39,17 +44,28 @@ export const EditDestroyButtons: React.FC = ({ onDelete setDeletionModal(!deletionModal); }; + /** + * Triggered when the user clicks on the 'destroy' button + */ + const handleDestroyRequest = (): void => { + if (showDestroyConfirmation) { + toggleDeletionModal(); + } else { + onDeleteConfirmed(); + } + }; + /** * The deletion has been confirmed by the user. * Call the API to trigger the deletion of the given item */ const onDeleteConfirmed = (): void => { - apiDestroy(itemId).then(() => { - onDeleteSuccess(deleteSuccessMessage || t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType })); + destroy(itemId).then(() => { + typeof onDeleteSuccess === 'function' && onDeleteSuccess(deleteSuccessMessage || t('app.admin.edit_destroy_buttons.deleted')); }).catch((error) => { onError(t('app.admin.edit_destroy_buttons.unable_to_delete') + error); }); - toggleDeletionModal(); + setDeletionModal(false); }; return ( @@ -58,7 +74,7 @@ export const EditDestroyButtons: React.FC = ({ onDelete {showEditButton && } - +
diff --git a/app/frontend/src/javascript/components/machines/machine-categories-list.tsx b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx index addc6ae43..430783fbb 100644 --- a/app/frontend/src/javascript/components/machines/machine-categories-list.tsx +++ b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx @@ -124,7 +124,7 @@ export const MachineCategoriesList: React.FC = ({ on onEdit={editMachineCategory(category)} itemId={category.id} itemType={t('app.admin.machine_categories_list.machine_category')} - apiDestroy={MachineCategoryAPI.destroy} /> + destroy={MachineCategoryAPI.destroy} />
diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index d66dd3882..17c33ec1e 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -333,7 +333,6 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc register={register} formState={formState} onError={onError} - onSuccess={onSuccess} getValues={getValues} resetField={resetField} /> } diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index 7f33ad0ba..601dcce53 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode, useEffect, useState } from 'react'; import { Control, FormState, UseFormGetValues, UseFormResetField } from 'react-hook-form/dist/types/form'; import { FormSwitch } from '../form/form-switch'; import { useTranslation } from 'react-i18next'; @@ -12,14 +12,13 @@ import MachineAPI from '../../api/machine'; import MachineCategoryAPI from '../../api/machine-category'; import { FormUnsavedList } from '../form/form-unsaved-list'; import { EditDestroyButtons } from '../base/edit-destroy-buttons'; -import PlanLimitationAPI from '../../api/plan-limitation'; +import { X } from 'phosphor-react'; interface PlanLimitFormProps { register: UseFormRegister, control: Control, formState: FormState, onError: (message: string) => void, - onSuccess: (message: string) => void, getValues: UseFormGetValues, resetField: UseFormResetField } @@ -27,7 +26,7 @@ interface PlanLimitFormProps { /** * Form tab to manage a subscription's usage limit */ -export const PlanLimitForm = ({ register, control, formState, onError, onSuccess, getValues, resetField }: PlanLimitFormProps) => { +export const PlanLimitForm = ({ register, control, formState, onError, getValues, resetField }: PlanLimitFormProps) => { const { t } = useTranslation('admin'); const { fields, append, remove, update } = useFieldArray({ control, name: 'plan_limitations_attributes' }); const limiting = useWatch({ control, name: 'limiting' }); @@ -89,16 +88,24 @@ export const PlanLimitForm = ({ register, control, for }; /** - * Callback triggered when a previously-saved limitation was deleted. Return a callback accepting a message. + * Callback triggered when a saved limitation is requested to be deleted */ - const onLimitationDeleted = (index: number): (message: string) => void => { - return (message: string) => { - onSuccess(message); - remove(index); - // This have a little drowback: remove(index) will set the form as "dirty", and trigger the "unsaved form alert", even if clicking on save or not - // won't change anything to the deleted item. To improve this we could do the following: do not destroy the limitation through the API and instead - // set {_destroy: true} and destroy the limitation when saving the form, but we need some UI for items about to be deleted - // update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true }); + const handleLimitationDelete = (index: number): () => Promise => { + return () => { + return new Promise((resolve) => { + update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true }); + resolve(); + }); + }; + }; + + /** + * Triggered when the user clicks on "cancel" for a limitated previsouly marked as deleted + */ + const cancelDeletion = (index: number): (event: React.MouseEvent) => void => { + return (event) => { + event.preventDefault(); + update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: false }); }; }; @@ -178,7 +185,7 @@ export const PlanLimitForm = ({ register, control, for if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false; return ( -
+
{t('app.admin.plan_limit_form.category')} @@ -191,14 +198,11 @@ export const PlanLimitForm = ({ register, control, for
- + showDestroyConfirmation={false} + destroy={handleLimitationDelete(index)} />
); @@ -213,7 +217,7 @@ export const PlanLimitForm = ({ register, control, for if (limitation.limitable_type !== 'Machine' || limitation._modified) return false; return ( -
+
{t('app.admin.plan_limit_form.machine')} @@ -223,17 +227,20 @@ export const PlanLimitForm = ({ register, control, for {t('app.admin.plan_limit_form.max_hours_per_day')}

{limitation.limit}

+ {limitation._destroy &&
{t('app.admin.plan_limit_form.ongoing_deletion')}
}
- + {t('app.admin.plan_limit_form.cancel_deletion')} + +

) || + + showDestroyConfirmation={false} + destroy={handleLimitationDelete(index)} />}
); diff --git a/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx index 3a2aa103f..de1126bad 100644 --- a/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx +++ b/app/frontend/src/javascript/components/pricing/machines/configure-packs-button.tsx @@ -114,7 +114,7 @@ export const ConfigurePacksButton: React.FC = ({ pack onEdit={() => handleRequestEdit(p)} itemId={p.id} itemType={t('app.admin.configure_packs_button.pack')} - apiDestroy={PrepaidPackAPI.destroy}/> + destroy={PrepaidPackAPI.destroy}/> = ({ product, onEdit, onDel onEdit={editProduct(product)} itemId={product.id} itemType={t('app.admin.store.product_item.product')} - apiDestroy={ProductAPI.destroy} /> + destroy={ProductAPI.destroy} />
); diff --git a/app/frontend/src/javascript/components/trainings/trainings.tsx b/app/frontend/src/javascript/components/trainings/trainings.tsx index 3ff49b787..f510507d2 100644 --- a/app/frontend/src/javascript/components/trainings/trainings.tsx +++ b/app/frontend/src/javascript/components/trainings/trainings.tsx @@ -199,7 +199,7 @@ export const Trainings: React.FC = ({ onError, onSuccess }) => { onEdit={() => toTrainingEdit(training)} itemId={training.id} itemType={t('app.admin.trainings.training')} - apiDestroy={TrainingAPI.destroy}/> + destroy={TrainingAPI.destroy}/>
))} diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index a9d8a1fac..5e1345622 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -58,6 +58,27 @@ } } } + + &.is-destroying { + background-color: var(--main-lightest); + .marker { + text-align: center; + font-weight: 500; + color: var(--main-dark); + margin: auto; + } + .actions > .cancel-action { + font-weight: normal; + svg { + vertical-align: middle; + margin-left: 1rem; + } + &:hover { + text-decoration: underline; + cursor: pointer; + } + } + } } } } diff --git a/app/policies/plan_limitation_policy.rb b/app/policies/plan_limitation_policy.rb deleted file mode 100644 index a2b2e94c0..000000000 --- a/app/policies/plan_limitation_policy.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Check the access policies for API::PlanLimitationsController -class PlanLimitationPolicy < ApplicationPolicy - def destroy? - user.admin? - end -end diff --git a/config/locales/app.admin.de.yml b/config/locales/app.admin.de.yml index 3bf5b5482..2234321cc 100644 --- a/config/locales/app.admin.de.yml +++ b/config/locales/app.admin.de.yml @@ -2,7 +2,7 @@ de: app: admin: edit_destroy_buttons: - deleted: "The {TYPE} was successfully deleted." + deleted: "Successfully deleted." unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" @@ -24,7 +24,7 @@ de: save: "Save" successfully_saved: "Your banner was successfully saved." machine_categories_list: - machine_categories: "Machines categories" + machine_categories: "Maschinen-Kategorien" add_a_machine_category: "Add a machine category" name: "Name" machines_number: "Number of machines" @@ -57,7 +57,7 @@ de: disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machines list." reservable: "Can this machine be reserved?" reservable_help: "When disabled, the machine will be shown in the default list of machines, but without the reservation button. If you already have created some availability slots for this machine, you may want to remove them: do it from the admin agenda." - save: "Save" + save: "Speichern" create_success: "The machine was created successfully" update_success: "The machine was updated successfully" training_form: @@ -74,7 +74,7 @@ de: associated_machines_help: "If you associate a machine to this training, the members will need to successfully pass this training before being able to reserve the machine." default_seats: "Default number of seats" public_page: "Show in training lists" - public_help: "When unchecked, this option will prevent the training from appearing in the trainings list." + public_help: "Wenn diese Option deaktiviert ist, wird verhindert, dass das Training in der Trainingliste erscheint." disable_training: "Disable the training" disabled_help: "When disabled, the training won't be reservable and won't appear by default in the trainings list." automatic_cancellation: "Automatic cancellation" @@ -90,39 +90,39 @@ de: validation_rule_info: "Define a rule that cancel an authorisation if the machines associated with the training are not reserved for a specific period of time. This rule prevails over the authorisations validity period." validation_rule_switch: "Activate the validation rule" validation_rule_period: "Time limit in months" - save: "Save" - create_success: "The training was created successfully" - update_success: "The training was updated successfully" + save: "Speichern" + create_success: "Die Schulung wurde erfolgreich erstellt" + update_success: "Die Schulung wurde erfolgreich aktualisiert" space_form: - ACTION_title: "{ACTION, select, create{New} other{Update the}} space" - watch_out_when_creating_a_new_space_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new space, its prices are initialized at 0 for all subscriptions." - consider_changing_its_prices_before_creating_any_reservation_slot: "Consider changing its prices before creating any reservation slot." + ACTION_title: "{ACTION, select, create{Neu} other{Aktualisiere den}} Raum" + watch_out_when_creating_a_new_space_its_prices_are_initialized_at_0_for_all_subscriptions: "Achtung! Beim Erstellen eines neuen Raums wird sein Preis für alle Abonnements mit 0 angelegt." + consider_changing_its_prices_before_creating_any_reservation_slot: "Ändern Sie ggf. die Preise, bevor Sie Reservierungs-Slots erstellen." name: "Name" - illustration: "Illustration" - description: "Description" - characteristics: "Characteristics" - attachments: "Attachments" - attached_files_pdf: "Attached files (pdf)" - add_an_attachment: "Add an attachment" - settings: "Settings" - default_seats: "Default number of seats" - disable_space: "Disable the space" - disabled_help: "When disabled, the space won't be reservable and won't appear by default in the spaces list." - save: "Save" - create_success: "The space was created successfully" - update_success: "The space was updated successfully" + illustration: "Abbildung" + description: "Beschreibung" + characteristics: "Eigenschaften" + attachments: "Dateianhänge" + attached_files_pdf: "Angehängte Dateien (pdf)" + add_an_attachment: "Anhang hinzufügen" + settings: "Einstellungen" + default_seats: "Standardanzahl der Sitze" + disable_space: "Raum deaktivieren" + disabled_help: "Wenn deaktiviert, ist der Raum nicht reservierbar und erscheint standardmäßig nicht in der Liste der Leerzeichen." + save: "Speichern" + create_success: "Der Raum wurde erfolgreich erstellt" + update_success: "Der Raum wurde erfolgreich aktualisiert" event_form: - ACTION_title: "{ACTION, select, create{New} other{Update the}} event" - title: "Title" + ACTION_title: "{ACTION, select, create{Neue} other{Aktualisiere die}} Veranstaltung" + title: "Titel" matching_visual: "Matching visual" description: "Description" attachments: "Attachments" attached_files_pdf: "Attached files (pdf)" add_a_new_file: "Add a new file" - event_category: "Event category" + event_category: "Veranstaltungskategorie" dates_and_opening_hours: "Dates and opening hours" all_day: "All day" - all_day_help: "Will the event last all day or do you want to set times?" + all_day_help: "Wird das Ereignis den ganzen Tag dauern oder möchtest du Zeiten festlegen?" start_date: "Start date" end_date: "End date" start_time: "Start time" @@ -135,33 +135,33 @@ de: fare_class: "Fare class" price: "Price" seats_available: "Seats available" - seats_help: "If you leave this field empty, this event will be available without reservations." - event_themes: "Event themes" + seats_help: "Wenn sie dieses Feld leer lassen, ist diese Veranstaltung ohne Reservierung." + event_themes: "Veranstaltungsthemen" age_range: "Age range" add_price: "Add a price" save: "Save" - create_success: "The event was created successfully" - events_updated: "{COUNT, plural, =1{One event was} other{{COUNT} Events were}} successfully updated" - events_not_updated: "{TOTAL, plural, =1{The event was} other{On {TOTAL} events {COUNT, plural, =1{one was} other{{COUNT} were}}}} not updated." - error_deleting_reserved_price: "Unable to remove the requested price because it is associated with some existing reservations" - other_error: "An unexpected error occurred while updating the event" + create_success: "Die Veranstaltung wurde erfolgreich erstellt" + events_updated: "{COUNT, plural, one {}=1{Eine Veranstaltung wurde} other{{COUNT} Veranstaltungen wurden}} erfolgreich aktualisiert" + events_not_updated: "{TOTAL, plural, =1{Die Veranstaltung war} other{Auf {TOTAL} Veranstaltungen {COUNT, plural, =1{eins war} other{{COUNT} waren}}}} nicht aktualisiert." + error_deleting_reserved_price: "Der angeforderte Preis konnte nicht gelöscht werden, da er mit einigen Reservierungen verknüpft ist" + other_error: "Beim Aktualisieren der Veranstaltung ist ein unerwarteter Fehler aufgetreten" recurring: none: "None" - every_days: "Every days" - every_week: "Every week" - every_month: "Every month" - every_year: "Every year" + every_days: "Täglich" + every_week: "Wöchentlich" + every_month: "Monatlich" + every_year: "Jährlich" plan_form: ACTION_title: "{ACTION, select, create{New} other{Update the}} plan" - tab_settings: "Settings" + tab_settings: "Einstellungen" tab_usage_limits: "Usage limits" - description: "Description" - general_settings: "General settings" + description: "Beschreibung" + general_settings: "Generelle Einstellungen" general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods." activation_and_payment: "Subscription activation and payment" name: "Name" name_max_length: "Name length must be less than 24 characters." - group: "Group" + group: "Gruppe" transversal: "Transversal plan" transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups." display: "Display" @@ -208,9 +208,8 @@ de: ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - confirmation_title: "Delete the limitation" - confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." - delete_success: "The limitation was successfully deleted." + cancel_deletion: "Cancel" + ongoing_deletion: "Ongoing deletion" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" @@ -237,10 +236,10 @@ de: spaces: "Spaces" update_recurrent_modal: title: "Periodic event update" - edit_recurring_event: "You're about to update a periodic event. What do you want to update?" - edit_this_event: "Only this event" + edit_recurring_event: "Sie bearbeiten eine wiederkehrende Veranstaltung. Was möchten Sie ändern?" + edit_this_event: "Nur diese Veranstaltung" edit_this_and_next: "This event and the followings" - edit_all: "All events" + edit_all: "Alle Veranstaltungen" date_wont_change: "Warning: you have changed the event date. This modification won't be propagated to other occurrences of the periodic event." confirm: "Update the {MODE, select, single{event} other{events}}" advanced_accounting_form: @@ -262,7 +261,7 @@ de: subscriptions: "Subscriptions" machine: "Machine reservation" training: "Training reservation" - event: "Event reservation" + event: "Veranstaltungsreservierung" space: "Space reservation" prepaid_pack: "Pack of prepaid-hours" product: "Product of the store" @@ -621,13 +620,13 @@ de: events_settings: title: "Settings" generic_text_block: "Editorial text block" - generic_text_block_info: "Displays an editorial block above the list of events visible to members." + generic_text_block_info: "Zeigt einen redaktionellen Block oberhalb der für Mitglieder sichtbaren Veranstaltungsliste." generic_text_block_switch: "Display editorial block" cta_switch: "Display a button" cta_label: "Button label" cta_url: "url" save: "Save" - update_success: "The events settings were successfully updated" + update_success: "Die Einstellungen wurden erfolgreich aktualisiert" #subscriptions, prices, credits and coupons management pricing: pricing_management: "Preisverwaltung" @@ -2322,8 +2321,8 @@ de: low_stock: "Low stock" threshold_level: "Minimum threshold level" threshold_alert: "Notify me when the threshold is reached" - events_history: "Events history" - event_type: "Events:" + events_history: "Veranstaltungsverlauf" + event_type: "Veranstaltungen:" reason: "Reason" stocks: "Stock:" internal: "Private stock" @@ -2420,7 +2419,7 @@ de: VAT_rate_machine: "Machine reservation" VAT_rate_space: "Space reservation" VAT_rate_training: "Training reservation" - VAT_rate_event: "Event reservation" + VAT_rate_event: "Veranstaltungsreservierung" VAT_rate_subscription: "Subscription" VAT_rate_product: "Products (store)" multi_VAT_notice: "Please note: The current general rate is {RATE}%. You can define different VAT rates for each category.

For example, you can override this value, only for machine reservations, by filling in the corresponding field beside. If you don't fill any value, the general rate will apply." diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index fd3c7234a..cb2120813 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -2,7 +2,7 @@ en: app: admin: edit_destroy_buttons: - deleted: "The {TYPE} was successfully deleted." + deleted: "Successfully deleted." unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" @@ -208,9 +208,8 @@ en: ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - confirmation_title: "Delete the limitation" - confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." - delete_success: "The limitation was successfully deleted." + cancel_deletion: "Cancel" + ongoing_deletion: "Ongoing deletion" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index 709f3535a..b2f74317b 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -2,7 +2,7 @@ es: app: admin: edit_destroy_buttons: - deleted: "The {TYPE} was successfully deleted." + deleted: "Successfully deleted." unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" @@ -208,9 +208,8 @@ es: ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - confirmation_title: "Delete the limitation" - confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." - delete_success: "The limitation was successfully deleted." + cancel_deletion: "Cancel" + ongoing_deletion: "Ongoing deletion" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index ef93f982c..24eb69b53 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -208,9 +208,8 @@ fr: ongoing_limitations: "Limites en cours" saved_limitations: "Limites enregistrées" cancel: "Annuler cette limite" - confirmation_title: "Supprimer la limite" - confirmation_message: "Êtes-vous sûr de vouloir supprimer cette limite ? Cela prendra effet immédiatement et ne sera pas résersible." - delete_success: "La limite a bien été supprimée." + cancel_deletion: "Annuler" + ongoing_deletion: "Suppression en cours" plan_limit_modal: title: "Gérer la limite d'usage" limit_reservations: "Limiter les réservations" diff --git a/config/locales/app.admin.no.yml b/config/locales/app.admin.no.yml index f8a9e446a..7a01f3cba 100644 --- a/config/locales/app.admin.no.yml +++ b/config/locales/app.admin.no.yml @@ -2,7 +2,7 @@ app: admin: edit_destroy_buttons: - deleted: "The {TYPE} was successfully deleted." + deleted: "Successfully deleted." unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" @@ -208,9 +208,8 @@ ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - confirmation_title: "Delete the limitation" - confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." - delete_success: "The limitation was successfully deleted." + cancel_deletion: "Cancel" + ongoing_deletion: "Ongoing deletion" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index 35c786b86..ebad84d16 100644 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -2,7 +2,7 @@ pt: app: admin: edit_destroy_buttons: - deleted: "The {TYPE} was successfully deleted." + deleted: "Successfully deleted." unable_to_delete: "Unable to delete: " delete_item: "Delete the {TYPE}" confirm_delete: "Delete" @@ -208,9 +208,8 @@ pt: ongoing_limitations: "Ongoing limitations" saved_limitations: "Saved limitations" cancel: "Cancel this limitation" - confirmation_title: "Delete the limitation" - confirmation_message: "Are you sure you want to delete this limitation? This will take effect immediately and cannot be undone." - delete_success: "The limitation was successfully deleted." + cancel_deletion: "Cancel" + ongoing_deletion: "Ongoing deletion" plan_limit_modal: title: "Manage limitation of use" limit_reservations: "Limit reservations" diff --git a/config/locales/app.admin.zu.yml b/config/locales/app.admin.zu.yml index 9e785700c..2283cb521 100644 --- a/config/locales/app.admin.zu.yml +++ b/config/locales/app.admin.zu.yml @@ -2,7 +2,7 @@ zu: app: admin: edit_destroy_buttons: - deleted: "crwdns36793:0{TYPE}crwdne36793:0" + deleted: "crwdns36793:0crwdne36793:0" unable_to_delete: "crwdns36795:0crwdne36795:0" delete_item: "crwdns36797:0{TYPE}crwdne36797:0" confirm_delete: "crwdns36799:0crwdne36799:0" @@ -208,9 +208,8 @@ zu: ongoing_limitations: "crwdns37433:0crwdne37433:0" saved_limitations: "crwdns37435:0crwdne37435:0" cancel: "crwdns37437:0crwdne37437:0" - confirmation_title: "crwdns37439:0crwdne37439:0" - confirmation_message: "crwdns37441:0crwdne37441:0" - delete_success: "crwdns37443:0crwdne37443:0" + cancel_deletion: "crwdns37487:0crwdne37487:0" + ongoing_deletion: "crwdns37489:0crwdne37489:0" plan_limit_modal: title: "crwdns37445:0crwdne37445:0" limit_reservations: "crwdns37447:0crwdne37447:0" diff --git a/config/routes.rb b/config/routes.rb index 6e3b53396..496db7a04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,7 +116,6 @@ Rails.application.routes.draw do patch 'cancel', on: :member end resources :plan_categories - resources :plan_limitations, only: [:destroy] resources :plans do get 'durations', on: :collection end From 6089e636e40904f6770731ffc1ca90d05a3129bd Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 15 Mar 2023 14:17:06 +0100 Subject: [PATCH 22/23] (feat) limit machine visibility per plan --- app/controllers/api/plans_controller.rb | 1 + .../components/form/form-unsaved-list.tsx | 15 +- .../javascript/components/plans/plan-form.tsx | 75 ++++-- .../modules/plans/plan-limit-form.scss | 4 +- app/models/cart_item/reservation.rb | 11 +- .../availabilities/availabilities_service.rb | 32 +-- .../availabilities/visibility_service.rb | 60 +++++ app/views/api/plans/_plan.json.jbuilder | 2 +- config/locales/app.admin.de.yml | 3 + config/locales/app.admin.en.yml | 3 + config/locales/app.admin.es.yml | 3 + config/locales/app.admin.fr.yml | 3 + config/locales/app.admin.no.yml | 3 + config/locales/app.admin.pt.yml | 3 + config/locales/app.admin.zu.yml | 3 + ...15095054_add_machine_visibility_to_plan.rb | 9 + db/schema.rb | 24 +- test/fixtures/users.yml | 8 + .../reservations/restricted_test.rb | 48 ++-- .../availabilities_service_test.rb | 2 +- .../availabilities/visibility_service_test.rb | 130 +++++++++ test/test_helper.rb | 5 + ...ion_create_for_restricted_slot_success.yml | 203 +++++++------- ...tions_create_for_restricted_slot_fails.yml | 254 ++---------------- ...ons_create_for_restricted_slot_success.yml | 203 +++++++------- 25 files changed, 594 insertions(+), 513 deletions(-) create mode 100644 app/services/availabilities/visibility_service.rb create mode 100644 db/migrate/20230315095054_add_machine_visibility_to_plan.rb rename test/services/{ => availabilities}/availabilities_service_test.rb (98%) create mode 100644 test/services/availabilities/visibility_service_test.rb diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index e2faf9e77..52d786cd5 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -82,6 +82,7 @@ class API::PlansController < API::ApiController @parameters = @parameters.require(:plan) .permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling, :limiting, :training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id, + :machines_visibility, plan_file_attributes: %i[id attachment _destroy], prices_attributes: %i[id amount], advanced_accounting_attributes: %i[code analytical_section], diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx index b791bd002..7a1099d24 100644 --- a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -9,7 +9,7 @@ import { FieldArrayPath } from 'react-hook-form/dist/types/path'; interface FormUnsavedListProps, TKeyName extends string> { fields: Array>, - onRemove?: (index: number) => void, + onRemove: (index: number) => void, register: UseFormRegister, className?: string, title: string, @@ -24,6 +24,19 @@ interface FormUnsavedListProps + *
+ * Attribute 1 + *

{item.attr1}

+ *
+ *
+ * ... + *
+ * + * ``` */ export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { const { t } = useTranslation('shared'); diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index 17c33ec1e..7384b17ba 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -56,13 +56,19 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc useEffect(() => { GroupAPI.index({ disabled: false }) - .then(res => setGroups(res.map(g => { return { value: g.id, label: g.name }; }))) + .then(res => setGroups(res.map(g => { + return { value: g.id, label: g.name }; + }))) .catch(onError); PlanCategoryAPI.index() - .then(res => setCategories(res.map(c => { return { value: c.id, label: c.name }; }))) + .then(res => setCategories(res.map(c => { + return { value: c.id, label: c.name }; + }))) .catch(onError); UserAPI.index({ role: 'partner' }) - .then(res => setPartners(res.map(p => { return { value: p.id, label: p.name }; }))) + .then(res => setPartners(res.map(p => { + return { value: p.id, label: p.name }; + }))) .catch(onError); }, []); @@ -106,7 +112,9 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc * Return the available options for the plan period */ const buildPeriodsOptions = (): Array> => { - return ['week', 'month', 'year'].map(d => { return { value: d, label: t(`app.admin.plan_form.${d}`) }; }); + return ['week', 'month', 'year'].map(d => { + return { value: d, label: t(`app.admin.plan_form.${d}`) }; + }); }; /** @@ -139,7 +147,7 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc * Render the content of the 'subscriptions settings' tab */ const renderSettingsTab = () => ( -
+

{t('app.admin.plan_form.description')}

@@ -183,12 +191,12 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc tooltip={t('app.admin.plan_form.transversal_help')} id="all_groups" />} {!allGroups && groups && } + formState={formState} + control={control} + rules={{ required: !allGroups }} + disabled={action === 'update'} + label={t('app.admin.plan_form.group')} + id="group_id" />}
= ({ action, plan, onError, onSuc {output.type === 'PartnerPlan' &&
{partners && } + options={partners} + control={control} + formState={formState} + rules={{ required: output.type === 'PartnerPlan' }} + tooltip={t('app.admin.plan_form.alert_partner_notification')} + label={t('app.admin.plan_form.notified_partner')} />} } onClick={tooglePartnerModal}> {t('app.admin.plan_form.new_user')} @@ -275,6 +283,21 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc
+
+
+

{t('app.admin.plan_form.slots_visibility')}

+

{t('app.admin.plan_form.slots_visibility_help')}

+
+
+ +
+
+

{t('app.admin.plan_form.display')}

@@ -287,11 +310,11 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc tooltip={t('app.admin.plan_form.category_help')} label={t('app.admin.plan_form.category')} />} + formState={formState} + id="ui_weight" + type="number" + label={t('app.admin.plan_form.visual_prominence')} + tooltip={t('app.admin.plan_form.visual_prominence_help')} />
@@ -300,10 +323,10 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc {action === 'update' && } + control={control} + onError={onError} + setValue={setValue} + register={register} />}
); diff --git a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss index 5e1345622..ef264a663 100644 --- a/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss +++ b/app/frontend/src/stylesheets/modules/plans/plan-limit-form.scss @@ -60,11 +60,11 @@ } &.is-destroying { - background-color: var(--main-lightest); + background-color: var(--alert-lightest); .marker { text-align: center; font-weight: 500; - color: var(--main-dark); + color: var(--alert-dark); margin: auto; } .actions > .cancel-action { diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 4a8a80b36..95bc0e5b9 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -64,7 +64,6 @@ class CartItem::Reservation < CartItem::BaseItem plan = pending_subscription&.plan || customer&.subscribed_plan reservation_deadline_minutes = Setting.get('reservation_deadline').to_i - reservation_deadline = reservation_deadline_minutes.minutes.since unless ReservationLimitService.authorized?(plan, customer, self, all_items) errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', { @@ -75,7 +74,7 @@ class CartItem::Reservation < CartItem::BaseItem end cart_item_reservation_slots.each do |sr| - return false unless validate_slot_reservation(sr, pending_subscription, reservation_deadline, errors) + return false unless validate_slot_reservation(sr, pending_subscription, reservation_deadline_minutes, errors) end true @@ -249,7 +248,7 @@ class CartItem::Reservation < CartItem::BaseItem # @param reservation_slot [CartItem::ReservationSlot] # @param pending_subscription [CartItem::Subscription, NilClass] - # @param reservation_deadline [Date,Time] + # @param reservation_deadline [Integer] # @param errors [ActiveModel::Errors] # @return [Boolean] def validate_slot_reservation(reservation_slot, pending_subscription, reservation_deadline, errors) @@ -270,12 +269,12 @@ class CartItem::Reservation < CartItem::BaseItem return false end - if slot.start_at < reservation_deadline && !operator.privileged? - errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes })) + if slot.start_at < reservation_deadline.minutes.since && !operator.privileged? + errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline })) return false end - unless availability.plan_ids.empty? && required_subscription?(availability, pending_subscription) + if availability.plan_ids.any? && !required_subscription?(availability, pending_subscription) errors.add(:availability, I18n.t('cart_item_validation.restricted')) return false end diff --git a/app/services/availabilities/availabilities_service.rb b/app/services/availabilities/availabilities_service.rb index 9ab7388e6..3f21c2d17 100644 --- a/app/services/availabilities/availabilities_service.rb +++ b/app/services/availabilities/availabilities_service.rb @@ -125,27 +125,17 @@ class Availabilities::AvailabilitiesService # @param range_end [ActiveSupport::TimeWithZone] # @return ActiveRecord::Relation def availabilities(availabilities, type, user, range_start, range_end) - # who made the request? - # 1) an admin (he can see all availabilities from 1 month ago to anytime in the future) - if @current_user&.admin? || @current_user&.manager? - window_start = [range_start, 1.month.ago].max - availabilities.includes(:tags, :slots) - .joins(:slots) - .where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', range_end, window_start, type) - .where('slots.start_at > ? AND slots.end_at < ?', window_start, range_end) - # 2) an user (he cannot see past availabilities neither those further than 1 (or 3) months in the future) - else - end_at = @maximum_visibility[:other] - end_at = @maximum_visibility[:year] if subscription_year?(user) && type != 'training' - end_at = @maximum_visibility[:year] if show_more_trainings?(user) && type == 'training' - window_end = [end_at, range_end].min - window_start = [range_start, @minimum_visibility].max - availabilities.includes(:tags, :slots) - .joins(:slots) - .where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window_end, window_start, type) - .where('slots.start_at > ? AND slots.end_at < ?', window_start, window_end) - .where('availability_tags.tag_id' => user&.tag_ids&.concat([nil])) - .where(lock: false) + window = Availabilities::VisibilityService.new.visibility(@current_user, type, range_start, range_end) + qry = availabilities.includes(:tags, :slots) + .joins(:slots) + .where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window[1], window[0], type) + .where('slots.start_at > ? AND slots.end_at < ?', window[0], window[1]) + unless @current_user&.privileged? + # non priviledged users cannot see availabilities with tags different than their own and locked tags + qry = qry.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil])) + .where(lock: false) end + + qry end end diff --git a/app/services/availabilities/visibility_service.rb b/app/services/availabilities/visibility_service.rb new file mode 100644 index 000000000..f5e953986 --- /dev/null +++ b/app/services/availabilities/visibility_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Return the maximum available visibility for a user +class Availabilities::VisibilityService + def initialize + @maximum_visibility = { + year: Setting.get('visibility_yearly').to_i.months.since, + other: Setting.get('visibility_others').to_i.months.since + } + @minimum_visibility = Setting.get('reservation_deadline').to_i.minutes.since + end + + # @param user [User,NilClass] + # @param available_type [String] 'training', 'space', 'machine' or 'event' + # @param range_start [ActiveSupport::TimeWithZone] + # @param range_end [ActiveSupport::TimeWithZone] + # @return [Array] as: [start,end] + def visibility(user, available_type, range_start, range_end) + if user&.privileged? + window_start = [range_start, 1.month.ago].max + window_end = range_end + else + end_at = @maximum_visibility[:other] + end_at = @maximum_visibility[:year] if subscription_year?(user) && available_type != 'training' + end_at = @maximum_visibility[:year] if show_more_trainings?(user) && available_type == 'training' + end_at = subscription_visibility(user, available_type) || end_at + window_end = [end_at, range_end].min + window_start = [range_start, @minimum_visibility].max + end + [window_start, window_end] + end + + private + + # @param user [User,NilClass] + def subscription_year?(user) + user&.subscribed_plan && + (user&.subscribed_plan&.interval == 'year' || + (user&.subscribed_plan&.interval == 'month' && user.subscribed_plan.interval_count >= 12)) + end + + # @param user [User,NilClass] + # @param available_type [String] 'training', 'space', 'machine' or 'event' + # @return [Time,NilClass] + def subscription_visibility(user, available_type) + return nil unless user&.subscribed_plan + return nil unless available_type == 'machine' + + machines = user&.subscribed_plan&.machines_visibility + machines&.hours&.since + end + + # members must have validated at least 1 training and must have a valid yearly subscription to view + # the trainings further in the futur. This is used to prevent users with a rolling subscription to take + # their first training in a very long delay. + # @param user [User,NilClass] + def show_more_trainings?(user) + user&.trainings&.size&.positive? && subscription_year?(user) + end +end diff --git a/app/views/api/plans/_plan.json.jbuilder b/app/views/api/plans/_plan.json.jbuilder index 095eff843..30e090760 100644 --- a/app/views/api/plans/_plan.json.jbuilder +++ b/app/views/api/plans/_plan.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type, - :ui_weight, :disabled, :monthly_payment, :plan_category_id, :limiting + :ui_weight, :disabled, :monthly_payment, :plan_category_id, :limiting, :machines_visibility json.amount plan.amount / 100.00 json.prices_attributes plan.prices, partial: 'api/prices/price', as: :price if plan.plan_file diff --git a/config/locales/app.admin.de.yml b/config/locales/app.admin.de.yml index 2234321cc..6cd621a7f 100644 --- a/config/locales/app.admin.de.yml +++ b/config/locales/app.admin.de.yml @@ -191,6 +191,9 @@ de: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" + slots_visibility: "Slots visibility" + slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings." + machines_visibility: "Visibility time limit, in hours (machines)" save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index cb2120813..6eea26de2 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -191,6 +191,9 @@ en: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" + slots_visibility: "Slots visibility" + slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings." + machines_visibility: "Visibility time limit, in hours (machines)" save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index b2f74317b..f77d12f35 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -191,6 +191,9 @@ es: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" + slots_visibility: "Slots visibility" + slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings." + machines_visibility: "Visibility time limit, in hours (machines)" save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 24eb69b53..335c91335 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -191,6 +191,9 @@ fr: partner_plan: "Abonnement partenaire" partner_plan_help: "Vous pouvez vendre des abonnements en partenariat avec un autre organisme. Ce faisant, l'autre entité sera informée lorsqu'un membre s'abonne à cette formule d'abonnement." partner_created: "Le partenaire a bien été créé" + slots_visibility: "Visibilité des créneaux" + slots_visibility_help: "Vous pouvez déterminer combien de temps en avance les abonnés peuvent voir et réserver les créneaux machines. Lorsque ce paramètre est défini, il devient prioritaire sur les paramètres généraux." + machines_visibility: "Délai de visibilité, en heures (machines)" save: "Enregistrer" create_success: "Création du/des formule(s) d'abonnement réussie(s). N'oubliez pas de redéfinir les tarifs." update_success: "La formule d'abonnement a bien été mise à jour" diff --git a/config/locales/app.admin.no.yml b/config/locales/app.admin.no.yml index 7a01f3cba..5eac37840 100644 --- a/config/locales/app.admin.no.yml +++ b/config/locales/app.admin.no.yml @@ -191,6 +191,9 @@ partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" + slots_visibility: "Slots visibility" + slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings." + machines_visibility: "Visibility time limit, in hours (machines)" save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index ebad84d16..f3ceb1d62 100644 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -191,6 +191,9 @@ pt: partner_plan: "Partner plan" partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan." partner_created: "The partner was successfully created" + slots_visibility: "Slots visibility" + slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings." + machines_visibility: "Visibility time limit, in hours (machines)" save: "Save" create_success: "Plan(s) successfully created. Don't forget to redefine prices." update_success: "The plan was updated successfully" diff --git a/config/locales/app.admin.zu.yml b/config/locales/app.admin.zu.yml index 2283cb521..75fb54e28 100644 --- a/config/locales/app.admin.zu.yml +++ b/config/locales/app.admin.zu.yml @@ -191,6 +191,9 @@ zu: partner_plan: "crwdns31949:0crwdne31949:0" partner_plan_help: "crwdns31951:0crwdne31951:0" partner_created: "crwdns31953:0crwdne31953:0" + slots_visibility: "crwdns37491:0crwdne37491:0" + slots_visibility_help: "crwdns37493:0crwdne37493:0" + machines_visibility: "crwdns37495:0crwdne37495:0" save: "crwdns37411:0crwdne37411:0" create_success: "crwdns31957:0crwdne31957:0" update_success: "crwdns31959:0crwdne31959:0" diff --git a/db/migrate/20230315095054_add_machine_visibility_to_plan.rb b/db/migrate/20230315095054_add_machine_visibility_to_plan.rb new file mode 100644 index 000000000..33639544d --- /dev/null +++ b/db/migrate/20230315095054_add_machine_visibility_to_plan.rb @@ -0,0 +1,9 @@ +# frozen_string_literal:true + +# From this migration, we add a machines_visibility parameter to plans. +# This parameter determines how far in advance subscribers can view and reserve machine slots. +class AddMachineVisibilityToPlan < ActiveRecord::Migration[5.2] + def change + add_column :plans, :machines_visibility, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 7555d7cf7..629437c52 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_03_09_094535) do +ActiveRecord::Schema.define(version: 2023_03_15_095054) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.integer "signaled_id" t.string "signaled_type" + t.integer "signaled_id" t.string "first_name" t.string "last_name" t.string "email" @@ -68,8 +68,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do t.string "locality" t.string "country" t.string "postal_code" - t.integer "placeable_id" t.string "placeable_type" + t.integer "placeable_id" t.datetime "created_at" t.datetime "updated_at" end @@ -93,8 +93,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do end create_table "assets", id: :serial, force: :cascade do |t| - t.integer "viewable_id" t.string "viewable_type" + t.integer "viewable_id" t.string "attachment" t.string "type" t.datetime "created_at" @@ -164,10 +164,10 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do create_table "cart_item_event_reservation_tickets", force: :cascade do |t| t.integer "booked" + t.bigint "event_price_category_id" t.bigint "cart_item_event_reservation_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "event_price_category_id" t.index ["cart_item_event_reservation_id"], name: "index_cart_item_tickets_on_cart_item_event_reservation" t.index ["event_price_category_id"], name: "index_cart_item_tickets_on_event_price_category" end @@ -282,8 +282,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do end create_table "credits", id: :serial, force: :cascade do |t| - t.integer "creditable_id" t.string "creditable_type" + t.integer "creditable_id" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -546,15 +546,15 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.integer "attached_object_id" t.string "attached_object_type" + t.integer "attached_object_id" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: {} + t.jsonb "meta_data", default: "{}" t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -771,6 +771,7 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do t.boolean "monthly_payment" t.bigint "plan_category_id" t.boolean "limiting" + t.integer "machines_visibility" t.index ["group_id"], name: "index_plans_on_group_id" t.index ["plan_category_id"], name: "index_plans_on_plan_category_id" end @@ -812,15 +813,14 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do t.text "conditions" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index "btrim(lower((name)::text))", name: "index_price_categories_on_TRIM_BOTH_FROM_LOWER_name", unique: true t.index ["name"], name: "index_price_categories_on_name", unique: true end create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.integer "priceable_id" t.string "priceable_type" + t.integer "priceable_id" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -984,8 +984,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.integer "reservable_id" t.string "reservable_type" + t.integer "reservable_id" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -994,8 +994,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.integer "resource_id" t.string "resource_type" + t.integer "resource_id" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index dc1a21bc7..c937d613c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,3 +1,4 @@ +# admin without subscription user_1: id: 1 username: admin @@ -30,6 +31,7 @@ user_1: merged_at: is_allow_newsletter: true +# member without subscription user_2: id: 2 username: jdupond @@ -62,6 +64,7 @@ user_2: merged_at: is_allow_newsletter: true +# member with 1 month subscription (plan 2/standard) user_3: id: 3 username: pdurand @@ -94,6 +97,7 @@ user_3: merged_at: is_allow_newsletter: false +# member with 1 month subscription (plan 3/students) user_4: id: 4 username: kdumas @@ -126,6 +130,7 @@ user_4: merged_at: is_allow_newsletter: false +# member with 10€ on wallet user_5: id: 5 username: vlonchamp @@ -158,6 +163,7 @@ user_5: merged_at: is_allow_newsletter: true +# partner of plan 2 user_6: id: 6 username: GilbertPartenaire @@ -190,6 +196,7 @@ user_6: merged_at: is_allow_newsletter: true +# member with 255€ on wallet user_7: id: 7 username: lseguin @@ -286,6 +293,7 @@ user_9: merged_at: is_allow_newsletter: true +# member with 1 year subscription user_10: id: 10 username: acamus diff --git a/test/integration/reservations/restricted_test.rb b/test/integration/reservations/restricted_test.rb index 35113d87b..1fd6ac493 100644 --- a/test/integration/reservations/restricted_test.rb +++ b/test/integration/reservations/restricted_test.rb @@ -72,7 +72,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest }.to_json, headers: default_headers end - assert_equal 201, response.status + assert_equal 201, response.status, response.body assert_equal reservations_count + 1, Reservation.count assert_equal invoices_count + 1, Invoice.count @@ -105,7 +105,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest } } - assert_equal 201, response.status + assert_equal 201, response.status, response.body # Check the id availability = json_response(response.body) @@ -141,7 +141,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest }.to_json, headers: default_headers end - assert_equal 422, response.status + assert_equal 422, response.status, response.body assert_match(/availability is restricted for subscribers/, response.body) assert_equal reservations_count, Reservation.count @@ -175,7 +175,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest } } - assert_equal 201, response.status + assert_equal 201, response.status, response.body # Check the id availability = json_response(response.body) @@ -187,27 +187,25 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest slot = Availability.find(availability[:id]).slots.first # book a reservation - VCR.use_cassette('reservations_create_for_restricted_slot_forced') do - post '/api/local_payment/confirm_payment', - params: { - customer_id: @jdupont.id, - items: [ - { - reservation: { - reservable_id: 2, - reservable_type: 'Machine', - slots_reservations_attributes: [ - { - slot_id: slot.id - } - ] - } + post '/api/local_payment/confirm_payment', + params: { + customer_id: @jdupont.id, + items: [ + { + reservation: { + reservable_id: 2, + reservable_type: 'Machine', + slots_reservations_attributes: [ + { + slot_id: slot.id + } + ] } - ] - }.to_json, headers: default_headers - end + } + ] + }.to_json, headers: default_headers - assert_equal 201, response.status + assert_equal 201, response.status, response.body # Check the result result = json_response(response.body) @@ -246,7 +244,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest } } - assert_equal 201, response.status + assert_equal 201, response.status, response.body # Check the id availability = json_response(response.body) @@ -287,7 +285,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest }.to_json, headers: default_headers end - assert_equal 201, response.status + assert_equal 201, response.status, response.body # Check the result result = json_response(response.body) diff --git a/test/services/availabilities_service_test.rb b/test/services/availabilities/availabilities_service_test.rb similarity index 98% rename from test/services/availabilities_service_test.rb rename to test/services/availabilities/availabilities_service_test.rb index 35024ac86..9d9e40315 100644 --- a/test/services/availabilities_service_test.rb +++ b/test/services/availabilities/availabilities_service_test.rb @@ -3,7 +3,7 @@ require 'test_helper' # Test the service returning the availabilities for the given resources -class AvailabilitiesServiceTest < ActiveSupport::TestCase +class Availabilities::AvailabilitiesServiceTest < ActiveSupport::TestCase setup do @no_subscription = User.find_by(username: 'jdupond') @with_subscription = User.find_by(username: 'kdumas') diff --git a/test/services/availabilities/visibility_service_test.rb b/test/services/availabilities/visibility_service_test.rb new file mode 100644 index 000000000..631d9c0d1 --- /dev/null +++ b/test/services/availabilities/visibility_service_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Test the service returning the visibility window for availabilities +class Availabilities::VisibilityServiceTest < ActiveSupport::TestCase + setup do + @admin = User.find_by(username: 'admin') + @no_subscription = User.find_by(username: 'jdupond') + @with_subscription = User.find_by(username: 'kdumas') + @with_1y_subscription = User.find_by(username: 'acamus') + # from the fixtures: + # - visibility_others = 1 month + # - visibility_yearly = 3 months + end + + test 'admin visibility for the coming month' do + starting = Time.current.beginning_of_day + ending = 1.month.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending) + assert_equal starting, window[0] + assert_equal ending, window[1] + end + + test 'admin visibility for the previous month' do + starting = 1.month.ago.end_of_day + ending = Time.current.beginning_of_day + window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending) + assert_equal starting, window[0] + assert_equal ending, window[1] + end + + test 'admin visibility for the coming year' do + starting = Time.current.beginning_of_day + ending = 1.year.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending) + assert_equal starting, window[0] + assert_equal ending, window[1] + end + + test 'member visibility for the coming month' do + starting = Time.current.beginning_of_day + ending = 1.month.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 1.month.from_now, window[1] + end + + test 'member visibility for the previous month' do + starting = 1.month.ago.end_of_day + ending = Time.current.beginning_of_day + window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_equal ending, window[1] + end + + test 'member visibility for the coming year' do + starting = Time.current.beginning_of_day + ending = 1.year.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 1.month.from_now, window[1] + end + + test 'subscriber visibility for the coming month' do + starting = Time.current.beginning_of_day + ending = 1.month.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 1.month.from_now, window[1] + end + + test 'subscriber visibility for the previous month' do + starting = 1.month.ago.end_of_day + ending = Time.current.beginning_of_day + window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_equal ending, window[1] + end + + test 'subscriber visibility for the coming year' do + starting = Time.current.beginning_of_day + ending = 1.year.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 1.month.from_now, window[1] + end + + test '1 year subscriber visibility for the coming month' do + starting = Time.current.beginning_of_day + ending = 1.month.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_equal ending, window[1] + end + + test '1 year subscriber visibility for the previous month' do + starting = 1.month.ago.end_of_day + ending = Time.current.beginning_of_day + window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_equal ending, window[1] + end + + test '1 year subscriber visibility for the coming year' do + starting = Time.current.beginning_of_day + ending = 1.year.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 3.months.from_now, window[1] + end + + test '1 year subscriber visibility for trainings in the coming year' do + starting = Time.current.beginning_of_day + ending = 1.year.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'training', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 1.month.from_now, window[1] + end + + test 'subscriber with plan custom visibility' do + plan = @with_subscription.subscribed_plan + plan.update(machines_visibility: 48) + starting = Time.current.beginning_of_day + ending = 1.month.from_now.end_of_day + window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'machine', starting, ending) + assert_datetimes_equal Time.current, window[0] + assert_datetimes_equal 48.hours.from_now, window[1] + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3dc895be4..d74d906b1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -111,6 +111,11 @@ class ActiveSupport::TestCase assert_not_nil actual, msg assert_equal expected.to_date, actual.to_date, msg end + + def assert_datetimes_equal(expected, actual, msg = nil) + assert_not_nil actual, msg + assert_equal expected.iso8601, actual.iso8601, msg + end end class ActionDispatch::IntegrationTest diff --git a/test/vcr_cassettes/reservations_and_subscription_create_for_restricted_slot_success.yml b/test/vcr_cassettes/reservations_and_subscription_create_for_restricted_slot_success.yml index bcdd51f7f..ef8d646d4 100644 --- a/test/vcr_cassettes/reservations_and_subscription_create_for_restricted_slot_success.yml +++ b/test/vcr_cassettes/reservations_and_subscription_create_for_restricted_slot_success.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://api.stripe.com/v1/payment_methods body: encoding: UTF-8 - string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314 headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -13,12 +13,14 @@ http_interactions: - Bearer sk_test_testfaketestfaketestfake Content-Type: - application/x-www-form-urlencoded + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_MQd4Z7i8cW9FYF","request_duration_ms":535}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -31,11 +33,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:07 GMT + - Wed, 15 Mar 2023 11:51:50 GMT Content-Type: - application/json Content-Length: - - '934' + - '930' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -50,19 +52,23 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - 8b19d06b-ed60-406d-8490-3d7062d47f67 + Original-Request: + - req_jrCbj5YQwrn3m7 Request-Id: - - req_tlTlxEJC4LyAQv + - req_jrCbj5YQwrn3m7 + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '6' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pm_1JZDGd2sOmf47Nz9LCckU76B", + "id": "pm_1Mlsry2sOmf47Nz9g8twwVyn", "object": "payment_method", "billing_details": { "address": { @@ -86,7 +92,7 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "generated_from": null, @@ -102,20 +108,19 @@ http_interactions: }, "wallet": null }, - "created": 1631532247, + "created": 1678881110, "customer": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "type": "card" } - recorded_at: Mon, 13 Sep 2021 11:24:07 GMT + recorded_at: Wed, 15 Mar 2023 11:51:50 GMT - request: method: post uri: https://api.stripe.com/v1/payment_intents body: encoding: UTF-8 - string: payment_method=pm_1JZDGd2sOmf47Nz9LCckU76B&amount=11500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt + string: payment_method=pm_1Mlsry2sOmf47Nz9g8twwVyn&amount=11500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -124,13 +129,13 @@ http_interactions: Content-Type: - application/x-www-form-urlencoded X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_tlTlxEJC4LyAQv","request_duration_ms":663}}' + - '{"last_request_metrics":{"request_id":"req_jrCbj5YQwrn3m7","request_duration_ms":586}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -143,11 +148,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:09 GMT + - Wed, 15 Mar 2023 11:51:52 GMT Content-Type: - application/json Content-Length: - - '4263' + - '4522' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -162,25 +167,33 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - bceb9ff3-545c-48c9-9f9b-4654dd50641b + Original-Request: + - req_2QEtRciNfNblB1 Request-Id: - - req_4hAutJ5WkAA9ps + - req_2QEtRciNfNblB1 + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '10' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pi_3JZDGd2sOmf47Nz91tgWkK3L", + "id": "pi_3Mlsrz2sOmf47Nz90UCTYKFx", "object": "payment_intent", "amount": 11500, "amount_capturable": 0, + "amount_details": { + "tip": {} + }, "amount_received": 11500, "application": null, "application_fee_amount": null, + "automatic_payment_methods": null, "canceled_at": null, "cancellation_reason": null, "capture_method": "automatic", @@ -188,7 +201,7 @@ http_interactions: "object": "list", "data": [ { - "id": "ch_3JZDGd2sOmf47Nz91HsbEa5U", + "id": "ch_3Mlsrz2sOmf47Nz9004R5HME", "object": "charge", "amount": 11500, "amount_captured": 11500, @@ -196,7 +209,7 @@ http_interactions: "application": null, "application_fee": null, "application_fee_amount": null, - "balance_transaction": "txn_3JZDGd2sOmf47Nz91MEnox3F", + "balance_transaction": "txn_3Mlsrz2sOmf47Nz90SLqRLy1", "billing_details": { "address": { "city": null, @@ -212,34 +225,33 @@ http_interactions: }, "calculated_statement_descriptor": "Stripe", "captured": true, - "created": 1631532248, + "created": 1678881111, "currency": "usd", "customer": "cus_8Di1wjdVktv5kt", "description": null, "destination": null, "dispute": null, "disputed": false, + "failure_balance_transaction": null, "failure_code": null, "failure_message": null, - "fraud_details": { - }, + "fraud_details": {}, "invoice": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "on_behalf_of": null, "order": null, "outcome": { "network_status": "approved_by_network", "reason": null, "risk_level": "normal", - "risk_score": 12, + "risk_score": 29, "seller_message": "Payment complete.", "type": "authorized" }, "paid": true, - "payment_intent": "pi_3JZDGd2sOmf47Nz91tgWkK3L", - "payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B", + "payment_intent": "pi_3Mlsrz2sOmf47Nz90UCTYKFx", + "payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn", "payment_method_details": { "card": { "brand": "visa", @@ -250,11 +262,12 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "installments": null, "last4": "4242", + "mandate": null, "network": "visa", "three_d_secure": null, "wallet": null @@ -263,16 +276,14 @@ http_interactions: }, "receipt_email": null, "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGd2sOmf47Nz91HsbEa5U/rcpt_KDeZXFrj8mqhXX4v6MKFwawzylc2kPA", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNjixqAGMgZncKXPXn06LBY8nUVkDGBhMzC5bIzAPJWsR_S75nAIhMIDBqALhrU35BgWxXMZpcMss_ty", "refunded": false, "refunds": { "object": "list", - "data": [ - - ], + "data": [], "has_more": false, "total_count": 0, - "url": "/v1/charges/ch_3JZDGd2sOmf47Nz91HsbEa5U/refunds" + "url": "/v1/charges/ch_3Mlsrz2sOmf47Nz9004R5HME/refunds" }, "review": null, "shipping": null, @@ -287,25 +298,26 @@ http_interactions: ], "has_more": false, "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3JZDGd2sOmf47Nz91tgWkK3L" + "url": "/v1/charges?payment_intent=pi_3Mlsrz2sOmf47Nz90UCTYKFx" }, - "client_secret": "pi_3JZDGd2sOmf47Nz91tgWkK3L_secret_DooP6j5YiNN0kzaXPdTGEeKeR", + "client_secret": "pi_3Mlsrz2sOmf47Nz90UCTYKFx_secret_af9V81CCyVZZ2J2sptymbRLES", "confirmation_method": "manual", - "created": 1631532247, + "created": 1678881111, "currency": "usd", "customer": "cus_8Di1wjdVktv5kt", "description": null, "invoice": null, "last_payment_error": null, + "latest_charge": "ch_3Mlsrz2sOmf47Nz9004R5HME", "livemode": false, - "metadata": { - }, + "metadata": {}, "next_action": null, "on_behalf_of": null, - "payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B", + "payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn", "payment_method_options": { "card": { "installments": null, + "mandate_options": null, "network": null, "request_three_d_secure": "automatic" } @@ -313,6 +325,7 @@ http_interactions: "payment_method_types": [ "card" ], + "processing": null, "receipt_email": null, "review": null, "setup_future_usage": null, @@ -324,13 +337,13 @@ http_interactions: "transfer_data": null, "transfer_group": null } - recorded_at: Mon, 13 Sep 2021 11:24:09 GMT + recorded_at: Wed, 15 Mar 2023 11:51:52 GMT - request: method: post - uri: https://api.stripe.com/v1/payment_intents/pi_3JZDGd2sOmf47Nz91tgWkK3L + uri: https://api.stripe.com/v1/payment_intents/pi_3Mlsrz2sOmf47Nz90UCTYKFx body: encoding: UTF-8 - string: description=Invoice+reference%3A+2109001%2FVL + string: description=Invoice+reference%3A+2303007%2FVL headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -339,13 +352,13 @@ http_interactions: Content-Type: - application/x-www-form-urlencoded X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_4hAutJ5WkAA9ps","request_duration_ms":1725}}' + - '{"last_request_metrics":{"request_id":"req_2QEtRciNfNblB1","request_duration_ms":1482}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -358,11 +371,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:10 GMT + - Wed, 15 Mar 2023 11:51:52 GMT Content-Type: - application/json Content-Length: - - '4290' + - '4549' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -377,25 +390,33 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - 51790c68-63e7-4b6e-96b1-4b6eef30b42b + Original-Request: + - req_Ms4raQ174jWmPK Request-Id: - - req_ysWG3JfyCp5xVD + - req_Ms4raQ174jWmPK + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '0' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pi_3JZDGd2sOmf47Nz91tgWkK3L", + "id": "pi_3Mlsrz2sOmf47Nz90UCTYKFx", "object": "payment_intent", "amount": 11500, "amount_capturable": 0, + "amount_details": { + "tip": {} + }, "amount_received": 11500, "application": null, "application_fee_amount": null, + "automatic_payment_methods": null, "canceled_at": null, "cancellation_reason": null, "capture_method": "automatic", @@ -403,7 +424,7 @@ http_interactions: "object": "list", "data": [ { - "id": "ch_3JZDGd2sOmf47Nz91HsbEa5U", + "id": "ch_3Mlsrz2sOmf47Nz9004R5HME", "object": "charge", "amount": 11500, "amount_captured": 11500, @@ -411,7 +432,7 @@ http_interactions: "application": null, "application_fee": null, "application_fee_amount": null, - "balance_transaction": "txn_3JZDGd2sOmf47Nz91MEnox3F", + "balance_transaction": "txn_3Mlsrz2sOmf47Nz90SLqRLy1", "billing_details": { "address": { "city": null, @@ -427,34 +448,33 @@ http_interactions: }, "calculated_statement_descriptor": "Stripe", "captured": true, - "created": 1631532248, + "created": 1678881111, "currency": "usd", "customer": "cus_8Di1wjdVktv5kt", "description": null, "destination": null, "dispute": null, "disputed": false, + "failure_balance_transaction": null, "failure_code": null, "failure_message": null, - "fraud_details": { - }, + "fraud_details": {}, "invoice": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "on_behalf_of": null, "order": null, "outcome": { "network_status": "approved_by_network", "reason": null, "risk_level": "normal", - "risk_score": 12, + "risk_score": 29, "seller_message": "Payment complete.", "type": "authorized" }, "paid": true, - "payment_intent": "pi_3JZDGd2sOmf47Nz91tgWkK3L", - "payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B", + "payment_intent": "pi_3Mlsrz2sOmf47Nz90UCTYKFx", + "payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn", "payment_method_details": { "card": { "brand": "visa", @@ -465,11 +485,12 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "installments": null, "last4": "4242", + "mandate": null, "network": "visa", "three_d_secure": null, "wallet": null @@ -478,16 +499,14 @@ http_interactions: }, "receipt_email": null, "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGd2sOmf47Nz91HsbEa5U/rcpt_KDeZXFrj8mqhXX4v6MKFwawzylc2kPA", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNjixqAGMgbuQcWgG5c6LBa-ZSU_-3ZZaF7tt6uBsD2Rs604Am5ssLMpXbt0FvRIOVw-pVEwxmP_5ACg", "refunded": false, "refunds": { "object": "list", - "data": [ - - ], + "data": [], "has_more": false, "total_count": 0, - "url": "/v1/charges/ch_3JZDGd2sOmf47Nz91HsbEa5U/refunds" + "url": "/v1/charges/ch_3Mlsrz2sOmf47Nz9004R5HME/refunds" }, "review": null, "shipping": null, @@ -502,25 +521,26 @@ http_interactions: ], "has_more": false, "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3JZDGd2sOmf47Nz91tgWkK3L" + "url": "/v1/charges?payment_intent=pi_3Mlsrz2sOmf47Nz90UCTYKFx" }, - "client_secret": "pi_3JZDGd2sOmf47Nz91tgWkK3L_secret_DooP6j5YiNN0kzaXPdTGEeKeR", + "client_secret": "pi_3Mlsrz2sOmf47Nz90UCTYKFx_secret_af9V81CCyVZZ2J2sptymbRLES", "confirmation_method": "manual", - "created": 1631532247, + "created": 1678881111, "currency": "usd", "customer": "cus_8Di1wjdVktv5kt", - "description": "Invoice reference: 2109001/VL", + "description": "Invoice reference: 2303007/VL", "invoice": null, "last_payment_error": null, + "latest_charge": "ch_3Mlsrz2sOmf47Nz9004R5HME", "livemode": false, - "metadata": { - }, + "metadata": {}, "next_action": null, "on_behalf_of": null, - "payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B", + "payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn", "payment_method_options": { "card": { "installments": null, + "mandate_options": null, "network": null, "request_three_d_secure": "automatic" } @@ -528,6 +548,7 @@ http_interactions: "payment_method_types": [ "card" ], + "processing": null, "receipt_email": null, "review": null, "setup_future_usage": null, @@ -539,5 +560,5 @@ http_interactions: "transfer_data": null, "transfer_group": null } - recorded_at: Mon, 13 Sep 2021 11:24:10 GMT + recorded_at: Wed, 15 Mar 2023 11:51:53 GMT recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/reservations_create_for_restricted_slot_fails.yml b/test/vcr_cassettes/reservations_create_for_restricted_slot_fails.yml index 98aef3105..f3ae5c1c5 100644 --- a/test/vcr_cassettes/reservations_create_for_restricted_slot_fails.yml +++ b/test/vcr_cassettes/reservations_create_for_restricted_slot_fails.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://api.stripe.com/v1/payment_methods body: encoding: UTF-8 - string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314 headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -13,14 +13,12 @@ http_interactions: - Bearer sk_test_testfaketestfaketestfake Content-Type: - application/x-www-form-urlencoded - X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_ysWG3JfyCp5xVD","request_duration_ms":520}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -33,11 +31,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:10 GMT + - Wed, 15 Mar 2023 11:51:20 GMT Content-Type: - application/json Content-Length: - - '934' + - '930' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -52,19 +50,23 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - 6a183d91-3220-4ad4-913c-f169a75aa488 + Original-Request: + - req_LQKka6p7rniNKT Request-Id: - - req_7RNGSU2vySHdHz + - req_LQKka6p7rniNKT + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '6' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pm_1JZDGg2sOmf47Nz9pfmMaPtb", + "id": "pm_1MlsrU2sOmf47Nz9voyfBlTb", "object": "payment_method", "billing_details": { "address": { @@ -88,7 +90,7 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "generated_from": null, @@ -104,227 +106,11 @@ http_interactions: }, "wallet": null }, - "created": 1631532250, + "created": 1678881080, "customer": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "type": "card" } - recorded_at: Mon, 13 Sep 2021 11:24:10 GMT -- request: - method: post - uri: https://api.stripe.com/v1/payment_intents - body: - encoding: UTF-8 - string: payment_method=pm_1JZDGg2sOmf47Nz9pfmMaPtb&amount=4200¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt - headers: - User-Agent: - - Stripe/v1 RubyBindings/5.29.0 - Authorization: - - Bearer sk_test_testfaketestfaketestfake - Content-Type: - - application/x-www-form-urlencoded - X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_7RNGSU2vySHdHz","request_duration_ms":628}}' - Stripe-Version: - - '2019-08-14' - X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Server: - - nginx - Date: - - Mon, 13 Sep 2021 11:24:12 GMT - Content-Type: - - application/json - Content-Length: - - '4258' - Connection: - - keep-alive - Access-Control-Allow-Credentials: - - 'true' - Access-Control-Allow-Methods: - - GET, POST, HEAD, OPTIONS, DELETE - Access-Control-Allow-Origin: - - "*" - Access-Control-Expose-Headers: - - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required - Access-Control-Max-Age: - - '300' - Cache-Control: - - no-cache, no-store - Request-Id: - - req_NgGOJxEFd8THv8 - Stripe-Version: - - '2019-08-14' - X-Stripe-C-Cost: - - '10' - Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload - body: - encoding: UTF-8 - string: | - { - "id": "pi_3JZDGh2sOmf47Nz91FT4yZ2t", - "object": "payment_intent", - "amount": 4200, - "amount_capturable": 0, - "amount_received": 4200, - "application": null, - "application_fee_amount": null, - "canceled_at": null, - "cancellation_reason": null, - "capture_method": "automatic", - "charges": { - "object": "list", - "data": [ - { - "id": "ch_3JZDGh2sOmf47Nz91FoAsBFe", - "object": "charge", - "amount": 4200, - "amount_captured": 4200, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3JZDGh2sOmf47Nz91yuRvukb", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": null, - "phone": null - }, - "calculated_statement_descriptor": "Stripe", - "captured": true, - "created": 1631532251, - "currency": "usd", - "customer": "cus_8Di1wjdVktv5kt", - "description": null, - "destination": null, - "dispute": null, - "disputed": false, - "failure_code": null, - "failure_message": null, - "fraud_details": { - }, - "invoice": null, - "livemode": false, - "metadata": { - }, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 2, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_3JZDGh2sOmf47Nz91FT4yZ2t", - "payment_method": "pm_1JZDGg2sOmf47Nz9pfmMaPtb", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 4, - "exp_year": 2022, - "fingerprint": "o52jybR7bnmNn6AT", - "funding": "credit", - "installments": null, - "last4": "4242", - "network": "visa", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGh2sOmf47Nz91FoAsBFe/rcpt_KDeZ4pRoBzCyvhebh2wUzvr5fmdZdtD", - "refunded": false, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3JZDGh2sOmf47Nz91FoAsBFe/refunds" - }, - "review": null, - "shipping": null, - "source": null, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3JZDGh2sOmf47Nz91FT4yZ2t" - }, - "client_secret": "pi_3JZDGh2sOmf47Nz91FT4yZ2t_secret_F3QBmBEtZjcaKblNLMUnLO6hD", - "confirmation_method": "manual", - "created": 1631532251, - "currency": "usd", - "customer": "cus_8Di1wjdVktv5kt", - "description": null, - "invoice": null, - "last_payment_error": null, - "livemode": false, - "metadata": { - }, - "next_action": null, - "on_behalf_of": null, - "payment_method": "pm_1JZDGg2sOmf47Nz9pfmMaPtb", - "payment_method_options": { - "card": { - "installments": null, - "network": null, - "request_three_d_secure": "automatic" - } - }, - "payment_method_types": [ - "card" - ], - "receipt_email": null, - "review": null, - "setup_future_usage": null, - "shipping": null, - "source": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - recorded_at: Mon, 13 Sep 2021 11:24:12 GMT + recorded_at: Wed, 15 Mar 2023 11:51:20 GMT recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/reservations_create_for_restricted_slot_success.yml b/test/vcr_cassettes/reservations_create_for_restricted_slot_success.yml index a4a9e06fc..280cfe25c 100644 --- a/test/vcr_cassettes/reservations_create_for_restricted_slot_success.yml +++ b/test/vcr_cassettes/reservations_create_for_restricted_slot_success.yml @@ -5,7 +5,7 @@ http_interactions: uri: https://api.stripe.com/v1/payment_methods body: encoding: UTF-8 - string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314 + string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314 headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -13,14 +13,12 @@ http_interactions: - Bearer sk_test_testfaketestfaketestfake Content-Type: - application/x-www-form-urlencoded - X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_NgGOJxEFd8THv8","request_duration_ms":1772}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -33,11 +31,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:13 GMT + - Wed, 15 Mar 2023 11:51:46 GMT Content-Type: - application/json Content-Length: - - '934' + - '930' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -52,19 +50,23 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - 4a6e0351-64bd-4ede-a018-7c03e320c3f5 + Original-Request: + - req_njTGdMZNpa6wpG Request-Id: - - req_px5zsAdlzgwwSe + - req_njTGdMZNpa6wpG + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '6' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pm_1JZDGj2sOmf47Nz9S8jhZkFt", + "id": "pm_1Mlsru2sOmf47Nz9s3VNHXYt", "object": "payment_method", "billing_details": { "address": { @@ -88,7 +90,7 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "generated_from": null, @@ -104,20 +106,19 @@ http_interactions: }, "wallet": null }, - "created": 1631532253, + "created": 1678881106, "customer": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "type": "card" } - recorded_at: Mon, 13 Sep 2021 11:24:13 GMT + recorded_at: Wed, 15 Mar 2023 11:51:46 GMT - request: method: post uri: https://api.stripe.com/v1/payment_intents body: encoding: UTF-8 - string: payment_method=pm_1JZDGj2sOmf47Nz9S8jhZkFt&amount=1500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8CzHcwBJtlA3IL + string: payment_method=pm_1Mlsru2sOmf47Nz9s3VNHXYt&amount=1500¤cy=usd&confirmation_method=manual&confirm=true&customer=cus_8CzHcwBJtlA3IL headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -126,13 +127,13 @@ http_interactions: Content-Type: - application/x-www-form-urlencoded X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_px5zsAdlzgwwSe","request_duration_ms":619}}' + - '{"last_request_metrics":{"request_id":"req_njTGdMZNpa6wpG","request_duration_ms":581}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -145,11 +146,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:15 GMT + - Wed, 15 Mar 2023 11:51:48 GMT Content-Type: - application/json Content-Length: - - '4259' + - '4517' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -164,25 +165,33 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - f50a36a7-97b5-4d67-9c77-43b5cbddccbe + Original-Request: + - req_EocoIWBH6tucS4 Request-Id: - - req_EMDO1Z1Uux0kJb + - req_EocoIWBH6tucS4 + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '10' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pi_3JZDGj2sOmf47Nz90n2aOsuM", + "id": "pi_3Mlsrv2sOmf47Nz91hRe49rq", "object": "payment_intent", "amount": 1500, "amount_capturable": 0, + "amount_details": { + "tip": {} + }, "amount_received": 1500, "application": null, "application_fee_amount": null, + "automatic_payment_methods": null, "canceled_at": null, "cancellation_reason": null, "capture_method": "automatic", @@ -190,7 +199,7 @@ http_interactions: "object": "list", "data": [ { - "id": "ch_3JZDGj2sOmf47Nz90qQzwGqL", + "id": "ch_3Mlsrv2sOmf47Nz91760imzX", "object": "charge", "amount": 1500, "amount_captured": 1500, @@ -198,7 +207,7 @@ http_interactions: "application": null, "application_fee": null, "application_fee_amount": null, - "balance_transaction": "txn_3JZDGj2sOmf47Nz90fOwfgv5", + "balance_transaction": "txn_3Mlsrv2sOmf47Nz915VOM7IM", "billing_details": { "address": { "city": null, @@ -214,34 +223,33 @@ http_interactions: }, "calculated_statement_descriptor": "Stripe", "captured": true, - "created": 1631532254, + "created": 1678881107, "currency": "usd", "customer": "cus_8CzHcwBJtlA3IL", "description": null, "destination": null, "dispute": null, "disputed": false, + "failure_balance_transaction": null, "failure_code": null, "failure_message": null, - "fraud_details": { - }, + "fraud_details": {}, "invoice": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "on_behalf_of": null, "order": null, "outcome": { "network_status": "approved_by_network", "reason": null, "risk_level": "normal", - "risk_score": 22, + "risk_score": 7, "seller_message": "Payment complete.", "type": "authorized" }, "paid": true, - "payment_intent": "pi_3JZDGj2sOmf47Nz90n2aOsuM", - "payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt", + "payment_intent": "pi_3Mlsrv2sOmf47Nz91hRe49rq", + "payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt", "payment_method_details": { "card": { "brand": "visa", @@ -252,11 +260,12 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "installments": null, "last4": "4242", + "mandate": null, "network": "visa", "three_d_secure": null, "wallet": null @@ -265,16 +274,14 @@ http_interactions: }, "receipt_email": null, "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGj2sOmf47Nz90qQzwGqL/rcpt_KDeZMofbRKdRjLxxUw4LZO1LIDJP1sR", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNTixqAGMgYuj2m72sQ6LBbKXya9pk9SHbdsAtPEkvHFS51bMvmjrLtHgTEs1uQ8YSkOpfxQRpKaC0PP", "refunded": false, "refunds": { "object": "list", - "data": [ - - ], + "data": [], "has_more": false, "total_count": 0, - "url": "/v1/charges/ch_3JZDGj2sOmf47Nz90qQzwGqL/refunds" + "url": "/v1/charges/ch_3Mlsrv2sOmf47Nz91760imzX/refunds" }, "review": null, "shipping": null, @@ -289,25 +296,26 @@ http_interactions: ], "has_more": false, "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3JZDGj2sOmf47Nz90n2aOsuM" + "url": "/v1/charges?payment_intent=pi_3Mlsrv2sOmf47Nz91hRe49rq" }, - "client_secret": "pi_3JZDGj2sOmf47Nz90n2aOsuM_secret_WR5cdTATWgOShT8ZHInabONRL", + "client_secret": "pi_3Mlsrv2sOmf47Nz91hRe49rq_secret_gLvEqjCexLXGNWDLb7g2NpnfB", "confirmation_method": "manual", - "created": 1631532253, + "created": 1678881107, "currency": "usd", "customer": "cus_8CzHcwBJtlA3IL", "description": null, "invoice": null, "last_payment_error": null, + "latest_charge": "ch_3Mlsrv2sOmf47Nz91760imzX", "livemode": false, - "metadata": { - }, + "metadata": {}, "next_action": null, "on_behalf_of": null, - "payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt", + "payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt", "payment_method_options": { "card": { "installments": null, + "mandate_options": null, "network": null, "request_three_d_secure": "automatic" } @@ -315,6 +323,7 @@ http_interactions: "payment_method_types": [ "card" ], + "processing": null, "receipt_email": null, "review": null, "setup_future_usage": null, @@ -326,13 +335,13 @@ http_interactions: "transfer_data": null, "transfer_group": null } - recorded_at: Mon, 13 Sep 2021 11:24:15 GMT + recorded_at: Wed, 15 Mar 2023 11:51:48 GMT - request: method: post - uri: https://api.stripe.com/v1/payment_intents/pi_3JZDGj2sOmf47Nz90n2aOsuM + uri: https://api.stripe.com/v1/payment_intents/pi_3Mlsrv2sOmf47Nz91hRe49rq body: encoding: UTF-8 - string: description=Invoice+reference%3A+2109001%2FVL + string: description=Invoice+reference%3A+2303007%2FVL headers: User-Agent: - Stripe/v1 RubyBindings/5.29.0 @@ -341,13 +350,13 @@ http_interactions: Content-Type: - application/x-www-form-urlencoded X-Stripe-Client-Telemetry: - - '{"last_request_metrics":{"request_id":"req_EMDO1Z1Uux0kJb","request_duration_ms":1528}}' + - '{"last_request_metrics":{"request_id":"req_EocoIWBH6tucS4","request_duration_ms":1593}}' Stripe-Version: - '2019-08-14' X-Stripe-Client-User-Agent: - - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin - MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 - PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}' + - '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux + version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld + (GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -360,11 +369,11 @@ http_interactions: Server: - nginx Date: - - Mon, 13 Sep 2021 11:24:15 GMT + - Wed, 15 Mar 2023 11:51:49 GMT Content-Type: - application/json Content-Length: - - '4286' + - '4544' Connection: - keep-alive Access-Control-Allow-Credentials: @@ -379,25 +388,33 @@ http_interactions: - '300' Cache-Control: - no-cache, no-store + Idempotency-Key: + - 75c78d8d-9df1-4bb7-af1b-7de3aeaa7056 + Original-Request: + - req_MQd4Z7i8cW9FYF Request-Id: - - req_Xmih0ndHQjzde4 + - req_MQd4Z7i8cW9FYF + Stripe-Should-Retry: + - 'false' Stripe-Version: - '2019-08-14' - X-Stripe-C-Cost: - - '0' Strict-Transport-Security: - - max-age=31556926; includeSubDomains; preload + - max-age=63072000; includeSubDomains; preload body: encoding: UTF-8 - string: | + string: |- { - "id": "pi_3JZDGj2sOmf47Nz90n2aOsuM", + "id": "pi_3Mlsrv2sOmf47Nz91hRe49rq", "object": "payment_intent", "amount": 1500, "amount_capturable": 0, + "amount_details": { + "tip": {} + }, "amount_received": 1500, "application": null, "application_fee_amount": null, + "automatic_payment_methods": null, "canceled_at": null, "cancellation_reason": null, "capture_method": "automatic", @@ -405,7 +422,7 @@ http_interactions: "object": "list", "data": [ { - "id": "ch_3JZDGj2sOmf47Nz90qQzwGqL", + "id": "ch_3Mlsrv2sOmf47Nz91760imzX", "object": "charge", "amount": 1500, "amount_captured": 1500, @@ -413,7 +430,7 @@ http_interactions: "application": null, "application_fee": null, "application_fee_amount": null, - "balance_transaction": "txn_3JZDGj2sOmf47Nz90fOwfgv5", + "balance_transaction": "txn_3Mlsrv2sOmf47Nz915VOM7IM", "billing_details": { "address": { "city": null, @@ -429,34 +446,33 @@ http_interactions: }, "calculated_statement_descriptor": "Stripe", "captured": true, - "created": 1631532254, + "created": 1678881107, "currency": "usd", "customer": "cus_8CzHcwBJtlA3IL", "description": null, "destination": null, "dispute": null, "disputed": false, + "failure_balance_transaction": null, "failure_code": null, "failure_message": null, - "fraud_details": { - }, + "fraud_details": {}, "invoice": null, "livemode": false, - "metadata": { - }, + "metadata": {}, "on_behalf_of": null, "order": null, "outcome": { "network_status": "approved_by_network", "reason": null, "risk_level": "normal", - "risk_score": 22, + "risk_score": 7, "seller_message": "Payment complete.", "type": "authorized" }, "paid": true, - "payment_intent": "pi_3JZDGj2sOmf47Nz90n2aOsuM", - "payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt", + "payment_intent": "pi_3Mlsrv2sOmf47Nz91hRe49rq", + "payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt", "payment_method_details": { "card": { "brand": "visa", @@ -467,11 +483,12 @@ http_interactions: }, "country": "US", "exp_month": 4, - "exp_year": 2022, + "exp_year": 2024, "fingerprint": "o52jybR7bnmNn6AT", "funding": "credit", "installments": null, "last4": "4242", + "mandate": null, "network": "visa", "three_d_secure": null, "wallet": null @@ -480,16 +497,14 @@ http_interactions: }, "receipt_email": null, "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGj2sOmf47Nz90qQzwGqL/rcpt_KDeZMofbRKdRjLxxUw4LZO1LIDJP1sR", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNXixqAGMgYPhCqQTfg6LBaYDHIkkSjReWHiH0dGP8uhn2RZUgWlfTBRya6YV380PXzkqIYppHOEGGj9", "refunded": false, "refunds": { "object": "list", - "data": [ - - ], + "data": [], "has_more": false, "total_count": 0, - "url": "/v1/charges/ch_3JZDGj2sOmf47Nz90qQzwGqL/refunds" + "url": "/v1/charges/ch_3Mlsrv2sOmf47Nz91760imzX/refunds" }, "review": null, "shipping": null, @@ -504,25 +519,26 @@ http_interactions: ], "has_more": false, "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3JZDGj2sOmf47Nz90n2aOsuM" + "url": "/v1/charges?payment_intent=pi_3Mlsrv2sOmf47Nz91hRe49rq" }, - "client_secret": "pi_3JZDGj2sOmf47Nz90n2aOsuM_secret_WR5cdTATWgOShT8ZHInabONRL", + "client_secret": "pi_3Mlsrv2sOmf47Nz91hRe49rq_secret_gLvEqjCexLXGNWDLb7g2NpnfB", "confirmation_method": "manual", - "created": 1631532253, + "created": 1678881107, "currency": "usd", "customer": "cus_8CzHcwBJtlA3IL", - "description": "Invoice reference: 2109001/VL", + "description": "Invoice reference: 2303007/VL", "invoice": null, "last_payment_error": null, + "latest_charge": "ch_3Mlsrv2sOmf47Nz91760imzX", "livemode": false, - "metadata": { - }, + "metadata": {}, "next_action": null, "on_behalf_of": null, - "payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt", + "payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt", "payment_method_options": { "card": { "installments": null, + "mandate_options": null, "network": null, "request_three_d_secure": "automatic" } @@ -530,6 +546,7 @@ http_interactions: "payment_method_types": [ "card" ], + "processing": null, "receipt_email": null, "review": null, "setup_future_usage": null, @@ -541,5 +558,5 @@ http_interactions: "transfer_data": null, "transfer_group": null } - recorded_at: Mon, 13 Sep 2021 11:24:15 GMT + recorded_at: Wed, 15 Mar 2023 11:51:49 GMT recorded_with: VCR 6.0.0 From 29693b446ee2049911f8b7f93fe34cd96fb6216d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 15 Mar 2023 14:22:16 +0100 Subject: [PATCH 23/23] (bug) new sentences should not keep translations --- scripts/translations/upload.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/translations/upload.sh b/scripts/translations/upload.sh index 00c614bea..eafb01c49 100755 --- a/scripts/translations/upload.sh +++ b/scripts/translations/upload.sh @@ -34,7 +34,7 @@ list_files() { update_file() { # params: FILE_ID, STORAGE_ID - curl -s -X PUT "https://api.crowdin.com/api/v2/projects/$PROJECT_ID/files/$1" -H "$(authorization)" -H "Content-Type: application/json" -d "{ \"storageId\": $2, \"updateOption\": \"keep_translations_and_approvals\" }" + curl -s -X PUT "https://api.crowdin.com/api/v2/projects/$PROJECT_ID/files/$1" -H "$(authorization)" -H "Content-Type: application/json" -d "{ \"storageId\": $2 }" } find_file_id() {