1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-01 21:52:19 +01:00

Merge branch 'family_account' into staging

This commit is contained in:
Du Peng 2023-05-31 18:27:49 +02:00
commit 0dbe1420ac
168 changed files with 3011 additions and 344 deletions

View File

@ -1,5 +1,23 @@
# Changelog Fab-manager
## v6.0.6 2023 May 4
- Fix a bug: invalid duration for machine/spaces reservations in statistics, when using slots of not 1 hour
- [TODO DEPLOY] `rails fablab:es:build_stats` THEN `rails fablab:maintenance:regenerate_statistics[2014,1]`
## v6.0.5 2023 May 2
- Fix a bug: unable to show calendar for Firefox and Safari
- Improved error message for event reservation
## v6.0.4 2023 April 25
- Fix a bug: notification is broken when delete a project
- Fix a bug: broken notifications email
- Fix a bug: unable to show calendar
- Update translations from Crowdin
- [TODO DEPLOY] `rails fablab:maintenance:clean_abuse_notifications`
## v6.0.3 2023 April 12
- Fix a bug: unable to install Fab-manager by setup.sh

View File

@ -269,6 +269,8 @@ GEM
net-smtp (0.3.3)
net-protocol
nio4r (2.5.8)
nokogiri (1.14.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-linux)
racc (~> 1.4)
oauth2 (1.4.4)
@ -524,6 +526,7 @@ GEM
zeitwerk (2.6.7)
PLATFORMS
x86_64-darwin-20
x86_64-linux
DEPENDENCIES

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
# API Controller for resources of type Child
# Children are used to provide a way to manage multiple users in the family account
class API::ChildrenController < API::APIController
before_action :authenticate_user!
before_action :set_child, only: %i[show update destroy validate]
def index
authorize Child
user_id = current_user.id
user_id = params[:user_id] if current_user.privileged? && params[:user_id]
@children = Child.where(user_id: user_id).includes(:supporting_document_files).order(:created_at)
end
def show
authorize @child
end
def create
@child = Child.new(child_params)
authorize @child
if ChildService.create(@child)
render status: :created
else
render json: @child.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @child
if @child.update(child_params)
render status: :ok
else
render json: @child.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @child
@child.destroy
head :no_content
end
def validate
authorize @child
cparams = params.require(:child).permit(:validated_at)
if ChildService.validate(@child, cparams[:validated_at].present?)
render :show, status: :ok, location: child_path(@child)
else
render json: @child.errors, status: :unprocessable_entity
end
end
private
def set_child
@child = Child.find(params[:id])
end
def child_params
params.require(:child).permit(:first_name, :last_name, :email, :phone, :birthday, :user_id,
supporting_document_files_attributes: %i[id supportable_id supportable_type
supporting_document_type_id
attachment _destroy])
end
end

View File

