diff --git a/CHANGELOG.md b/CHANGELOG.md index 652ceb0f4..98e10f82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog Fab-manager +## v6.0.6 2023 May 4 + +- Fix a bug: invalid duration for machine/spaces reservations in statistics, when using slots of not 1 hour +- [TODO DEPLOY] `rails fablab:es:build_stats` THEN `rails fablab:maintenance:regenerate_statistics[2014,1]` + +## v6.0.5 2023 May 2 + +- Fix a bug: unable to show calendar for Firefox and Safari +- Improved error message for event reservation + +## v6.0.4 2023 April 25 + +- Fix a bug: notification is broken when delete a project +- Fix a bug: broken notifications email +- Fix a bug: unable to show calendar +- Update translations from Crowdin +- [TODO DEPLOY] `rails fablab:maintenance:clean_abuse_notifications` + ## v6.0.3 2023 April 12 - Fix a bug: unable to install Fab-manager by setup.sh diff --git a/Gemfile.lock b/Gemfile.lock index 4606b128b..5cb4eaf82 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -269,6 +269,8 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.8) + nokogiri (1.14.3-x86_64-darwin) + racc (~> 1.4) nokogiri (1.14.3-x86_64-linux) racc (~> 1.4) oauth2 (1.4.4) @@ -524,6 +526,7 @@ GEM zeitwerk (2.6.7) PLATFORMS + x86_64-darwin-20 x86_64-linux DEPENDENCIES diff --git a/app/controllers/api/children_controller.rb b/app/controllers/api/children_controller.rb new file mode 100644 index 000000000..ac1d98b78 --- /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).includes(:supporting_document_files).order(:created_at) + end + + def show + authorize @child + end + + def create + @child = Child.new(child_params) + authorize @child + if ChildService.create(@child) + render status: :created + else + render json: @child.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @child + + if @child.update(child_params) + render status: :ok + else + render json: @child.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @child + @child.destroy + head :no_content + end + + def validate + authorize @child + + cparams = params.require(:child).permit(:validated_at) + if ChildService.validate(@child, cparams[:validated_at].present?) + render :show, status: :ok, location: child_path(@child) + else + render json: @child.errors, status: :unprocessable_entity + end + end + + private + + def set_child + @child = Child.find(params[:id]) + end + + def child_params + params.require(:child).permit(:first_name, :last_name, :email, :phone, :birthday, :user_id, + supporting_document_files_attributes: %i[id supportable_id supportable_type + supporting_document_type_id + attachment _destroy]) + end +end diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index ce4b42bd9..4d05f6b56 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -96,7 +96,7 @@ class API::EventsController < API::APIController # handle general properties event_preparams = params.required(:event).permit(:title, :description, :start_date, :start_time, :end_date, :end_time, :amount, :nb_total_places, :availability_id, :all_day, :recurrence, - :recurrence_end_at, :category_id, :event_theme_ids, :age_range_id, + :recurrence_end_at, :category_id, :event_theme_ids, :age_range_id, :event_type, event_theme_ids: [], event_image_attributes: %i[id attachment], event_files_attributes: %i[id attachment _destroy], diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb index 14eaf310b..3583ac12a 100644 --- a/app/controllers/api/payzen_controller.rb +++ b/app/controllers/api/payzen_controller.rb @@ -72,7 +72,7 @@ class API::PayzenController < API::PaymentsController cart = shopping_cart - if order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' } + if order['answer']['transactions'].all? { |transaction| transaction['status'] == 'PAID' } render on_payment_success(params[:order_id], cart) else render json: order['answer'], status: :unprocessable_entity @@ -86,10 +86,11 @@ class API::PayzenController < API::PaymentsController client = PayZen::Transaction.new transaction = client.get(params[:transaction_uuid]) + order = PayZen::Order.new.get(params[:order_id]) cart = shopping_cart - if transaction['answer']['status'] == 'PAID' + if transaction['answer']['status'] == 'PAID' && order['answer']['transactions'].all? { |t| t['status'] == 'PAID' } render on_payment_success(params[:order_id], cart) else render json: transaction['answer'], status: :unprocessable_entity 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..5ed6a7036 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,7 @@ 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); useEffect(() => { EventCategoryAPI.index() @@ -69,6 +70,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(() => { @@ -168,6 +170,20 @@ 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 (
@@ -203,6 +219,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 /> + 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() && <> +

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

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

