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

(ui) refactor plan form

Also: add advanced accounting to plans
This commit is contained in:
Sylvain 2022-11-14 17:54:14 +01:00
parent 85fcc71d6b
commit be8ae01ba4
34 changed files with 828 additions and 989 deletions

View File

@ -83,7 +83,8 @@ class API::PlansController < API::ApiController
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
:training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id,
plan_file_attributes: %i[id attachment _destroy],
prices_attributes: %i[id amount])
prices_attributes: %i[id amount],
advanced_accounting_attributes: %i[code analytical_section])
end
end
end

View File

@ -11,7 +11,7 @@ class API::UsersController < API::ApiController
if %w[partner manager].include?(params[:role])
@users = User.with_role(params[:role].to_sym).includes(:profile)
else
head 403
head :forbidden
end
end

View File

@ -1,10 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter, UserRole } from '../models/user';
import { User, MemberIndexFilter, UserRole } from '../models/user';
export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> {
static async list (filters: MemberIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.post('/api/members/list', filters);
return res?.data;
}

View File

@ -1,6 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Plan, PlansDuration } from '../models/plan';
import ApiLib from '../lib/api';
export default class PlanAPI {
static async index (): Promise<Array<Plan>> {
@ -12,4 +13,29 @@ export default class PlanAPI {
const res: AxiosResponse<Array<PlansDuration>> = await apiClient.get('/api/plans/durations');
return res?.data;
}
static async create (plan: Plan): Promise<Plan> {
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
const res: AxiosResponse<Plan> = await apiClient.post('/api/plans', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (plan: Plan): Promise<Plan> {
const data = ApiLib.serializeAttachments(plan, 'plan', ['plan_file_attributes']);
const res: AxiosResponse<Plan> = await apiClient.put(`/api/plans/${plan.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async get (id: number): Promise<Plan> {
const res: AxiosResponse<Plan> = await apiClient.get(`/api/plans/${id}`);
return res?.data;
}
}

View File

@ -0,0 +1,21 @@
import apiClient from './clients/api-client';
import ApiLib from '../lib/api';
import { UserIndexFilter, User } from '../models/user';
import { AxiosResponse } from 'axios';
import { Partner } from '../models/plan';
export default class UserAPI {
static async index (filters: UserIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/users${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async create (user: Partner|User): Promise<User> {
const data = {
user: user as Partner,
manager: user as User
};
const res: AxiosResponse<User> = await apiClient.post('/api/users', data);
return res?.data;
}
}

View File

@ -0,0 +1,46 @@
import React, { ReactNode, useEffect, useState } from 'react';
type tabId = string|number;
interface Tab {
id: tabId,
title: ReactNode,
content: ReactNode,
onSelected?: () => void,
}
interface FabTabsProps {
tabs: Array<Tab>,
defaultTab?: tabId,
className?: string
}
/**
* Tabulation system
*/
export const FabTabs: React.FC<FabTabsProps> = ({ tabs, defaultTab, className }) => {
const [active, setActive] = useState<Tab>(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
useEffect(() => {
setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
}, [tabs]);
/**
* Callback triggered when a tab a selected
*/
const onTabSelected = (tab: Tab) => {
setActive(tab);
if (typeof tab.onSelected === 'function') tab.onSelected();
};
return (
<div className={`fab-tabs ${className || ''}`}>
<div className="tabs">
{tabs.filter(Boolean).map((tab, index) => (
<p key={index} className={active?.id === tab.id ? 'is-active' : ''} onClick={() => onTabSelected(tab)}>{tab.title}</p>
))}
</div>
{active?.content}
</div>
);
};

View File

@ -23,7 +23,7 @@ interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>,
/**
* This component allows to upload file, in forms managed by react-hook-form.
*/
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<FileType>(defaultFile);
@ -72,7 +72,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
const placeholder = (): string => hasFile() ? t('app.shared.form_file_upload.edit') : t('app.shared.form_file_upload.browse');
return (
<div className={`form-file-upload ${classNames}`}>
<div className={`form-file-upload ${label ? 'with-label' : ''} ${classNames}`}>
{hasFile() && (
<span>{file.attachment_name}</span>
)}
@ -89,6 +89,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
className="image-file-input"
accept={accept}
register={register}
label={label}
formState={formState}
rules={rules}
disabled={disabled}

View File

@ -0,0 +1,57 @@
import React from 'react';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Partner } from '../../models/plan';
import UserAPI from '../../api/user';
import { User } from '../../models/user';
import { FormInput } from '../form/form-input';
interface PartnerModalProps {
isOpen: boolean,
toggleModal: () => void,
onError: (message: string) => void,
onPartnerCreated: (partner: User) => void,
}
/**
* Modal dialog to add o new user with role 'partner'
*/
export const PartnerModal: React.FC<PartnerModalProps> = ({ isOpen, toggleModal, onError, onPartnerCreated }) => {
const { t } = useTranslation('admin');
const { handleSubmit, register, formState } = useForm<Partner>();
/**
* Callback triggered when the user validates the partner form: create the partner on the API
*/
const onSubmit: SubmitHandler<Partner> = (data: Partner) => {
UserAPI.create(data).then(onPartnerCreated).catch(onError);
};
return (
<FabModal isOpen={isOpen}
title={t('app.admin.partner_modal.title')}
toggleModal={toggleModal}
confirmButton={t('app.admin.partner_modal.create_partner')}
onConfirmSendFormId="partner-form"
closeButton>
<form onSubmit={handleSubmit(onSubmit)} id="partner-form">
<FormInput register={register}
label={t('app.admin.partner_modal.first_name')}
id="first_name"
rules={{ required: true }}
formState={formState} />
<FormInput register={register}
label={t('app.admin.partner_modal.surname')}
id="last_name"
rules={{ required: true }}
formState={formState} />
<FormInput register={register}
label={t('app.admin.partner_modal.email')}
id="email"
rules={{ required: true }}
formState={formState} />
</form>
</FabModal>
);
};

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { Plan } from '../../models/plan';
import { Interval, Plan } from '../../models/plan';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import PlanAPI from '../../api/plan';
@ -12,6 +12,17 @@ import GroupAPI from '../../api/group';
import { SelectOption } from '../../models/select';
import { FormSelect } from '../form/form-select';
import { FormSwitch } from '../form/form-switch';
import PlanCategoryAPI from '../../api/plan-category';
import FormatLib from '../../lib/format';
import { FabAlert } from '../base/fab-alert';
import { FormRichText } from '../form/form-rich-text';
import { FormFileUpload } from '../form/form-file-upload';
import UserAPI from '../../api/user';
import { FabButton } from '../base/fab-button';
import { UserPlus } from 'phosphor-react';
import { PartnerModal } from './partner-modal';
import { PlanPricingForm } from './plan-pricing-form';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
declare const Application: IApplication;
@ -27,16 +38,25 @@ interface PlanFormProps {
*/
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<Plan>({ defaultValues: { ...plan } });
const _output = useWatch<Plan>({ control }); // eslint-disable-line
const output = useWatch<Plan>({ control }); // eslint-disable-line
const { t } = useTranslation('admin');
const [groups, setGroups] = useState<Array<SelectOption<number>>>(null);
const [categories, setCategories] = useState<Array<SelectOption<number>>>(null);
const [allGroups, setAllGroups] = useState<boolean>(false);
const [partners, setPartners] = useState<Array<SelectOption<number>>>(null);
const [isOpenPartnerModal, setIsOpenPartnerModal] = useState<boolean>(false);
useEffect(() => {
GroupAPI.index({ disabled: false })
.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 }; })))
.catch(onError);
UserAPI.index({ role: 'partner' })
.then(res => setPartners(res.map(p => { return { value: p.id, label: p.name }; })))
.catch(onError);
}, []);
/**
@ -63,26 +83,187 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
}
};
/**
* Callback triggere when the user switches the 'partner plan' button.
*/
const handlePartnershipChange = (checked: boolean) => {
if (checked) {
setValue('type', 'PartnerPlan');
} else {
setValue('type', 'Plan');
}
};
/**
* Return the available options for the plan period
*/
const buildPeriodsOptions = (): Array<SelectOption<string>> => {
return ['week', 'month', 'year'].map(d => { return { value: d, label: t(`app.admin.plan_form.${d}`) }; });
};
/**
* Callback triggered when the user changes the period of the current plan
*/
const handlePeriodUpdate = (period: Interval) => {
if (period === 'week') {
setValue('monthly_payment', false);
}
};
/**
* Open/closes the partner creation modal
*/
const tooglePartnerModal = () => {
setIsOpenPartnerModal(!isOpenPartnerModal);
};
/**
* Callback triggered when a user with role partner was created in the dedicated modal form
*/
const handleNewPartner = (user) => {
tooglePartnerModal();
onSuccess(t('app.admin.plan_form.partner_created'));
partners.push({ value: user.id, label: user.name });
setValue('partner_id', user.id);
};
return (
<form className="plan-form" onSubmit={handleSubmit(onSubmit)}>
<h4>{t('app.admin.plan_form.general_information')}</h4>
<FormInput register={register}
id="base_name"
formState={formState}
rules={{ required: true, maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') } }}
label={t('app.admin.plan_form.name')} />
<FormSwitch control={control}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />
{!allGroups && groups && <FormSelect options={groups}
control={control}
rules={{ required: !allGroups }}
label={t('app.admin.plan_form.group')}
id="group_id" />}
</form>
<div className="plan-form">
<form onSubmit={handleSubmit(onSubmit)}>
<h4>{t('app.admin.plan_form.general_information')}</h4>
<FormInput register={register}
id="base_name"
formState={formState}
rules={{
required: true,
maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') }
}}
label={t('app.admin.plan_form.name')} />
{action === 'create' && <FormSwitch control={control}
formState={formState}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />}
{!allGroups && groups && <FormSelect options={groups}
formState={formState}
control={control}
rules={{ required: !allGroups }}
disabled={action === 'update'}
label={t('app.admin.plan_form.group')}
id="group_id" />}
{categories && <FormSelect options={categories}
formState={formState}
control={control}
id="plan_category_id"
tooltip={t('app.admin.plan_form.category_help')}
label={t('app.admin.plan_form.category')} />}
{action === 'update' && <FabAlert level="warning">
{t('app.admin.plan_form.edit_amount_info')}
</FabAlert>}
<FormInput register={register}
formState={formState}
id="amount"
type="number"
addOn={FormatLib.currencySymbol()}
rules={{ required: true }}
label={t('app.admin.plan_form.subscription_price')} />
<FormInput register={register}
formState={formState}
id="ui_weight"
type="number"
label={t('app.admin.plan_form.visual_prominence')}
tooltip={t('app.admin.plan_form.visual_prominence_help')} />
<FormSwitch control={control}
formState={formState}
id="is_rolling"
label={t('app.admin.plan_form.rolling_subscription')}
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.rolling_subscription_help')} />
<FormSwitch control={control}
formState={formState}
id="monthly_payment"
label={t('app.admin.plan_form.monthly_payment')}
disabled={action === 'update' || output.interval === 'week'}
tooltip={t('app.admin.plan_form.monthly_payment_help')} />
<FormRichText control={control}
formState={formState}
id="description"
label={t('app.admin.plan_form.description')}
limit={200}
heading link blockquote />
<FormFileUpload setValue={setValue}
register={register}
formState={formState}
defaultFile={output.plan_file_attributes}
id="plan_file_attributes"
className="plan-sheet"
label={t('app.admin.plan_form.information_sheet')} />
<FormSwitch control={control}
formState={formState}
id="disabled"
label={t('app.admin.plan_form.disabled')}
tooltip={t('app.admin.plan_form.disabled_help')} />
<h4>{t('app.admin.plan_form.duration')}</h4>
<div className="duration">
<FormInput register={register}
rules={{ required: true, min: 1 }}
disabled={action === 'update'}
formState={formState}
label={t('app.admin.plan_form.number_of_periods')}
type="number"
id="interval_count" />
<FormSelect options={buildPeriodsOptions()}
control={control}
disabled={action === 'update'}
onChange={handlePeriodUpdate}
id="interval"
label={t('app.admin.plan_form.period')}
formState={formState}
rules={{ required: true }} />
</div>
<h4>{t('app.admin.plan_form.partnership')}</h4>
<div className="partnership">
<FormSwitch control={control}
id="partnership"
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.partner_plan_help')}
defaultValue={plan?.type === 'PartnerPlan'}
onChange={handlePartnershipChange}
formState={formState}
label={t('app.admin.plan_form.partner_plan')} />
<FormInput register={register} type="hidden" id="type" />
{output.type === 'PartnerPlan' && <div className="partner">
<FabButton className="add-partner is-info" icon={<UserPlus size={20} />} onClick={tooglePartnerModal}>
{t('app.admin.plan_form.new_user')}
</FabButton>
{partners && <FormSelect id="partner_id"
options={partners}
control={control}
formState={formState}
rules={{ required: output.type === 'PartnerPlan' }}
label={t('app.admin.plan_form.notified_partner')} />}
{output.partner_id && <FabAlert level="info">
{t('app.admin.plan_form.alert_partner_notification')}
</FabAlert>}
</div>}
</div>
<AdvancedAccountingForm register={register} onError={onError} />
{action === 'update' && <PlanPricingForm formState={formState}
control={control}
onError={onError}
setValue={setValue}
register={register} />}
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.plan_form.ACTION_plan', { ACTION: action })}
</FabButton>
</form>
<PartnerModal isOpen={isOpenPartnerModal}
toggleModal={tooglePartnerModal}
onError={onError}
onPartnerCreated={handleNewPartner} />
</div>
);
};

View File

@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { Plan } from '../../models/plan';
import { FormInput } from '../form/form-input';
import { Machine } from '../../models/machine';
import { Space } from '../../models/space';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import MachineAPI from '../../api/machine';
import SpaceAPI from '../../api/space';
import { Price } from '../../models/price';
import FormatLib from '../../lib/format';
import { FabTabs } from '../base/fab-tabs';
import PlanAPI from '../../api/plan';
import { FormSelect } from '../form/form-select';
import { SelectOption } from '../../models/select';
interface PlanPricingFormProps<TContext extends object> {
register: UseFormRegister<Plan>,
control: Control<Plan, TContext>
formState: FormState<Plan>,
setValue: UseFormSetValue<Plan>,
onError: (message: string) => void,
}
/**
* Sub-form to define prices for machines and spaces, for the current plan
*/
export const PlanPricingForm = <TContext extends object>({ register, control, formState, setValue, onError }: PlanPricingFormProps<TContext>) => {
const { t } = useTranslation('admin');
const { fields } = useFieldArray({ control, name: 'prices_attributes' });
const [machines, setMachines] = useState<Array<Machine>>(null);
const [spaces, setSpaces] = useState<Array<Space>>(null);
const [settings, setSettings] = useState<Map<SettingName, string>>(null);
const [plans, setPlans] = useState<Array<SelectOption<number>>>(null);
useEffect(() => {
SettingAPI.query(['spaces_module', 'machines_module']).then(setSettings).catch(onError);
PlanAPI.index()
.then(res => setPlans(res.map(p => { return { value: p.id, label: p.name }; })))
.catch(onError);
}, []);
useEffect(() => {
if (settings?.get('machines_module') === 'true') {
MachineAPI.index().then(setMachines).catch(onError);
}
if (settings?.get('spaces_module') === 'true') {
SpaceAPI.index().then(setSpaces).catch(onError);
}
}, [settings]);
/**
* Copy prices from the selected plan
*/
const handleCopyPrices = (planId: number) => {
PlanAPI.get(planId).then(parent => {
parent.prices_attributes.forEach(price => {
const index = fields.findIndex(p => p.priceable_type === price.priceable_type && p.priceable_id === price.priceable_id);
setValue(`prices_attributes.${index}.amount`, price.amount);
});
}).catch(onError);
};
/**
* Render the form element for the given price
*/
const renderPriceElement = (price: Price, index: number) => {
const item: Space | Machine = (price.priceable_type === 'Machine' && machines?.find(m => m.id === price.priceable_id)) ||
(price.priceable_type === 'Space' && spaces?.find(s => s.id === price.priceable_id));
if (!item?.disabled) {
return (
<div key={index}>
<FormInput register={register}
id={`prices_attributes.${index}.id`}
formState={formState}
type="hidden" />
<FormInput register={register}
label={item?.name}
id={`prices_attributes.${index}.amount`}
rules={{ required: true, min: 0 }}
step={0.01}
formState={formState}
type="number"
addOn={FormatLib.currencySymbol()} />
</div>
);
}
};
return (
<>
<h4>{t('app.admin.plan_pricing_form.prices')}</h4>
{plans && <FormSelect options={plans}
label={t('app.admin.plan_pricing_form.copy_prices_from')}
tooltip={t('app.admin.plan_pricing_form.copy_prices_from_help')}
control={control}
onChange={handleCopyPrices}
id="parent_plan_id" />}
{<FabTabs tabs={[
machines && {
id: 'machines',
title: t('app.admin.plan_pricing_form.machines'),
content: fields.filter(p => p.priceable_type === 'Machine').map((price, index) =>
renderPriceElement(price, index)
)
},
spaces && {
id: 'spaces',
title: t('app.admin.plan_pricing_form.spaces'),
content: fields.filter(p => p.priceable_type === 'Space').map((price, index) =>
renderPriceElement(price, index)
)
}
]} />}
</>
);
};

View File

@ -24,6 +24,7 @@ import { SelectOption, ChecklistOption } from '../../models/select';
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
import { FormMultiImageUpload } from '../form/form-multi-image-upload';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
import { FabTabs } from '../base/fab-tabs';
interface ProductFormProps {
product: Product,
@ -44,7 +45,6 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount));
const [productCategories, setProductCategories] = useState<SelectOption<number, string | JSX.Element>[]>([]);
const [machines, setMachines] = useState<ChecklistOption<number>[]>([]);
const [stockTab, setStockTab] = useState<boolean>(false);
const [openCloneModal, setOpenCloneModal] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
@ -149,6 +149,157 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
setOpenCloneModal(!openCloneModal);
};
/**
* This function render the content of the 'products settings' tab
*/
const renderSettingsTab = () => (
<section>
<div className="subgrid">
<FormInput id="name"
register={register}
rules={{ required: true }}
formState={formState}
onChange={handleNameChange}
label={t('app.admin.store.product_form.name')}
className="span-7" />
<FormInput id="sku"
register={register}
formState={formState}
label={t('app.admin.store.product_form.sku')}
className="span-3" />
</div>
<div className="subgrid">
<FormInput id="slug"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.store.product_form.slug')}
className='span-7' />
<FormSwitch control={control}
id="is_active"
formState={formState}
label={t('app.admin.store.product_form.is_show_in_store')}
tooltip={t('app.admin.store.product_form.active_price_info')}
onChange={handleIsActiveChanged}
className='span-3' />
</div>
<hr />
<div className="price-data">
<div className="header-switch">
<h4>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
<FormSwitch control={control}
id="is_active_price"
label={t('app.admin.store.product_form.is_active_price')}
defaultValue={isActivePrice}
onChange={toggleIsActivePrice} />
</div>
{isActivePrice && <div className="price-data-content">
<FormInput id="amount"
type="number"
register={register}
rules={{ required: isActivePrice, min: 0 }}
step={0.01}
formState={formState}
label={t('app.admin.store.product_form.price')}
nullable />
<FormInput id="quantity_min"
type="number"
rules={{ required: true }}
register={register}
formState={formState}
label={t('app.admin.store.product_form.quantity_min')} />
</div>}
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_images')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert>
<FormMultiImageUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_image')}
register={register}
control={control}
id="product_images_attributes"
className="product-images" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
</FabAlert>
<FormSelect options={productCategories}
control={control}
id="product_category_id"
formState={formState}
label={t('app.admin.store.product_form.linking_product_to_category')} />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
</FabAlert>
<FormChecklist options={machines}
control={control}
id="machine_ids"
formState={formState} />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_description')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
</FabAlert>
<FormRichText control={control}
heading
bulletList
blockquote
link
limit={6000}
id="description" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_files')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
</FabAlert>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_file')}
control={control}
accept="application/pdf"
register={register}
id="product_files_attributes"
className="product-documents" />
</div>
<hr />
<AdvancedAccountingForm register={register} onError={onError} />
<div className="main-actions">
<FabButton type="submit" className="main-action-btn" disabled={saving}>
{!saving && t('app.admin.store.product_form.save')}
{saving && <i className="fa fa-spinner fa-pulse fa-fw" />}
</FabButton>
</div>
</section>
);
return (
<>
<header>
@ -168,158 +319,24 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
</header>
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<div className='tabs'>
<p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
</div>
{stockTab
? <ProductStockForm currentFormValues={output as Product} register={register} control={control} formState={formState} setValue={setValue} onError={onError} onSuccess={onSuccess} />
: <section>
<div className="subgrid">
<FormInput id="name"
register={register}
rules={{ required: true }}
formState={formState}
onChange={handleNameChange}
label={t('app.admin.store.product_form.name')}
className="span-7" />
<FormInput id="sku"
register={register}
formState={formState}
label={t('app.admin.store.product_form.sku')}
className="span-3" />
</div>
<div className="subgrid">
<FormInput id="slug"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.store.product_form.slug')}
className='span-7' />
<FormSwitch control={control}
id="is_active"
formState={formState}
label={t('app.admin.store.product_form.is_show_in_store')}
tooltip={t('app.admin.store.product_form.active_price_info')}
onChange={handleIsActiveChanged}
className='span-3' />
</div>
<hr />
<div className="price-data">
<div className="header-switch">
<h4>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
<FormSwitch control={control}
id="is_active_price"
label={t('app.admin.store.product_form.is_active_price')}
defaultValue={isActivePrice}
onChange={toggleIsActivePrice} />
</div>
{isActivePrice && <div className="price-data-content">
<FormInput id="amount"
type="number"
register={register}
rules={{ required: isActivePrice, min: 0 }}
step={0.01}
formState={formState}
label={t('app.admin.store.product_form.price')}
nullable />
<FormInput id="quantity_min"
type="number"
rules={{ required: true }}
register={register}
formState={formState}
label={t('app.admin.store.product_form.quantity_min')} />
</div>}
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_images')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
</FabAlert>
<FormMultiImageUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_image')}
register={register}
control={control}
id="product_images_attributes"
className="product-images" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
</FabAlert>
<FormSelect options={productCategories}
control={control}
id="product_category_id"
formState={formState}
label={t('app.admin.store.product_form.linking_product_to_category')} />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
</FabAlert>
<FormChecklist options={machines}
control={control}
id="machine_ids"
formState={formState} />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_description')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
</FabAlert>
<FormRichText control={control}
heading
bulletList
blockquote
link
limit={6000}
id="description" />
</div>
<hr />
<div>
<h4>{t('app.admin.store.product_form.product_files')}</h4>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
</FabAlert>
<FormMultiFileUpload setValue={setValue}
addButtonLabel={t('app.admin.store.product_form.add_product_file')}
control={control}
accept="application/pdf"
register={register}
id="product_files_attributes"
className="product-documents" />
</div>
<hr />
<AdvancedAccountingForm register={register} onError={onError} />
<div className="main-actions">
<FabButton type="submit" className="main-action-btn" disabled={saving}>
{!saving && t('app.admin.store.product_form.save')}
{saving && <i className="fa fa-spinner fa-pulse fa-fw" />}
</FabButton>
</div>
</section>
}
<FabTabs tabs={[
{
id: 'settings',
title: t('app.admin.store.product_form.product_parameters'),
content: renderSettingsTab()
},
{
id: 'stock',
title: t('app.admin.store.product_form.stock_management'),
content: <ProductStockForm currentFormValues={output as Product}
register={register}
control={control}
formState={formState}
setValue={setValue}
onError={onError}
onSuccess={onSuccess} />
}
]} />
</form>
</>
);

View File

@ -170,13 +170,13 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
$scope.orderAdmin = null;
// partners list
$scope.partners = partnersPromise.users;
$scope.partners = partnersPromise;
// Partners ordering/sorting. Default: not sorted
$scope.orderPartner = null;
// managers list
$scope.managers = managersPromise.users;
$scope.managers = managersPromise;
// Managers ordering/sorting. Default: not sorted
$scope.orderManager = null;

View File

@ -18,204 +18,27 @@
*/
'use strict';
/* COMMON CODE */
class PlanController {
constructor ($scope, groups, prices, partners, CSRF, _t) {
// protection against request forgery
CSRF.setMetaTags();
// groups list
$scope.groups = groups
.filter(function (g) { return !g.disabled; })
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }));
$scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' });
// dynamically translate a label if needed
$scope.translateLabel = function (group, prop) {
return group[prop] && group[prop].match(/^app\./) ? _t(group[prop]) : group[prop];
};
// users with role 'partner', notifiable for a partner plan
$scope.partners = partners.users;
// Subscriptions prices, machines prices and training prices, per groups
$scope.group_pricing = prices;
/**
* For use with 'ng-class', returns the CSS class name for the uploads previews.
* The preview may show a placeholder or the content of the file depending on the upload state.
* @param v {*} any attribute, will be tested for truthiness (see JS evaluation rules)
*/
$scope.fileinputClass = function (v) {
if (v) {
return 'fileinput-exists';
} else {
return 'fileinput-new';
}
};
/**
* Mark the provided file for deletion
* @param file {Object}
*/
$scope.deleteFile = function (file) {
if ((file != null) && (file.id != null)) {
return file._destroy = true;
}
};
/**
* Check and limit
* @param content
*/
$scope.limitDescriptionSize = function (content) {
alert(content);
};
}
}
/**
* 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) {
/* PUBLIC SCOPE */
// current form is used to create a new plan
$scope.mode = 'creation';
// list of all plan categories
$scope.planCategories = planCategories;
// we add an empty element to let the user select 'no category'
$scope.planCategories.unshift({ id: null, name: '' });
// prices bindings
$scope.prices = {
training: {},
machine: {}
};
// form inputs bindings
$scope.plan = {
type: null,
group_id: null,
interval: null,
intervalCount: 0,
amount: null,
is_rolling: false,
partnerId: null,
partnerContact: null,
ui_weight: 0,
monthly_payment: false,
plan_category_id: null
};
// API URL where the form will be posted
$scope.actionUrl = '/api/plans/';
// HTTP method for the rest API
$scope.method = 'POST';
// protection against request forgery
CSRF.setMetaTags();
/**
* Checks if the partner contact is a valid data. Used in the form validation process
* @returns {boolean}
* Shows an error message forwarded from a child component
*/
$scope.partnerIsValid = function () { return ($scope.plan.type === 'Plan') || ($scope.plan.partnerId || ($scope.plan.partnerContact && $scope.plan.partnerContact.email)); };
/**
* Open a modal dialog allowing the admin to create a new partner user
*/
$scope.openPartnerNewModal = function (subscription) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/shared/_partner_new_modal.html',
size: 'lg',
controller: ['$scope', '$uibModalInstance', 'User', function ($scope, $uibModalInstance, User) {
$scope.partner = {};
$scope.ok = function () {
User.save(
{},
{ user: $scope.partner },
function (user) {
$scope.partner.id = user.id;
$scope.partner.name = `${user.first_name} ${user.last_name}`;
$uibModalInstance.close($scope.partner);
},
function (error) {
growl.error(_t('app.admin.plans.new.unable_to_save_this_user_check_that_there_isnt_an_already_a_user_with_the_same_name'));
console.error(error);
}
);
};
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}]
});
// once the form was validated successfully ...
return modalInstance.result.then(function (partner) {
$scope.partners.push(partner);
return $scope.plan.partnerId = partner.id;
});
$scope.onError = function (message) {
growl.error(message);
};
/**
* This will update the monthly_payment value when the user toggles the switch button
* @param checked {Boolean}
* Shows a success message forwarded from a child react components
*/
$scope.toggleMonthlyPayment = function (checked) {
toggle('monthly_payment', checked);
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* This will update the is_rolling value when the user toggles the switch button
* @param checked {Boolean}
*/
$scope.toggleIsRolling = function (checked) {
toggle('is_rolling', checked);
};
/**
* Display some messages and redirect the user, once the form was submitted, depending on the result status
* (failed/succeeded).
* @param content {Object}
*/
$scope.afterSubmit = function (content) {
if (content.plan_ids === null || content.plan_ids === undefined) {
return growl.error(_t('app.admin.plans.new.unable_to_create_the_subscription_please_try_again'));
} else {
growl.success(_t('app.admin.plans.new.successfully_created_subscriptions_dont_forget_to_redefine_prices'));
if (content.plan_ids.length > 1) {
return $state.go('app.admin.pricing');
} else {
return $state.go('app.admin.plans.edit', { id: content.plan_ids[0] });
}
}
};
/* PRIVATE SCOPE */
const initialize = function () {
$scope.$watch(scope => scope.plan.interval,
(newValue, oldValue) => {
if (newValue === 'week') { $scope.plan.monthly_payment = false; }
}
);
};
/**
* Asynchronously updates the given property with the new provided value
* @param property {string}
* @param value {*}
*/
const toggle = function (property, value) {
setTimeout(() => {
$scope.plan[property] = value;
$scope.$apply();
}, 50);
};
initialize();
return new PlanController($scope, groups, prices, partners, CSRF, _t);
}
]);
@ -224,133 +47,11 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
*/
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) {
/* PUBLIC SCOPE */
// List of spaces
$scope.spaces = spaces;
// List of plans
$scope.plans = plans;
// List of machines
$scope.machines = machines;
// List of groups
$scope.allGroups = groups;
// list of all plan categories
$scope.planCategories = planCategories;
// we add an empty element to let the user select 'no category'
$scope.planCategories.unshift({ id: null, name: '' });
// current form is used for edition mode
$scope.mode = 'edition';
// edited plan data
$scope.plan = Object.assign({}, planPromise, { group_id: `${planPromise.group_id}` });
if ($scope.plan.type === null) { $scope.plan.type = 'Plan'; }
if ($scope.plan.disabled) { $scope.plan.disabled = 'true'; }
// protection against request forgery
CSRF.setMetaTags();
$scope.suscriptionPlan = cleanPlan(planPromise);
// API URL where the form will be posted
$scope.actionUrl = `/api/plans/${$transition$.params().id}`;
// HTTP method for the rest API
$scope.method = 'PATCH';
$scope.selectedGroup = function () {
const group = $scope.groups.filter(g => g.id === $scope.plan.group_id);
return $scope.translateLabel(group[0], 'name');
};
/**
* If a parent plan was set ($scope.plan.parent), the prices will be copied from this parent plan into
* the current plan prices list. Otherwise, the current plan prices will be erased.
*/
$scope.copyPricesFromPlan = function () {
if ($scope.plan.parent) {
return Plan.get({ id: $scope.plan.parent }, function (parentPlan) {
Array.from(parentPlan.prices).map(function (parentPrice) {
return (function () {
const result = [];
for (const childKey in $scope.plan.prices) {
const childPrice = $scope.plan.prices[childKey];
if ((childPrice.priceable_type === parentPrice.priceable_type) && (childPrice.priceable_id === parentPrice.priceable_id)) {
$scope.plan.prices[childKey].amount = parentPrice.amount;
break;
} else {
result.push(undefined);
}
}
return result;
})();
});
}
);
// if no plan were selected, unset every prices
} else {
return (function () {
const result = [];
for (const key in $scope.plan.prices) {
const price = $scope.plan.prices[key];
result.push($scope.plan.prices[key].amount = 0);
}
return result;
})();
}
};
/**
* Display some messages once the form was submitted, depending on the result status (failed/succeeded)
* @param content {Object}
*/
$scope.afterSubmit = function (content) {
if ((content.id == null) && (content.plan_ids == null)) {
return growl.error(_t('app.admin.plans.edit.unable_to_save_subscription_changes_please_try_again'));
} else {
growl.success(_t('app.admin.plans.edit.subscription_successfully_changed'));
return $state.go('app.admin.pricing');
}
};
/**
* Generate a string identifying the given plan by literal humain-readable name
* @param plan {Object} Plan object, as recovered from GET /api/plan/:id
* @param groups {Array} List of Groups objects, as recovered from GET /api/groups
* @param short {boolean} If true, the generated name will contains the group slug, otherwise the group full name
* will be included.
* @returns {String}
*/
$scope.humanReadablePlanName = function (plan, groups, short) { return `${$filter('humanReadablePlanName')(plan, groups, short)}`; };
/**
* Retrieve the machine from its ID
* @param machine_id {number} machine identifier
* @returns {Object} Machine
*/
$scope.getMachine = function (machine_id) {
for (const machine of Array.from($scope.machines)) {
if (machine.id === machine_id) {
return machine;
}
}
};
/**
* Retrieve the space from its ID
* @param space_id {number} space identifier
* @returns {Object} Space
*/
$scope.getSpace = function (space_id) {
for (const space of Array.from($scope.spaces)) {
if (space.id === space_id) {
return space;
}
}
};
/**
* Shows an error message forwarded from a child component
*/
@ -371,9 +72,6 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
delete plan.$resolved;
return plan;
}
// Using the PlansController
return new PlanController($scope, groups, prices, partners, CSRF, _t);
}
]);

View File

@ -1,15 +1,9 @@
import { Price } from './price';
import { FileType } from './file';
export enum Interval {
Year = 'year',
Month = 'month',
Week = 'week'
}
export type Interval = 'year' | 'month' | 'week';
export enum PlanType {
Plan = 'Plan',
PartnerPlan = 'PartnerPlan'
}
export type PlanType = 'Plan' | 'PartnerPlan';
export interface Partner {
first_name: string,
@ -33,13 +27,11 @@ export interface Plan {
disabled: boolean,
monthly_payment: boolean
amount: number
prices: Array<Price>,
plan_file_attributes: {
id: number,
attachment_identifier: string
},
prices_attributes: Array<Price>,
plan_file_attributes: FileType,
plan_file_url: string,
partners: Array<Partner>
partner_id?: number,
partners?: Array<Partner>
}
export interface PlansDuration {

View File

@ -1,6 +1,7 @@
import { Plan } from './plan';
import { TDateISO, TDateISODate } from '../typings/date-iso';
import { supportedNetworks, SupportedSocialNetwork } from './social-network';
import { ApiFilter } from './api';
export type UserRole = 'member' | 'manager' | 'admin';
@ -10,13 +11,13 @@ type ProfileAttributesSocial = {
export interface User {
id: number,
username: string,
username?: string,
email: string,
group_id: number,
role: UserRole
group_id?: number,
role?: UserRole
name: string,
need_completion: boolean,
ip_address: string,
ip_address?: string,
mapped_from_sso?: string[],
password?: string,
password_confirmation?: string,
@ -25,13 +26,13 @@ export interface User {
id: number,
first_name: string,
last_name: string,
interest: string,
software_mastered: string,
phone: string,
website: string,
job: string,
tours: Array<string>,
user_avatar_attributes: {
interest?: string,
software_mastered?: string,
phone?: string,
website?: string,
job?: string,
tours?: Array<string>,
user_avatar_attributes?: {
id: number,
attachment?: File,
attachment_url?: string,
@ -39,21 +40,21 @@ export interface User {
_destroy?: boolean
}
},
invoicing_profile_attributes: {
id: number,
invoicing_profile_attributes?: {
id?: number,
address_attributes: {
id: number,
id?: number,
address: string
},
organization_attributes: {
id: number,
organization_attributes?: {
id?: number,
name: string,
address_attributes: {
id: number,
id?: number,
address: string
}
},
user_profile_custom_fields_attributes: Array<
user_profile_custom_fields_attributes?: Array<
{
id?: number,
value: string,
@ -62,14 +63,14 @@ export interface User {
}
>
},
statistic_profile_attributes: {
id: number,
statistic_profile_attributes?: {
id?: number,
gender: string,
birthday: TDateISODate
training_ids: Array<number>
},
subscribed_plan: Plan,
subscription: {
subscribed_plan?: Plan,
subscription?: {
id: number,
expired_at: TDateISO,
canceled_at: TDateISO,
@ -83,16 +84,17 @@ export interface User {
amount: number
}
},
training_credits: Array<number>,
machine_credits: Array<{ machine_id: number, hours_used: number }>,
last_sign_in_at: TDateISO
validated_at: TDateISO,
tag_ids: Array<number>
training_credits?: Array<number>,
machine_credits?: Array<{ machine_id: number, hours_used: number }>,
last_sign_in_at?: TDateISO
validated_at?: TDateISO,
tag_ids?: Array<number>,
resource?: Plan // for users with role=partner, there will be the associated plan
}
type OrderingKey = 'last_name' | 'first_name' | 'email' | 'phone' | 'group' | 'plan' | 'id'
export interface UserIndexFilter {
export interface MemberIndexFilter {
search?: string,
filter?: 'inactive_for_3_years' | 'not_confirmed',
order_by?: OrderingKey | `-${OrderingKey}`,
@ -100,6 +102,10 @@ export interface UserIndexFilter {
size?: number
}
export interface UserIndexFilter extends ApiFilter {
role?: 'partner' | 'manager'
}
const socialMappings = supportedNetworks.map(network => {
return { [`profile_attributes.${network}`]: `profile.${network}` };
});

View File

@ -2,10 +2,6 @@
Application.Services.factory('User', ['$resource', function ($resource) {
return $resource('/api/users/:id',
{ id: '@id' }, {
query: {
isArray: false
}
}
{ id: '@id' }
);
}]);

View File

@ -29,6 +29,7 @@
@import "modules/base/fab-panel";
@import "modules/base/fab-popover";
@import "modules/base/fab-state-label";
@import "modules/base/fab-tabs";
@import "modules/base/fab-text-editor";
@import "modules/base/fab-tooltip";
@import "modules/base/labelled-input";

View File

@ -0,0 +1,26 @@
.fab-tabs {
.tabs {
display: flex;
justify-content: space-between;
p {
flex: 1;
margin-bottom: 4rem;
padding: 0.8rem;
text-align: center;
color: var(--main);
border-bottom: 1px solid var(--gray-soft-dark);
&.is-active {
color: var(--gray-hard-dark);
border: 1px solid var(--gray-soft-dark);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
&:hover {
cursor: pointer;
}
}
}
}

View File

@ -7,6 +7,12 @@
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
&.with-label {
margin-top: 2.6rem;
position: relative;
margin-bottom: 1.6rem;
}
.actions {
margin-left: auto;
display: flex;
@ -21,5 +27,11 @@
.image-file-input {
margin-bottom: 0;
}
.form-item-header { // label
position: absolute;
top: -1.5em;
left: 0;
margin-bottom: 0.8rem;
}
}
}

View File

@ -57,7 +57,7 @@
.image-file-input {
margin-bottom: 0;
}
.form-item-header {
.form-item-header { // label
position: absolute;
top: -1.5em;
left: 0;

View File

@ -1,4 +1,24 @@
.plan-form {
.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;
}

View File

@ -1,26 +1,6 @@
.product-form {
grid-column: 2 / -2;
.tabs {
display: flex;
justify-content: space-between;
p {
flex: 1;
margin-bottom: 4rem;
padding: 0.8rem;
text-align: center;
color: var(--main);
border-bottom: 1px solid var(--gray-soft-dark);
&.is-active {
color: var(--gray-hard-dark);
border: 1px solid var(--gray-soft-dark);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
&:hover { cursor: pointer; }
}
}
h4 {
margin: 0 0 2.4rem;
@include title-base;

View File

@ -30,10 +30,10 @@
</thead>
<tbody>
<tr ng-repeat="manager in managers | filter:searchFilter | orderBy: orderManager">
<td class="text-c">{{ manager.last_name }}</td>
<td class="text-c">{{ manager.first_name }}</td>
<td class="text-c">{{ manager.profile_attributes.last_name }}</td>
<td class="text-c">{{ manager.profile_attributes.first_name }}</td>
<td>{{ manager.email }}</td>
<td>{{ manager.phone }}</td>
<td>{{ manager.profile_attributes.phone }}</td>
<td>
<button class="btn btn-default edit-member" ui-sref="app.admin.members_edit({id: manager.id})">
<i class="fa fa-edit"></i>

View File

@ -28,8 +28,8 @@
</thead>
<tbody>
<tr ng-repeat="partner in partners | filter:searchFilter | orderBy: orderPartner">
<td class="text-c">{{ partner.last_name }}</td>
<td class="text-c">{{ partner.first_name }}</td>
<td class="text-c">{{ partner.profile_attributes.last_name }}</td>
<td class="text-c">{{ partner.profile_attributes.first_name }}</td>
<td>{{ partner.email }}</td>
<td><a ui-sref="app.admin.plans.edit({id:partner.resource.id})">{{ partner.resource ? partner.resource.base_name : '' }}</a></td>
<td>

View File

@ -1,194 +0,0 @@
<h2 translate>{{ 'app.shared.plan.general_information' }}</h2>
<input type="hidden" name="_method" value="{{method}}">
<div class="form-group" ng-class="{'has-error': planForm['plan[base_name]'].$dirty && planForm['plan[base_name]'].$invalid}">
<label for="plan[base_name]">{{ 'app.shared.plan.name' | translate }} *</label>
<input type="text" id="plan[base_name]"
name="plan[base_name]"
class="form-control"
ng-maxlength="24"
ng-trim="false"
ng-model="plan.base_name"
required="true"/>
<span class="help-block error" ng-show="planForm['plan[base_name]'].$dirty && planForm['plan[base_name]'].$error.required" translate>{{ 'app.shared.plan.name_is_required' }}</span>
<span class="help-block error" ng-show="planForm['plan[base_name]'].$dirty && planForm['plan[base_name]'].$error.maxlength" translate>{{ 'app.shared.plan.name_length_must_be_less_than_24_characters' }}</span>
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[type]'].$dirty && planForm['plan[type]'].$invalid}">
<label for="plan[type]">{{ 'app.shared.plan.type' | translate }} *</label>
<select id="plan[type]"
name="plan[type]"
class="form-control"
ng-model="plan.type"
required="true"
ng-disabled="method == 'PATCH'">
<option value="Plan" ng-selected="plan.type == 'Plan'" translate>{{ 'app.shared.plan.standard' }}</option>
<option value="PartnerPlan" ng-selected="plan.type == 'PartnerPlan'" translate>{{ 'app.shared.plan.partner' }}</option>
</select>
<span class="help-block error" ng-show="planForm['plan[type]'].$dirty && planForm['plan[type]'].$error.required" translate>{{ 'app.shared.plan.type_is_required' }}</span>
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[group_id]'].$dirty && planForm['plan[group_id]'].$invalid}">
<label for="plan[group_id]">{{ 'app.shared.plan.group' | translate }} *</label>
<select id="plan[group_id]"
name="plan[group_id]"
class="form-control"
ng-model="plan.group_id"
required="true"
ng-if="method !== 'PATCH'"
ng-options="item.id as translateLabel(item, 'name') group by translateLabel(item, 'category') for item in groups track by item.id">
</select>
<input type="text"
id="plan[group_id]"
ng-value="selectedGroup()"
ng-if="method == 'PATCH'"
class="form-control"
disabled />
<input type="hidden"
name="plan[group_id]"
ng-value="plan.group_id"
ng-if="method == 'PATCH'"/>
<span class="help-block" ng-show="planForm['plan[group_id]'].$dirty && planForm['plan[group_id]'].$error.required" translate>{{ 'app.shared.plan.group_is_required' }}</span>
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[plan_category_id]'].$dirty && planForm['plan[group_id]'].$invalid}">
<label for="plan[plan_category_id]">{{ 'app.shared.plan.category' | translate }}</label>
<select id="plan[plan_category_id]"
class="form-control"
ng-model="plan.plan_category_id"
ng-options="cat.id as cat.name for cat in planCategories">
</select>
<input type="hidden"
name="plan[plan_category_id]"
ng-value="plan.plan_category_id"/>
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[interval]'].$dirty && planForm['plan[interval]'].$invalid}">
<label for="plan[interval]">{{ 'app.shared.plan.period' | translate }} *</label>
<select id="plan[interval]"
name="plan[interval]"
class="form-control"
ng-model="plan.interval"
ng-disabled="method == 'PATCH'"
required="true">
<option value="week" ng-selected="plan.interval == 'week'" translate>{{ 'app.shared.plan.week' }}</option>
<option value="month" ng-selected="plan.interval == 'month'" translate>{{ 'app.shared.plan.month' }}</option>
<option value="year" ng-selected="plan.interval == 'year'" translate>{{ 'app.shared.plan.year' }}</option>
</select>
<span class="help-block" ng-show="planForm['plan[interval]'].$dirty && planForm['plan[interval]'].$error.required" translate>{{ 'app.shared.plan.period_is_required' }}</span>
</div>
<div class="form-group" ng-class="{'has-error': planForm['plan[interval_count]'].$dirty && planForm['plan[interval_count]'].$invalid}">
<label for="plan[interval]">{{ 'app.shared.plan.number_of_periods' | translate }} *</label>
<input id="plan[interval_count]"
name="plan[interval_count]"
class="form-control"
type="number"
ng-model="plan.interval_count"
ng-disabled="method == 'PATCH'"
required="true"
min="1"/>
<span class="help-block" ng-show="planForm['plan[interval_count]'].$dirty && planForm['plan[interval_count]'].$error.required" translate>{{ 'app.shared.plan.number_of_periods_is_required' }}</span>
</div>
<div class="form-group">
<div class="input-group" ng-class="{'has-error': planForm['plan[amount]'].$dirty && planForm['plan[amount]'].$invalid}">
<label for="plan[amount]">{{ 'app.shared.plan.subscription_price' | translate }} *</label>
<div class="input-group">
<span class="input-group-addon">{{currencySymbol}}</span>
<input id="plan[amount]"
name="plan[amount]"
type="number"
class="form-control"
ng-required="true"
ng-model="plan.amount"/>
</div>
<span class="help-block" ng-show="planForm['plan[amount]'].$dirty && planForm['plan[amount]'].$error.required" translate>{{ 'app.shared.plan.price_is_required' }}</span>
<span class="help-block alert alert-warning" ng-if="method == 'PATCH'">
<i class="fa fa-warning"></i>
{{ 'app.shared.plan.edit_amount_info' | translate }}
</span>
</div>
</div>
<div class="form-group">
<label translate>{{ 'app.shared.plan.visual_prominence_of_the_subscription' }}</label>
<input ng-model="plan.ui_weight"
type="number"
name="plan[ui_weight]"
class="form-control">
<span class="help-block">
{{ 'app.shared.plan.on_the_subscriptions_page_the_most_prominent_subscriptions_will_be_placed_at_the_top_of_the_list' | translate }}
{{ 'app.shared.plan.an_evelated_number_means_a_higher_prominence' | translate }}
</span>
</div>
<div class="input-group m-t-md">
<label for="plan[is_rolling]" class="control-label m-r-md">{{ 'app.shared.plan.rolling_subscription' | translate }} *</label>
<switch id="plan[is_rolling]" checked="plan.is_rolling" on-change="toggleIsRolling" class-name="'v-middle'" ng-if="plan && method != 'PATCH'"></switch>
<span ng-if="method == 'PATCH'">{{ (plan.is_rolling ? 'app.shared.buttons.yes' : 'app.shared.buttons.no') | translate }}</span>
<input type="hidden" name="plan[is_rolling]" value="{{plan.is_rolling}}"/>
<span class="help-block">
{{ 'app.shared.plan.a_rolling_subscription_will_begin_the_day_of_the_first_training' | translate }}
{{ 'app.shared.plan.otherwise_it_will_begin_as_soon_as_it_is_bought' | translate }}
</span>
</div>
<div class="input-group m-t-md">
<label for="plan[monthly_payment]" class="control-label m-r-md">{{ 'app.shared.plan.monthly_payment' | translate }} *</label>
<switch id="plan[monthly_payment]" disabled="plan.interval === 'week' || (plan.interval === 'month' && plan.interval_count === 1)" checked="plan.monthly_payment" on-change="toggleMonthlyPayment" class-name="'v-middle'" ng-if="plan && method != 'PATCH'"></switch>
<span ng-if="method == 'PATCH'">{{ (plan.monthly_payment ? 'app.shared.buttons.yes' : 'app.shared.buttons.no') | translate }}</span>
<input type="hidden" id="plan_monthly_input" name="plan[monthly_payment]" value="{{plan.monthly_payment}}" />
<span class="help-block" translate>{{ 'app.shared.plan.monthly_payment_info' }}</span>
</div>
<div class="input-group m-t-md plan-description-input">
<label for="plan[description]" class="control-label m-r-md" translate>{{ 'app.shared.plan.description' }}</label>
<div class="medium-editor-input">
<div ng-model="plan.description"
medium-editor
options='{
"placeholder": "{{ "app.shared.plan.type_a_short_description" | translate }}",
"buttons": ["bold", "italic", "anchor", "unorderedlist", "header2" ],
"targetBlank": true,
}'>
</div>
</div>
<input type="hidden" id="plan[description]" name="plan[description]" value="{{plan.description}}" />
</div>
<!-- PDF description attachement -->
<input type="hidden" ng-model="plan.plan_file_attributes.id" name="plan[plan_file_attributes][id]" ng-value="plan.plan_file_attributes.id" />
<input type="hidden" ng-model="plan.plan_file_attributes._destroy" name="plan[plan_file_attributes][_destroy]" ng-value="plan.plan_file_attributes._destroy"/>
<label class="m-t-md" translate>{{ 'app.shared.plan.information_sheet' }}</label>
<div class="fileinput input-group" data-provides="fileinput" ng-class="fileinputClass(plan.plan_file_attributes)">
<div class="form-control" data-trigger="fileinput">
<i class="glyphicon glyphicon-file fileinput-exists"></i> <span class="fileinput-filename">{{file.attachment || plan.plan_file_attributes.attachment_identifier}}</span>
</div>
<span class="input-group-addon btn btn-default btn-file"><span class="fileinput-new" translate>{{ 'app.shared.plan.attach_an_information_sheet' }}</span>
<span class="fileinput-exists" translate>{{ 'app.shared.buttons.change' }}</span><input type="file"
name="plan[plan_file_attributes][attachment]"
accept="image/jpeg,image/gif,image/png,application/pdf"></span>
<a class="input-group-addon btn btn-danger fileinput-exists" data-dismiss="fileinput" ng-click="deleteFile(file || plan.plan_file_attributes)"><i class="fa fa-trash-o"></i></a>
</div>
<div class="form-group m-t-md" ng-show="plan.type == 'PartnerPlan' && method != 'PATCH'">
<input type="hidden" ng-model="plan.partnerId" name="plan[partner_id]" ng-value="plan.partnerId" />
<label for="plan[partner_id]">{{ 'app.shared.plan.notified_partner' | translate }} *</label>
<div class="input-group">
<select class="form-control"
ng-model="plan.partnerId"
ng-options="p.id as (p.name + ' <'+p.email+'>') for p in partners"
id="plan[partner_id]">
<option value=""></option>
</select>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="openPartnerNewModal()"><i class="fa fa-user-plus"></i> {{ 'app.shared.plan.new_user' | translate }}</button>
</span>
</div>
<span class="help-block" translate>{{ 'app.shared.plan.as_part_of_a_partner_subscription_some_notifications_may_be_sent_to_this_user' }}</span>
</div>
<div class="form-group" ng-show="plan.partners">
<label>Partenaire notifié</label>
<span ng-repeat="partner in plan.partners">
<input type="text" class="form-control" disabled value="{{ partner.first_name}} {{partner.last_name }}">
</span>
</div>

View File

@ -7,7 +7,7 @@
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<section class="heading-title">
<h1>{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ plan.base_name }}</h1>
<h1>{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ suscriptionPlan.base_name }}</h1>
</section>
</div>
@ -29,94 +29,5 @@
</div>
</div>
<div id="planForm">
<form name="planForm" novalidate="novalidate" class="col-lg-7 col-lg-offset-2 m-t-lg form-group" action="{{ actionUrl }}" ng-upload="afterSubmit(content)" upload-options-enable-rails-csrf="true">
<ng-include src="'/admin/plans/_form.html'"></ng-include>
<div class="input-group m-t-md">
<label for="plan[disabled]" class="control-label m-r-md">{{ 'app.shared.plan.disabled' | translate }}</label>
<input bs-switch
ng-model="plan.disabled"
id="plan[disabled]"
type="checkbox"
class="form-control"
switch-on-text="{{ 'app.shared.buttons.yes' | translate }}"
switch-off-text="{{ 'app.shared.buttons.no' | translate }}"
switch-animate="true"
ng-true-value="'true'"
ng-false-value="'false'"/>
<input type="hidden" name="plan[disabled]" value="{{plan.disabled}}"/>
<span class="help-block" translate>{{ 'app.shared.plan.disable_plan_will_not_unsubscribe_users' }}</span>
</div>
<h2 class="m-t-xl" translate>{{ 'app.admin.plans.edit.prices' }}</h2>
<div class="form-group col-md-6 col-lg-offset-6">
<input type="hidden" ng-model="plan.parent" name="plan[parent_id]" ng-value="plan.parent"/>
<label for="parentPlan" translate>{{ 'app.admin.plans.edit.copy_prices_from' }}</label>
<select id="parentPlan" ng-options="plan.id as humanReadablePlanName(plan, allGroups) for plan in plans" ng-model="plan.parent" ng-change="copyPricesFromPlan()" class="form-control">
<option value=""></option>
</select>
</div>
<div ng-show="$root.modules.machines">
<h3 translate>{{ 'app.admin.plans.edit.machines' }}</h3>
<table class="table">
<thead>
<th translate>{{ 'app.admin.plans.edit.machine' }}</th>
<th translate>{{ 'app.admin.plans.edit.hourly_rate' }}</th>
</thead>
<tbody>
<tr ng-repeat="price in plan.prices" ng-if="price.priceable_type === 'Machine'" ng-hide="getMachine(price.priceable_id).disabled">
<td style="width: 60%;">{{ getMachine(price.priceable_id).name }} (id {{ price.priceable_id }}) *</td>
<td>
<div class="input-group" ng-class="{'has-error': planForm['plan[prices_attributes][][amount]'].$dirty && planForm['plan[prices_attributes][][amount]'].$invalid}">
<span class="input-group-addon">{{currencySymbol}}</span>
<input type="number" class="form-control" name="plan[prices_attributes][][amount]" ng-value="price.amount" required="true"/>
<input type="hidden" class="form-control" name="plan[prices_attributes][][id]" ng-value="price.id"/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div ng-show="$root.modules.spaces">
<h3 translate>{{ 'app.admin.plans.edit.spaces' }}</h3>
<table class="table">
<thead>
<th translate>{{ 'app.admin.plans.edit.space' }}</th>
<th translate>{{ 'app.admin.plans.edit.hourly_rate' }}</th>
</thead>
<tbody>
<tr ng-repeat="price in plan.prices" ng-if="price.priceable_type === 'Space'" ng-hide="getSpace(price.priceable_id).disabled">
<td style="width: 60%;">{{ getSpace(price.priceable_id).name }} *</td>
<td>
<div class="input-group" ng-class="{'has-error': planForm['plan[prices_attributes][][amount]'].$dirty && planForm['plan[prices_attributes][][amount]'].$invalid}">
<span class="input-group-addon">{{currencySymbol}}</span>
<input type="number" class="form-control" name="plan[prices_attributes][][amount]" ng-value="price.amount" required="true"/>
<input type="hidden" class="form-control" name="plan[prices_attributes][][id]" ng-value="price.id"/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ul>
<li ng-repeat="(key, errors) in planForm.$error track by $index"> <strong>{{ key }}</strong> errors
<ul>
<li ng-repeat="e in errors">{{ e.$name }} has an error: <strong>{{ key }}</strong>.</li>
</ul>
</li>
</ul>
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'app.shared.buttons.confirm_changes' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="planForm.$invalid"/>
</div>
</form>
</div>
</div>
</div>

View File

@ -17,16 +17,10 @@
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div id="planForm">
<form name="planForm" novalidate="novalidate" class="col-lg-10 col-lg-offset-2 m-t-lg form-group" action="{{ actionUrl }}" ng-upload="afterSubmit(content)" upload-options-enable-rails-csrf="true">
<ng-include src="'/admin/plans/_form.html'"></ng-include>
<div class="panel-footer no-padder">
<input type="submit" value="{{ 'app.shared.buttons.save' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="planForm.$invalid || !partnerIsValid()"/>
</div>
</form>
<div class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<plan-form action="'create'" on-error="onError" on-success="onSuccess"></plan-form>
</div>
</div>
</div>

View File

@ -11,6 +11,10 @@ class PartnerPlan < Plan
User.joins(:roles).where(roles: { name: 'partner', resource_type: 'PartnerPlan', resource_id: id })
end
def partner_id
partners.first.id
end
private
def assign_default_values

View File

@ -21,6 +21,9 @@ class Plan < ApplicationRecord
accepts_nested_attributes_for :prices
accepts_nested_attributes_for :plan_file, allow_destroy: true, reject_if: :all_blank
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
after_create :create_machines_prices
after_create :create_spaces_prices
after_create :create_statistic_type

View File

@ -3,11 +3,12 @@
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
json.amount plan.amount / 100.00
json.prices plan.prices, partial: 'api/prices/price', as: :price
json.prices_attributes plan.prices, partial: 'api/prices/price', as: :price
if plan.plan_file
json.plan_file_attributes do
json.id plan.plan_file.id
json.attachment_identifier plan.plan_file.attachment_identifier
json.attachment_name plan.plan_file.attachment_identifier
json.attachment_url plan.plan_file.attachment.url
end
end
@ -17,4 +18,12 @@ if plan.respond_to?(:partners)
json.last_name partner.last_name
json.email partner.email
end
json.partner_id plan.partner_id
end
if plan.advanced_accounting
json.advanced_accounting_attributes do
json.partial! 'api/advanced_accounting/advanced_accounting', advanced_accounting: plan.advanced_accounting
end
end

View File

@ -1,4 +1,7 @@
# frozen_string_literal: true
json.extract! @user, :id, :email, :first_name, :last_name
json.extract! @user, :id, :email
json.name @user.profile.full_name
json.profile_attributes do
json.extract! @user.profile, :first_name, :last_name
end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
json.users @users do |user|
json.extract! user, :id, :email, :first_name, :last_name
json.phone user.profile.phone
json.array!(@users) do |user|
json.extract! user, :id, :email
json.name user.profile.full_name
json.profile_attributes do
json.extract! user.profile, :first_name, :last_name, :phone
end
json.resource user.roles.last.resource
end

View File

@ -83,54 +83,51 @@ en:
general_information: "General information"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
type: "Type"
partner: "Partner"
standard: "Standard"
type_is_required: "Type is required."
group: "Group"
groups: "Groups"
all: "All"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
group_is_required: "Group is required."
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"
number_of_periods_is_required: "Number of periods is required."
period: "Period"
year: "Year"
month: "Month"
week: "Week"
period_is_required: "Period is required."
subscription_price: "Subscription price"
price_is_required: "Price is required."
edit_amount_info: "Please note that if you change the price of this plan, the new price will only apply to new subscribers. Current subscriptions will stay unchanged, even those with running payment schedule."
visual_prominence_of_the_subscription: "Visual prominence of the subscription"
on_the_subscriptions_page_the_most_prominent_subscriptions_will_be_placed_at_the_top_of_the_list: "On the subscriptions page, the most prominent subscriptions will be placed at the top of the list."
an_evelated_number_means_a_higher_prominence: "An elevated number means a higher prominence."
edit_amount_info: "Please note that if you change the price of this plan, the new price will only apply to new subscribers. Current subscriptions will stay unchanged, even those with a running payment schedule."
visual_prominence: "Visual prominence of the subscription"
visual_prominence_help: "On the subscriptions page, the most prominent subscriptions will be placed at the top of the list. An elevated number means a higher prominence."
rolling_subscription: "Rolling subscription?"
a_rolling_subscription_will_begin_the_day_of_the_first_training: "A rolling subscription will begin the day of the first trainings."
otherwise_it_will_begin_as_soon_as_it_is_bought: "Otherwise, it will begin as soon as it is bought."
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_info: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
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"
type_a_short_description: "Type a short description"
information_sheet: "Information sheet"
attach_an_information_sheet: "Attach an information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
as_part_of_a_partner_subscription_some_notifications_may_be_sent_to_this_user: "As part of a partner subscription, some notifications may be sent to this user."
new_partner: "New partner"
first_name: "First name"
first_name_is_required: "First name is required."
surname: "Last name"
surname_is_required: "Last name is required."
email_address: "Email address"
email_address_is_required: "Email address is required."
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disable_plan_will_not_unsubscribe_users: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
duration: "Duration"
partnership: "Partnership"
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"
create_success: "The plan was created successfully"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
first_name: "First name"
surname: "Last name"
email: "Email address"
plan_pricing_form:
prices: "Prices"
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"
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?"
@ -624,21 +621,9 @@ en:
#add a subscription plan on the platform
new:
add_a_subscription_plan: "Add a subscription plan"
unable_to_create_the_subscription_please_try_again: "Unable to create the subscription plan. Please try again."
successfully_created_subscriptions_dont_forget_to_redefine_prices: "Subscription(s) successfully created. Don't forget to redefine prices."
unable_to_save_this_user_check_that_there_isnt_an_already_a_user_with_the_same_name: "Unable to save this user. Check that there isn't an already defined user with the same name."
#edit a subscription plan / machine slots prices
edit:
subscription_plan: "Subscription plan:"
prices: "Prices"
copy_prices_from: "Copy prices from"
machines: "Machines"
machine: "Machine"
hourly_rate: "Hourly rate"
spaces: "Spaces"
space: "Space"
unable_to_save_subscription_changes_please_try_again: "Unable to save subscription changes. Please try again."
subscription_successfully_changed: "Subscription successfully changed."
#list of all invoices & invoicing parameters
invoices:
invoices: "Invoices"

View File

@ -172,86 +172,6 @@ en:
method_check: "By check"
card_collection_info: "By validating, you'll be prompted for the member's card number. This card will be automatically charged at the deadlines."
check_collection_info: "By validating, you confirm that you have {DEADLINES} checks, allowing you to collect all the monthly payments."
event_themes:
title: "Event themes"
select_theme: "Pick up a theme…"
#event edition form
event:
title: "Title"
title_is_required: "Title is required."
matching_visual: "Matching visual"
choose_a_picture: "Choose a picture"
description: "Description"
description_is_required: "Description is required."
attachments: "Attachments"
add_a_new_file: "Add a new file"
event_type: "Event type"
dates_and_opening_hours: "Dates and opening hours"
all_day: "All day"
start_date: "Start date"
end_date: "End date"
start_time: "Start time"
end_time: "End time"
recurrence: "Recurrence"
_and_ends_on: "and ends on"
prices_and_availabilities: "Prices and availabilities"
standard_rate: "Standard rate"
0_equal_free: "0 = free"
tickets_available: "Tickets available"
event_themes: "Event themes"
select_theme: "Pick up a theme..."
age_range: "Age range"
add_price: "Add a price"
#subscription plan edition form
plan:
general_information: "General information"
name: "Name"
name_is_required: "Name is required"
name_length_must_be_less_than_24_characters: "Name length must be less than 24 characters."
type: "Type"
partner: "Partner"
standard: "Standard"
type_is_required: "Type is required."
group: "Group"
groups: "Groups"
all: "All"
transversal_all_groups: "Transversal (all groups)"
group_is_required: "Group is required."
category: "Category"
number_of_periods: "Number of periods"
number_of_periods_is_required: "Number of periods is required."
period: "Period"
year: "Year"
month: "Month"
week: "Week"
period_is_required: "Period is required."
subscription_price: "Subscription price"
price_is_required: "Price is required."
edit_amount_info: "Please note that if you change the price of this plan, the new price will only apply to new subscribers. Current subscriptions will stay unchanged, even those with running payment schedule."
visual_prominence_of_the_subscription: "Visual prominence of the subscription"
on_the_subscriptions_page_the_most_prominent_subscriptions_will_be_placed_at_the_top_of_the_list: "On the subscriptions page, the most prominent subscriptions will be placed at the top of the list."
an_evelated_number_means_a_higher_prominence: "An elevated number means a higher prominence."
rolling_subscription: "Rolling subscription?"
a_rolling_subscription_will_begin_the_day_of_the_first_training: "A rolling subscription will begin the day of the first trainings."
otherwise_it_will_begin_as_soon_as_it_is_bought: "Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_info: "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"
type_a_short_description: "Type a short description"
information_sheet: "Information sheet"
attach_an_information_sheet: "Attach an information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
as_part_of_a_partner_subscription_some_notifications_may_be_sent_to_this_user: "As part of a partner subscription, some notifications may be sent to this user."
new_partner: "New partner"
first_name: "First name"
first_name_is_required: "First name is required."
surname: "Last name"
surname_is_required: "Last name is required."
email_address: "Email address"
email_address_is_required: "Email address is required."
disabled: "Disable subscription"
disable_plan_will_not_unsubscribe_users: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
#partial form to edit/create a user (admin view)
user_admin:
user: "User"