@ -96,7 +96,7 @@ class API::EventsController < API::APIController
# handle general properties
event_preparams = params.required(:event).permit(:title, :description, :start_date, :start_time, :end_date, :end_time,
:amount, :nb_total_places, :availability_id, :all_day, :recurrence,
:recurrence_end_at, :category_id, :event_theme_ids, :age_range_id,
:recurrence_end_at, :category_id, :event_theme_ids, :age_range_id, :event_type,
event_theme_ids: [],
event_image_attributes: %i[id attachment],
event_files_attributes: %i[id attachment _destroy],

View File

@ -72,7 +72,7 @@ class API::PayzenController < API::PaymentsController
cart = shopping_cart
if order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' }
if order['answer']['transactions'].all? { |transaction| transaction['status'] == 'PAID' }
render on_payment_success(params[:order_id], cart)
else
render json: order['answer'], status: :unprocessable_entity
@ -86,10 +86,11 @@ class API::PayzenController < API::PaymentsController
client = PayZen::Transaction.new
transaction = client.get(params[:transaction_uuid])
order = PayZen::Order.new.get(params[:order_id])
cart = shopping_cart
if transaction['answer']['status'] == 'PAID'
if transaction['answer']['status'] == 'PAID' && order['answer']['transactions'].all? { |t| t['status'] == 'PAID' }
render on_payment_success(params[:order_id], cart)
else
render json: transaction['answer'], status: :unprocessable_entity

View File

@ -48,6 +48,6 @@ class API::SupportingDocumentFilesController < API::APIController
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_file_params
params.required(:supporting_document_file).permit(:supporting_document_type_id, :attachment, :user_id)
params.required(:supporting_document_file).permit(:supporting_document_type_id, :attachment, :supportable_id, :supportable_type)
end
end

View File

@ -27,6 +27,7 @@ class API::SupportingDocumentRefusalsController < API::APIController
# Never trust parameters from the scary internet, only allow the white list through.
def supporting_document_refusal_params
params.required(:supporting_document_refusal).permit(:message, :operator_id, :user_id, supporting_document_type_ids: [])
params.required(:supporting_document_refusal).permit(:message, :operator_id, :supportable_id, :supportable_type,
supporting_document_type_ids: [])
end
end

View File

@ -45,6 +45,6 @@ class API::SupportingDocumentTypesController < API::APIController
end
def supporting_document_type_params
params.require(:supporting_document_type).permit(:name, group_ids: [])
params.require(:supporting_document_type).permit(:name, :document_type, group_ids: [])
end
end

View File

@ -0,0 +1,46 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Child, ChildIndexFilter } from '../models/child';
import ApiLib from '../lib/api';
export default class ChildAPI {
static async index (filters: ChildIndexFilter): Promise<Array<Child>> {
const res: AxiosResponse<Array<Child>> = await apiClient.get(`/api/children${ApiLib.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<Child> {
const res: AxiosResponse<Child> = await apiClient.get(`/api/children/${id}`);
return res?.data;
}
static async create (child: Child): Promise<Child> {
const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']);
const res: AxiosResponse<Child> = await apiClient.post('/api/children', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (child: Child): Promise<Child> {
const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']);
const res: AxiosResponse<Child> = await apiClient.put(`/api/children/${child.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async destroy (childId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/children/${childId}`);
return res?.data;
}
static async validate (child: Child): Promise<Child> {
const res: AxiosResponse<Child> = await apiClient.patch(`/api/children/${child.id}/validate`, { child });
return res?.data;
}
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import * as React from 'react';
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption } from '../../models/event';
import { Event, EventDecoration, EventPriceCategoryAttributes, RecurrenceOption, EventType } from '../../models/event';
import EventAPI from '../../api/event';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
@ -40,7 +40,7 @@ interface EventFormProps {
* Form to edit or create events
*/
export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, onSuccess }) => {
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: { ...event } });
const { handleSubmit, register, control, setValue, formState } = useForm<Event>({ defaultValues: Object.assign({ event_type: 'standard' }, event) });
const output = useWatch<Event>({ control });
const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' });
@ -54,6 +54,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState<boolean>(false);
const [updatingEvent, setUpdatingEvent] = useState<Event>(null);
const [isActiveAccounting, setIsActiveAccounting] = useState<boolean>(false);
const [isActiveFamilyAccount, setIsActiveFamilyAccount] = useState<boolean>(false);
useEffect(() => {
EventCategoryAPI.index()
@ -69,6 +70,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
.then(data => setPriceCategoriesOptions(data.map(c => decorationToOption(c))))
.catch(onError);
SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError);
SettingAPI.get('family_account').then(res => setIsActiveFamilyAccount(res.value === 'true')).catch(onError);
}, []);
useEffect(() => {
@ -168,6 +170,20 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
];
};
/**
* This method provides event type options
*/
const buildEventTypeOptions = (): Array<SelectOption<EventType>> => {
const options = [
{ label: t('app.admin.event_form.event_types.standard'), value: 'standard' as EventType },
{ label: t('app.admin.event_form.event_types.nominative'), value: 'nominative' as EventType }
];
if (isActiveFamilyAccount) {
options.push({ label: t('app.admin.event_form.event_types.family'), value: 'family' as EventType });
}
return options;
};
return (
<div className="event-form">
<header>
@ -203,6 +219,12 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
label={t('app.admin.event_form.description')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect id="event_type"
control={control}
formState={formState}
label={t('app.admin.event_form.event_type')}
options={buildEventTypeOptions()}
rules={{ required: true }} />
<FormSelect id="category_id"
control={control}
formState={formState}

View File

@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import { Child } from '../../models/child';
import { FormInput } from '../form/form-input';
import { FabButton } from '../base/fab-button';
import { FormFileUpload } from '../form/form-file-upload';
import { FileType } from '../../models/file';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { User } from '../../models/user';
import { SupportingDocumentsRefusalModal } from '../supporting-documents/supporting-documents-refusal-modal';
import { FabAlert } from '../base/fab-alert';
interface ChildFormProps {
child: Child;
operator: User;
onSubmit: (data: Child) => void;
supportingDocumentsTypes: Array<SupportingDocumentType>;
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* A form for creating or editing a child.
*/
export const ChildForm: React.FC<ChildFormProps> = ({ child, onSubmit, supportingDocumentsTypes, operator, onSuccess, onError }) => {
const { t } = useTranslation('public');
const { register, formState, handleSubmit, setValue, control } = useForm<Child>({
defaultValues: child
});
const output = useWatch<Child>({ control }); // eslint-disable-line
const [refuseModalIsOpen, setRefuseModalIsOpen] = useState<boolean>(false);
/**
* get the name of the supporting document type by id
*/
const getSupportingDocumentsTypeName = (id: number): string => {
const supportingDocumentType = supportingDocumentsTypes.find((supportingDocumentType) => supportingDocumentType.id === id);
return supportingDocumentType ? supportingDocumentType.name : '';
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator?.role === 'admin' || operator?.role === 'manager');
};
/**
* Open/closes the modal dialog to refuse the documents
*/
const toggleRefuseModal = (): void => {
setRefuseModalIsOpen(!refuseModalIsOpen);
};
/**
* Callback triggered when the refusal was successfully saved
*/
const onSaveRefusalSuccess = (message: string): void => {
setRefuseModalIsOpen(false);
onSuccess(message);
};
return (
<div className="child-form">
{!isPrivileged() &&
<FabAlert level='info'>
<p>{t('app.public.child_form.child_form_info')}</p>
</FabAlert>
}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grp">
<FormInput id="first_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.first_name')}
/>
<FormInput id="last_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.last_name')}
/>
</div>
<div className="grp">
<FormInput id="birthday"
register={register}
rules={{ required: true, validate: (value) => moment(value).isAfter(moment().subtract(18, 'year')) }}
formState={formState}
label={t('app.public.child_form.birthday')}
type="date"
max={moment().format('YYYY-MM-DD')}
min={moment().subtract(18, 'year').format('YYYY-MM-DD')}
/>
<FormInput id="phone"
register={register}
formState={formState}
label={t('app.public.child_form.phone')}
type="tel"
/>
</div>
<FormInput id="email"
register={register}
formState={formState}
label={t('app.public.child_form.email')}
/>
{!isPrivileged() && <>
<h3 className="missing-file">{t('app.public.child_form.supporting_documents')}</h3>
{output.supporting_document_files_attributes.map((sf, index) => {
return (
<FormFileUpload key={index}
defaultFile={sf as FileType}
id={`supporting_document_files_attributes.${index}`}
accept="application/pdf"
setValue={setValue}
label={getSupportingDocumentsTypeName(sf.supporting_document_type_id)}
showRemoveButton={false}
register={register}
formState={formState} />
);
})}
</>}
<div className="actions">
<FabButton type="button" className='is-secondary' onClick={handleSubmit(onSubmit)}>
{t('app.public.child_form.save')}
</FabButton>
</div>
{isPrivileged() && <>
<h3 className="missing-file">{t('app.public.child_form.supporting_documents')}</h3>
<div className="document-list">
{output.supporting_document_files_attributes.map((sf, index) => {
return (
<div key={index} className="document-list-item">
<span className="type">{getSupportingDocumentsTypeName(sf.supporting_document_type_id)}</span>
{sf.attachment_url && (
<div className='file'>
<p>{sf.attachment}</p>
<a href={sf.attachment_url} target="_blank" rel="noreferrer" className='fab-button is-black'>
<span className="fab-button--icon-only"><i className="fas fa-eye"></i></span>
</a>
</div>
)}
{!sf.attachment_url && (
<div className="missing">
<p>{t('app.public.child_form.to_complete')}</p>
</div>
)}
</div>
);
})}
</div>
</>}
{isPrivileged() && <>
<FabAlert level='info'>
<p>{t('app.public.child_form.refuse_documents_info')}</p>
</FabAlert>
<div className="actions">
<FabButton className="refuse-btn is-secondary" onClick={toggleRefuseModal}>{t('app.public.child_form.refuse_documents')}</FabButton>
<SupportingDocumentsRefusalModal
isOpen={refuseModalIsOpen}
proofOfIdentityTypes={supportingDocumentsTypes}
toggleModal={toggleRefuseModal}
operator={operator}
supportable={child}
documentType="Child"
onError={onError}
onSuccess={onSaveRefusalSuccess} />
</div>
</>}
</form>
</div>
);
};

View File

@ -0,0 +1,71 @@
import React from 'react';
import { Child } from '../../models/child';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import FormatLib from '../../lib/format';
import { DeleteChildModal } from './delete-child-modal';
import ChildAPI from '../../api/child';
import { PencilSimple, Trash, UserSquare } from 'phosphor-react';
interface ChildItemProps {
child: Child;
size: 'sm' | 'lg';
onEdit: (child: Child) => void;
onDelete: (child: Child, error: string) => void;
onError: (error: string) => void;
}
/**
* A child item.
*/
export const ChildItem: React.FC<ChildItemProps> = ({ child, size, onEdit, onDelete, onError }) => {
const { t } = useTranslation('public');
const [isOpenDeleteChildModal, setIsOpenDeleteChildModal] = React.useState<boolean>(false);
/**
* Toggle the delete child modal
*/
const toggleDeleteChildModal = () => setIsOpenDeleteChildModal(!isOpenDeleteChildModal);
/**
* Delete a child
*/
const deleteChild = () => {
ChildAPI.destroy(child.id).then(() => {
toggleDeleteChildModal();
onDelete(child, t('app.public.child_item.deleted'));
}).catch((e) => {
console.error(e);
onError(t('app.public.child_item.unable_to_delete'));
});
};
return (
<div className={`child-item ${size} ${child.validated_at ? 'is-validated' : ''}`}>
<div className='status'>
<UserSquare size={24} weight="light" />
</div>
<div>
<span>{t('app.public.child_item.last_name')}</span>
<p>{child.last_name}</p>
</div>
<div>
<span>{t('app.public.child_item.first_name')}</span>
<p>{child.first_name}</p>
</div>
<div>
<span>{t('app.public.child_item.birthday')}</span>
<p>{FormatLib.date(child.birthday)}</p>
</div>
<div className="actions edit-destroy-buttons">
<FabButton onClick={() => onEdit(child)} className="edit-btn">
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton onClick={toggleDeleteChildModal} className="delete-btn">
<Trash size={20} weight="fill" />
</FabButton>
<DeleteChildModal isOpen={isOpenDeleteChildModal} toggleModal={toggleDeleteChildModal} child={child} onDelete={deleteChild} />
</div>
</div>
);
};

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from '../base/fab-modal';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { ChildForm } from './child-form';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { ChildValidation } from './child-validation';
import { User } from '../../models/user';
interface ChildModalProps {
child?: Child;
operator: User;
isOpen: boolean;
toggleModal: () => void;
onSuccess: (child: Child, msg: string) => void;
onError: (error: string) => void;
supportingDocumentsTypes: Array<SupportingDocumentType>;
}
/**
* A modal for creating or editing a child.
*/
export const ChildModal: React.FC<ChildModalProps> = ({ child, isOpen, toggleModal, onSuccess, onError, supportingDocumentsTypes, operator }) => {
const { t } = useTranslation('public');
/**
* Save the child to the API
*/
const handleSaveChild = async (data: Child): Promise<void> => {
try {
if (child?.id) {
await ChildAPI.update(data);
} else {
await ChildAPI.create(data);
}
toggleModal();
onSuccess(data, '');
} catch (error) {
onError(error);
}
};
return (
<FabModal title={t(`app.public.child_modal.${child?.id ? 'edit' : 'new'}_child`)}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={false} >
{(operator?.role === 'admin' || operator?.role === 'manager') &&
<ChildValidation child={child} onSuccess={onSuccess} onError={onError} />
}
<ChildForm
child={child}
onSubmit={handleSaveChild}
supportingDocumentsTypes={supportingDocumentsTypes}
operator={operator}
onSuccess={onSuccess}
onError={onError}
/>
</FabModal>
);
};

View File

@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import * as React from 'react';
import Switch from 'react-switch';
import _ from 'lodash';
import { useTranslation } from 'react-i18next';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { TDateISO } from '../../typings/date-iso';
interface ChildValidationProps {
child: Child
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component allows to configure boolean value for a setting.
*/
export const ChildValidation: React.FC<ChildValidationProps> = ({ child, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [value, setValue] = useState<boolean>(!!(child?.validated_at));
useEffect(() => {
setValue(!!(child?.validated_at));
}, [child]);
/**
* Callback triggered when the 'switch' is changed.
*/
const handleChanged = (_value: boolean) => {
setValue(_value);
const _child = _.clone(child);
if (_value) {
_child.validated_at = new Date().toISOString() as TDateISO;
} else {
_child.validated_at = null;
}
ChildAPI.validate(_child)
.then(() => {
onSuccess(t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_success`));
}).catch(err => {
setValue(!_value);
onError(t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_error`) + err);
});
};
return (
<div className="child-validation">
<label htmlFor="child-validation-switch">{t('app.admin.child_validation.validate_child')}</label>
<Switch checked={value} id="child-validation-switch" onChange={handleChanged} className="switch"></Switch>
</div>
);
};

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { react2angular } from 'react2angular';
import { Child } from '../../models/child';
import ChildAPI from '../../api/child';
import { User } from '../../models/user';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ChildModal } from './child-modal';
import { ChildItem } from './child-item';
import { FabButton } from '../base/fab-button';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
declare const Application: IApplication;
interface ChildrenDashboardProps {
user: User;
operator: User;
adminPanel?: boolean;
onSuccess: (error: string) => void;
onError: (error: string) => void;
}
/**
* A list of children belonging to the current user.
*/
export const ChildrenDashboard: React.FC<ChildrenDashboardProps> = ({ user, operator, adminPanel, onError, onSuccess }) => {
const { t } = useTranslation('public');
const [children, setChildren] = useState<Array<Child>>([]);
const [isOpenChildModal, setIsOpenChildModal] = useState<boolean>(false);
const [child, setChild] = useState<Child>();
const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState<Array<SupportingDocumentType>>([]);
useEffect(() => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
SupportingDocumentTypeAPI.index({ document_type: 'Child' }).then(tData => {
setSupportingDocumentsTypes(tData);
});
}, [user]);
/**
* Open the add child modal
*/
const addChild = () => {
setIsOpenChildModal(true);
setChild({
user_id: user.id,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
return { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Open the edit child modal
*/
const editChild = (child: Child) => {
setIsOpenChildModal(true);
setChild({
...child,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
const file = child.supporting_document_files_attributes.find(f => f.supporting_document_type_id === t.id);
return file || { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Delete a child
*/
const handleDeleteChildSuccess = (_child: Child, msg: string) => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
onSuccess(msg);
};
/**
* Handle save child success from the API
*/
const handleSaveChildSuccess = (_data: Child, msg: string) => {
ChildAPI.index({ user_id: user.id }).then(setChildren);
if (msg) {
onSuccess(msg);
}
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator?.role === 'admin' || operator?.role === 'manager');
};
return (
<section className='children-dashboard'>
<header>
{adminPanel
? <h2>{t('app.public.children_dashboard.heading')}</h2>
: <h2>{t('app.public.children_dashboard.member_heading')}</h2>
}
{!isPrivileged() && (
<div className="grpBtn">
<FabButton className="main-action-btn" onClick={addChild}>
{t('app.public.children_dashboard.add_child')}
</FabButton>
</div>
)}
</header>
<div className="children-list">
{children.map(child => (
<ChildItem key={child.id} child={child} size='lg' onEdit={editChild} onDelete={handleDeleteChildSuccess} onError={onError} />
))}
</div>
<ChildModal child={child} isOpen={isOpenChildModal} toggleModal={() => setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} />
</section>
);
};
const ChildrenDashboardWrapper: React.FC<ChildrenDashboardProps> = (props) => {
return (
<Loader>
<ChildrenDashboard {...props} />
</Loader>
);
};
Application.Components.component('childrenDashboard', react2angular(ChildrenDashboardWrapper, ['user', 'operator', 'adminPanel', 'onSuccess', 'onError']));

View File

@ -0,0 +1,37 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import { Child } from '../../models/child';
interface DeleteChildModalProps {
isOpen: boolean,
toggleModal: () => void,
child: Child,
onDelete: (child: Child) => void,
}
/**
* Modal dialog to remove a requested child
*/
export const DeleteChildModal: React.FC<DeleteChildModalProps> = ({ isOpen, toggleModal, onDelete, child }) => {
const { t } = useTranslation('public');
/**
* Callback triggered when the child confirms the deletion
*/
const handleDeleteChild = () => {
onDelete(child);
};
return (
<FabModal title={t('app.public.delete_child_modal.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.public.delete_child_modal.confirm')}
onConfirm={handleDeleteChild}
className="delete-child-modal">
<p>{t('app.public.delete_child_modal.confirm_delete_child')}</p>
</FabModal>
);
};

View File

@ -19,12 +19,13 @@ type FormFileUploadProps<TFieldValues> = FormComponent<TFieldValues> & AbstractF
accept?: string,
onFileChange?: (value: FileType) => void,
onFileRemove?: () => void,
showRemoveButton?: boolean,
}
/**
* This component allows to upload file, in forms managed by react-hook-form.
*/
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, 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, showRemoveButton = true }: FormFileUploadProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [file, setFile] = useState<FileType>(defaultFile);
@ -74,9 +75,10 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, re
return (
<div className={`form-file-upload ${label ? 'with-label' : ''} ${classNames}`}>
{hasFile() && (
<span>{file.attachment_name}</span>
)}
{hasFile()
? <span>{file.attachment_name}</span>
: <span className='placeholder'>{t('app.shared.form_file_upload.placeholder')}</span>
}
<div className="actions">
{file?.id && file?.attachment_url && (
<a href={file.attachment_url}
@ -100,7 +102,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, label, re
id={`${id}[attachment_files]`}
onChange={onFileSelected}
placeholder={placeholder()}/>
{hasFile() &&
{showRemoveButton && hasFile() &&
<FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />
}
</div>

View File

@ -22,13 +22,15 @@ type FormInputProps<TFieldValues, TInputType> = FormComponent<TFieldValues> & Ab
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
nullable?: boolean,
ariaLabel?: string,
maxLength?: number
maxLength?: number,
max?: number | string,
min?: number | string,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel, maxLength }: FormInputProps<TFieldValues, TInputType>) => {
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, addOnAriaLabel, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false, ariaLabel, maxLength, max, min }: FormInputProps<TFieldValues, TInputType>) => {
const [characterCount, setCharacterCount] = useState<number>(0);
/**
@ -100,7 +102,9 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
placeholder={placeholder}
accept={accept}
maxLength={maxLength} />
maxLength={maxLength}
max={max}
min={min}/>
{(type === 'file' && placeholder) && <span className='fab-button is-black file-placeholder'>{placeholder}</span>}
{maxLength && <span className='countdown'>{characterCount} / {maxLength}</span>}
{addOn && addOnAction && <button aria-label={addOnAriaLabel} type="button" onClick={addOnAction} className={`addon ${addOnClassName || ''} is-btn`}>{addOn}</button>}

View File

@ -54,7 +54,7 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator.role === 'admin' || operator.role === 'manager');
return (operator?.role === 'admin' || operator?.role === 'manager');
};
/**

View File

@ -75,7 +75,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
if (updateCard) return onSuccess(null);
const transaction = event.clientAnswer.transactions[0];
if (event.clientAnswer.orderStatus === 'PAID') {
if (event.clientAnswer.orderStatus === 'PAID' && transaction?.status === 'PAID') {
confirmPayment(event, transaction).then((confirmation) => {
PayZenKR.current.removeForms().then(() => {
onSuccess(confirmation);

View File

@ -49,7 +49,7 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
SupportingDocumentTypeAPI.index({ group_id: currentUser.group_id }).then(tData => {
setSupportingDocumentsTypes(tData);
});
SupportingDocumentFileAPI.index({ user_id: currentUser.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: currentUser.id, supportable_type: 'User' }).then(fData => {
setSupportingDocumentsFiles(fData);
});
}, []);
@ -106,7 +106,8 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
for (const proofOfIdentityTypeId of Object.keys(files)) {
const formData = new FormData();
formData.append('supporting_document_file[user_id]', currentUser.id.toString());
formData.append('supporting_document_file[supportable_id]', currentUser.id.toString());
formData.append('supporting_document_file[supportable_type]', 'User');
formData.append('supporting_document_file[supporting_document_type_id]', proofOfIdentityTypeId);
formData.append('supporting_document_file[attachment]', files[proofOfIdentityTypeId]);
const proofOfIdentityFile = getSupportingDocumentsFileByType(parseInt(proofOfIdentityTypeId, 10));
@ -117,7 +118,7 @@ export const SupportingDocumentsFiles: React.FC<SupportingDocumentsFilesProps> =
}
}
if (Object.keys(files).length > 0) {
SupportingDocumentFileAPI.index({ user_id: currentUser.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: currentUser.id, supportable_type: 'User' }).then(fData => {
setSupportingDocumentsFiles(fData);
setFiles({});
onSuccess(t('app.logged.dashboard.supporting_documents_files.file_successfully_uploaded'));

View File

@ -5,6 +5,7 @@ import { FabModal } from '../base/fab-modal';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import { SupportingDocumentRefusal } from '../../models/supporting-document-refusal';
import { User } from '../../models/user';
import { Child } from '../../models/child';
import SupportingDocumentRefusalAPI from '../../api/supporting-document-refusal';
import { SupportingDocumentsRefusalForm } from './supporting-documents-refusal-form';
@ -15,19 +16,21 @@ interface SupportingDocumentsRefusalModalProps {
onError: (message: string) => void,
proofOfIdentityTypes: Array<SupportingDocumentType>,
operator: User,
member: User
supportable: User | Child,
documentType: 'User' | 'Child',
}
/**
* Modal dialog to notify the member that his documents are refused
*/
export const SupportingDocumentsRefusalModal: React.FC<SupportingDocumentsRefusalModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, member, onError }) => {
export const SupportingDocumentsRefusalModal: React.FC<SupportingDocumentsRefusalModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, supportable, onError, documentType }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<SupportingDocumentRefusal>({
id: null,
operator_id: operator.id,
user_id: member.id,
supportable_id: supportable.id,
supportable_type: documentType,
supporting_document_type_ids: [],
message: ''
});

View File

@ -63,6 +63,7 @@ export const SupportingDocumentsTypeForm: React.FC<SupportingDocumentsTypeFormPr
{t('app.admin.settings.account.supporting_documents_type_form.type_form_info')}
</div>
<form name="supportingDocumentTypeForm">
{supportingDocumentType?.document_type === 'User' &&
<div className="field">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.account.supporting_documents_type_form.select_group')}
@ -70,6 +71,7 @@ export const SupportingDocumentsTypeForm: React.FC<SupportingDocumentsTypeFormPr
options={buildOptions()}
isMulti />
</div>
}
<div className="field">
<FabInput id="supporting_document_type_name"
icon={<i className="fa fa-edit" />}

View File

@ -14,18 +14,19 @@ interface SupportingDocumentsTypeModalProps {
onError: (message: string) => void,
groups: Array<Group>,
proofOfIdentityType?: SupportingDocumentType,
documentType: 'User' | 'Child',
}
/**
* Modal dialog to create/edit a supporting documents type
*/
export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModalProps> = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups }) => {
export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModalProps> = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups, documentType }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<SupportingDocumentType>({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
const [data, setData] = useState<SupportingDocumentType>({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '', document_type: documentType });
useEffect(() => {
setData({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
setData({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '', document_type: documentType });
}, [proofOfIdentityType]);
/**
@ -63,7 +64,7 @@ export const SupportingDocumentsTypeModal: React.FC<SupportingDocumentsTypeModal
* Check if the form is valid (not empty)
*/
const isPreventedSaveType = (): boolean => {
return !data.name || data.group_ids.length === 0;
return !data.name || (documentType === 'User' && data.group_ids.length === 0);
};
return (

View File

@ -15,18 +15,20 @@ import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
import { FabPanel } from '../base/fab-panel';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
import { PencilSimple, Trash } from 'phosphor-react';
declare const Application: IApplication;
interface SupportingDocumentsTypesListProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
documentType: 'User' | 'Child',
}
/**
* This component shows a list of all types of supporting documents (e.g. student ID, Kbis extract, etc.)
*/
const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps> = ({ onSuccess, onError }) => {
const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps> = ({ onSuccess, onError, documentType }) => {
const { t } = useTranslation('admin');
// list of displayed supporting documents type
@ -48,7 +50,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
useEffect(() => {
GroupAPI.index({ disabled: false }).then(data => {
setGroups(data);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(pData);
});
});
@ -91,7 +93,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
*/
const onSaveTypeSuccess = (message: string): void => {
setModalIsOpen(false);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(orderTypes(pData, supportingDocumentsTypeOrder));
onSuccess(message);
}).catch((error) => {
@ -121,7 +123,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
*/
const onDestroySuccess = (message: string): void => {
setDestroyModalIsOpen(false);
SupportingDocumentTypeAPI.index().then(pData => {
SupportingDocumentTypeAPI.index({ document_type: documentType }).then(pData => {
setSupportingDocumentsTypes(pData);
setSupportingDocumentsTypes(orderTypes(pData, supportingDocumentsTypeOrder));
onSuccess(message);
@ -190,6 +192,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
window.location.href = '/#!/admin/members?tabs=1';
};
if (documentType === 'User') {
return (
<FabPanel className="supporting-documents-types-list" header={<div>
<span>{t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')}</span>
@ -211,6 +214,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
documentType={documentType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
@ -245,12 +249,12 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
<td>{getGroupsNames(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<div className="buttons">
<div className="edit-destroy-buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<i className="fa fa-edit" />
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<i className="fa fa-trash" />
<Trash size={20} weight="fill" />
</FabButton>
</div>
</td>
@ -267,6 +271,59 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
</div>
</FabPanel>
);
} else if (documentType === 'Child') {
return (
<div className="supporting-documents-types-list">
<div className="types-list">
<div className="title">
<h3>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}</h3>
<FabButton onClick={addType}>{t('app.admin.settings.account.supporting_documents_types_list.add_type')}</FabButton>
</div>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
documentType={documentType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteSupportingDocumentsTypeModal isOpen={destroyModalIsOpen}
proofOfIdentityTypeId={supportingDocumentsTypeId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<div className="document-list">
{supportingDocumentsTypes.map(poit => {
return (
<div key={poit.id} className="document-list-item">
<div className='file'>
<p>{poit.name}</p>
<div className="edit-destroy-buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<Trash size={20} weight="fill" />
</FabButton>
</div>
</div>
</div>
);
})}
</div>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</div>
</div>
);
} else {
return null;
}
};
const SupportingDocumentsTypesListWrapper: React.FC<SupportingDocumentsTypesListProps> = (props) => {
@ -277,4 +334,4 @@ const SupportingDocumentsTypesListWrapper: React.FC<SupportingDocumentsTypesList
);
};
Application.Components.component('supportingDocumentsTypesList', react2angular(SupportingDocumentsTypesListWrapper, ['onSuccess', 'onError']));
Application.Components.component('supportingDocumentsTypesList', react2angular(SupportingDocumentsTypesListWrapper, ['onSuccess', 'onError', 'documentType']));

View File

@ -19,6 +19,7 @@ declare const Application: IApplication;
interface SupportingDocumentsValidationProps {
operator: User,
member: User
documentType: 'User' | 'Child',
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
@ -26,7 +27,7 @@ interface SupportingDocumentsValidationProps {
/**
* This component shows a list of supporting documents file of member, admin can download and valid
**/
const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps> = ({ operator, member, onSuccess, onError }) => {
const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps> = ({ operator, member, onSuccess, onError, documentType }) => {
const { t } = useTranslation('admin');
// list of supporting documents type
@ -39,7 +40,7 @@ const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps
SupportingDocumentTypeAPI.index({ group_id: member.group_id }).then(tData => {
setDocumentsTypes(tData);
});
SupportingDocumentFileAPI.index({ user_id: member.id }).then(fData => {
SupportingDocumentFileAPI.index({ supportable_id: member.id, supportable_type: 'User' }).then(fData => {
setDocumentsFiles(fData);
});
}, []);
@ -112,7 +113,8 @@ const SupportingDocumentsValidation: React.FC<SupportingDocumentsValidationProps
proofOfIdentityTypes={documentsTypes}
toggleModal={toggleModal}
operator={operator}
member={member}
supportable={member}
documentType={documentType}
onError={onError}
onSuccess={onSaveRefusalSuccess}/>
</FabPanel>
@ -131,4 +133,4 @@ const SupportingDocumentsValidationWrapper: React.FC<SupportingDocumentsValidati
export { SupportingDocumentsValidationWrapper as SupportingDocumentsValidation };
Application.Components.component('supportingDocumentsValidation', react2angular(SupportingDocumentsValidationWrapper, ['operator', 'member', 'onSuccess', 'onError']));
Application.Components.component('supportingDocumentsValidation', react2angular(SupportingDocumentsValidationWrapper, ['operator', 'member', 'onSuccess', 'onError', 'documentType']));

View File

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Member } from '../../models/member';
import { Child } from '../../models/child';
import { FabButton } from '../base/fab-button';
import { CaretDown, User, Users, PencilSimple, Trash } from 'phosphor-react';
import { ChildItem } from '../family-account/child-item';
interface MembersListItemProps {
member: Member,
onError: (message: string) => void,
onSuccess: (message: string) => void
onEditChild: (child: Child) => void;
onDeleteChild: (child: Child, error: string) => void;
onDeleteMember: (memberId: number) => void;
}
/**
* Members list
*/
export const MembersListItem: React.FC<MembersListItemProps> = ({ member, onError, onEditChild, onDeleteChild, onDeleteMember }) => {
const { t } = useTranslation('admin');
const [childrenList, setChildrenList] = useState(false);
/**
* Redirect to the given user edition page
*/
const toMemberEdit = (memberId: number): void => {
window.location.href = `/#!/admin/members/${memberId}/edit`;
};
return (
<div key={member.id} className={`members-list-item ${member.validated_at ? 'is-validated' : ''} ${member.need_completion ? 'is-incomplet' : ''}`}>
<div className="left-col">
<div className='status'>
{(member.children.length > 0)
? <Users size={24} weight="bold" />
: <User size={24} weight="bold" />
}
</div>
{(member.children.length > 0) &&
<FabButton onClick={() => setChildrenList(!childrenList)} className={`toggle ${childrenList ? 'open' : ''}`}>
<CaretDown size={24} weight="bold" />
</FabButton>
}
</div>
<div className="member">
<div className="member-infos">
<div>
<span>{t('app.admin.members_list_item.surname')}</span>
<p>{member.profile.last_name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.first_name')}</span>
<p>{member.profile.first_name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.phone')}</span>
<p>{member.profile.phone || '---'}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.email')}</span>
<p>{member.email}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.group')}</span>
<p>{member.group.name}</p>
</div>
<div>
<span>{t('app.admin.members_list_item.subscription')}</span>
<p>{member.subscribed_plan?.name || '---'}</p>
</div>
</div>
<div className="member-actions edit-destroy-buttons">
<FabButton onClick={() => toMemberEdit(member.id)} className="edit-btn">
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton onClick={() => onDeleteMember(member.id)} className="delete-btn">
<Trash size={20} weight="fill" />
</FabButton>
</div>
</div>
{ (member.children.length > 0) &&
<div className={`member-children ${childrenList ? 'open' : ''}`}>
<hr />
{member.children.map((child: Child) => (
<ChildItem key={child.id} child={child} size='sm' onEdit={onEditChild} onDelete={onDeleteChild} onError={onError} />
))}
</div>
}
</div>
);
};

View File

@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { Member } from '../../models/member';
import { MembersListItem } from './members-list-item';
import { SupportingDocumentType } from '../../models/supporting-document-type';
import SupportingDocumentTypeAPI from '../../api/supporting-document-type';
import { Child } from '../../models/child';
import { ChildModal } from '../family-account/child-modal';
import { User } from '../../models/user';
declare const Application: IApplication;
interface MembersListProps {
members: Member[],
operator: User,
onError: (message: string) => void,
onSuccess: (message: string) => void
onDeleteMember: (memberId: number) => void;
onDeletedChild: (memberId: number, childId: number) => void;
onUpdatedChild: (memberId: number, child: Child) => void;
}
/**
* Members list
*/
export const MembersList: React.FC<MembersListProps> = ({ members, onError, onSuccess, operator, onDeleteMember, onDeletedChild, onUpdatedChild }) => {
const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState<Array<SupportingDocumentType>>([]);
const [child, setChild] = useState<Child>();
const [isOpenChildModal, setIsOpenChildModal] = useState<boolean>(false);
useEffect(() => {
SupportingDocumentTypeAPI.index({ document_type: 'Child' }).then(tData => {
setSupportingDocumentsTypes(tData);
});
}, []);
/**
* Open the edit child modal
*/
const editChild = (child: Child) => {
setIsOpenChildModal(true);
setChild({
...child,
supporting_document_files_attributes: supportingDocumentsTypes.map(t => {
const file = child.supporting_document_files_attributes.find(f => f.supporting_document_type_id === t.id);
return file || { supporting_document_type_id: t.id };
})
} as Child);
};
/**
* Delete a child
*/
const handleDeleteChildSuccess = (c: Child, msg: string) => {
onDeletedChild(c.user_id, c.id);
onSuccess(msg);
};
/**
* Handle save child success from the API
*/
const handleSaveChildSuccess = (c: Child, msg: string) => {
onUpdatedChild(c.user_id, c);
if (msg) {
onSuccess(msg);
}
};
return (
<div className="members-list">
{members.map(member => (
<MembersListItem key={member.id} member={member} onError={onError} onSuccess={onSuccess} onDeleteMember={onDeleteMember} onEditChild={editChild} onDeleteChild={handleDeleteChildSuccess} />
))}
<ChildModal child={child} isOpen={isOpenChildModal} toggleModal={() => setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} />
</div>
);
};
const MembersListWrapper: React.FC<MembersListProps> = (props) => {
return (
<Loader>
<MembersList {...props} />
</Loader>
);
};
Application.Components.component('membersList', react2angular(MembersListWrapper, ['members', 'onError', 'onSuccess', 'operator', 'onDeleteMember', 'onDeletedChild', 'onUpdatedChild']));

View File

@ -69,8 +69,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
snapDuration: BOOKING_SNAP,
selectable: true,
selectHelper: true,
minTime: moment.duration(moment(bookingWindowStart.setting.value).format('HH:mm:ss')),
maxTime: moment.duration(moment(bookingWindowEnd.setting.value).format('HH:mm:ss')),
minTime: moment.duration(moment.utc(bookingWindowStart.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
maxTime: moment.duration(moment.utc(bookingWindowEnd.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
select (start, end, jsEvent, view) {
return calendarSelectCb(start, end, jsEvent, view);
},

View File

@ -291,7 +291,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
Member.delete(
{ id: memberId },
function () {
$scope.members.splice(findItemIdxById($scope.members, memberId), 1);
$scope.members = _.filter($scope.members, function (m) { return m.id !== memberId; });
return growl.success(_t('app.admin.members.member_successfully_deleted'));
},
function (error) {
@ -303,6 +303,32 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
);
};
$scope.onDeletedChild = function (memberId, childId) {
$scope.members = $scope.members.map(function (member) {
if (member.id === memberId) {
member.children = _.filter(member.children, function (c) { return c.id !== childId; });
return member;
}
return member;
});
};
$scope.onUpdatedChild = function (memberId, child) {
$scope.members = $scope.members.map(function (member) {
if (member.id === memberId) {
member.children = member.children.map(function (c) {
if (c.id === child.id) {
return child;
}
return c;
});
console.log(member.children);
return member;
}
return member;
});
};
/**
* Ask for confirmation then delete the specified administrator
* @param admins {Array} full list of administrators
@ -588,6 +614,20 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
}
};
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**
* Callback triggered in case of success
*/
$scope.onSuccess = (message) => {
growl.success(message);
};
/* PRIVATE SCOPE */
/**

View File

@ -71,8 +71,8 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
$scope.subscriptionExplicationsAlert = { name: 'subscription_explications_alert', value: settingsPromise.subscription_explications_alert };
$scope.eventExplicationsAlert = { name: 'event_explications_alert', value: settingsPromise.event_explications_alert };
$scope.spaceExplicationsAlert = { name: 'space_explications_alert', value: settingsPromise.space_explications_alert };
$scope.windowStart = { name: 'booking_window_start', value: settingsPromise.booking_window_start };
$scope.windowEnd = { name: 'booking_window_end', value: settingsPromise.booking_window_end };
$scope.windowStart = { name: 'booking_window_start', value: moment.utc(settingsPromise.booking_window_start).format('YYYY-MM-DD HH:mm:ss') };
$scope.windowEnd = { name: 'booking_window_end', value: moment.utc(settingsPromise.booking_window_end).format('YYYY-MM-DD HH:mm:ss') };
$scope.mainColorSetting = { name: 'main_color', value: settingsPromise.main_color };
$scope.secondColorSetting = { name: 'secondary_color', value: settingsPromise.secondary_color };
$scope.nameGenre = { name: 'name_genre', value: settingsPromise.name_genre };
@ -487,8 +487,12 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
// we prevent the admin from setting the closing time before the opening time
$scope.$watch('windowEnd.value', function (newValue, oldValue, scope) {
if ($scope.windowStart && moment($scope.windowStart.value).isAfter(newValue)) {
return $scope.windowEnd.value = oldValue;
if (scope.windowStart) {
const startTime = moment($scope.windowStart.value).format('HH:mm:ss');
const endTime = moment(newValue).format('HH:mm:ss');
if (startTime >= endTime) {
scope.windowEnd.value = oldValue;
}
}
});

View File

@ -204,8 +204,8 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
center: 'title',
right: ''
},
minTime: moment.duration(moment(bookingWindowStart.setting.value).format('HH:mm:ss')),
maxTime: moment.duration(moment(bookingWindowEnd.setting.value).format('HH:mm:ss')),
minTime: moment.duration(moment.utc(bookingWindowStart.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
maxTime: moment.duration(moment.utc(bookingWindowEnd.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
defaultView: window.innerWidth <= 480 ? 'agendaDay' : 'agendaWeek',
eventClick (event, jsEvent, view) {
return calendarEventClickCb(event, jsEvent, view);

View File

@ -0,0 +1,23 @@
'use strict';
Application.Controllers.controller('ChildrenController', ['$scope', 'memberPromise', 'growl',
function ($scope, memberPromise, growl) {
// Current user's profile
$scope.user = memberPromise;
/**
* Callback used to display a error message
*/
$scope.onError = function (message) {
console.error(message);
growl.error(message);
};
/**
* Callback used to display a success message
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
}
]);

View File

@ -136,8 +136,8 @@ Application.Controllers.controller('EventsController', ['$scope', '$state', 'Eve
}
]);
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'SlotsReservation', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, SlotsReservation, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment) {
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'SlotsReservation', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment', 'Child',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, SlotsReservation, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment, Child) {
/* PUBLIC SCOPE */
// reservations for the currently shown event
@ -150,6 +150,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.ctrl =
{ member: {} };
// children for the member
$scope.children = [];
// parameters for a new reservation
$scope.reserve = {
nbPlaces: {
@ -160,7 +163,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
toReserve: false,
amountTotal: 0,
totalNoCoupon: 0,
totalSeats: 0
totalSeats: 0,
bookingUsers: {
normal: []
},
};
// Discount coupon to apply to the basket, if any
@ -195,6 +201,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// Global config: is the user validation required ?
$scope.enableUserValidationRequired = settingsPromise.user_validation_required === 'true';
// Global config: is the child validation required ?
$scope.enableChildValidationRequired = settingsPromise.child_validation_required === 'true';
// online payments (by card)
$scope.onlinePayment = {
showModal: false,
@ -226,9 +235,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Callback to call when the number of tickets to book changes in the current booking
*/
$scope.changeNbPlaces = function () {
$scope.changeNbPlaces = function (priceType) {
// compute the total remaining places
let remain = $scope.event.nb_free_places - $scope.reserve.nbReservePlaces;
let remain = ($scope.event.event_type === 'family' ? ($scope.children.length + 1) : $scope.event.nb_free_places) - $scope.reserve.nbReservePlaces;
for (let ticket in $scope.reserve.tickets) {
remain -= $scope.reserve.tickets[ticket];
}
@ -247,17 +256,41 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}
if ($scope.event.event_type === 'nominative' || $scope.event.event_type === 'family') {
const nbBookingUsers = $scope.reserve.bookingUsers[priceType].length;
const nbReservePlaces = priceType === 'normal' ? $scope.reserve.nbReservePlaces : $scope.reserve.tickets[priceType];
if (nbReservePlaces > nbBookingUsers) {
_.times(nbReservePlaces - nbBookingUsers, () => {
$scope.reserve.bookingUsers[priceType].push({ event_price_category_id: priceType === 'normal' ? null : priceType, bookedUsers: buildBookedUsersOptions() });
});
} else {
_.times(nbBookingUsers - nbReservePlaces, () => {
$scope.reserve.bookingUsers[priceType].pop();
});
}
}
// recompute the total price
return $scope.computeEventAmount();
};
$scope.changeBookedUser = function () {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
user.bookedUsers = buildBookedUsersOptions(user.booked);
}
}
}
/**
* Callback to reset the current reservation parameters
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
*/
$scope.cancelReserve = function (e) {
e.preventDefault();
return resetEventReserve();
resetEventReserve();
updateNbReservePlaces();
return;
};
$scope.isUserValidatedByType = () => {
@ -322,6 +355,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id);
getChildren($scope.ctrl.member.id).then(() => {
updateNbReservePlaces();
});
});
}
};
@ -372,7 +408,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
, function (response) {
// reservation failed
growl.error(response && response.data && response.data.card && response.data.card[0] || 'server error');
growl.error(response && response.data && _.keys(response.data)[0] && response.data[_.keys(response.data)[0]][0] || 'server error');
// unset the attempting marker
$scope.attempting = false;
})
@ -583,6 +619,38 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
growl.error(message);
};
/**
* Checks if the reservation of current event is valid
*/
$scope.reservationIsValid = () => {
if ($scope.event.event_type === 'nominative') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (!_.trim(user.name)) {
return false;
}
}
}
}
if ($scope.event.event_type === 'family') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (!user.booked) {
return false;
}
if ($scope.enableChildValidationRequired && user.booked.type === 'Child' && !user.booked.validatedAt) {
return false;
}
}
}
}
return true;
}
$scope.isUnder18YearsAgo = (date) => {
return moment(date).isAfter(moment().subtract(18, 'year'));
}
/* PRIVATE SCOPE */
/**
@ -602,6 +670,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// get the current user's reservations into $scope.reservations
if ($scope.currentUser) {
getReservations($scope.event.id, 'Event', $scope.currentUser.id);
getChildren($scope.currentUser.id).then(function (children) {
updateNbReservePlaces();
});
}
// watch when a coupon is applied to re-compute the total price
@ -626,6 +697,74 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}).$promise.then(function (reservations) { $scope.reservations = reservations; });
};
/**
* Retrieve the children for the user
* @param user_id {number} the user's id (current or managed)
*/
const getChildren = function (user_id) {
return Child.query({
user_id
}).$promise.then(function (children) {
$scope.children = children;
return $scope.children;
});
};
/**
* Update the number of places reserved by the current user
*/
const hasBookedUser = function (userKey) {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
if (user.booked && user.booked.key === userKey) {
return true;
}
}
}
return false;
};
/**
* Build the list of options for the select box of the booked users
* @param booked {object} the booked user
*/
const buildBookedUsersOptions = function (booked) {
const options = [];
const userKey = `user_${$scope.ctrl.member.id}`;
if ((booked && booked.key === userKey) || !hasBookedUser(userKey)) {
options.push({ key: userKey, name: $scope.ctrl.member.name, type: 'User', id: $scope.ctrl.member.id });
}
for (const child of $scope.children) {
const key = `child_${child.id}`;
if ((booked && booked.key === key) || !hasBookedUser(key)) {
options.push({
key,
name: child.first_name + ' ' + child.last_name,
id: child.id,
type: 'Child',
validatedAt: child.validated_at,
birthday: child.birthday
});
}
}
return options;
};
/**
* update number of places available for each price category for the family event
*/
const updateNbReservePlaces = function () {
if ($scope.event.event_type === 'family') {
const maxPlaces = $scope.children.length + 1;
if ($scope.event.nb_free_places > maxPlaces) {
$scope.reserve.nbPlaces.normal = __range__(0, maxPlaces, true);
for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) {
$scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, maxPlaces, true);
}
}
}
};
/**
* Create a hash map implementing the Reservation specs
* @param reserve {Object} Reservation parameters (places...)
@ -638,7 +777,8 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
reservable_type: 'Event',
slots_reservations_attributes: [],
nb_reserve_places: reserve.nbReservePlaces,
tickets_attributes: []
tickets_attributes: [],
booking_users_attributes: []
};
reservation.slots_reservations_attributes.push({
@ -656,6 +796,19 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}
if (event.event_type === 'nominative' || event.event_type === 'family') {
for (const key of Object.keys($scope.reserve.bookingUsers)) {
for (const user of $scope.reserve.bookingUsers[key]) {
reservation.booking_users_attributes.push({
event_price_category_id: user.event_price_category_id,
name: user.booked ? user.booked.name : _.trim(user.name),
booked_id: user.booked ? user.booked.id : undefined,
booked_type: user.booked ? user.booked.type : undefined,
});
}
}
}
return { reservation };
};
@ -688,11 +841,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
tickets: {},
toReserve: false,
amountTotal: 0,
totalSeats: 0
totalSeats: 0,
bookingUsers: {
normal: [],
},
};
for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) {
$scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, $scope.event.nb_free_places, true);
$scope.reserve.bookingUsers[evt_px_cat.id] = [];
$scope.reserve.tickets[evt_px_cat.id] = 0;
}
@ -815,6 +972,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.reservations.push(reservation);
});
resetEventReserve();
updateNbReservePlaces();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
if ($scope.currentUser.role === 'admin') {

View File

@ -447,8 +447,8 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')),
maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')),
minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
eventClick (event, jsEvent, view) {
return calendarEventClickCb(event, jsEvent, view);
},

View File

@ -385,8 +385,8 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')),
maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')),
minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
eventClick (event, jsEvent, view) {
return calendarEventClickCb(event, jsEvent, view);
},

View File

@ -155,8 +155,8 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')),
maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')),
minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),
eventClick (event, jsEvent, view) {
return calendarEventClickCb(event, jsEvent, view);
},

View File

@ -35,6 +35,9 @@ export default class ApiLib {
if (file?.is_main) {
data.set(`${name}[${attr}][${i}][is_main]`, file.is_main.toString());
}
if (file?.supporting_document_type_id) {
data.set(`${name}[${attr}][${i}][supporting_document_type_id]`, file.supporting_document_type_id.toString());
}
});
} else {
if (object[attr]?.attachment_files && object[attr]?.attachment_files[0]) {

View File

@ -0,0 +1,27 @@
import { TDateISODate, TDateISO } from '../typings/date-iso';
import { ApiFilter } from './api';
export interface ChildIndexFilter extends ApiFilter {
user_id: number,
}
export interface Child {
id?: number,
last_name: string,
first_name: string,
email?: string,
phone?: string,
birthday: TDateISODate,
user_id: number,
validated_at?: TDateISO,
supporting_document_files_attributes?: Array<{
id?: number,
supportable_id?: number,
supportable_type?: 'User' | 'Child',
supporting_document_type_id: number,
attachment?: File,
attachment_name?: string,
attachment_url?: string,
_destroy?: boolean
}>,
}

View File

@ -11,6 +11,7 @@ export interface EventPriceCategoryAttributes {
}
export type RecurrenceOption = 'none' | 'day' | 'week' | 'month' | 'year';
export type EventType = 'standard' | 'nominative' | 'family';
export interface Event {
id?: number,
@ -63,7 +64,8 @@ export interface Event {
}>,
recurrence: RecurrenceOption,
recurrence_end_at: Date,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
event_type: EventType,
}
export interface EventDecoration {

View File

@ -0,0 +1,49 @@
import { TDateISO } from '../typings/date-iso';
import { Child } from './child';
export interface Member {
maxMembers: number
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
phone: string
}
need_completion?: boolean
group: {
name: string
}
subscribed_plan?: Plan
validated_at: TDateISO
children: Child[]
}
interface Plan {
id: number
base_name: string
name: string
amount: number
interval: string
interval_count: number
training_credit_nb: number
training_credits: [
{
training_id: number
},
{
training_id: number
}
]
machine_credits: [
{
machine_id: number
hours: number
},
{
machine_id: number
hours: number
}
]
}

View File

@ -45,6 +45,13 @@ export interface Reservation {
},
total_booked_seats?: number,
created_at?: TDateISO,
booking_users_attributes?: {
id: number,
name: string,
event_price_category_id: number,
booked_id: number,
booked_type: string,
}
}
export interface ReservationIndexFilter extends ApiFilter {

View File

@ -178,7 +178,8 @@ export const accountSettings = [
'external_id',
'user_change_group',
'user_validation_required',
'user_validation_required_list'
'user_validation_required_list',
'family_account'
] as const;
export const analyticsSettings = [

View File

@ -1,12 +1,14 @@
import { ApiFilter } from './api';
export interface SupportingDocumentFileIndexFilter extends ApiFilter {
user_id: number,
supportable_id: number,
supportable_type?: 'User' | 'Child',
}
export interface SupportingDocumentFile {
id?: number,
attachment?: string,
user_id?: number,
supportable_id?: number,
supportable_type?: 'User' | 'Child',
supporting_document_type_id: number,
}

View File

@ -1,13 +1,15 @@
import { ApiFilter } from './api';
export interface SupportingDocumentRefusalIndexFilter extends ApiFilter {
user_id: number,
supportable_id: number,
supportable_type: 'User' | 'Child',
}
export interface SupportingDocumentRefusal {
id: number,
message: string,
user_id: number,
supportable_id: number,
supportable_type: 'User' | 'Child',
operator_id: number,
supporting_document_type_ids: Array<number>,
}

View File

@ -2,10 +2,12 @@ import { ApiFilter } from './api';
export interface SupportingDocumentTypeIndexfilter extends ApiFilter {
group_id?: number,
document_type?: 'User' | 'Child'
}
export interface SupportingDocumentType {
id: number,
name: string,
group_ids: Array<number>
group_ids: Array<number>,
document_type: 'User' | 'Child'
}

View File

@ -28,9 +28,9 @@ angular.module('application.router', ['ui.router'])
logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }],
sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }],
modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module', 'spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module', 'store_module']" }).$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden', 'family_account']" }).$promise; }]
},
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) {
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'settingsPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, settingsPromise, CSRF) {
// Retrieve Anti-CSRF tokens from cookies
CSRF.setMetaTags();
// Application logo
@ -47,6 +47,9 @@ angular.module('application.router', ['ui.router'])
publicAgenda: (modulesPromise.public_agenda_module === 'true'),
statistics: (modulesPromise.statistics_module === 'true')
};
$rootScope.settings = {
familyAccount: (settingsPromise.family_account === 'true')
};
}]
})
.state('app.public', {
@ -151,6 +154,15 @@ angular.module('application.router', ['ui.router'])
}
}
})
.state('app.logged.dashboard.children', {
url: '/children',
views: {
'main@': {
templateUrl: '/dashboard/children.html',
controller: 'ChildrenController'
}
}
})
.state('app.logged.dashboard.settings', {
url: '/settings',
views: {
@ -615,7 +627,7 @@ angular.module('application.router', ['ui.router'])
resolve: {
eventPromise: ['Event', '$transition$', function (Event, $transition$) { return Event.get({ id: $transition$.params().id }).$promise; }],
priceCategoriesPromise: ['PriceCategory', function (PriceCategory) { return PriceCategory.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list', 'child_validation_required']" }).$promise; }]
}
})

View File

@ -0,0 +1,11 @@
'use strict';
Application.Services.factory('Child', ['$resource', function ($resource) {
return $resource('/api/children/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
}
}
);
}]);

View File

@ -52,6 +52,9 @@
@import "modules/events/event-form";
@import "modules/events/update-recurrent-modal";
@import "modules/events/events-settings.scss";
@import "modules/family-account/child-form";
@import "modules/family-account/child-item";
@import "modules/family-account/children-dashboard";
@import "modules/form/abstract-form-item";
@import "modules/form/form-input";
@import "modules/form/form-multi-file-upload";

View File

@ -1,6 +1,9 @@
.edit-destroy-buttons {
width: max-content;
flex-shrink: 0;
border-radius: var(--border-radius-sm);
overflow: hidden;
button {
@include btn;
border-radius: 0;

View File

@ -30,6 +30,7 @@
animation: 0.3s ease-out slideInFromTop;
position: relative;
top: 90px;
max-width: 100vw;
margin: auto;
opacity: 1;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);

View File

@ -0,0 +1,43 @@
.child-form {
.grp {
display: flex;
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item:first-child { margin-right: 2.4rem; }
}
hr { width: 100%; }
.actions {
align-self: flex-end;
}
.document-list {
margin-bottom: 1.6rem;
display: flex;
flex-direction: column;
gap: 1.6rem;
&-item {
display: flex;
flex-direction: column;
gap: 0.8rem;
.type {
@include text-sm;
}
.file,
.missing {
padding: 0.8rem 0.8rem 0.8rem 1.6rem;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
p { margin: 0; }
}
.missing {
background-color: var(--gray-soft-light);
}
}
}
}

View File

@ -0,0 +1,62 @@
.child-item {
width: 100%;
display: grid;
grid-template-columns: min-content 1fr;
align-items: flex-start;
gap: 1.6rem 2.4rem;
background-color: var(--gray-soft-lightest);
&.lg {
padding: 1.6rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
}
&.sm {
.actions button {
height: 3rem !important;
min-height: auto;
}
}
& > div:not(.actions) {
display: flex;
flex-direction: column;
span {
@include text-xs;
color: var(--gray-hard-light);
}
}
p {
margin: 0;
@include text-base(600);
}
&.sm p {
@include text-sm(500);
}
.status {
grid-row: 1/5;
align-self: stretch;
display: flex;
align-items: center;
}
&.is-validated .status svg {
color: var(--success-dark);
}
.actions {
align-self: center;
justify-self: flex-end;
}
@media (min-width: 768px) {
grid-template-columns: min-content repeat(3, 1fr);
.status { grid-row: auto; }
.actions {
grid-column-end: -1;
display: flex;
}
}
@media (min-width: 1024px) {
grid-template-columns: min-content repeat(3, 1fr) max-content;
}
}

View File

@ -0,0 +1,20 @@
.children-dashboard {
max-width: 1600px;
margin: 0 auto;
padding-bottom: 6rem;
@include grid-col(12);
gap: 3.2rem;
align-items: flex-start;
header {
@include header();
padding-bottom: 0;
grid-column: 2 / -2;
}
.children-list {
grid-column: 2 / -2;
display: flex;
flex-direction: column;
gap: 1.6rem;
}
}

View File

@ -13,6 +13,8 @@
margin-bottom: 1.6rem;
}
.placeholder { color: var(--gray-soft-darkest); }
.actions {
margin-left: auto;
display: flex;

View File

@ -2,3 +2,108 @@
width: 16px;
height: 21px;
}
.members-list {
width: 100%;
margin: 2.4rem 0;
display: flex;
flex-direction: column;
gap: 2.4rem;
&-item {
width: 100%;
padding: 1.6rem;
display: grid;
grid-template-columns: 48px 1fr;
gap: 0 2.4rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
&.is-validated .left-col .status svg { color: var(--success-dark); }
&.is-incomplet .left-col .status svg { color: var(--alert); }
.left-col {
grid-row: span 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
.status {
display: flex;
align-items: center;
}
.toggle {
height: fit-content;
background-color: var(--gray-soft);
border: none;
svg { transition: transform 0.5s ease-in-out; }
&.open svg { transform: rotate(-180deg); }
}
}
.member {
display: flex;
flex-direction: column;
gap: 2.4rem;
&-infos {
flex: 1;
display: grid;
gap: 1.6rem;
& > div:not(.actions) {
display: flex;
flex-direction: column;
span {
@include text-xs;
color: var(--gray-hard-light);
}
}
p {
margin: 0;
@include text-base(600);
line-height: 1.5;
}
}
&-actions {
align-self: flex-end;
}
}
.member-children {
max-height: 0;
display: flex;
flex-direction: column;
gap: 1.6rem;
overflow-y: hidden;
transition: max-height 0.5s ease-in-out;
&.open {
max-height: 17rem;
overflow-y: auto;
}
hr { margin: 1.6rem 0 0; }
.child-item:last-of-type { padding-bottom: 0; }
}
@media (min-width: 768px) {
.member-infos {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.member {
flex-direction: row;
&-actions {
align-self: center;
}
}
}
@media (min-width: 1220px) {
.member-infos {
grid-template-columns: repeat(3, 1fr);
}
}
}
}

View File

@ -37,6 +37,7 @@
}
.title {
margin-bottom: 1.6rem;
display: flex;
flex-direction: row;
justify-content: space-between;
@ -64,12 +65,28 @@
width: 20%;
}
}
tbody {
.buttons {
.edit-btn {
margin-right: 5px;
}
.document-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min-content, 50rem));
gap: 1.6rem;
&-item {
display: flex;
flex-direction: column;
gap: 0.8rem;
.type {
@include text-sm;
}
.file {
padding: 0.8rem 0.8rem 0.8rem 1.6rem;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
p { margin: 0; }
}
}
}

View File

@ -1,4 +1,4 @@
.user-validation {
.user-validation, .child-validation {
label {
margin-bottom: 0;
vertical-align: middle;
@ -9,3 +9,7 @@
vertical-align: middle;
}
}
.child-validation {
margin: 0 0 2rem;
text-align: center;
}

View File

@ -29,7 +29,11 @@
<tbody>
<tr ng-repeat="reservation in reservations" ng-class="{'disabled': isCancelled(reservation)}">
<td class="text-c">
<a ui-sref="app.logged.members_show({id: reservation.user_id})">{{ reservation.user_full_name }} </a>
<a ui-sref="app.logged.members_show({id: reservation.user_id})" ng-if="event.event_type === 'standard'">{{ reservation.user_full_name }} </a>
<div ng-repeat="bu in reservation.booking_users_attributes">
<span ng-if="bu.booked_type !== 'User'">{{bu.name}}</span>
<a ui-sref="app.logged.members_show({id: bu.booked_id})" ng-if="bu.booked_type === 'User'">{{bu.name}}</a>
</div>
</td>
<td>{{ reservation.created_at | amDateFormat:'LL LTS' }}</td>
<td>

View File

@ -62,10 +62,15 @@
</uib-tab>
<uib-tab heading="{{ 'app.shared.user_admin.children' | translate }}" ng-if="$root.settings.familyAccount">
<children-dashboard user="user" operator="currentUser" admin-panel="true" on-success="onSuccess" on-error="onError" />
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.supporting_documents' | translate }}" ng-show="hasProofOfIdentityTypes">
<supporting-documents-validation
operator="currentUser"
member="user"
document-type="User"
on-error="onError"
on-success="onSuccess" />
</uib-tab>
@ -208,10 +213,20 @@
<span ng-if="r.nb_reserve_places > 0">
<br/>
<span translate translate-values="{ NUMBER: r.nb_reserve_places }">{{ 'app.admin.members_edit.NUMBER_full_price_tickets_reserved' }}</span>
<span ng-repeat="bu in r.booking_users_attributes | filter:{event_price_category_id:null}">
<br/>
<span ng-if="bu.booked_type !== 'User'">{{bu.name}}</span>
<a ui-sref="app.logged.members_show({id: bu.booked_id})" ng-if="bu.booked_type === 'User'">{{bu.name}}</a>
</span>
</span>
<span ng-repeat="ticket in r.tickets">
<br/>
<span translate translate-values="{ NUMBER: ticket.booked, NAME: ticket.price_category.name }">{{ 'app.admin.members_edit.NUMBER_NAME_tickets_reserved' }}</span>
<span ng-repeat="bu in r.booking_users_attributes | filter:{event_price_category_id:ticket.event_price_category_id}">
<br/>
<span ng-if="bu.booked_type !== 'User'">{{bu.name}}</span>
<a ui-sref="app.logged.members_show({id: bu.booked_id})" ng-if="bu.booked_type === 'User'">{{bu.name}}</a>
</span>
</span>
</li>
</ul>

View File

@ -17,11 +17,12 @@
</div>
</div>
</div>
<div class="col-md-12">
<button type="button" class="btn btn-warning m-t m-b" ui-sref="app.admin.members_new" translate>
<button type="button" class="btn btn-warning m-b" ui-sref="app.admin.members_new" translate>
{{ 'app.admin.members.add_a_new_member' }}
</button>
<div class="pull-right exports-buttons" ng-show="isAuthorized('admin')">
<div class="pull-right exports-buttons m-b" ng-show="isAuthorized('admin')">
<a class="btn btn-default" ng-href="api/members/export_members.xlsx" target="export-frame" ng-click="alertExport('members')">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.members' | translate }}
</a>
@ -34,46 +35,10 @@
<iframe name="export-frame" height="0" width="0" class="none"></iframe>
</div>
<table class="table members-list">
<thead>
<tr>
<th style="width:4%" class="hidden-xs" ng-if="enableUserValidationRequired"></th>
<th style="width:8%" ng-show="displayUsername"><a ng-click="setOrderMember('username')">{{ 'app.admin.members.username' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='username', 'fa fa-sort-alpha-desc': member.order=='-username', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('last_name')">{{ 'app.admin.members.surname' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='last_name', 'fa fa-sort-alpha-desc': member.order=='-last_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('first_name')">{{ 'app.admin.members.first_name' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='first_name', 'fa fa-sort-alpha-desc': member.order=='-first_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="hidden-xs"><a ng-click="setOrderMember('email')">{{ 'app.admin.members.email' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='email', 'fa fa-sort-alpha-desc': member.order=='-email', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:8%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('phone')">{{ 'app.admin.members.phone' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-numeric-asc': member.order=='phone', 'fa fa-sort-numeric-desc': member.order=='-phone', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm"><a ng-click="setOrderMember('group')">{{ 'app.admin.members.user_type' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='group', 'fa fa-sort-alpha-desc': member.order=='-group', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('plan')">{{ 'app.admin.members.subscription' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='plan', 'fa fa-sort-alpha-desc': member.order=='-plan', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:12%" class="buttons-col"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="m in members">
<td class="text-center" ng-if="enableUserValidationRequired">
<span ng-class="{ 'text-success': !!m.validated_at }"><i class="fa fa-user-check"></i></span>
</td>
<td class="text-c" ng-show="displayUsername">{{ m.username }}</td>
<td class="text-c">{{ m.profile.last_name }}</td>
<td class="text-c">{{ m.profile.first_name }}</td>
<td class="hidden-xs">{{ m.email }}</td>
<td class="hidden-xs hidden-sm hidden-md">{{ m.profile.phone }}</td>
<td class="text-u-c text-sm hidden-xs hidden-sm">{{ m.group.name }}</td>
<td class="hidden-xs hidden-sm hidden-md">{{ m.subscribed_plan | humanReadablePlanName }}</td>
<td>
<div class="buttons">
<button class="btn btn-default edit-member" ui-sref="app.admin.members_edit({id: m.id})">
<i class="fa fa-edit"></i>
</button>
<button class="btn btn-danger delete-member" ng-click="deleteMember(m.id)" ng-show="isAuthorized('admin')">
<i class="fa fa-trash"></i>
</button>
<span class="label label-danger text-white" ng-show="m.need_completion" translate>{{ 'app.shared.user_admin.incomplete_profile' }}</span>
<div>
<members-list members="members" on-success="onSuccess" on-error="onError" operator="currentUser" on-delete-member="deleteMember" on-deleted-child="onDeletedChild" on-updated-child="onUpdatedChild" />
</div>
</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-warning show-more" ng-click="showNextMembers()" ng-hide="member.noMore"><i class="fa fa-search-plus" aria-hidden="true"></i> {{ 'app.admin.members.display_more_users' | translate }}</button>
</div>

View File

@ -51,6 +51,30 @@
<user-validation-setting on-success="onSuccess" on-error="onError" />
</div>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.family_account' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.family_account_info_html' | translate"></p>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="'family_account'"
settings="allSettings"
label="'app.admin.settings.enable_family_account' | translate"
on-success="onSuccess"
on-error="onError">
</div>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="'child_validation_required'"
settings="allSettings"
label="'app.admin.settings.child_validation_required_label' | translate"
on-success="onSuccess"
on-error="onError">
</div>
</div>
<div class="row">
<div class="col-md-12">
<supporting-documents-types-list on-success="onSuccess" on-error="onError" document-type="'Child'" />
</div>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.captcha' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.captcha_info_html' | translate"></p>
@ -156,4 +180,4 @@
</div>
<supporting-documents-types-list on-success="onSuccess" on-error="onError"/>
<supporting-documents-types-list on-success="onSuccess" on-error="onError" document-type="'User'" />

View File

@ -0,0 +1,11 @@
<div>
<section class="heading">
<div class="row no-gutter">
<ng-include src="'/dashboard/nav.html'"></ng-include>
</div>
</section>
<children-dashboard user="currentUser" operator="currentUser" on-success="onSuccess" on-error="onError" />
</div>

View File

@ -26,12 +26,20 @@
translate-values="{NUMBER: r.nb_reserve_places}">
{{ 'app.logged.dashboard.events.NUMBER_normal_places_reserved' }}
</span>
<span ng-repeat="bu in r.booking_users_attributes | filter:{event_price_category_id:null}">
<br/>
<span>{{bu.name}}</span>
</span>
<span ng-repeat="ticket in r.tickets">
<br/>
<span translate
translate-values="{NUMBER: ticket.booked, NAME: ticket.price_category.name}">
{{ 'app.logged.dashboard.events.NUMBER_of_NAME_places_reserved' }}
</span>
<span ng-repeat="bu in r.booking_users_attributes | filter:{event_price_category_id:ticket.event_price_category_id}">
<br/>
<span>{{bu.name}}</span>
</span>
</span>
</li>
</ul>

View File

@ -11,6 +11,7 @@
<h4 class="m-l text-sm" translate>{{ 'app.public.common.dashboard' }}</h4>
<ul class="nav-page nav nav-pills text-u-c text-sm">
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.profile" translate>{{ 'app.public.common.my_profile' }}</a></li>
<li ng-show="$root.settings.familyAccount" ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.children" translate>{{ 'app.public.common.my_children' }}</a></li>
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.settings" translate>{{ 'app.public.common.my_settings' }}</a></li>
<li ng-if="!isAuthorized(['admin', 'manager']) && hasProofOfIdentityTypes" ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.supporting_document_files" translate>{{ 'app.public.common.my_supporting_documents_files' }}</a></li>
<li ui-sref-active="active"><a class="text-black" ui-sref="app.logged.dashboard.projects" translate>{{ 'app.public.common.my_projects' }}</a></li>

View File

@ -49,7 +49,7 @@
<div class="col-sm-12 col-md-12 col-lg-4">
<section class="widget panel b-a m" ng-if="event.event_files_attributes">
<section class="widget panel b-a m" ng-if="event.event_files_attributes.length">
<div class="panel-heading b-b">
<span class="badge bg-warning pull-right">{{event.event_files_attributes.length}}</span>
<h3 translate>{{ 'app.public.events_show.downloadable_documents' }}</h3>
@ -72,8 +72,11 @@
</div>
<div class="panel-content wrapper">
<div>
<span ng-if="event.event_type === 'nominative'" class="v-middle badge text-base bg-event" translate="">{{ 'app.public.events_show.event_type.nominative' }}</span>
<span ng-if="event.event_type === 'family'" class="v-middle badge text-base bg-event" translate="">{{ 'app.public.events_show.event_type.family' }}</span>
</div>
<h5>{{event.category.name}}</h5>
<dl class="text-sm">
<dt ng-repeat="theme in event.event_themes">
<i class="fa fa-tags" aria-hidden="true"></i> {{theme.name}}
@ -116,16 +119,78 @@
<div class="row">
<label class="col-sm-6 control-label">{{ 'app.public.events_show.full_price_' | translate }} <span class="text-blue">{{event.amount | currency}}</span></label>
<div class="col-sm-6">
<select ng-model="reserve.nbReservePlaces" ng-change="changeNbPlaces()" ng-options="i for i in reserve.nbPlaces.normal">
<select ng-model="reserve.nbReservePlaces" ng-change="changeNbPlaces('normal')" ng-options="i for i in reserve.nbPlaces.normal">
</select> {{ 'app.public.events_show.ticket' | translate:{NUMBER:reserve.nbReservePlaces} }}
</div>
<div class="col-sm-12 m-b" ng-if="event.event_type === 'nominative' && reserve.nbReservePlaces > 0">
<div ng-repeat="user in reserve.bookingUsers.normal">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<input type="text" class="form-control" ng-model="user.name" ng-required="true">
</div>
</div>
<div class="col-sm-12 m-b" ng-if="ctrl.member.id && event.event_type === 'family' && reserve.nbReservePlaces > 0">
<div ng-repeat="user in reserve.bookingUsers.normal">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<select ng-model="user.booked"
ng-options="option.name for option in user.bookedUsers track by option.key"
ng-change="changeBookedUser()"
name="booked"
ng-required="true"
class="form-control">
<option value=""></option>
</select>
<uib-alert type="danger" ng-if="enableChildValidationRequired && user.booked && user.booked.type === 'Child' && !user.booked.validatedAt" style="margin-bottom: 0.8rem;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_validation_required_alert' }}</span>
</span>
</uib-alert>
<uib-alert type="danger" ng-if="user.booked && user.booked.type === 'Child' && !isUnder18YearsAgo(user.booked.birthday)" style="margin-bottom: 0.8rem;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_birthday_must_be_under_18_years_ago_alert' }}</span>
</span>
</uib-alert>
</div>
</div>
</div>
<div class="row" ng-repeat="price in event.event_price_categories_attributes">
<label class="col-sm-6 control-label">{{price.category.name}} : <span class="text-blue">{{price.amount | currency}}</span></label>
<div class="col-sm-6">
<select ng-model="reserve.tickets[price.id]" ng-change="changeNbPlaces()" ng-options="i for i in reserve.nbPlaces[price.id]">
<select ng-model="reserve.tickets[price.id]" ng-change="changeNbPlaces(price.id)" ng-options="i for i in reserve.nbPlaces[price.id]">
</select> {{ 'app.public.events_show.ticket' | translate:{NUMBER:reserve.tickets[price.id]} }}
</div>
<div class="col-sm-12 m-b" ng-if="event.event_type === 'nominative' && reserve.tickets[price.id] > 0">
<div ng-repeat="user in reserve.bookingUsers[price.id]">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<input type="text" class="form-control" ng-model="user.name" ng-required="true">
</div>
</div>
<div class="col-sm-12 m-b" ng-if="ctrl.member.id && event.event_type === 'family' && reserve.tickets[price.id] > 0">
<div ng-repeat="user in reserve.bookingUsers[price.id]">
<label class="" translate>{{ 'app.public.events_show.last_name_and_first_name '}}</label>
<select ng-model="user.booked"
ng-options="option.name for option in user.bookedUsers track by option.key"
ng-change="changeBookedUser()"
name="booked"
ng-required="true"
class="form-control">
<option value=""></option>
</select>
<uib-alert type="danger" ng-if="enableChildValidationRequired && user.booked && user.booked.type === 'Child' && !user.booked.validatedAt">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_validation_required_alert' }}</span>
</p>
</uib-alert>
<uib-alert type="danger" ng-if="user.booked && user.booked.type === 'Child' && !isUnder18YearsAgo(user.booked.birthday)">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.cart.child_birthday_must_be_under_18_years_ago_alert' }}</span>
</p>
</uib-alert>
</div>
</div>
</div>
<div ng-show="currentUser.role == 'admin'" class="m-t">
@ -157,7 +222,7 @@
<div ng-hide="isCancelled(reservation)" class="well well-warning">
<div class="font-sbold text-u-c text-sm">{{ 'app.public.events_show.you_booked_DATE' | translate:{DATE:(reservation.created_at | amDateFormat:'L LT')} }}</div>
<div class="font-sbold text-sm" ng-if="reservation.nb_reserve_places > 0">{{ 'app.public.events_show.full_price_' | translate }} {{reservation.nb_reserve_places}} {{ 'app.public.events_show.ticket' | translate:{NUMBER:reservation.nb_reserve_places} }}</div>
<div class="font-sbold text-sm" ng-repeat="ticket in reservation.tickets">
<div class="font-sbold text-sm" ng-repeat="ticket in reservation.tickets_attributes">
{{ticket.event_price_category.price_category.name}} : {{ticket.booked}} {{ 'app.public.events_show.ticket' | translate:{NUMBER:ticket.booked} }}
</div>
<div class="clear" ng-if="event.recurrence_events.length > 0 && reservationCanModify(reservation)">
@ -190,11 +255,11 @@
</div>
</div>
<div class="panel-footer no-padder ng-scope" ng-if="event.amount">
<div class="panel-footer no-padder ng-scope" ng-if="event.amount && reservationIsValid()">
<button class="btn btn-valid btn-info btn-block p-l btn-lg text-u-c r-b text-base" ng-click="payEvent()" ng-if="reserve.totalSeats > 0">{{ 'app.public.events_show.confirm_and_pay' | translate }} {{reserve.amountTotal | currency}}</button>
</div>
<div class="panel-footer no-padder ng-scope" ng-if="event.amount == 0">
<div class="panel-footer no-padder ng-scope" ng-if="event.amount == 0 && reservationIsValid()">
<button class="btn btn-valid btn-info btn-block p-l btn-lg text-u-c r-b text-base" ng-click="validReserveEvent()" ng-if="reserve.totalSeats > 0" ng-disabled="attempting">{{ 'app.shared.buttons.confirm' | translate }}</button>
</div>

View File

@ -12,8 +12,11 @@
</ui-select-choices>
</ui-select>
{{member}}
<div class="alert alert-danger m-t" style="margin-bottom: 0 !important;" ng-if="enableUserValidationRequired && ctrl.member.id && !ctrl.member.validated_at">
<uib-alert type="danger" ng-if="enableUserValidationRequired && ctrl.member.id && !ctrl.member.validated_at" style="margin-bottom: 0;">
<span class="text-sm">
<i class="fa fa-warning"></i>
<span translate>{{ 'app.shared.member_select.member_not_validated' }}</span>
</div>
</span>
</uib-alert>
</div>
</div>

View File

@ -40,6 +40,7 @@
</a>
<ul uib-dropdown-menu class="animated fadeInRight">
<li><a ui-sref="app.logged.dashboard.profile" translate>{{ 'app.public.common.my_profile' }}</a></li>
<li ng-if="$root.settings.familyAccount"><a ui-sref="app.logged.dashboard.children" translate>{{ 'app.public.common.my_children' }}</a></li>
<li><a ui-sref="app.logged.dashboard.settings" translate>{{ 'app.public.common.my_settings' }}</a></li>
<li ng-if="!isAuthorized(['admin', 'manager']) && hasProofOfIdentityTypes"><a ui-sref="app.logged.dashboard.supporting_document_files" translate>{{ 'app.public.common.my_supporting_documents_files' }}</a></li>
<li><a ui-sref="app.logged.dashboard.projects" translate>{{ 'app.public.common.my_projects' }}</a></li>

View File

@ -41,7 +41,7 @@ module ExcelHelper
unless type.simple
data.push hit['_source']['stat']
styles.push nil
types.push :string
types.push :float
end
[data, styles, types]

View File

@ -167,6 +167,8 @@ module SettingsHelper
user_validation_required
user_validation_required_list
show_username_in_admin_list
family_account
child_validation_required
store_module
store_withdrawal_instructions
store_hidden

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# BookingUser is a class for save the booking info of reservation
# booked can be a User or a Child (polymorphic)
class BookingUser < ApplicationRecord
belongs_to :reservation
belongs_to :booked, polymorphic: true
belongs_to :event_price_category
end

5
app/models/cart_item.rb Normal file
View File

@ -0,0 +1,5 @@
module CartItem
def self.table_name_prefix
"cart_item_"
end
end

View File

@ -13,6 +13,11 @@ class CartItem::EventReservation < CartItem::Reservation
foreign_type: 'cart_item_type', as: :cart_item
accepts_nested_attributes_for :cart_item_reservation_slots
has_many :cart_item_event_reservation_booking_users, class_name: 'CartItem::EventReservationBookingUser', dependent: :destroy,
inverse_of: :cart_item_event_reservation,
foreign_key: 'cart_item_event_reservation_id'
accepts_nested_attributes_for :cart_item_event_reservation_booking_users
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
@ -63,6 +68,14 @@ class CartItem::EventReservation < CartItem::Reservation
booked: t.booked
}
end,
booking_users_attributes: cart_item_event_reservation_booking_users.map do |b|
{
event_price_category_id: b.event_price_category_id,
booked_type: b.booked_type,
booked_id: b.booked_id,
name: b.name
}
end,
nb_reserve_places: normal_tickets,
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# A relation table between a pending event reservation and reservation users for this event
class CartItem::EventReservationBookingUser < ApplicationRecord
self.table_name = 'cart_item_event_reservation_booking_users'
belongs_to :cart_item_event_reservation, class_name: 'CartItem::EventReservation', inverse_of: :cart_item_event_reservation_booking_users
belongs_to :event_price_category, inverse_of: :cart_item_event_reservation_tickets
belongs_to :booked, polymorphic: true
end

24
app/models/child.rb Normal file
View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
# Child is a modal for a child of a user
class Child < ApplicationRecord
belongs_to :user
has_many :supporting_document_files, as: :supportable, dependent: :destroy
accepts_nested_attributes_for :supporting_document_files, allow_destroy: true, reject_if: :all_blank
has_many :supporting_document_refusals, as: :supportable, dependent: :destroy
validates :first_name, presence: true
validates :last_name, presence: true
# validates :email, presence: true, format: { with: Devise.email_regexp }
validate :validate_age
# birthday should less than 18 years ago
def validate_age
errors.add(:birthday, I18n.t('.errors.messages.birthday_less_than_18_years_ago')) if birthday.blank? || birthday < 18.years.ago
end
def full_name
"#{(first_name || '').humanize.titleize} #{(last_name || '').humanize.titleize}"
end
end

View File

@ -8,7 +8,7 @@ module StatConcern
attribute :type, String
attribute :subType, String
attribute :date, String
attribute :stat, Integer
attribute :stat, Float
attribute :userId, Integer
attribute :gender, String
attribute :age, Integer

View File

@ -33,6 +33,8 @@ class Event < ApplicationRecord
has_many :cart_item_event_reservations, class_name: 'CartItem::EventReservation', dependent: :destroy
validates :event_type, inclusion: { in: %w[standard nominative family] }, presence: true
attr_accessor :recurrence, :recurrence_end_at
before_save :update_nb_free_places

View File

@ -40,6 +40,8 @@ class Project < ApplicationRecord
has_many :project_steps, dependent: :destroy
accepts_nested_attributes_for :project_steps, allow_destroy: true
has_many :abuses, as: :signaled, dependent: :destroy, class_name: 'Abuse'
# validations
validates :author, :name, presence: true

View File

@ -23,6 +23,9 @@ class Reservation < ApplicationRecord
has_many :prepaid_pack_reservations, dependent: :destroy
has_many :booking_users, dependent: :destroy
accepts_nested_attributes_for :booking_users, allow_destroy: true
validates :reservable_id, :reservable_type, presence: true
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }
validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) }

View File

@ -6,7 +6,7 @@ class SupportingDocumentFile < ApplicationRecord
mount_uploader :attachment, SupportingDocumentFileUploader
belongs_to :supporting_document_type
belongs_to :user
belongs_to :supportable, polymorphic: true
validates :attachment, file_size: { maximum: ENV.fetch('MAX_SUPPORTING_DOCUMENT_FILE_SIZE', 5.megabytes).to_i }
end

View File

@ -2,7 +2,7 @@
# An admin can mark an uploaded document as refused, this will notify the member
class SupportingDocumentRefusal < ApplicationRecord
belongs_to :user
belongs_to :supportable, polymorphic: true
belongs_to :operator, class_name: 'User', inverse_of: :supporting_document_refusals
has_many :supporting_document_refusals_types, dependent: :destroy
has_many :supporting_document_types, through: :supporting_document_refusals_types

View File

@ -8,4 +8,6 @@ class SupportingDocumentType < ApplicationRecord
has_many :supporting_document_refusals_types, dependent: :destroy
has_many :supporting_document_refusals, through: :supporting_document_refusals_types
validates :document_type, presence: true, inclusion: { in: %w[User Child] }
end

View File

@ -47,12 +47,15 @@ class User < ApplicationRecord
has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify, inverse_of: :user
has_many :supporting_document_files, dependent: :destroy
has_many :supporting_document_refusals, dependent: :destroy
has_many :supporting_document_files, as: :supportable, dependent: :destroy
has_many :supporting_document_refusals, as: :supportable, dependent: :destroy
has_many :notifications, as: :receiver, dependent: :destroy
has_many :notification_preferences, dependent: :destroy
has_many :children, dependent: :destroy
accepts_nested_attributes_for :children, allow_destroy: true
# fix for create admin user
before_save do
email&.downcase!

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# Check the access policies for API::ChildrenController
class ChildPolicy < ApplicationPolicy
def index?
!user.organization?
end
def create?
!user.organization? && user.id == record.user_id
end
def show?
user.privileged? || user.id == record.user_id
end
def update?
user.privileged? || user.id == record.user_id
end
def destroy?
user.privileged? || user.id == record.user_id
end
def validate?
user.privileged?
end
end

View File

@ -46,7 +46,7 @@ class SettingPolicy < ApplicationPolicy
external_id machines_banner_active machines_banner_text machines_banner_cta_active machines_banner_cta_label
machines_banner_cta_url trainings_banner_active trainings_banner_text trainings_banner_cta_active trainings_banner_cta_label
trainings_banner_cta_url events_banner_active events_banner_text events_banner_cta_active events_banner_cta_label
events_banner_cta_url]
events_banner_cta_url family_account child_validation_required]
end
##

View File

@ -6,15 +6,11 @@ class SupportingDocumentFilePolicy < ApplicationPolicy
user.privileged?
end
def create?
user.privileged? or record.user_id == user.id
%w[create update download].each do |action|
define_method "#{action}?" do
user.privileged? ||
(record.supportable_type == 'User' && record.supportable_id.to_i == user.id) ||
(record.supportable_type == 'Child' && user.children.exists?(id: record.supportable_id.to_i))
end
def update?
user.privileged? or record.user_id == user.id
end
def download?
user.privileged? or record.user_id == user.id
end
end

View File

@ -171,7 +171,8 @@ class CartService
event: reservable,
cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes],
normal_tickets: cart_item[:nb_reserve_places],
cart_item_event_reservation_tickets_attributes: cart_item[:tickets_attributes] || {})
cart_item_event_reservation_tickets_attributes: cart_item[:tickets_attributes] || {},
cart_item_event_reservation_booking_users_attributes: cart_item[:booking_users_attributes] || {})
when Space
CartItem::SpaceReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
# ChildService
class ChildService
def self.create(child)
if child.save
NotificationCenter.call type: 'notify_admin_child_created',
receiver: User.admins_and_managers,
attached_object: child
all_files_are_upload = true
SupportingDocumentType.where(document_type: 'Child').each do |sdt|
file = sdt.supporting_document_files.find_by(supportable: child)
all_files_are_upload = false if file.nil? || file.attachment_identifier.nil?
end
if all_files_are_upload
NotificationCenter.call type: 'notify_admin_user_child_supporting_document_files_created',
receiver: User.admins_and_managers,
attached_object: child
end
return true
end
false
end
def self.update(child, child_params)
if child.update(child_params)
all_files_are_upload = true
SupportingDocumentType.where(document_type: 'Child').each do |sdt|
file = sdt.supporting_document_files.find_by(supportable: child)
all_files_are_upload = false if file.nil? || file.attachment_identifier.nil?
end
if all_files_are_upload
NotificationCenter.call type: 'notify_admin_user_child_supporting_document_files_updated',
receiver: User.admins_and_managers,
attached_object: child
end
return true
end
false
end
def self.validate(child, is_valid)
is_updated = child.update(validated_at: is_valid ? Time.current : nil)
if is_updated
if is_valid
NotificationCenter.call type: 'notify_user_child_is_validated',
receiver: child.user,
attached_object: child
else
NotificationCenter.call type: 'notify_user_child_is_invalidated',
receiver: child.user,
attached_object: child
end
end
is_updated
end
end

View File

@ -33,8 +33,8 @@ class EventService
end
def date_range(starting, ending, all_day)
start_date = Time.zone.parse(starting[:date])
end_date = Time.zone.parse(ending[:date])
start_date = Date.parse(starting[:date])
end_date = Date.parse(ending[:date])
start_time = starting[:time] ? Time.zone.parse(starting[:time]) : nil
end_time = ending[:time] ? Time.zone.parse(ending[:time]) : nil
if all_day || start_time.nil? || end_time.nil?

View File

@ -4,7 +4,7 @@
class Members::ListService
class << self
def list(params)
@query = User.includes(:profile, :group, :statistic_profile)
@query = User.includes(:profile, :group, :statistic_profile, :children)
.joins(:profile,
:statistic_profile,
:group,
@ -27,10 +27,10 @@ class Members::ListService
# ILIKE => PostgreSQL case-insensitive LIKE
if params[:search].size.positive?
@query = @query.where('users.username ILIKE :search OR ' \
'profiles.first_name ILIKE :search OR ' \
'profiles.last_name ILIKE :search OR ' \
"profiles.first_name || ' ' || profiles.last_name ILIKE :search OR " \
'profiles.phone ILIKE :search OR ' \
'email ILIKE :search OR ' \
'users.email ILIKE :search OR ' \
"children.first_name || ' ' || children.last_name ILIKE :search OR " \
'groups.name ILIKE :search OR ' \
'plans.base_name ILIKE :search', search: "%#{params[:search]}%")
end

View File

@ -39,11 +39,11 @@ module Statistics::Concerns::HelpersConcern
def difference_in_hours(start_at, end_at)
if start_at.to_date == end_at.to_date
((end_at - start_at) / 3600.0).to_i
((end_at - start_at) / 3600.0).to_f
else
end_at_to_start_date = end_at.change(year: start_at.year, month: start_at.month, day: start_at.day)
hours = ((end_at_to_start_date - start_at) / 60 / 60).to_i
hours = ((end_at.to_date - start_at.to_date).to_i + 1) * hours if end_at.to_date > start_at.to_date
hours = ((end_at_to_start_date - start_at) / 60 / 60).to_f
hours = ((end_at.to_date - start_at.to_date).to_f + 1) * hours if end_at.to_date > start_at.to_date
hours
end
end

View File

@ -57,7 +57,7 @@ class Statistics::FetcherService
machine_type: r.reservable.friendly_id,
machine_name: r.reservable.name,
slot_dates: r.slots.map(&:start_at).map(&:to_date),
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_i,
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_f,
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))
yield result
end
@ -81,7 +81,7 @@ class Statistics::FetcherService
space_name: r.reservable.name,
space_type: r.reservable.slug,
slot_dates: r.slots.map(&:start_at).map(&:to_date),
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_i,
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_f,
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))
yield result
end

View File

@ -4,23 +4,32 @@
class SupportingDocumentFileService
def self.list(operator, filters = {})
files = []
if filters[:user_id].present? && (operator.privileged? || filters[:user_id].to_i == operator.id)
files = SupportingDocumentFile.where(user_id: filters[:user_id])
if filters[:supportable_id].present? && can_list?(operator, filters[:supportable_id], filters[:supportable_type])
files = SupportingDocumentFile.where(supportable_id: filters[:supportable_id], supportable_type: filters[:supportable_type])
end
files
end
def self.can_list?(operator, supportable_id, supportable_type)
operator.privileged? ||
(supportable_type == 'User' && supportable_id.to_i == operator.id) ||
(supportable_type == 'Child' && operator.children.exists?(id: supportable_id.to_i))
end
def self.create(supporting_document_file)
saved = supporting_document_file.save
if saved
user = User.find(supporting_document_file.user_id)
all_files_are_upload = true
if supporting_document_file.supportable_type == 'User'
user = supporting_document_file.supportable
user.group.supporting_document_types.each do |type|
file = type.supporting_document_files.find_by(user_id: supporting_document_file.user_id)
file = type.supporting_document_files.find_by(supportable_id: supporting_document_file.supportable_id,
supportable_type: supporting_document_file.supportable_type)
all_files_are_upload = false unless file
end
if all_files_are_upload
end
if all_files_are_upload && (supporting_document_file.supportable_type == 'User')
NotificationCenter.call type: 'notify_admin_user_supporting_document_files_created',
receiver: User.admins_and_managers,
attached_object: user
@ -32,13 +41,16 @@ class SupportingDocumentFileService
def self.update(supporting_document_file, params)
updated = supporting_document_file.update(params)
if updated
user = supporting_document_file.user
all_files_are_upload = true
if supporting_document_file.supportable_type == 'User'
user = supporting_document_file.supportable
user.group.supporting_document_types.each do |type|
file = type.supporting_document_files.find_by(user_id: supporting_document_file.user_id)
file = type.supporting_document_files.find_by(supportable_id: supporting_document_file.supportable_id,
supportable_type: supporting_document_file.supportable_type)
all_files_are_upload = false unless file
end
if all_files_are_upload
end
if all_files_are_upload && (supporting_document_file.supportable_type == 'User')
NotificationCenter.call type: 'notify_admin_user_supporting_document_files_updated',
receiver: User.admins_and_managers,
attached_object: supporting_document_file

View File

@ -4,7 +4,10 @@
class SupportingDocumentRefusalService
def self.list(filters = {})
refusals = []
refusals = SupportingDocumentRefusal.where(user_id: filters[:user_id]) if filters[:user_id].present?
if filters[:supportable_id].present?
refusals = SupportingDocumentRefusal.where(supportable_id: filters[:supportable_id],
supportable_type: filters[:supportable_type])
end
refusals
end
@ -12,12 +15,22 @@ class SupportingDocumentRefusalService
saved = supporting_document_refusal.save
if saved
case supporting_document_refusal.supportable_type
when 'User'
NotificationCenter.call type: 'notify_admin_user_supporting_document_refusal',
receiver: User.admins_and_managers,
attached_object: supporting_document_refusal
NotificationCenter.call type: 'notify_user_supporting_document_refusal',
receiver: supporting_document_refusal.user,
receiver: supporting_document_refusal.supportable,
attached_object: supporting_document_refusal
when 'Child'
NotificationCenter.call type: 'notify_admin_user_child_supporting_document_refusal',
receiver: User.admins_and_managers,
attached_object: SupportingDocumentRefusal.last
NotificationCenter.call type: 'notify_user_child_supporting_document_refusal',
receiver: SupportingDocumentRefusal.last.supportable.user,
attached_object: SupportingDocumentRefusal.last
end
end
saved
end

View File

@ -9,7 +9,7 @@ class SupportingDocumentTypeService
group.supporting_document_types.includes(:groups)
else
SupportingDocumentType.all
SupportingDocumentType.where(document_type: filters[:document_type] || 'User')
end
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
json.extract! child, :id, :first_name, :last_name, :email, :birthday, :phone, :user_id, :validated_at
json.supporting_document_files_attributes child.supporting_document_files do |f|
json.id f.id
json.supportable_id f.supportable_id
json.supportable_type f.supportable_type
json.supporting_document_type_id f.supporting_document_type_id
json.attachment f.attachment.file&.filename
json.attachment_name f.attachment_identifier
json.attachment_url f.attachment_identifier ? "/api/supporting_document_files/#{f.id}/download" : nil
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'child', child: @child

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
json.array! @children do |child|
json.partial! 'child', child: child
end

View File

@ -0,0 +1,3 @@
# forzen_string_literal: true
json.partial! 'child', child: @child

View File

@ -0,0 +1,3 @@
# forzen_string_literal: true
json.partial! 'child', child: @child

Some files were not shown because too many files have changed in this diff Show More