{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() && <> + +

{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..f950e207e --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-modal.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { Child } from '../../models/child'; +import ChildAPI from '../../api/child'; +import { ChildForm } from './child-form'; +import { SupportingDocumentType } from '../../models/supporting-document-type'; +import { ChildValidation } from './child-validation'; +import { User } from '../../models/user'; + +interface ChildModalProps { + child?: Child; + operator: User; + isOpen: boolean; + toggleModal: () => void; + onSuccess: (child: Child, msg: string) => void; + onError: (error: string) => void; + supportingDocumentsTypes: Array; +} + +/** + * 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 => { + try { + if (child?.id) { + await ChildAPI.update(data); + } else { + await ChildAPI.create(data); + } + toggleModal(); + onSuccess(data, ''); + } catch (error) { + onError(error); + } + }; + + return ( + + {(operator?.role === 'admin' || operator?.role === 'manager') && + + } + + + ); +}; 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..1705ad6cf --- /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: (message: 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(() => { + onSuccess(t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_success`)); + }).catch(err => { + setValue(!_value); + onError(t(`app.admin.child_validation.${_value ? 'validate' : 'invalidate'}_child_error`) + err); + }); + }; + + return ( +
+ + +
+ ); +}; 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/payment-schedule/payment-schedule-item-actions.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx index caf059353..7a7e8f307 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx @@ -54,7 +54,7 @@ export const PaymentScheduleItemActions: React.FC { - return (operator.role === 'admin' || operator.role === 'manager'); + return (operator?.role === 'admin' || operator?.role === 'manager'); }; /** diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index 541880808..7f7a6223b 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -75,7 +75,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE if (updateCard) return onSuccess(null); const transaction = event.clientAnswer.transactions[0]; - if (event.clientAnswer.orderStatus === 'PAID') { + if (event.clientAnswer.orderStatus === 'PAID' && transaction?.status === 'PAID') { confirmPayment(event, transaction).then((confirmation) => { PayZenKR.current.removeForms().then(() => { onSuccess(confirmation); 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`; + }; + + 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/calendar.js b/app/frontend/src/javascript/controllers/admin/calendar.js index cb290a790..3e59d6b76 100644 --- a/app/frontend/src/javascript/controllers/admin/calendar.js +++ b/app/frontend/src/javascript/controllers/admin/calendar.js @@ -69,8 +69,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state snapDuration: BOOKING_SNAP, selectable: true, selectHelper: true, - minTime: moment.duration(moment(bookingWindowStart.setting.value).format('HH:mm:ss')), - maxTime: moment.duration(moment(bookingWindowEnd.setting.value).format('HH:mm:ss')), + minTime: moment.duration(moment.utc(bookingWindowStart.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), + maxTime: moment.duration(moment.utc(bookingWindowEnd.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), select (start, end, jsEvent, view) { return calendarSelectCb(start, end, jsEvent, view); }, diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index e26a3da48..e7b388827 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -291,7 +291,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', Member.delete( { id: memberId }, function () { - $scope.members.splice(findItemIdxById($scope.members, memberId), 1); + $scope.members = _.filter($scope.members, function (m) { return m.id !== memberId; }); return growl.success(_t('app.admin.members.member_successfully_deleted')); }, function (error) { @@ -303,6 +303,32 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', ); }; + $scope.onDeletedChild = function (memberId, childId) { + $scope.members = $scope.members.map(function (member) { + if (member.id === memberId) { + member.children = _.filter(member.children, function (c) { return c.id !== childId; }); + return member; + } + return member; + }); + }; + + $scope.onUpdatedChild = function (memberId, child) { + $scope.members = $scope.members.map(function (member) { + if (member.id === memberId) { + member.children = member.children.map(function (c) { + if (c.id === child.id) { + return child; + } + return c; + }); + console.log(member.children); + return member; + } + return member; + }); + }; + /** * Ask for confirmation then delete the specified administrator * @param admins {Array} full list of administrators @@ -588,6 +614,20 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', } }; + /** + * Callback triggered in case of error + */ + $scope.onError = (message) => { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + /* PRIVATE SCOPE */ /** diff --git a/app/frontend/src/javascript/controllers/admin/settings.js b/app/frontend/src/javascript/controllers/admin/settings.js index 4e07dcc30..91ffa6818 100644 --- a/app/frontend/src/javascript/controllers/admin/settings.js +++ b/app/frontend/src/javascript/controllers/admin/settings.js @@ -71,8 +71,8 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope' $scope.subscriptionExplicationsAlert = { name: 'subscription_explications_alert', value: settingsPromise.subscription_explications_alert }; $scope.eventExplicationsAlert = { name: 'event_explications_alert', value: settingsPromise.event_explications_alert }; $scope.spaceExplicationsAlert = { name: 'space_explications_alert', value: settingsPromise.space_explications_alert }; - $scope.windowStart = { name: 'booking_window_start', value: settingsPromise.booking_window_start }; - $scope.windowEnd = { name: 'booking_window_end', value: settingsPromise.booking_window_end }; + $scope.windowStart = { name: 'booking_window_start', value: moment.utc(settingsPromise.booking_window_start).format('YYYY-MM-DD HH:mm:ss') }; + $scope.windowEnd = { name: 'booking_window_end', value: moment.utc(settingsPromise.booking_window_end).format('YYYY-MM-DD HH:mm:ss') }; $scope.mainColorSetting = { name: 'main_color', value: settingsPromise.main_color }; $scope.secondColorSetting = { name: 'secondary_color', value: settingsPromise.secondary_color }; $scope.nameGenre = { name: 'name_genre', value: settingsPromise.name_genre }; @@ -487,8 +487,12 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope' // we prevent the admin from setting the closing time before the opening time $scope.$watch('windowEnd.value', function (newValue, oldValue, scope) { - if ($scope.windowStart && moment($scope.windowStart.value).isAfter(newValue)) { - return $scope.windowEnd.value = oldValue; + if (scope.windowStart) { + const startTime = moment($scope.windowStart.value).format('HH:mm:ss'); + const endTime = moment(newValue).format('HH:mm:ss'); + if (startTime >= endTime) { + scope.windowEnd.value = oldValue; + } } }); diff --git a/app/frontend/src/javascript/controllers/calendar.js b/app/frontend/src/javascript/controllers/calendar.js index a4f4ac162..e0977c9dd 100644 --- a/app/frontend/src/javascript/controllers/calendar.js +++ b/app/frontend/src/javascript/controllers/calendar.js @@ -204,8 +204,8 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ center: 'title', right: '' }, - minTime: moment.duration(moment(bookingWindowStart.setting.value).format('HH:mm:ss')), - maxTime: moment.duration(moment(bookingWindowEnd.setting.value).format('HH:mm:ss')), + minTime: moment.duration(moment.utc(bookingWindowStart.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), + maxTime: moment.duration(moment.utc(bookingWindowEnd.setting.value.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), defaultView: window.innerWidth <= 480 ? 'agendaDay' : 'agendaWeek', eventClick (event, jsEvent, view) { return calendarEventClickCb(event, jsEvent, view); 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 8fe4e618a..0c7a86483 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,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' /** * Callback to call when the number of tickets to book changes in the current booking */ - $scope.changeNbPlaces = function () { + $scope.changeNbPlaces = function (priceType) { // compute the total remaining places - let remain = $scope.event.nb_free_places - $scope.reserve.nbReservePlaces; + let remain = ($scope.event.event_type === 'family' ? ($scope.children.length + 1) : $scope.event.nb_free_places) - $scope.reserve.nbReservePlaces; for (let ticket in $scope.reserve.tickets) { remain -= $scope.reserve.tickets[ticket]; } @@ -247,17 +256,41 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' } } + if ($scope.event.event_type === 'nominative' || $scope.event.event_type === 'family') { + const nbBookingUsers = $scope.reserve.bookingUsers[priceType].length; + const nbReservePlaces = priceType === 'normal' ? $scope.reserve.nbReservePlaces : $scope.reserve.tickets[priceType]; + if (nbReservePlaces > nbBookingUsers) { + _.times(nbReservePlaces - nbBookingUsers, () => { + $scope.reserve.bookingUsers[priceType].push({ event_price_category_id: priceType === 'normal' ? null : priceType, bookedUsers: buildBookedUsersOptions() }); + }); + } else { + _.times(nbBookingUsers - nbReservePlaces, () => { + $scope.reserve.bookingUsers[priceType].pop(); + }); + } + } + // recompute the total price return $scope.computeEventAmount(); }; + $scope.changeBookedUser = function () { + for (const key of Object.keys($scope.reserve.bookingUsers)) { + for (const user of $scope.reserve.bookingUsers[key]) { + user.bookedUsers = buildBookedUsersOptions(user.booked); + } + } + } + /** * Callback to reset the current reservation parameters * @param e {Object} see https://docs.angularjs.org/guide/expression#-event- */ $scope.cancelReserve = function (e) { e.preventDefault(); - return resetEventReserve(); + resetEventReserve(); + updateNbReservePlaces(); + return; }; $scope.isUserValidatedByType = () => { @@ -322,6 +355,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' Member.get({ id: $scope.ctrl.member.id }, function (member) { $scope.ctrl.member = member; getReservations($scope.event.id, 'Event', $scope.ctrl.member.id); + getChildren($scope.ctrl.member.id).then(() => { + updateNbReservePlaces(); + }); }); } }; @@ -372,7 +408,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' } , function (response) { // reservation failed - growl.error(response && response.data && response.data.card && response.data.card[0] || 'server error'); + growl.error(response && response.data && _.keys(response.data)[0] && response.data[_.keys(response.data)[0]][0] || 'server error'); // unset the attempting marker $scope.attempting = false; }) @@ -583,6 +619,38 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' growl.error(message); }; + /** + * Checks if the reservation of current event is valid + */ + $scope.reservationIsValid = () => { + if ($scope.event.event_type === 'nominative') { + for (const key of Object.keys($scope.reserve.bookingUsers)) { + for (const user of $scope.reserve.bookingUsers[key]) { + if (!_.trim(user.name)) { + return false; + } + } + } + } + if ($scope.event.event_type === 'family') { + for (const key of Object.keys($scope.reserve.bookingUsers)) { + for (const user of $scope.reserve.bookingUsers[key]) { + if (!user.booked) { + return false; + } + if ($scope.enableChildValidationRequired && user.booked.type === 'Child' && !user.booked.validatedAt) { + return false; + } + } + } + } + return true; + } + + $scope.isUnder18YearsAgo = (date) => { + return moment(date).isAfter(moment().subtract(18, 'year')); + } + /* PRIVATE SCOPE */ /** @@ -602,6 +670,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' // get the current user's reservations into $scope.reservations if ($scope.currentUser) { getReservations($scope.event.id, 'Event', $scope.currentUser.id); + getChildren($scope.currentUser.id).then(function (children) { + updateNbReservePlaces(); + }); } // watch when a coupon is applied to re-compute the total price @@ -626,6 +697,74 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' }).$promise.then(function (reservations) { $scope.reservations = reservations; }); }; + /** + * Retrieve the children for the user + * @param user_id {number} the user's id (current or managed) + */ + const getChildren = function (user_id) { + return Child.query({ + user_id + }).$promise.then(function (children) { + $scope.children = children; + return $scope.children; + }); + }; + + /** + * Update the number of places reserved by the current user + */ + const hasBookedUser = function (userKey) { + for (const key of Object.keys($scope.reserve.bookingUsers)) { + for (const user of $scope.reserve.bookingUsers[key]) { + if (user.booked && user.booked.key === userKey) { + return true; + } + } + } + return false; + }; + + /** + * Build the list of options for the select box of the booked users + * @param booked {object} the booked user + */ + const buildBookedUsersOptions = function (booked) { + const options = []; + const userKey = `user_${$scope.ctrl.member.id}`; + if ((booked && booked.key === userKey) || !hasBookedUser(userKey)) { + options.push({ key: userKey, name: $scope.ctrl.member.name, type: 'User', id: $scope.ctrl.member.id }); + } + for (const child of $scope.children) { + const key = `child_${child.id}`; + if ((booked && booked.key === key) || !hasBookedUser(key)) { + options.push({ + key, + name: child.first_name + ' ' + child.last_name, + id: child.id, + type: 'Child', + validatedAt: child.validated_at, + birthday: child.birthday + }); + } + } + return options; + }; + + /** + * update number of places available for each price category for the family event + */ + const updateNbReservePlaces = function () { + if ($scope.event.event_type === 'family') { + const maxPlaces = $scope.children.length + 1; + if ($scope.event.nb_free_places > maxPlaces) { + $scope.reserve.nbPlaces.normal = __range__(0, maxPlaces, true); + for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) { + $scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, maxPlaces, true); + } + } + } + }; + /** * Create a hash map implementing the Reservation specs * @param reserve {Object} Reservation parameters (places...) @@ -638,7 +777,8 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' reservable_type: 'Event', slots_reservations_attributes: [], nb_reserve_places: reserve.nbReservePlaces, - tickets_attributes: [] + tickets_attributes: [], + booking_users_attributes: [] }; reservation.slots_reservations_attributes.push({ @@ -656,6 +796,19 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' } } + if (event.event_type === 'nominative' || event.event_type === 'family') { + for (const key of Object.keys($scope.reserve.bookingUsers)) { + for (const user of $scope.reserve.bookingUsers[key]) { + reservation.booking_users_attributes.push({ + event_price_category_id: user.event_price_category_id, + name: user.booked ? user.booked.name : _.trim(user.name), + booked_id: user.booked ? user.booked.id : undefined, + booked_type: user.booked ? user.booked.type : undefined, + }); + } + } + } + return { reservation }; }; @@ -688,11 +841,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' tickets: {}, toReserve: false, amountTotal: 0, - totalSeats: 0 + totalSeats: 0, + bookingUsers: { + normal: [], + }, }; for (let evt_px_cat of Array.from($scope.event.event_price_categories_attributes)) { $scope.reserve.nbPlaces[evt_px_cat.id] = __range__(0, $scope.event.nb_free_places, true); + $scope.reserve.bookingUsers[evt_px_cat.id] = []; $scope.reserve.tickets[evt_px_cat.id] = 0; } @@ -815,6 +972,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' $scope.reservations.push(reservation); }); resetEventReserve(); + updateNbReservePlaces(); $scope.reserveSuccess = true; $scope.coupon.applied = null; if ($scope.currentUser.role === 'admin') { diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index c6fa655c9..9d8556865 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -447,8 +447,8 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran // fullCalendar (v2) configuration $scope.calendarConfig = CalendarConfig({ - minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')), - maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')), + minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), + maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), eventClick (event, jsEvent, view) { return calendarEventClickCb(event, jsEvent, view); }, diff --git a/app/frontend/src/javascript/controllers/spaces.js.erb b/app/frontend/src/javascript/controllers/spaces.js.erb index 13899c7c2..d4bded6da 100644 --- a/app/frontend/src/javascript/controllers/spaces.js.erb +++ b/app/frontend/src/javascript/controllers/spaces.js.erb @@ -385,8 +385,8 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi // fullCalendar (v2) configuration $scope.calendarConfig = CalendarConfig({ - minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')), - maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')), + minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), + maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), eventClick (event, jsEvent, view) { return calendarEventClickCb(event, jsEvent, view); }, diff --git a/app/frontend/src/javascript/controllers/trainings.js.erb b/app/frontend/src/javascript/controllers/trainings.js.erb index 929e56148..0fa0a18a8 100644 --- a/app/frontend/src/javascript/controllers/trainings.js.erb +++ b/app/frontend/src/javascript/controllers/trainings.js.erb @@ -155,8 +155,8 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra // fullCalendar (v2) configuration $scope.calendarConfig = CalendarConfig({ - minTime: moment.duration(moment(settingsPromise.booking_window_start).format('HH:mm:ss')), - maxTime: moment.duration(moment(settingsPromise.booking_window_end).format('HH:mm:ss')), + minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), + maxTime: moment.duration(moment.utc(settingsPromise.booking_window_end.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')), eventClick (event, jsEvent, view) { return calendarEventClickCb(event, jsEvent, view); }, 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..892ca95dc 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,8 @@ export interface Event { }>, recurrence: RecurrenceOption, recurrence_end_at: Date, - advanced_accounting_attributes?: AdvancedAccounting + advanced_accounting_attributes?: AdvancedAccounting, + event_type: EventType, } 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 b3fbe012b..db950a40d 100644 --- a/app/frontend/src/javascript/models/reservation.ts +++ b/app/frontend/src/javascript/models/reservation.ts @@ -45,6 +45,13 @@ export interface Reservation { }, total_booked_seats?: number, created_at?: TDateISO, + booking_users_attributes?: { + id: number, + name: string, + event_price_category_id: number, + booked_id: number, + booked_type: string, + } } export interface ReservationIndexFilter extends ApiFilter { diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 27f84db14..5090487cb 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -178,7 +178,8 @@ export const accountSettings = [ 'external_id', 'user_change_group', 'user_validation_required', - 'user_validation_required_list' + 'user_validation_required_list', + 'family_account' ] as const; export const analyticsSettings = [ 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 26ce00a93..936162a4e 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: { @@ -615,7 +627,7 @@ angular.module('application.router', ['ui.router']) resolve: { eventPromise: ['Event', '$transition$', function (Event, $transition$) { return Event.get({ id: $transition$.params().id }).$promise; }], priceCategoriesPromise: ['PriceCategory', function (PriceCategory) { return PriceCategory.query().$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list']" }).$promise; }] + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['booking_move_enable', 'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'event_explications_alert', 'online_payment_module', 'user_validation_required', 'user_validation_required_list', 'child_validation_required']" }).$promise; }] } }) 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/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index b70e769c6..cf9ff38aa 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -52,6 +52,9 @@ @import "modules/events/event-form"; @import "modules/events/update-recurrent-modal"; @import "modules/events/events-settings.scss"; +@import "modules/family-account/child-form"; +@import "modules/family-account/child-item"; +@import "modules/family-account/children-dashboard"; @import "modules/form/abstract-form-item"; @import "modules/form/form-input"; @import "modules/form/form-multi-file-upload"; 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/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/reservations.html b/app/frontend/templates/admin/events/reservations.html index 81d87e088..b7a04527c 100644 --- a/app/frontend/templates/admin/events/reservations.html +++ b/app/frontend/templates/admin/events/reservations.html @@ -29,7 +29,11 @@ -
{{ reservation.user_full_name }} + {{ reservation.user_full_name }} +
+ {{bu.name}} + {{bu.name}} +
{{ reservation.created_at | amDateFormat:'LL LTS' }} diff --git a/app/frontend/templates/admin/members/edit.html b/app/frontend/templates/admin/members/edit.html index c3479225e..752bffe3a 100644 --- a/app/frontend/templates/admin/members/edit.html +++ b/app/frontend/templates/admin/members/edit.html @@ -62,10 +62,15 @@ + + + + @@ -208,10 +213,20 @@
{{ 'app.admin.members_edit.NUMBER_full_price_tickets_reserved' }} + +
+ {{bu.name}} + {{bu.name}} +

{{ 'app.admin.members_edit.NUMBER_NAME_tickets_reserved' }} + +
+ {{bu.name}} + {{bu.name}} +
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.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..7be0fb820 100644 --- a/app/frontend/templates/admin/settings/compte.html +++ b/app/frontend/templates/admin/settings/compte.html @@ -51,6 +51,30 @@
+
+

{{ 'app.admin.settings.family_account' }}

+

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

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

@@ -156,4 +180,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..9af91bd8f 100644 --- a/app/frontend/templates/dashboard/events.html +++ b/app/frontend/templates/dashboard/events.html @@ -26,12 +26,20 @@ translate-values="{NUMBER: r.nb_reserve_places}"> {{ 'app.logged.dashboard.events.NUMBER_normal_places_reserved' }} + +
+ {{bu.name}} +

{{ 'app.logged.dashboard.events.NUMBER_of_NAME_places_reserved' }} + +
+ {{bu.name}} +
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' }}