diff --git a/app/controllers/api/children_controller.rb b/app/controllers/api/children_controller.rb new file mode 100644 index 000000000..3725695ba --- /dev/null +++ b/app/controllers/api/children_controller.rb @@ -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 diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index ce4b42bd9..781e170ba 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -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], diff --git a/app/controllers/api/reservations_controller.rb b/app/controllers/api/reservations_controller.rb index 3bfe28d08..260fec568 100644 --- a/app/controllers/api/reservations_controller.rb +++ b/app/controllers/api/reservations_controller.rb @@ -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 diff --git a/app/controllers/api/slots_reservations_controller.rb b/app/controllers/api/slots_reservations_controller.rb index 2fcda5e83..00a355735 100644 --- a/app/controllers/api/slots_reservations_controller.rb +++ b/app/controllers/api/slots_reservations_controller.rb @@ -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 diff --git a/app/controllers/api/supporting_document_files_controller.rb b/app/controllers/api/supporting_document_files_controller.rb index 34868aacb..93d7b86b5 100644 --- a/app/controllers/api/supporting_document_files_controller.rb +++ b/app/controllers/api/supporting_document_files_controller.rb @@ -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 diff --git a/app/controllers/api/supporting_document_refusals_controller.rb b/app/controllers/api/supporting_document_refusals_controller.rb index 1da9cabe4..8cf619e8e 100644 --- a/app/controllers/api/supporting_document_refusals_controller.rb +++ b/app/controllers/api/supporting_document_refusals_controller.rb @@ -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 diff --git a/app/controllers/api/supporting_document_types_controller.rb b/app/controllers/api/supporting_document_types_controller.rb index 68a57569a..dc2223ab2 100644 --- a/app/controllers/api/supporting_document_types_controller.rb +++ b/app/controllers/api/supporting_document_types_controller.rb @@ -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 diff --git a/app/frontend/src/javascript/api/child.ts b/app/frontend/src/javascript/api/child.ts new file mode 100644 index 000000000..04a5d373a --- /dev/null +++ b/app/frontend/src/javascript/api/child.ts @@ -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> { + const res: AxiosResponse> = await apiClient.get(`/api/children${ApiLib.filtersToQuery(filters)}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/children/${id}`); + return res?.data; + } + + static async create (child: Child): Promise { + const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']); + const res: AxiosResponse = await apiClient.post('/api/children', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async update (child: Child): Promise { + const data = ApiLib.serializeAttachments(child, 'child', ['supporting_document_files_attributes']); + const res: AxiosResponse = await apiClient.put(`/api/children/${child.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return res?.data; + } + + static async destroy (childId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/children/${childId}`); + return res?.data; + } + + static async validate (child: Child): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/children/${child.id}/validate`, { child }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/events/event-form.tsx b/app/frontend/src/javascript/components/events/event-form.tsx index e8a89c29f..8102db622 100644 --- a/app/frontend/src/javascript/components/events/event-form.tsx +++ b/app/frontend/src/javascript/components/events/event-form.tsx @@ -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 = ({ action, event, onError, onSuccess }) => { - const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: { ...event } }); + const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: Object.assign({ event_type: 'standard' }, event) }); const output = useWatch({ control }); const { fields, append, remove } = useFieldArray({ control, name: 'event_price_categories_attributes' }); @@ -54,6 +54,9 @@ export const EventForm: React.FC = ({ action, event, onError, on const [isOpenRecurrentModal, setIsOpenRecurrentModal] = useState(false); const [updatingEvent, setUpdatingEvent] = useState(null); const [isActiveAccounting, setIsActiveAccounting] = useState(false); + const [isActiveFamilyAccount, setIsActiveFamilyAccount] = useState(false); + const [isAcitvePreRegistration, setIsActivePreRegistration] = useState(event?.pre_registration); + const [submitting, setSubmitting] = useState(false); useEffect(() => { EventCategoryAPI.index() @@ -69,6 +72,7 @@ export const EventForm: React.FC = ({ 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 = ({ action, event, onError, on * Callback triggered when the user validates the machine form: handle create or update */ const onSubmit: SubmitHandler = (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 = ({ 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 = ({ action, event, onError, on ]; }; + /** + * This method provides event type options + */ + const buildEventTypeOptions = (): Array> => { + 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 (

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

- + {t('app.admin.event_form.save')}
@@ -203,6 +226,12 @@ export const EventForm: React.FC = ({ action, event, onError, on label={t('app.admin.event_form.description')} limit={null} heading bulletList blockquote link video image /> + = ({ action, event, onError, on formState={formState} options={ageRangeOptions} label={t('app.admin.event_form.age_range')} />} + + {isAcitvePreRegistration && + + }
diff --git a/app/frontend/src/javascript/components/events/event-reservation-item.tsx b/app/frontend/src/javascript/components/events/event-reservation-item.tsx new file mode 100644 index 000000000..7075c367d --- /dev/null +++ b/app/frontend/src/javascript/components/events/event-reservation-item.tsx @@ -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 = ({ 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 ( + <> + + {reservation.booking_users_attributes.filter(u => u.event_price_category_id === ticket.event_price_category_id).map(u => { + return ( +

{u.name}

+ ); + })} + + ); + }; + + /** + * 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 ( +
+
+
+ +

{reservation.event_title}

+ {formatDate()} +
+
+ {/* {reservation.event_type === 'family' && + {t('app.logged.event_reservation_item.family')} + } + {reservation.event_type === 'nominative' && + {t('app.logged.event_reservation_item.nominative')} + } */} + {reservation.event_pre_registration && + // eslint-disable-next-line fabmanager/no-bootstrap, fabmanager/no-utilities + {t('app.logged.event_reservation_item.pre_registration')} + } +
+
+
+
+ + {reservation.booking_users_attributes.filter(u => !u.event_price_category_id).map(u => { + return ( +

{u.name}

+ ); + })} + {reservation.tickets.map(ticket => { + return buildTicket(ticket); + })} +
+ {reservation.event_pre_registration && +
+ +

{preRegistrationStatus()}

+
+ } +
+
+ ); +}; + +Application.Components.component('eventReservationItem', react2angular(EventReservationItem, ['reservation'])); diff --git a/app/frontend/src/javascript/components/family-account/child-form.tsx b/app/frontend/src/javascript/components/family-account/child-form.tsx new file mode 100644 index 000000000..00887017f --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-form.tsx @@ -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; + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * A form for creating or editing a child. + */ +export const ChildForm: React.FC = ({ child, onSubmit, supportingDocumentsTypes, operator, onSuccess, onError }) => { + const { t } = useTranslation('public'); + + const { register, formState, handleSubmit, setValue, control } = useForm({ + defaultValues: child + }); + const output = useWatch({ control }); // eslint-disable-line + const [refuseModalIsOpen, setRefuseModalIsOpen] = useState(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 ( +
+ {!isPrivileged() && + +

{t('app.public.child_form.child_form_info')}

+
+ } +
+
+ + +
+
+ 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')} + /> + +
+ + + {!isPrivileged() && supportingDocumentsTypes?.length > 0 && <> +

{t('app.public.child_form.supporting_documents')}

+ {output.supporting_document_files_attributes.map((sf, index) => { + return ( + + ); + })} + } + +
+ + {t('app.public.child_form.save')} + +
+ + {isPrivileged() && supportingDocumentsTypes?.length > 0 && <> +

{t('app.public.child_form.supporting_documents')}

+
+ {output.supporting_document_files_attributes.map((sf, index) => { + return ( +
+ {getSupportingDocumentsTypeName(sf.supporting_document_type_id)} + {sf.attachment_url && ( +
+

{sf.attachment}

+ + + +
+ )} + {!sf.attachment_url && ( +
+

{t('app.public.child_form.to_complete')}

+
+ )} +
+ ); + })} +
+ } + + {isPrivileged() && supportingDocumentsTypes?.length > 0 && <> + +

{t('app.public.child_form.refuse_documents_info')}

+
+
+ {t('app.public.child_form.refuse_documents')} + +
+ } + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/family-account/child-item.tsx b/app/frontend/src/javascript/components/family-account/child-item.tsx new file mode 100644 index 000000000..4192d2c04 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-item.tsx @@ -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 = ({ child, size, onEdit, onDelete, onError }) => { + const { t } = useTranslation('public'); + const [isOpenDeleteChildModal, setIsOpenDeleteChildModal] = React.useState(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 ( +
+
+ +
+
+ {t('app.public.child_item.last_name')} +

{child.last_name}

+
+
+ {t('app.public.child_item.first_name')} +

{child.first_name}

+
+
+ {t('app.public.child_item.birthday')} +

{FormatLib.date(child.birthday)}

+
+
+ onEdit(child)} className="edit-btn"> + + + + + + +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/family-account/child-modal.tsx b/app/frontend/src/javascript/components/family-account/child-modal.tsx new file mode 100644 index 000000000..942dc60a2 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-modal.tsx @@ -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; +} + +/** + * A modal for creating or editing a child. + */ +export const ChildModal: React.FC = ({ child, isOpen, toggleModal, onSuccess, onError, supportingDocumentsTypes, operator }) => { + const { t } = useTranslation('public'); + + /** + * Save the child to the API + */ + const handleSaveChild = async (data: Child): Promise => { + 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 ( + + {(operator?.role === 'admin' || operator?.role === 'manager') && + + } + onSuccess(child, msg)} + onError={onError} + /> + + ); +}; diff --git a/app/frontend/src/javascript/components/family-account/child-validation.tsx b/app/frontend/src/javascript/components/family-account/child-validation.tsx new file mode 100644 index 000000000..92f6db8db --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-validation.tsx @@ -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 = ({ child, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [value, setValue] = useState(!!(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 ( +
+ + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/family-account/children-dashboard.tsx b/app/frontend/src/javascript/components/family-account/children-dashboard.tsx new file mode 100644 index 000000000..c9f856d58 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/children-dashboard.tsx @@ -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 = ({ user, operator, adminPanel, onError, onSuccess }) => { + const { t } = useTranslation('public'); + + const [children, setChildren] = useState>([]); + const [isOpenChildModal, setIsOpenChildModal] = useState(false); + const [child, setChild] = useState(); + const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState>([]); + + 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 ( +
+
+ {adminPanel + ?

{t('app.public.children_dashboard.heading')}

+ :

{t('app.public.children_dashboard.member_heading')}

+ } + {!isPrivileged() && ( +
+ + {t('app.public.children_dashboard.add_child')} + +
+ )} +
+ +
+ {children.map(child => ( + + ))} +
+ setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} /> +
+ ); +}; + +const ChildrenDashboardWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('childrenDashboard', react2angular(ChildrenDashboardWrapper, ['user', 'operator', 'adminPanel', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/family-account/delete-child-modal.tsx b/app/frontend/src/javascript/components/family-account/delete-child-modal.tsx new file mode 100644 index 000000000..ba844d796 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/delete-child-modal.tsx @@ -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 = ({ isOpen, toggleModal, onDelete, child }) => { + const { t } = useTranslation('public'); + + /** + * Callback triggered when the child confirms the deletion + */ + const handleDeleteChild = () => { + onDelete(child); + }; + + return ( + +

{t('app.public.delete_child_modal.confirm_delete_child')}

+
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx index 253974e96..22b58a252 100644 --- a/app/frontend/src/javascript/components/form/form-file-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -19,12 +19,13 @@ type FormFileUploadProps = FormComponent & 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 = ({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { +export const FormFileUpload = ({ id, label, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, showRemoveButton = true }: FormFileUploadProps) => { const { t } = useTranslation('shared'); const [file, setFile] = useState(defaultFile); @@ -74,9 +75,10 @@ export const FormFileUpload = ({ id, label, re return (
- {hasFile() && ( - {file.attachment_name} - )} + {hasFile() + ? {file.attachment_name} + : {t('app.shared.form_file_upload.placeholder')} + } diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index d4a4331e9..5a1aed014 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -22,13 +22,15 @@ type FormInputProps = FormComponent & Ab onChange?: (event: React.ChangeEvent) => 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 = ({ 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) => { +export const FormInput = ({ 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) => { const [characterCount, setCharacterCount] = useState(0); /** @@ -100,7 +102,9 @@ export const FormInput = ({ id, re disabled={typeof disabled === 'function' ? disabled(id) : disabled} placeholder={placeholder} accept={accept} - maxLength={maxLength} /> + maxLength={maxLength} + max={max} + min={min}/> {(type === 'file' && placeholder) && {placeholder}} {maxLength && {characterCount} / {maxLength}} {addOn && addOnAction && } diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-files.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-files.tsx index 8c5ecf5f0..a1b5b4f6b 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-files.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-files.tsx @@ -49,7 +49,7 @@ export const SupportingDocumentsFiles: React.FC = 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 = 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 = } } 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')); diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-refusal-modal.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-refusal-modal.tsx index 1e2336024..6a436af10 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-refusal-modal.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-refusal-modal.tsx @@ -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, 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 = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, member, onError }) => { +export const SupportingDocumentsRefusalModal: React.FC = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, supportable, onError, documentType }) => { const { t } = useTranslation('admin'); const [data, setData] = useState({ id: null, operator_id: operator.id, - user_id: member.id, + supportable_id: supportable.id, + supportable_type: documentType, supporting_document_type_ids: [], message: '' }); diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx index 3e26b2963..5005f4d4b 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-form.tsx @@ -63,13 +63,15 @@ export const SupportingDocumentsTypeForm: React.FC
-
- +
+ }
} diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-modal.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-modal.tsx index be457f531..c42c490cd 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-modal.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-type-modal.tsx @@ -14,18 +14,19 @@ interface SupportingDocumentsTypeModalProps { onError: (message: string) => void, groups: Array, proofOfIdentityType?: SupportingDocumentType, + documentType: 'User' | 'Child', } /** * Modal dialog to create/edit a supporting documents type */ -export const SupportingDocumentsTypeModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups }) => { +export const SupportingDocumentsTypeModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups, documentType }) => { const { t } = useTranslation('admin'); - const [data, setData] = useState({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' }); + const [data, setData] = useState({ 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 { - return !data.name || data.group_ids.length === 0; + return !data.name || (documentType === 'User' && data.group_ids.length === 0); }; return ( diff --git a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx index 8fb888908..52a388983 100644 --- a/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx +++ b/app/frontend/src/javascript/components/supporting-documents/supporting-documents-types-list.tsx @@ -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 = ({ onSuccess, onError }) => { +const SupportingDocumentsTypesList: React.FC = ({ onSuccess, onError, documentType }) => { const { t } = useTranslation('admin'); // list of displayed supporting documents type @@ -48,7 +50,7 @@ const SupportingDocumentsTypesList: React.FC 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 */ 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 */ 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 window.location.href = '/#!/admin/members?tabs=1'; }; - return ( - - {t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')} -
}> -
-
-

{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}

- - - {t('app.admin.settings.account.supporting_documents_types_list.create_groups')} - + if (documentType === 'User') { + return ( + + {t('app.admin.settings.account.supporting_documents_types_list.add_supporting_documents_types')} +
}> +
+
+

{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_info')}

+ + + {t('app.admin.settings.account.supporting_documents_types_list.create_groups')} + +
+ +
+

{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}

+ {t('app.admin.settings.account.supporting_documents_types_list.add_type')} +
+ + + + + + + + + + + + + + {supportingDocumentsTypes.map(poit => { + return ( + + + + + + ); + })} + +
+ + {t('app.admin.settings.account.supporting_documents_types_list.group_name')} + + + + + {t('app.admin.settings.account.supporting_documents_types_list.name')} + + +
{getGroupsNames(poit.group_ids)}{poit.name} +
+ + + + + + +
+
+ {!hasTypes() && ( +

+ +

+ )}
+ + ); + } else if (documentType === 'Child') { + return ( +
+
+
+

{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}

+ {t('app.admin.settings.account.supporting_documents_types_list.add_type')} +
-
-

{t('app.admin.settings.account.supporting_documents_types_list.supporting_documents_type_title')}

- {t('app.admin.settings.account.supporting_documents_types_list.add_type')} + + + +
+ {supportingDocumentsTypes.map(poit => { + return ( +
+
+

{poit.name}

+
+ + + + + + +
+
+
+ ); + })} +
+ + {!hasTypes() && ( +

+ +

+ )}
- - - - - - - - - - - - - - {supportingDocumentsTypes.map(poit => { - return ( - - - - - - ); - })} - -
- - {t('app.admin.settings.account.supporting_documents_types_list.group_name')} - - - - - {t('app.admin.settings.account.supporting_documents_types_list.name')} - - -
{getGroupsNames(poit.group_ids)}{poit.name} -
- - - - - - -
-
- {!hasTypes() && ( -

- -

- )}
- - ); + ); + } else { + return null; + } }; const SupportingDocumentsTypesListWrapper: React.FC = (props) => { @@ -277,4 +334,4 @@ const SupportingDocumentsTypesListWrapper: React.FC 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 = ({ operator, member, onSuccess, onError }) => { +const SupportingDocumentsValidation: React.FC = ({ operator, member, onSuccess, onError, documentType }) => { const { t } = useTranslation('admin'); // list of supporting documents type @@ -39,7 +40,7 @@ const SupportingDocumentsValidation: React.FC { 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 @@ -131,4 +133,4 @@ const SupportingDocumentsValidationWrapper: React.FC 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 = ({ 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 ( +
+
+
+ {(member.children.length > 0) + ? + : + } +
+ {(member.children.length > 0) && + setChildrenList(!childrenList)} className={`toggle ${childrenList ? 'open' : ''}`}> + + + } +
+ +
+
+
+ {t('app.admin.members_list_item.surname')} +

{member.profile.last_name}

+
+
+ {t('app.admin.members_list_item.first_name')} +

{member.profile.first_name}

+
+
+ {t('app.admin.members_list_item.phone')} +

{member.profile.phone || '---'}

+
+
+ {t('app.admin.members_list_item.email')} +

{member.email}

+
+
+ {t('app.admin.members_list_item.group')} +

{member.group.name}

+
+
+ {t('app.admin.members_list_item.subscription')} +

{member.subscribed_plan?.name || '---'}

+
+
+ +
+ toMemberEdit(member.id)} className="edit-btn"> + + + onDeleteMember(member.id)} className="delete-btn"> + + +
+
+ + { (member.children.length > 0) && +
+
+ {member.children.map((child: Child) => ( + + ))} +
+ } +
+ ); +}; diff --git a/app/frontend/src/javascript/components/user/members-list.tsx b/app/frontend/src/javascript/components/user/members-list.tsx new file mode 100644 index 000000000..ad911c0a0 --- /dev/null +++ b/app/frontend/src/javascript/components/user/members-list.tsx @@ -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 = ({ members, onError, onSuccess, operator, onDeleteMember, onDeletedChild, onUpdatedChild }) => { + const [supportingDocumentsTypes, setSupportingDocumentsTypes] = useState>([]); + const [child, setChild] = useState(); + const [isOpenChildModal, setIsOpenChildModal] = useState(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 ( +
+ {members.map(member => ( + + ))} + setIsOpenChildModal(false)} onSuccess={handleSaveChildSuccess} onError={onError} supportingDocumentsTypes={supportingDocumentsTypes} operator={operator} /> +
+ ); +}; + +const MembersListWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('membersList', react2angular(MembersListWrapper, ['members', 'onError', 'onSuccess', 'operator', 'onDeleteMember', 'onDeletedChild', 'onUpdatedChild'])); diff --git a/app/frontend/src/javascript/controllers/admin/events.js b/app/frontend/src/javascript/controllers/admin/events.js index 6ce0cadee..420097e01 100644 --- a/app/frontend/src/javascript/controllers/admin/events.js +++ b/app/frontend/src/javascript/controllers/admin/events.js @@ -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()); + }); + }; }]); /** diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index 3957098d2..1f9acabd4 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -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 */ /** diff --git a/app/frontend/src/javascript/controllers/children.js b/app/frontend/src/javascript/controllers/children.js new file mode 100644 index 000000000..1fc180294 --- /dev/null +++ b/app/frontend/src/javascript/controllers/children.js @@ -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); + }; + } +]); diff --git a/app/frontend/src/javascript/controllers/events.js.erb b/app/frontend/src/javascript/controllers/events.js.erb index 09b5ab08c..f4e92afe9 100644 --- a/app/frontend/src/javascript/controllers/events.js.erb +++ b/app/frontend/src/javascript/controllers/events.js.erb @@ -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; } diff --git a/app/frontend/src/javascript/lib/api.ts b/app/frontend/src/javascript/lib/api.ts index 1ef3b9b82..76e99d6fc 100644 --- a/app/frontend/src/javascript/lib/api.ts +++ b/app/frontend/src/javascript/lib/api.ts @@ -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]) { diff --git a/app/frontend/src/javascript/models/child.ts b/app/frontend/src/javascript/models/child.ts new file mode 100644 index 000000000..b9fcaca9a --- /dev/null +++ b/app/frontend/src/javascript/models/child.ts @@ -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 + }>, +} diff --git a/app/frontend/src/javascript/models/event.ts b/app/frontend/src/javascript/models/event.ts index 5cca1d9e8..7f72b1974 100644 --- a/app/frontend/src/javascript/models/event.ts +++ b/app/frontend/src/javascript/models/event.ts @@ -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 { diff --git a/app/frontend/src/javascript/models/member.ts b/app/frontend/src/javascript/models/member.ts new file mode 100644 index 000000000..3c9e8cad2 --- /dev/null +++ b/app/frontend/src/javascript/models/member.ts @@ -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 + } + ] +} diff --git a/app/frontend/src/javascript/models/reservation.ts b/app/frontend/src/javascript/models/reservation.ts index 56d3aef5e..14529b2cb 100644 --- a/app/frontend/src/javascript/models/reservation.ts +++ b/app/frontend/src/javascript/models/reservation.ts @@ -27,10 +27,11 @@ export interface Reservation { slots_reservations_attributes: Array, 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 { diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 12e9ef207..ae114d96c 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -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 = [ diff --git a/app/frontend/src/javascript/models/supporting-document-file.ts b/app/frontend/src/javascript/models/supporting-document-file.ts index 581c75fb0..c762b4664 100644 --- a/app/frontend/src/javascript/models/supporting-document-file.ts +++ b/app/frontend/src/javascript/models/supporting-document-file.ts @@ -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, } diff --git a/app/frontend/src/javascript/models/supporting-document-refusal.ts b/app/frontend/src/javascript/models/supporting-document-refusal.ts index 29f045bbe..f220baf4e 100644 --- a/app/frontend/src/javascript/models/supporting-document-refusal.ts +++ b/app/frontend/src/javascript/models/supporting-document-refusal.ts @@ -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, } diff --git a/app/frontend/src/javascript/models/supporting-document-type.ts b/app/frontend/src/javascript/models/supporting-document-type.ts index a2dcf3c78..2c590e1f1 100644 --- a/app/frontend/src/javascript/models/supporting-document-type.ts +++ b/app/frontend/src/javascript/models/supporting-document-type.ts @@ -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 + group_ids: Array, + document_type: 'User' | 'Child' } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index e95029e91..2005f7bb2 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -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; }] } }) diff --git a/app/frontend/src/javascript/services/child.js b/app/frontend/src/javascript/services/child.js new file mode 100644 index 000000000..07d0d916c --- /dev/null +++ b/app/frontend/src/javascript/services/child.js @@ -0,0 +1,11 @@ +'use strict'; + +Application.Services.factory('Child', ['$resource', function ($resource) { + return $resource('/api/children/:id', + { id: '@id' }, { + update: { + method: 'PUT' + } + } + ); +}]); diff --git a/app/frontend/src/javascript/services/reservation.js b/app/frontend/src/javascript/services/reservation.js index 95273fbb4..d83e7003a 100644 --- a/app/frontend/src/javascript/services/reservation.js +++ b/app/frontend/src/javascript/services/reservation.js @@ -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 } } ); diff --git a/app/frontend/src/javascript/services/slots_reservation.js b/app/frontend/src/javascript/services/slots_reservation.js index 4476d6371..c6d59c9cf 100644 --- a/app/frontend/src/javascript/services/slots_reservation.js +++ b/app/frontend/src/javascript/services/slots_reservation.js @@ -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' } } ); diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 759d95f9f..9d763ef92 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -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"; diff --git a/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss b/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss index 6adaece60..949b4b028 100644 --- a/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss +++ b/app/frontend/src/stylesheets/modules/base/edit-destroy-buttons.scss @@ -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; diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index fb34b42c8..777b29999 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -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); diff --git a/app/frontend/src/stylesheets/modules/events/event-reservation.scss b/app/frontend/src/stylesheets/modules/events/event-reservation.scss new file mode 100644 index 000000000..eacfb1ec2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/events/event-reservation.scss @@ -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); + } + } + + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/events/events-dashboard.scss b/app/frontend/src/stylesheets/modules/events/events-dashboard.scss new file mode 100644 index 000000000..7c6c70bc9 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/events/events-dashboard.scss @@ -0,0 +1,5 @@ +.events-dashboard { + max-width: 1600px; + margin: 0 auto; + padding-bottom: 6rem; +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/family-account/child-form.scss b/app/frontend/src/stylesheets/modules/family-account/child-form.scss new file mode 100644 index 000000000..b1ed219d4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/family-account/child-form.scss @@ -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); + } + } + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/family-account/child-item.scss b/app/frontend/src/stylesheets/modules/family-account/child-item.scss new file mode 100644 index 000000000..f505a73bd --- /dev/null +++ b/app/frontend/src/stylesheets/modules/family-account/child-item.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/family-account/children-dashboard.scss b/app/frontend/src/stylesheets/modules/family-account/children-dashboard.scss new file mode 100644 index 000000000..bed5aae61 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/family-account/children-dashboard.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss index 5707bbc45..b1bad6758 100644 --- a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -13,6 +13,8 @@ margin-bottom: 1.6rem; } + .placeholder { color: var(--gray-soft-darkest); } + .actions { margin-left: auto; display: flex; diff --git a/app/frontend/src/stylesheets/modules/members.scss b/app/frontend/src/stylesheets/modules/members.scss index 34a9209f9..b54e92a35 100644 --- a/app/frontend/src/stylesheets/modules/members.scss +++ b/app/frontend/src/stylesheets/modules/members.scss @@ -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); + } + } + } } \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-types-list.scss b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-types-list.scss index b9fca3534..30837f400 100644 --- a/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-types-list.scss +++ b/app/frontend/src/stylesheets/modules/supporting-documents/supporting-documents-types-list.scss @@ -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; } } -} +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/user/user-validation.scss b/app/frontend/src/stylesheets/modules/user/user-validation.scss index 81a741931..3c9e1b2b6 100644 --- a/app/frontend/src/stylesheets/modules/user/user-validation.scss +++ b/app/frontend/src/stylesheets/modules/user/user-validation.scss @@ -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; +} \ No newline at end of file diff --git a/app/frontend/templates/admin/events/monitoring.html b/app/frontend/templates/admin/events/monitoring.html index 91b2a73b7..ed4aa9d61 100644 --- a/app/frontend/templates/admin/events/monitoring.html +++ b/app/frontend/templates/admin/events/monitoring.html @@ -12,8 +12,9 @@ {{ 'app.admin.events.title' }} {{ 'app.admin.events.dates' }} - {{ 'app.admin.events.booking' }} - + {{ 'app.admin.events.types' }} + {{ 'app.admin.events.booking' }} + @@ -48,8 +49,16 @@ + + {{ 'app.admin.events.event_type.standard' }} + {{ 'app.admin.events.event_type.nominative' }} + {{ 'app.admin.events.event_type.family' }} + {{ 'app.admin.events.pre_registration' }} + + {{ event.nb_total_places - event.nb_free_places }} / {{ event.nb_total_places }} +
{{'app.admin.events.NUMBER_pre_registered' | translate:{NUMBER:event.nb_places_for_pre_registration} }}
{{ 'app.admin.events.cancelled' }} {{ 'app.admin.events.without_reservation' }} @@ -57,10 +66,10 @@
diff --git a/app/frontend/templates/admin/events/pay_reservation_modal.html b/app/frontend/templates/admin/events/pay_reservation_modal.html new file mode 100644 index 000000000..b4059d18f --- /dev/null +++ b/app/frontend/templates/admin/events/pay_reservation_modal.html @@ -0,0 +1,52 @@ + + + diff --git a/app/frontend/templates/admin/events/reservations.html b/app/frontend/templates/admin/events/reservations.html index 81d87e088..69590232c 100644 --- a/app/frontend/templates/admin/events/reservations.html +++ b/app/frontend/templates/admin/events/reservations.html @@ -20,10 +20,13 @@ - - - - + + + + + + + @@ -31,16 +34,49 @@ + + + diff --git a/app/frontend/templates/admin/members/edit.html b/app/frontend/templates/admin/members/edit.html index c3479225e..287a61911 100644 --- a/app/frontend/templates/admin/members/edit.html +++ b/app/frontend/templates/admin/members/edit.html @@ -62,10 +62,15 @@ + + + + @@ -202,19 +207,11 @@

{{ 'app.admin.members_edit.next_events' | translate }}

-
    -
  • - {{r.reservable.title}} - {{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }} - -
    - {{ 'app.admin.members_edit.NUMBER_full_price_tickets_reserved' }} -
    - -
    - {{ 'app.admin.members_edit.NUMBER_NAME_tickets_reserved' }} -
    -
  • -
+
+
+ +
+
{{ 'app.admin.members_edit.no_upcoming_events' }}
@@ -225,11 +222,11 @@

{{ 'app.admin.members_edit.passed_events' | translate }}

-
    -
  • - {{r.reservable.title}} - {{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }} -
  • -
+
+
+ +
+
{{ 'app.admin.members_edit.no_passed_events' }}
diff --git a/app/frontend/templates/admin/members/members.html b/app/frontend/templates/admin/members/members.html index b0be69229..22fe77590 100644 --- a/app/frontend/templates/admin/members/members.html +++ b/app/frontend/templates/admin/members/members.html @@ -17,11 +17,12 @@ +
- -
{{ 'app.admin.event_reservations.user' }}{{ 'app.admin.event_reservations.payment_date' }}{{ 'app.admin.event_reservations.reserved_tickets' }}{{ 'app.admin.event_reservations.booked_by' }}{{ 'app.admin.event_reservations.reservations' }}{{ 'app.admin.event_reservations.date' }}{{ 'app.admin.event_reservations.reserved_tickets' }}{{ 'app.admin.event_reservations.status' }}{{ 'app.admin.event_reservations.validation' }}
{{ reservation.user_full_name }} + {{ reservation.user_full_name }} +
+ {{bu.name}} + ({{ 'app.admin.event_reservations.age' | translate:{NUMBER: bu.age} }}) +
+
{{ reservation.created_at | amDateFormat:'LL LTS' }} {{ 'app.admin.event_reservations.full_price_' | translate }} {{reservation.nb_reserve_places}}
{{ticket.event_price_category.price_category.name}} : {{ticket.booked}} -
{{ 'app.admin.event_reservations.canceled' }}
+
+ {{ 'app.admin.event_reservations.event_status.pre_registered' }} + {{ 'app.admin.event_reservations.event_status.to_pay' }} + {{ 'app.admin.event_reservations.event_status.registered' }} + {{ 'app.admin.event_reservations.event_status.not_validated' }} + {{ 'app.admin.event_reservations.event_status.paid' }} + {{ 'app.admin.event_reservations.event_status.present' }} + {{ 'app.admin.event_reservations.event_status.canceled' }} + +
+
+ + +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'app.admin.members.username' | translate }} {{ 'app.admin.members.surname' | translate }} {{ 'app.admin.members.first_name' | translate }}
- - {{ m.username }}{{ m.profile.last_name }}{{ m.profile.first_name }} -
- - - {{ 'app.shared.user_admin.incomplete_profile' }} -
-
+
+ +
+
diff --git a/app/frontend/templates/admin/settings/compte.html b/app/frontend/templates/admin/settings/compte.html index 0ed115706..241196c69 100644 --- a/app/frontend/templates/admin/settings/compte.html +++ b/app/frontend/templates/admin/settings/compte.html @@ -51,6 +51,7 @@
+

{{ 'app.admin.settings.captcha' }}

@@ -73,6 +74,37 @@
+ +
+
+ {{ 'app.admin.settings.family_account' }} +
+
+
+

+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
{{ 'app.admin.settings.accounts_management' }} @@ -156,4 +188,4 @@
- + diff --git a/app/frontend/templates/dashboard/children.html b/app/frontend/templates/dashboard/children.html new file mode 100644 index 000000000..022cfa3a0 --- /dev/null +++ b/app/frontend/templates/dashboard/children.html @@ -0,0 +1,11 @@ +
+
+
+ +
+ +
+ + + +
diff --git a/app/frontend/templates/dashboard/events.html b/app/frontend/templates/dashboard/events.html index 1783502d7..f9333495d 100644 --- a/app/frontend/templates/dashboard/events.html +++ b/app/frontend/templates/dashboard/events.html @@ -8,7 +8,7 @@ -
+
@@ -16,25 +16,11 @@

{{ 'app.logged.dashboard.events.your_next_events' | translate }}

-
    -
  • - {{r.reservable.title}} - - - {{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }} -
    - - {{ 'app.logged.dashboard.events.NUMBER_normal_places_reserved' }} - - -
    - - {{ 'app.logged.dashboard.events.NUMBER_of_NAME_places_reserved' }} - -
    -
  • -
+
+
+ +
+
{{ 'app.logged.dashboard.events.no_events_to_come' }}
@@ -45,11 +31,11 @@

{{ 'app.logged.dashboard.events.your_previous_events' | translate }}

-
    -
  • - {{r.reservable.title}} - {{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }} -
  • -
+
+
+ +
+
{{ 'app.logged.dashboard.events.no_passed_events' }}
diff --git a/app/frontend/templates/dashboard/nav.html b/app/frontend/templates/dashboard/nav.html index 8da97d01a..f0dc4bd10 100644 --- a/app/frontend/templates/dashboard/nav.html +++ b/app/frontend/templates/dashboard/nav.html @@ -11,6 +11,7 @@

{{ 'app.public.common.dashboard' }}