1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +01:00

Merge branch 'pre_inscription' into family_compte_pre_inscription

This commit is contained in:
Du Peng 2023-09-11 17:59:14 +02:00
commit 12b1ff5f0e
175 changed files with 3904 additions and 353 deletions

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).where('birthday >= ?', 18.years.ago).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 ChildService.update(@child, 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,8 @@ 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,
:pre_registration, :pre_registration_end_date,
event_theme_ids: [],
event_image_attributes: %i[id attachment],
event_files_attributes: %i[id attachment _destroy],

View File

@ -4,7 +4,7 @@
# Reservations are used for Training, Machine, Space and Event
class API::ReservationsController < API::APIController
before_action :authenticate_user!
before_action :set_reservation, only: %i[show update]
before_action :set_reservation, only: %i[show update confirm_payment]
respond_to :json
def index
@ -34,6 +34,16 @@ class API::ReservationsController < API::APIController
end
end
def confirm_payment
authorize @reservation
invoice = ReservationConfirmPaymentService.new(@reservation, current_user, params[:coupon_code], params[:offered]).call
if invoice
render :show, status: :ok, location: @reservation
else
render json: @reservation.errors, status: :unprocessable_entity
end
end
private
def set_reservation

View File

@ -5,7 +5,7 @@
# availability by Availability.slot_duration, or otherwise globally by Setting.get('slot_duration')
class API::SlotsReservationsController < API::APIController
before_action :authenticate_user!
before_action :set_slots_reservation, only: %i[update cancel]
before_action :set_slots_reservation, only: %i[update cancel validate invalidate]
respond_to :json
def update
@ -23,6 +23,16 @@ class API::SlotsReservationsController < API::APIController
SlotsReservationsService.cancel(@slot_reservation)
end
def validate
authorize @slot_reservation
SlotsReservationsService.validate(@slot_reservation)
end
def invalidate
authorize @slot_reservation
SlotsReservationsService.invalidate(@slot_reservation)
end
private
def set_slots_reservation

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,9 @@ 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);
const [isAcitvePreRegistration, setIsActivePreRegistration] = useState<boolean>(event?.pre_registration);
const [submitting, setSubmitting] = useState<boolean>(false);
useEffect(() => {
EventCategoryAPI.index()
@ -69,6 +72,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(() => {
@ -97,6 +101,11 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
* Callback triggered when the user validates the machine form: handle create or update
*/
const onSubmit: SubmitHandler<Event> = (data: Event) => {
setSubmitting(true);
if (submitting) return;
if (data.pre_registration_end_date?.toString() === 'Invalid Date') {
data.pre_registration_end_date = null;
}
if (action === 'update') {
if (event?.recurrence_events?.length > 0) {
setUpdatingEvent(data);
@ -108,7 +117,7 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
EventAPI.create(data).then(res => {
onSuccess(t(`app.admin.event_form.${action}_success`));
window.location.href = `/#!/events/${res.id}`;
}).catch(onError);
}).catch(onError).finally(() => setSubmitting(false));
}
};
@ -168,11 +177,25 @@ 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>
<h2>{t('app.admin.event_form.ACTION_title', { ACTION: action })}</h2>
<FabButton onClick={handleSubmit(onSubmit)} className="fab-button save-btn is-main">
<FabButton onClick={handleSubmit(onSubmit)} disabled={submitting} className="fab-button save-btn is-main">
{t('app.admin.event_form.save')}
</FabButton>
</header>
@ -203,6 +226,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}
@ -219,6 +248,19 @@ export const EventForm: React.FC<EventFormProps> = ({ action, event, onError, on
formState={formState}
options={ageRangeOptions}
label={t('app.admin.event_form.age_range')} />}
<FormSwitch control={control}
id="pre_registration"
label={t('app.admin.event_form.pre_registration')}
formState={formState}
tooltip={t('app.admin.event_form.pre_registration_help')}
onChange={setIsActivePreRegistration} />
{isAcitvePreRegistration &&
<FormInput id="pre_registration_end_date"
type="date"
register={register}
formState={formState}
label={t('app.admin.event_form.pre_registration_end_date')} />
}
</div>
</section>

View File

@ -0,0 +1,109 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import _ from 'lodash';
import { Reservation } from '../../models/reservation';
import FormatLib from '../../lib/format';
import { IApplication } from '../../models/application';
declare const Application: IApplication;
interface EventReservationItemProps {
reservation: Reservation;
}
/**
* event reservation item component
*/
export const EventReservationItem: React.FC<EventReservationItemProps> = ({ reservation }) => {
const { t } = useTranslation('logged');
/**
* Return the formatted localized date of the event
*/
const formatDate = (): string => {
return `${FormatLib.date(reservation.start_at)} ${FormatLib.time(reservation.start_at)} - ${FormatLib.time(reservation.end_at)}`;
};
/**
* Build the ticket for event price category user reservation
*/
const buildTicket = (ticket) => {
return (
<>
<label>{t('app.logged.event_reservation_item.NUMBER_of_NAME_places_reserved', { NUMBER: ticket.booked, NAME: ticket.price_category.name })}</label>
{reservation.booking_users_attributes.filter(u => u.event_price_category_id === ticket.event_price_category_id).map(u => {
return (
<p key={u.id} className='name'>{u.name}</p>
);
})}
</>
);
};
/**
* Return the pre-registration status
*/
const preRegistrationStatus = () => {
if (!_.isBoolean(reservation.is_valid) && !reservation.canceled_at && !reservation.is_paid) {
return t('app.logged.event_reservation_item.in_the_process_of_validation');
} else if (reservation.is_valid && !reservation.canceled_at && !reservation.is_paid && reservation.amount !== 0) {
return t('app.logged.event_reservation_item.settle_your_payment');
} else if (reservation.is_valid && !reservation.canceled_at && !reservation.is_paid && reservation.amount === 0) {
return t('app.logged.event_reservation_item.registered');
} else if (!reservation.is_valid && !reservation.canceled_at) {
return t('app.logged.event_reservation_item.not_validated');
} else if (reservation.is_paid && !reservation.canceled_at && reservation.amount !== 0) {
return t('app.logged.event_reservation_item.paid');
} else if (reservation.is_paid && !reservation.canceled_at && reservation.amount === 0) {
return t('app.logged.event_reservation_item.present');
} else if (reservation.canceled_at) {
return t('app.logged.event_reservation_item.canceled');
}
};
return (
<div className="event-reservation-item">
<div className="event-reservation-item__event">
<div className="infos">
<label>{t('app.logged.event_reservation_item.event')}</label>
<p>{reservation.event_title}</p>
<span className='date'>{formatDate()}</span>
</div>
<div className="types">
{/* {reservation.event_type === 'family' &&
<span className="">{t('app.logged.event_reservation_item.family')}</span>
}
{reservation.event_type === 'nominative' &&
<span className="">{t('app.logged.event_reservation_item.nominative')}</span>
} */}
{reservation.event_pre_registration &&
// eslint-disable-next-line fabmanager/no-bootstrap, fabmanager/no-utilities
<span className="badge text-xs bg-info">{t('app.logged.event_reservation_item.pre_registration')}</span>
}
</div>
</div>
<div className="event-reservation-item__reservation">
<div className='list'>
<label>{t('app.logged.event_reservation_item.NUMBER_normal_places_reserved', { NUMBER: reservation.nb_reserve_places })}</label>
{reservation.booking_users_attributes.filter(u => !u.event_price_category_id).map(u => {
return (
<p key={u.id} className='name'>{u.name}</p>
);
})}
{reservation.tickets.map(ticket => {
return buildTicket(ticket);
})}
</div>
{reservation.event_pre_registration &&
<div className='status'>
<label>{t('app.logged.event_reservation_item.tracking_your_reservation')}</label>
<p className="">{preRegistrationStatus()}</p>
</div>
}
</div>
</div>
);
};
Application.Components.component('eventReservationItem', react2angular(EventReservationItem, ['reservation']));

View File

@ -0,0 +1,181 @@
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() && supportingDocumentsTypes?.length > 0 && <>
<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"
rules={{ required: !sf.attachment }}
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() && supportingDocumentsTypes?.length > 0 && <>
<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() && supportingDocumentsTypes?.length > 0 && <>
<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,65 @@
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> => {
let c: Child = data;
try {
if (child?.id) {
c = await ChildAPI.update(data);
} else {
c = await ChildAPI.create(data);
}
toggleModal();
onSuccess(c, '');
} 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={(msg) => onSuccess(child, msg)}
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: (child: Child, msg: 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((c) => {
onSuccess(c, 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

@ -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,13 +63,15 @@ export const SupportingDocumentsTypeForm: React.FC<SupportingDocumentsTypeFormPr
{t('app.admin.settings.account.supporting_documents_type_form.type_form_info')}
</div>
<form name="supportingDocumentTypeForm">
<div className="field">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.account.supporting_documents_type_form.select_group')}
onChange={handleGroupsChange}
options={buildOptions()}
isMulti />
</div>
{supportingDocumentType?.document_type === 'User' &&
<div className="field">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.account.supporting_documents_type_form.select_group')}
onChange={handleGroupsChange}
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,83 +192,138 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
window.location.href = '/#!/admin/members?tabs=1';
};
return (
<FabPanel className="supporting-documents-types-list" header={<div>
<span>{t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')}</span>
</div>}>
<div className="types-list">
<div className="groups">
<p>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}</p>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_groups_info" />
<FabButton onClick={addGroup}>{t('app.admin.settings.account.supporting_documents_types_list.create_groups')}</FabButton>
</FabAlert>
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>
</div>}>
<div className="types-list">
<div className="groups">
<p>{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}</p>
<FabAlert level="warning">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_groups_info" />
<FabButton onClick={addGroup}>{t('app.admin.settings.account.supporting_documents_types_list.create_groups')}</FabButton>
</FabAlert>
</div>
<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}/>
<table>
<thead>
<tr>
<th className="group-name">
<a onClick={setTypeOrder('group_name')}>
{t('app.admin.settings.account.supporting_documents_types_list.group_name')}
<i className={orderClassName('group_name')} />
</a>
</th>
<th className="name">
<a onClick={setTypeOrder('name')}>
{t('app.admin.settings.account.supporting_documents_types_list.name')}
<i className={orderClassName('name')} />
</a>
</th>
<th className="actions"></th>
</tr>
</thead>
<tbody>
{supportingDocumentsTypes.map(poit => {
return (
<tr key={poit.id}>
<td>{getGroupsNames(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<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>
</td>
</tr>
);
})}
</tbody>
</table>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</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>
<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>
<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>
<SupportingDocumentsTypeModal isOpen={modalIsOpen}
groups={groups}
proofOfIdentityType={supportingDocumentsType}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteSupportingDocumentsTypeModal isOpen={destroyModalIsOpen}
proofOfIdentityTypeId={supportingDocumentsTypeId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table>
<thead>
<tr>
<th className="group-name">
<a onClick={setTypeOrder('group_name')}>
{t('app.admin.settings.account.supporting_documents_types_list.group_name')}
<i className={orderClassName('group_name')} />
</a>
</th>
<th className="name">
<a onClick={setTypeOrder('name')}>
{t('app.admin.settings.account.supporting_documents_types_list.name')}
<i className={orderClassName('name')} />
</a>
</th>
<th className="actions"></th>
</tr>
</thead>
<tbody>
{supportingDocumentsTypes.map(poit => {
return (
<tr key={poit.id}>
<td>{getGroupsNames(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<div className="buttons">
<FabButton className="edit-btn" onClick={editType(poit)}>
<i className="fa fa-edit" />
</FabButton>
<FabButton className="delete-btn" onClick={destroyType(poit.id)}>
<i className="fa fa-trash" />
</FabButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{!hasTypes() && (
<p className="no-types-info">
<HtmlTranslate trKey="app.admin.settings.account.supporting_documents_types_list.no_types" />
</p>
)}
</div>
</FabPanel>
);
);
} 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,104 @@
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`;
};
/**
* member and all his children are validated
*/
const memberIsValidated = (): boolean => {
return member.validated_at && member.children.every((child) => child.validated_at);
};
return (
<div key={member.id} className={`members-list-item ${memberIsValidated() ? '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

@ -436,7 +436,7 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state',
/**
* Controller used in the reservations listing page for a specific event
*/
Application.Controllers.controller('ShowEventReservationsController', ['$scope', 'eventPromise', 'reservationsPromise', function ($scope, eventPromise, reservationsPromise) {
Application.Controllers.controller('ShowEventReservationsController', ['$scope', 'eventPromise', 'reservationsPromise', 'dialogs', 'SlotsReservation', 'growl', '_t', 'Price', 'Wallet', '$uibModal', 'Event', function ($scope, eventPromise, reservationsPromise, dialogs, SlotsReservation, growl, _t, Price, Wallet, $uibModal, Event) {
// retrieve the event from the ID provided in the current URL
$scope.event = eventPromise;
@ -451,6 +451,219 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope',
$scope.isCancelled = function (reservation) {
return !!(reservation.slots_reservations_attributes[0].canceled_at);
};
/**
* Test if the provided reservation has been validated
* @param reservation {Reservation}
* @returns {boolean}
*/
$scope.isValidated = function (reservation) {
return reservation.slots_reservations_attributes[0].is_valid === true || reservation.slots_reservations_attributes[0].is_valid === 'true';
};
/**
* Test if the provided reservation has been invalidated
* @param reservation {Reservation}
* @returns {boolean}
*/
$scope.isInvalidated = function (reservation) {
return reservation.slots_reservations_attributes[0].is_valid === false || reservation.slots_reservations_attributes[0].is_valid === 'false';
};
/**
* Get the price of a reservation
* @param reservation {Reservation}
*/
$scope.reservationAmount = function (reservation) {
let amount = 0;
for (const user of reservation.booking_users_attributes) {
if (user.event_price_category_id) {
const price_category = _.find($scope.event.event_price_categories_attributes, { id: user.event_price_category_id });
if (price_category) {
amount += price_category.amount;
}
} else {
amount += $scope.event.amount;
}
}
return amount;
};
/**
* Callback to validate a reservation
* @param reservation {Reservation}
*/
$scope.validateReservation = function (reservation) {
SlotsReservation.validate({
id: reservation.slots_reservations_attributes[0].id
}, () => { // successfully validated
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_validated'));
const index = $scope.reservations.indexOf(reservation);
$scope.reservations[index].slots_reservations_attributes[0].is_valid = true;
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
});
}, () => {
growl.warning(_t('app.admin.event_reservations.validation_failed'));
});
};
/**
* Callback to invalidate a reservation
* @param reservation {Reservation}
*/
$scope.invalidateReservation = function (reservation) {
SlotsReservation.invalidate({
id: reservation.slots_reservations_attributes[0].id
}, () => { // successfully validated
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_invalidated'));
const index = $scope.reservations.indexOf(reservation);
$scope.reservations[index].slots_reservations_attributes[0].is_valid = false;
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
});
}, () => {
growl.warning(_t('app.admin.event_reservations.invalidation_failed'));
});
};
const mkCartItems = function (reservation, coupon) {
return {
customer_id: reservation.user_id,
items: [{
reservation: {
...reservation,
slots_reservations_attributes: reservation.slots_reservations_attributes.map(sr => ({ slot_id: sr.slot_id })),
tickets_attributes: reservation.tickets_attributes.map(t => ({ booked: t.booked, event_price_category_id: t.event_price_category.id })),
booking_users_attributes: reservation.booking_users_attributes.map(bu => (
{ name: bu.name, event_price_category_id: bu.event_price_category_id, booked_id: bu.booked_id, booked_type: bu.booked_type }
))
}
}],
coupon_code: ((coupon ? coupon.code : undefined)),
payment_method: ''
};
};
$scope.payReservation = function (reservation) {
const modalInstance = $uibModal.open({
templateUrl: '/admin/events/pay_reservation_modal.html',
size: 'sm',
resolve: {
event () {
return $scope.event;
},
reservation () {
return reservation;
},
price () {
return Price.compute(mkCartItems(reservation)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
cartItems () {
return mkCartItems(reservation);
}
},
controller: ['$scope', '$uibModalInstance', 'reservation', 'price', 'wallet', 'cartItems', 'helpers', '$filter', '_t', 'Reservation', 'event',
function ($scope, $uibModalInstance, reservation, price, wallet, cartItems, helpers, $filter, _t, Reservation, event) {
$scope.event = event;
// User's wallet amount
$scope.wallet = wallet;
// Price
$scope.price = price;
// Cart items
$scope.cartItems = cartItems;
// price to pay
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation
$scope.reservation = reservation;
$scope.coupon = { applied: null };
$scope.offered = false;
$scope.payment = false;
// Button label
$scope.setValidButtonName = function () {
if ($scope.amount > 0 && !$scope.offered) {
$scope.validButtonName = _t('app.admin.event_reservations.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
} else {
$scope.validButtonName = _t('app.shared.buttons.confirm');
}
};
/**
* Compute the total amount for the current reservation according to the previously set parameters
*/
$scope.computeEventAmount = function () {
Price.compute(mkCartItems(reservation, $scope.coupon.applied), function (res) {
$scope.price = res;
$scope.amount = helpers.getAmountToPay($scope.price.price, wallet.amount);
$scope.setValidButtonName();
});
};
// Callback to validate the payment
$scope.ok = function () {
$scope.attempting = true;
return Reservation.confirm_payment({
id: reservation.id,
coupon_code: $scope.coupon.applied ? $scope.coupon.applied.code : null,
offered: $scope.offered
}, function (res) {
$uibModalInstance.close(res);
return $scope.attempting = true;
}
, function (response) {
$scope.alerts = [];
angular.forEach(response, function (v, k) {
angular.forEach(v, function (err) {
$scope.alerts.push({
msg: k + ': ' + err,
type: 'danger'
});
});
});
return $scope.attempting = false;
});
};
// Callback to cancel the payment
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
$scope.$watch('coupon.applied', function (newValue, oldValue) {
if ((newValue !== null) || (oldValue !== null)) {
return $scope.computeEventAmount();
}
});
$scope.setValidButtonName();
}]
});
modalInstance.result.then(function (reservation) {
$scope.reservations = $scope.reservations.map((r) => {
if (r.id === reservation.id) {
return reservation;
}
if ($scope.reservationAmount(reservation) === 0) {
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_present'));
} else {
growl.success(_t('app.admin.event_reservations.reservation_was_successfully_paid'));
}
return r;
});
}, function () {
console.log('Pay reservation modal dismissed at: ' + new Date());
});
};
}]);
/**

View File

@ -293,7 +293,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) {
@ -305,6 +305,31 @@ 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;
});
return member;
}
return member;
});
};
/**
* Ask for confirmation then delete the specified administrator
* @param admins {Array} full list of administrators
@ -590,6 +615,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

@ -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,23 @@ 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) {
let reservedPlaces = 0;
if ($scope.event.event_type === 'family') {
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
reservedPlaces = reservations.reduce((sum, reservation) => {
return sum + reservation.booking_users_attributes.length;
}, 0);
}
let nb_free_places = $scope.event.nb_free_places;
if ($scope.event.event_type === 'family') {
const maxPlaces = $scope.children.length + 1 - reservedPlaces;
nb_free_places = Math.min(maxPlaces, $scope.event.nb_free_places);
}
// compute the total remaining places
let remain = $scope.event.nb_free_places - $scope.reserve.nbReservePlaces;
let remain = nb_free_places - $scope.reserve.nbReservePlaces;
for (let ticket in $scope.reserve.tickets) {
remain -= $scope.reserve.tickets[ticket];
}
@ -247,17 +270,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 = () => {
@ -265,10 +312,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
};
$scope.isShowReserveEventButton = () => {
return $scope.event.nb_free_places > 0 &&
const bookable = $scope.event.nb_free_places > 0 &&
!$scope.reserve.toReserve &&
$scope.now.isBefore($scope.eventEndDateTime) &&
helpers.isUserValidatedByType($scope.ctrl.member, $scope.settings, 'event');
if ($scope.event.pre_registration) {
const endDate = $scope.event.pre_registration_end_date || $scope.event.end_date
return bookable && $scope.now.isSameOrBefore(endDate, 'day');
} else {
return bookable;
}
};
/**
@ -321,7 +374,11 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
if ($scope.ctrl.member) {
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id);
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id).then(function () {
getChildren($scope.ctrl.member.id).then(function (children) {
updateNbReservePlaces();
});
});
});
}
};
@ -399,8 +456,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
let index;
growl.success(_t('app.public.events_show.reservation_was_successfully_cancelled'));
index = $scope.reservations.indexOf(reservation);
$scope.event.nb_free_places = $scope.event.nb_free_places + reservation.total_booked_seats;
$scope.reservations[index].slots_reservations_attributes[0].canceled_at = new Date();
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
});
}, function(error) {
growl.warning(_t('app.public.events_show.cancellation_failed'));
});
@ -583,6 +642,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.validated_at) {
return false;
}
}
}
}
return true;
}
$scope.isUnder18YearsAgo = (date) => {
return moment(date).isAfter(moment().subtract(18, 'year'));
}
/* PRIVATE SCOPE */
/**
@ -601,7 +692,11 @@ 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);
getReservations($scope.event.id, 'Event', $scope.currentUser.id).then(function () {
getChildren($scope.currentUser.id).then(function (children) {
updateNbReservePlaces();
});
});
}
// watch when a coupon is applied to re-compute the total price
@ -619,13 +714,98 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* @param user_id {number} the user's id (current or managed)
*/
const getReservations = function (reservable_id, reservable_type, user_id) {
Reservation.query({
return Reservation.query({
reservable_id,
reservable_type,
user_id
}).$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;
}
}
}
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
for (const r of reservations) {
for (const user of r.booking_users_attributes) {
const key = user.booked_type === 'User' ? `user_${user.booked_id}` : `child_${user.booked_id}`;
if (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',
validated_at: 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' && $scope.ctrl.member.id) {
const reservations = $scope.reservations.filter((reservation) => {
return !reservation.slots_reservations_attributes[0].canceled_at;
});
const reservedPlaces = reservations.reduce((sum, reservation) => {
return sum + reservation.booking_users_attributes.length;
}, 0);
const maxPlaces = $scope.children.length + 1 - reservedPlaces;
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 +818,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 +837,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 +882,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;
}
@ -810,13 +1008,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* @param invoice {Object} the invoice for the booked reservation
*/
const afterPayment = function (invoice) {
Reservation.get({ id: invoice.main_object.id }, function (reservation) {
$scope.event.nb_free_places = $scope.event.nb_free_places - reservation.total_booked_seats;
$scope.reservations.push(reservation);
Event.get({ id: $scope.event.id }).$promise.then(function (event) {
$scope.event = event;
getReservations($scope.event.id, 'Event', $scope.ctrl.member.id).then(function () {
updateNbReservePlaces();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
});
resetEventReserve();
});
resetEventReserve();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
if ($scope.currentUser.role === 'admin') {
return $scope.ctrl.member = null;
}

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,10 @@ export interface Event {
}>,
recurrence: RecurrenceOption,
recurrence_end_at: Date,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
event_type: EventType,
pre_registration?: boolean,
pre_registration_end_date?: TDateISODate | Date,
}
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

@ -27,10 +27,11 @@ export interface Reservation {
slots_reservations_attributes: Array<SlotsReservation>,
reservable?: {
id: number,
name: string
name: string,
amount?: number
},
nb_reserve_places?: number,
tickets_attributes?: {
tickets_attributes?: Array<{
event_price_category_id: number,
event_price_category?: {
id: number,
@ -40,11 +41,40 @@ export interface Reservation {
name: string
}
},
booked: boolean,
booked: number,
created_at?: TDateISO
},
}>,
tickets?: Array<{
event_price_category_id: number,
event_price_category?: {
id: number,
price_category_id: number,
price_category: {
id: number,
name: string
}
},
booked: number,
created_at?: TDateISO
}>,
total_booked_seats?: number,
created_at?: TDateISO,
booking_users_attributes?: Array<{
id: number,
name: string,
event_price_category_id: number,
booked_id: number,
booked_type: string,
}>,
start_at: TDateISO,
end_at: TDateISO,
event_type?: string,
event_title?: string,
event_pre_registration?: boolean,
canceled_at?: TDateISO,
is_valid?: boolean,
is_paid?: boolean,
amount?: number
}
export interface ReservationIndexFilter extends ApiFilter {

View File

@ -179,7 +179,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: {
@ -624,7 +636,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

@ -5,6 +5,11 @@ Application.Services.factory('Reservation', ['$resource', function ($resource) {
{ id: '@id' }, {
update: {
method: 'PUT'
},
confirm_payment: {
method: 'POST',
url: '/api/reservations/confirm_payment',
isArray: false
}
}
);

View File

@ -9,6 +9,14 @@ Application.Services.factory('SlotsReservation', ['$resource', function ($resour
cancel: {
method: 'PUT',
url: '/api/slots_reservations/:id/cancel'
},
validate: {
method: 'PUT',
url: '/api/slots_reservations/:id/validate'
},
invalidate: {
method: 'PUT',
url: '/api/slots_reservations/:id/invalidate'
}
}
);

View File

@ -48,11 +48,16 @@
@import "modules/dashboard/reservations/prepaid-packs-panel";
@import "modules/dashboard/reservations/reservations-dashboard";
@import "modules/dashboard/reservations/reservations-panel";
@import "modules/events/event";
@import "modules/events/events";
@import "modules/events/event-form";
@import "modules/events/event-reservation";
@import "modules/events/event";
@import "modules/events/events-dashboard";
@import "modules/events/events-settings";
@import "modules/events/events";
@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,60 @@
.event-reservation {
display: flex;
flex-direction: column;
gap: 1.6rem;
&-item {
padding: 1.6rem 1.6rem 0;
display: flex;
flex-direction: column;
background-color: var(--gray-soft-lightest);
border-radius: var(--border-radius);
label {
margin: 0;
@include text-xs;
color: var(--gray-hard-light);
}
p {
margin: 0;
@include text-base(600);
}
.date { @include text-sm; }
&__event {
padding-bottom: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.6rem;
border-bottom: 1px solid var(--gray-soft-dark);
}
&__reservation {
display: flex;
& > div {
padding: 1.2rem 1.6rem 1.2rem 0;
flex: 1;
}
.list {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
label:not(:first-of-type) {
margin-top: 1rem;
}
}
.name { @include text-sm(500); }
.status {
padding-left: 1.6rem;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--gray-soft-dark);
}
}
}
}

View File

@ -0,0 +1,5 @@
.events-dashboard {
max-width: 1600px;
margin: 0 auto;
padding-bottom: 6rem;
}

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

@ -1,4 +1,109 @@
.promote-member img {
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; }
}
}
}
@ -78,4 +95,4 @@
text-align: center;
}
}
}
}

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

@ -12,8 +12,9 @@
<tr>
<th style="width:30%" translate>{{ 'app.admin.events.title' }}</th>
<th style="width:30%" translate>{{ 'app.admin.events.dates' }}</th>
<th style="width:10%" translate>{{ 'app.admin.events.booking' }}</th>
<th style="width:30%"></th>
<th style="width:15%" translate>{{ 'app.admin.events.types' }}</th>
<th style="width:10%" translate>{{ 'app.admin.events.booking' }}</th>
<th style="width:15%"></th>
</tr>
</thead>
<tbody>
@ -48,8 +49,16 @@
</span>
</td>
<td>
<span ng-if="event.event_type === 'standard'" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.events.event_type.standard' }}</span>
<span ng-if="event.event_type === 'nominative'" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.events.event_type.nominative' }}</span>
<span ng-if="event.event_type === 'family'" class="v-middle badge text-sm bg-atelier" translate="">{{ 'app.admin.events.event_type.family' }}</span>
<span ng-if="event.pre_registration" class="v-middle badge text-sm bg-info" translate="">{{ 'app.admin.events.pre_registration' }}</span>
</td>
<td style="vertical-align:middle">
<span class="ng-binding" ng-if="event.nb_total_places > 0">{{ event.nb_total_places - event.nb_free_places }} / {{ event.nb_total_places }}</span>
<div class="ng-binding" ng-if="event.pre_registration">{{'app.admin.events.NUMBER_pre_registered' | translate:{NUMBER:event.nb_places_for_pre_registration} }}</div>
<span class="badge font-sbold cancelled" ng-if="event.nb_total_places == -1" translate>{{ 'app.admin.events.cancelled' }}</span>
<span class="badge font-sbold" ng-if="!event.nb_total_places" translate>{{ 'app.admin.events.without_reservation' }}</span>
</td>
@ -57,10 +66,10 @@
<td style="vertical-align:middle">
<div class="buttons">
<a class="btn btn-default" ui-sref="app.admin.event_reservations({id: event.id})">
<i class="fa fa-bookmark"></i> {{ 'app.admin.events.view_reservations' | translate }}
<i class="fa fa-eye"></i>
</a>
<a class="btn btn-default" ui-sref="app.admin.events_edit({id: event.id})">
<i class="fa fa-edit"></i> {{ 'app.shared.buttons.edit' | translate }}
<i class="fa fa-edit"></i>
</a>
</div>
</td>

View File

@ -0,0 +1,52 @@
<div class="modal-header">
<img ng-src="{{logoBlack.custom_asset_file_attributes.attachment_url}}" alt="{{logo.custom_asset_file_attributes.attachment}}" class="modal-logo"/>
<h1 translate ng-show="reservation && amount !== 0">{{ 'app.admin.event_reservations.confirm_payment' }}</h1>
<h1 translate ng-show="reservation && amount === 0">{{ 'app.admin.event_reservations.confirm_present' }}</h1>
</div>
<div class="modal-body">
<div ng-show="amount !== 0">
<div class="row" ng-show="!offered">
<wallet-info current-user="currentUser"
cart="cartItems"
price="price.price"
wallet="wallet"/>
</div>
<div class="row m-b">
<div class="col-md-12">
<label for="offerSlot" class="control-label m-r" translate>{{ 'app.admin.event_reservations.offer_this_reservation' }}</label>
<input bs-switch
ng-model="offered"
id="offerSlot"
type="checkbox"
class="form-control"
switch-on-text="{{ 'app.shared.buttons.yes' | translate }}"
switch-off-text="{{ 'app.shared.buttons.no' | translate }}"
switch-animate="true"
ng-change="computeEventAmount()"/>
</div>
</div>
<coupon show="true" coupon="coupon.applied" total="price.price_without_coupon" user-id="{{reservation.user_id}}"></coupon>
<div class="row">
<div class="form-group col-sm-12">
<div class="checkbox-group">
<input type="checkbox"
name="paymentReceived"
id="paymentReceived"
ng-model="payment" />
<label for="paymentReceived" translate>{{ 'app.admin.event_reservations.i_have_received_the_payment' }}</label>
</div>
</div>
</div>
</div>
<div ng-if="amount === 0">
<p translate>{{ 'app.admin.event_reservations.confirm_present_info' }}</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-info" ng-if="amount !== 0" ng-click="ok()" ng-disabled="attempting || !payment" ng-bind-html="validButtonName"></button>
<button class="btn btn-info" ng-if="amount === 0" ng-click="ok()" ng-disabled="attempting" ng-bind-html="validButtonName"></button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -20,10 +20,13 @@
<table class="table" ng-if="reservations.length > 0">
<thead>
<tr>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.user' }}</th>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.payment_date' }}</th>
<th style="width:25%" translate>{{ 'app.admin.event_reservations.reserved_tickets' }}</th>
<th style="width:25%"></th>
<th translate>{{ 'app.admin.event_reservations.booked_by' }}</th>
<th translate>{{ 'app.admin.event_reservations.reservations' }}</th>
<th translate>{{ 'app.admin.event_reservations.date' }}</th>
<th translate>{{ 'app.admin.event_reservations.reserved_tickets' }}</th>
<th ng-if="event.pre_registration" translate>{{ 'app.admin.event_reservations.status' }}</th>
<th ng-if="event.pre_registration" translate>{{ 'app.admin.event_reservations.validation' }}</th>
<th></th>
</tr>
</thead>
<tbody>
@ -31,16 +34,49 @@
<td class="text-c">
<a ui-sref="app.logged.members_show({id: reservation.user_id})">{{ reservation.user_full_name }} </a>
</td>
<td>
<span ng-if="event.event_type === 'standard'">{{ reservation.user_full_name }} </span>
<div ng-repeat="bu in reservation.booking_users_attributes">
<span>{{bu.name}}</span>
<span ng-if="bu.booked_type === 'Child'" class="m-l-sm">({{ 'app.admin.event_reservations.age' | translate:{NUMBER: bu.age} }})</span>
</div>
</td>
<td>{{ reservation.created_at | amDateFormat:'LL LTS' }}</td>
<td>
<span ng-if="reservation.nb_reserve_places > 0">{{ 'app.admin.event_reservations.full_price_' | translate }} {{reservation.nb_reserve_places}}<br/></span>
<span ng-repeat="ticket in reservation.tickets_attributes">{{ticket.event_price_category.price_category.name}} : {{ticket.booked}}</span>
<div ng-show="isCancelled(reservation)" class="canceled-marker" translate>{{ 'app.admin.event_reservations.canceled' }}</div>
</td>
<td ng-if="event.pre_registration">
<span ng-if="!isValidated(reservation) && !isInvalidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-info" translate="">{{ 'app.admin.event_reservations.event_status.pre_registered' }}</span>
<span ng-if="reservationAmount(reservation) !== 0 && isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.event_reservations.event_status.to_pay' }}</span>
<span ng-if="reservationAmount(reservation) === 0 && isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-stage" translate="">{{ 'app.admin.event_reservations.event_status.registered' }}</span>
<span ng-if="isInvalidated(reservation) && !isCancelled(reservation) && !reservation.is_paid" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.event_reservations.event_status.not_validated' }}</span>
<span ng-if="reservationAmount(reservation) !== 0 && reservation.is_paid && !isCancelled(reservation)" class="v-middle badge text-sm bg-success" translate="">{{ 'app.admin.event_reservations.event_status.paid' }}</span>
<span ng-if="reservationAmount(reservation) === 0 && reservation.is_paid && !isCancelled(reservation)" class="v-middle badge text-sm bg-success" translate="">{{ 'app.admin.event_reservations.event_status.present' }}</span>
<span ng-if="isCancelled(reservation)" class="v-middle badge text-sm bg-event" translate="">{{ 'app.admin.event_reservations.event_status.canceled' }}</span>
</td>
<td ng-if="event.pre_registration">
<div>
<div ng-if="!isCancelled(reservation) && !reservation.is_paid">
<label class="m-r-sm">
<span translate>{{ 'app.admin.event_reservations.negative' }}</span>
<input type="radio" name="invalidate-{{reservation.id}}" ng-value="false" ng-click="invalidateReservation(reservation)" ng-model="reservation.slots_reservations_attributes[0].is_valid" ng-disabled="reservation.total_booked_seats > event.nb_free_places && !reservation.slots_reservations_attributes[0].is_valid">
</label>
<label>
<span translate>{{ 'app.admin.event_reservations.affirmative' }}</span>
<input type="radio" name="validate-{{reservation.id}}" ng-value="true" ng-click="validateReservation(reservation)" ng-model="reservation.slots_reservations_attributes[0].is_valid" ng-disabled="reservation.total_booked_seats > event.nb_free_places && !reservation.slots_reservations_attributes[0].is_valid" >
</label>
</div>
<button class="btn btn-default" ng-click="payReservation(reservation)" ng-if="isValidated(reservation) && !isCancelled(reservation) && !reservation.is_paid">
<span ng-if="reservationAmount(reservation) !== 0" translate>{{ 'app.admin.event_reservations.pay' }}</span>
<span ng-if="reservationAmount(reservation) === 0" translate>{{ 'app.admin.event_reservations.present' }}</span>
</button>
</div>
</td>
<td>
<div class="buttons">
<button class="btn btn-default" ui-sref="app.public.events_show({id: event.id})">
<i class="fa fa-tag"></i> {{ 'app.admin.event_reservations.show_the_event' | translate }}
<i class="fa fa-eye"></i>
</button>
</div>
</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>
@ -202,19 +207,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.admin.members_edit.next_events' | translate }}</h4>
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<a class="font-sbold" ui-sref="app.public.events_show({id: r.reservable.id})">{{r.reservable.title}}</a> - <span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
<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>
<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>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'future').length == 0" translate>{{ 'app.admin.members_edit.no_upcoming_events' }}</div>
</div>
</div>
@ -225,11 +222,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.admin.members_edit.passed_events' | translate }}</h4>
</div>
<div class="widget-content bg-light auto wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<span class="font-sbold">{{r.reservable.title}}</span> - <span class="label label-info text-white wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'passed').length == 0" translate>{{ 'app.admin.members_edit.no_passed_events' }}</div>
</div>
</div>

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>
</td>
</tr>
</tbody>
</table>
<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>
<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,7 @@
<user-validation-setting on-success="onSuccess" on-error="onError" />
</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>
@ -73,6 +74,37 @@
</div>
</div>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.family_account' }}</span>
</div>
<div class="panel-body">
<div class="row">
<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>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.accounts_management' }}</span>
@ -156,4 +188,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

@ -8,7 +8,7 @@
</section>
<div class="row no-gutter">
<div class="row events-dashboard">
<div class="col-md-6">
<div class="widget panel b-a m m-t-lg">
@ -16,25 +16,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.logged.dashboard.events.your_next_events' | translate }}</h4>
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'" class="m-b">
<a class="font-sbold" ui-sref="app.public.events_show({id: r.reservable.id})">{{r.reservable.title}}</a>
-
<span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
<br/>
<span translate
translate-values="{NUMBER: r.nb_reserve_places}">
{{ 'app.logged.dashboard.events.NUMBER_normal_places_reserved' }}
</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>
</li>
</ul>
<div class="list-unstyled event-reservation" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'future'">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'future').length == 0" translate>{{ 'app.logged.dashboard.events.no_events_to_come' }}</div>
</div>
</div>
@ -45,11 +31,11 @@
<h4 class="text-u-c"><i class="fa fa-tag m-r-xs"></i> {{ 'app.logged.dashboard.events.your_previous_events' | translate }}</h4>
</div>
<div class="widget-content bg-light auto wrapper r-b">
<ul class="list-unstyled" ng-if="user.events_reservations.length > 0">
<li ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<span class="font-sbold">{{r.reservable.title}}</span> - <span class="label label-info text-white wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>
<div class="list-unstyled" ng-if="user.events_reservations.length > 0">
<div ng-repeat="r in user.events_reservations | eventsReservationsFilter:'passed'" class="m-b">
<event-reservation-item reservation="r"></event-reservation-item>
</div>
</div>
<div ng-if="(user.events_reservations | eventsReservationsFilter:'passed').length == 0" translate>{{ 'app.logged.dashboard.events.no_passed_events' }}</div>
</div>
</div>

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,12 @@
</div>
<div class="panel-content wrapper">
<div>
<span ng-if="event.event_type === 'nominative'" class="v-middle badge text-xs bg-event" translate="">{{ 'app.public.events_show.event_type.nominative' }}</span>
<span ng-if="event.event_type === 'family'" class="v-middle badge text-xs bg-event" translate="">{{ 'app.public.events_show.event_type.family' }}</span>
<span ng-if="event.pre_registration" class="v-middle badge text-xs bg-info" translate="">{{ 'app.public.events_show.pre_registration' }}</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}}
@ -86,6 +90,8 @@
<dt><i class="fas fa-clock"></i> {{ 'app.public.events_show.opening_hours' | translate }}</dt>
<dd ng-if="event.all_day"><span translate>{{ 'app.public.events_show.all_day' }}</span></dd>
<dd ng-if="!event.all_day">{{ 'app.public.events_show.from_time' | translate }} <span class="text-u-l">{{event.start_time}}</span> {{ 'app.public.events_show.to_time' | translate }} <span class="text-u-l">{{event.end_time}}</span></dd>
<dt ng-if="event.pre_registration_end_date"><i class="fa fa-calendar" aria-hidden="true"></i> {{ 'app.public.events_show.pre_registration_end_date' | translate }}</dt>
<dd ng-if="event.pre_registration_end_date"><span class="text-u-l">{{event.pre_registration_end_date | amDateFormat:'L'}}</span></dd>
</dl>
<div class="text-sm" ng-if="event.amount">
@ -116,19 +122,81 @@
<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.validated_at" 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.validated_at">
<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">
<div ng-show="currentUser.role == 'admin' && !event.pre_registration" class="m-t">
<label for="offerSlot" class="control-label m-r" translate>{{ 'app.public.events_show.make_a_gift_of_this_reservation' }}</label>
<input bs-switch
ng-model="event.offered"
@ -150,14 +218,18 @@
<a class="pull-right m-t-xs text-u-l ng-scope" ng-click="cancelReserve($event)" ng-show="reserve.toReserve" translate>{{ 'app.shared.buttons.cancel' }}</a>
</div>
<div ng-if="reserveSuccess" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_payment_has_been_successfully_registered' | translate }}<br>
{{ 'app.public.events_show.you_can_find_your_reservation_s_details_on_your_' | translate }} <a ui-sref="app.logged.dashboard.invoices" translate>{{ 'app.public.events_show.dashboard' }}</a>
<div ng-if="reserveSuccess && !event.pre_registration" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_payment_has_been_successfully_registered' | translate }}<br>
{{ 'app.public.events_show.you_can_find_your_reservation_s_details_on_your_' | translate }} <a ui-sref="app.logged.dashboard.events" translate>{{ 'app.public.events_show.dashboard' }}</a>
</div>
<div ng-if="reserveSuccess && event.pre_registration" class="alert alert-success">{{ 'app.public.events_show.thank_you_your_pre_registration_has_been_successfully_saved' | translate }}<br>
{{ 'app.public.events_show.informed_by_email_your_pre_registration' | translate }}
</div>
<div class="m-t-sm" ng-if="reservations && !reserve.toReserve" ng-repeat="reservation in reservations">
<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-u-c text-sm" ng-if="!event.pre_registration">{{ 'app.public.events_show.you_booked_DATE' | translate:{DATE:(reservation.created_at | amDateFormat:'L LT')} }}</div>
<div class="font-sbold text-u-c text-sm" ng-if="event.pre_registration">{{ 'app.public.events_show.you_pre_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)">
@ -178,7 +250,10 @@
<span ng-show="reservations.length > 0" translate>{{ 'app.public.events_show.thanks_for_coming' }}</span>
<a ui-sref="app.public.events_list" translate>{{ 'app.public.events_show.view_event_list' }}</a>
</div>
<button class="btn btn-warning-full rounded btn-block text-sm" ng-click="reserveEvent()" ng-show="isShowReserveEventButton()">{{ 'app.public.events_show.book' | translate }}</button>
<button class="btn btn-warning-full rounded btn-block text-sm" ng-click="reserveEvent()" ng-show="isShowReserveEventButton()">
<span ng-if="event.pre_registration">{{ 'app.public.events_show.pre_book' | translate }}</span>
<span ng-if="!event.pre_registration">{{ 'app.public.events_show.book' | translate }}</span>
</button>
<uib-alert type="danger" ng-if="ctrl.member.id && !isUserValidatedByType()">
<p class="text-sm">
<i class="fa fa-warning"></i>
@ -186,15 +261,15 @@
</p>
</uib-alert>
<coupon show="reserve.totalSeats > 0 && ctrl.member" coupon="coupon.applied" total="reserve.totalNoCoupon" user-id="{{ctrl.member.id}}"></coupon>
<coupon show="reserve.totalSeats > 0 && ctrl.member && !event.pre_registration" coupon="coupon.applied" total="reserve.totalNoCoupon" user-id="{{ctrl.member.id}}"></coupon>
</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.pre_registration && 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.pre_registration || 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">
<span translate>{{ 'app.shared.member_select.member_not_validated' }}</span>
</div>
<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>
</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

@ -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

@ -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
@ -85,14 +87,21 @@ class Event < ApplicationRecord
if nb_total_places.nil?
self.nb_free_places = nil
else
reserved_places = reservations.joins(:slots_reservations)
.where('slots_reservations.canceled_at': nil)
.map(&:total_booked_seats)
.inject(0) { |sum, t| sum + t }
reserved = reservations.joins(:slots_reservations).where('slots_reservations.canceled_at': nil)
reserved = reserved.where('slots_reservations.is_valid': true) if pre_registration?
reserved_places = reserved.map(&:total_booked_seats).inject(0) { |sum, t| sum + t }
self.nb_free_places = (nb_total_places - reserved_places)
end
end
def nb_places_for_pre_registration
reservations.joins(:slots_reservations)
.where('slots_reservations.canceled_at': nil)
.where('slots_reservations.is_valid': nil)
.map(&:total_booked_seats)
.inject(0) { |sum, t| sum + t }
end
def all_day?
availability.start_at.hour.zero?
end

View File

@ -24,6 +24,8 @@ class Reservation < ApplicationRecord
has_many :prepaid_pack_reservations, dependent: :destroy
belongs_to :reservation_context
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) }
@ -130,15 +132,27 @@ class Reservation < ApplicationRecord
end
def notify_member_create_reservation
NotificationCenter.call type: 'notify_member_create_reservation',
receiver: user,
attached_object: self
if reservable_type == 'Event' && reservable.pre_registration?
NotificationCenter.call type: 'notify_member_pre_booked_reservation',
receiver: user,
attached_object: self
else
NotificationCenter.call type: 'notify_member_create_reservation',
receiver: user,
attached_object: self
end
end
def notify_admin_member_create_reservation
NotificationCenter.call type: 'notify_admin_member_create_reservation',
receiver: User.admins_and_managers,
attached_object: self
if reservable_type == 'Event' && reservable.pre_registration?
NotificationCenter.call type: 'notify_admin_member_pre_booked_reservation',
receiver: User.admins_and_managers,
attached_object: self
else
NotificationCenter.call type: 'notify_admin_member_create_reservation',
receiver: User.admins_and_managers,
attached_object: self
end
end
def notify_member_limitation_reached

View File

@ -60,15 +60,26 @@ class ShoppingCart
items.each do |item|
objects.push(save_item(item))
end
update_credits(objects)
update_packs(objects)
event_reservation = objects.find { |o| o.is_a?(Reservation) && o.reservable_type == 'Event' }
if event_reservation&.reservable&.pre_registration
payment = Invoice.new(
invoicing_profile: @customer.invoicing_profile,
statistic_profile: @customer.statistic_profile,
operator_profile_id: @operator.invoicing_profile.id,
payment_method: @payment_method,
total: 0
)
else
update_credits(objects)
update_packs(objects)
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer)
next if Setting.get('prevent_invoices_zero') && price[:total].zero?
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer)
next if Setting.get('prevent_invoices_zero') && price[:total].zero?
payment.save
payment.post_save(payment_id, payment_type)
payment.save
payment.post_save(payment_id, payment_type)
end
end
success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:blank?).all?

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,8 +2,9 @@
# An admin can mark an uploaded document as refused, this will notify the member
class SupportingDocumentRefusal < ApplicationRecord
belongs_to :user
include NotificationAttachedObject
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
has_and_belongs_to_many :supporting_document_types
end

View File

@ -6,6 +6,7 @@ class SupportingDocumentType < ApplicationRecord
has_many :supporting_document_files, dependent: :destroy
has_many :supporting_document_refusals_types, dependent: :destroy
has_many :supporting_document_refusals, through: :supporting_document_refusals_types
has_and_belongs_to_many :supporting_document_refusals, dependent: :destroy
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

@ -5,7 +5,8 @@ class LocalPaymentPolicy < ApplicationPolicy
def confirm_payment?
# only admins and managers can offer free extensions of a subscription
has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension }
event = record.shopping_cart.items.find { |item| item.is_a? CartItem::EventReservation }
((user.admin? || user.manager?) && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
((user.admin? || user.manager?) && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days) || event&.reservable&.pre_registration
end
end

View File

@ -9,4 +9,8 @@ class ReservationPolicy < ApplicationPolicy
def update?
user.admin? || user.manager? || record.user == user
end
def confirm_payment?
user.admin? || user.manager?
end
end

View File

@ -47,7 +47,7 @@ class SettingPolicy < ApplicationPolicy
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 projects_list_member_filter_presence projects_list_date_filters_presence advanced_accounting
project_categories_filter_placeholder project_categories_wording reservation_context_feature]
project_categories_filter_placeholder project_categories_wording reservation_context_feature family_account child_validation_required]
end
##

View File

@ -15,4 +15,12 @@ class SlotsReservationPolicy < ApplicationPolicy
def cancel?
user.admin? || user.manager? || record.reservation.user == user
end
def validate?
user.admin? || user.manager?
end
def invalidate?
user.admin? || user.manager?
end
end

View File

@ -6,15 +6,11 @@ class SupportingDocumentFilePolicy < ApplicationPolicy
user.privileged?
end
def create?
user.privileged? or record.user_id == user.id
end
def update?
user.privileged? or record.user_id == user.id
end
def download?
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
end
end

View File

@ -173,7 +173,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,61 @@
# 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)
if file.nil? || file.attachment_identifier.nil? || child_params['supporting_document_files_attributes']['0']['attachment'].blank?
all_files_are_upload = false
end
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

@ -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

@ -0,0 +1,86 @@
# frozen_string_literal: true
# confirm payment of a pre-registration reservation
class ReservationConfirmPaymentService
def initialize(reservation, operator, coupon, offered)
@reservation = reservation
@operator = operator
@offered = offered
@coupon = CartItem::Coupon.new(
customer_profile: @reservation.user.invoicing_profile,
operator_profile: @operator.invoicing_profile,
coupon: Coupon.find_by(code: coupon)
)
end
def total
slots_reservations = @reservation.slots_reservations.map do |sr|
{
slot_id: sr.slot_id,
offered: @offered
}
end
tickets = @reservation.tickets.map do |t|
{
event_price_category_id: t.event_price_category_id,
booked: t.booked
}
end
booking_users = @reservation.booking_users.map do |bu|
{
name: bu.name,
event_price_category_id: bu.event_price_category_id,
booked_id: bu.booked_id,
booked_type: bu.booked_type
}
end
event_reservation = CartItem::EventReservation.new(customer_profile: @reservation.user.invoicing_profile,
operator_profile: @operator.invoicing_profile,
event: @reservation.reservable,
cart_item_reservation_slots_attributes: slots_reservations,
normal_tickets: @reservation.nb_reserve_places,
cart_item_event_reservation_tickets_attributes: tickets,
cart_item_event_reservation_booking_users_attributes: booking_users)
all_elements = {
slots: @reservation.slots_reservations.map do |sr|
{ start_at: sr.slot.start_at, end_at: sr.slot.end_at, price: event_reservation.price[:amount] }
end
}
total_amount = event_reservation.price[:amount]
coupon_info = @coupon.price(total_amount)
# return result
{
elements: all_elements,
total: coupon_info[:total_with_coupon].to_i,
before_coupon: coupon_info[:total_without_coupon].to_i,
coupon: @coupon.coupon
}
end
def call
price = total
invoice = InvoicesService.create(
price,
@operator.invoicing_profile.id,
[@reservation],
@reservation.user
)
if Setting.get('prevent_invoices_zero') && price[:total].zero?
@reservation.slots_reservations.first.update(is_confirm: true)
return invoice
end
ActiveRecord::Base.transaction do
WalletService.debit_user_wallet(invoice, @reservation.user)
invoice.save
invoice.post_save
@reservation.slots_reservations.first.update(is_confirm: true)
end
invoice
end
end

View File

@ -19,6 +19,9 @@ class Slots::ReservationsService
.where('reservations.reservable_type': reservable_types)
.where('reservations.reservable_id': reservables.map { |r| r.try(:id) })
.where('slots_reservations.canceled_at': nil)
if reservables.first.is_a?(Event) && reservables.first&.pre_registration?
reservations = reservations.where('slots_reservations.is_valid': true)
end
user_ids = reservations.includes(reservation: :statistic_profile)
.map(&:reservation)

View File

@ -21,5 +21,43 @@ class SlotsReservationsService
rescue Faraday::ConnectionFailed
warn 'Unable to update data in elasticsearch'
end
def validate(slot_reservation)
if slot_reservation.update(is_valid: true)
reservable = slot_reservation.reservation.reservable
if reservable.is_a?(Event)
reservable.update_nb_free_places
reservable.save
end
Slots::PlacesCacheService.refresh(slot_reservation.slot)
NotificationCenter.call type: 'notify_member_reservation_validated',
receiver: slot_reservation.reservation.user,
attached_object: slot_reservation.reservation
NotificationCenter.call type: 'notify_admin_reservation_validated',
receiver: User.admins_and_managers,
attached_object: slot_reservation.reservation
return true
end
false
end
def invalidate(slot_reservation)
if slot_reservation.update(is_valid: false)
reservable = slot_reservation.reservation.reservable
if reservable.is_a?(Event)
reservable.update_nb_free_places
reservable.save
end
Slots::PlacesCacheService.refresh(slot_reservation.slot)
NotificationCenter.call type: 'notify_member_reservation_invalidated',
receiver: slot_reservation.reservation.user,
attached_object: slot_reservation.reservation
NotificationCenter.call type: 'notify_admin_reservation_invalidated',
receiver: User.admins_and_managers,
attached_object: slot_reservation.reservation
return true
end
false
end
end
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
user.group.supporting_document_types.each do |type|
file = type.supporting_document_files.find_by(user_id: supporting_document_file.user_id)
all_files_are_upload = false unless file
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(supportable_id: supporting_document_file.supportable_id,
supportable_type: supporting_document_file.supportable_type)
all_files_are_upload = false unless file
end
end
if all_files_are_upload
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
user.group.supporting_document_types.each do |type|
file = type.supporting_document_files.find_by(user_id: supporting_document_file.user_id)
all_files_are_upload = false unless file
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(supportable_id: supporting_document_file.supportable_id,
supportable_type: supporting_document_file.supportable_type)
all_files_are_upload = false unless file
end
end
if all_files_are_upload
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
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,
attached_object: supporting_document_refusal
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.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