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

(wip) add/edit user children

This commit is contained in:
Du Peng 2023-04-03 18:23:49 +02:00
parent 7d10132953
commit 5365cbdaba
23 changed files with 509 additions and 4 deletions

View File

@ -0,0 +1,52 @@
# 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]
def index
@children = policy_scope(Child)
end
def show
authorize @child
end
def create
@child = Child.new(child_params)
authorize @child
if @child.save
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
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)
end
end

View File

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

View File

@ -0,0 +1,53 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Child } from '../../models/child';
import { TDateISODate } from '../../typings/date-iso';
import { FormInput } from '../form/form-input';
interface ChildFormProps {
child: Child;
onChange: (field: string, value: string | TDateISODate) => void;
}
/**
* A form for creating or editing a child.
*/
export const ChildForm: React.FC<ChildFormProps> = ({ child, onChange }) => {
const { t } = useTranslation('public');
const { register, formState } = useForm<Child>({
defaultValues: child
});
/**
* Handle the change of a child form field
*/
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
onChange(event.target.id, event.target.value);
};
return (
<div className="child-form">
<div className="info-area">
{t('app.public.child_form.child_form_info')}
</div>
<form>
<FormInput id="first_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.first_name')}
onChange={handleChange}
/>
<FormInput id="last_name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.public.child_form.last_name')}
onChange={handleChange}
/>
</form>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Child } from '../../models/child';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
interface ChildItemProps {
child: Child;
onEdit: (child: Child) => void;
onDelete: (child: Child) => void;
}
/**
* A child item.
*/
export const ChildItem: React.FC<ChildItemProps> = ({ child, onEdit, onDelete }) => {
const { t } = useTranslation('public');
return (
<div className="child-item">
<div>
<div>{t('app.public.child_item.last_name')}</div>
<div>{child.last_name}</div>
</div>
<div>
<div>{t('app.public.child_item.first_name')}</div>
<div>{child.first_name}</div>
</div>
<div>
<div>{t('app.public.child_item.birthday')}</div>
<div>{child.birthday}</div>
</div>
<div className="actions">
<FabButton icon={<i className="fa fa-edit" />} onClick={() => onEdit(child)} className="edit-button" />
<FabButton icon={<i className="fa fa-trash" />} onClick={() => onDelete(child)} className="delete-button" />
</div>
</div>
);
};

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from '../base/fab-modal';
import { Child } from '../../models/child';
import { TDateISODate } from '../../typings/date-iso';
import ChildAPI from '../../api/child';
import { ChildForm } from './child-form';
interface ChildModalProps {
child?: Child;
isOpen: boolean;
toggleModal: () => void;
}
/**
* A modal for creating or editing a child.
*/
export const ChildModal: React.FC<ChildModalProps> = ({ child, isOpen, toggleModal }) => {
const { t } = useTranslation('public');
const [data, setData] = useState<Child>(child);
console.log(child, data);
/**
* Save the child to the API
*/
const handleSaveChild = async (): Promise<void> => {
try {
if (child?.id) {
await ChildAPI.update(data);
} else {
await ChildAPI.create(data);
}
toggleModal();
} catch (error) {
console.error(error);
}
};
/**
* Check if the form is valid to save the child
*/
const isPreventedSaveChild = (): boolean => {
return !data?.first_name || !data?.last_name;
};
/**
* Handle the change of a child form field
*/
const handleChildChanged = (field: string, value: string | TDateISODate): void => {
setData({
...data,
[field]: value
});
};
return (
<FabModal title={t(`app.public.child_modal.${child?.id ? 'edit' : 'new'}_child`)}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.public.child_modal.save')}
onConfirm={handleSaveChild}
preventConfirm={isPreventedSaveChild()}>
<ChildForm child={child} onChange={handleChildChanged} />
</FabModal>
);
};

View File

@ -0,0 +1,86 @@
import React, { useState, useEffect } from 'react';
import { react2angular } from 'react2angular';
import { Child } from '../../models/child';
// import { ChildListItem } from './child-list-item';
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';
declare const Application: IApplication;
interface ChildrenListProps {
currentUser: User;
}
/**
* A list of children belonging to the current user.
*/
export const ChildrenList: React.FC<ChildrenListProps> = ({ currentUser }) => {
const { t } = useTranslation('public');
const [children, setChildren] = useState<Array<Child>>([]);
const [isOpenChildModal, setIsOpenChildModal] = useState<boolean>(false);
const [child, setChild] = useState<Child>();
useEffect(() => {
ChildAPI.index({ user_id: currentUser.id }).then(setChildren);
}, [currentUser]);
/**
* Open the add child modal
*/
const addChild = () => {
setIsOpenChildModal(true);
setChild({ user_id: currentUser.id } as Child);
};
/**
* Open the edit child modal
*/
const editChild = (child: Child) => {
setIsOpenChildModal(true);
setChild(child);
};
/**
* Delete a child
*/
const deleteChild = (child: Child) => {
ChildAPI.destroy(child.id).then(() => {
setChildren(children.filter(c => c.id !== child.id));
});
};
return (
<section>
<header>
<h2>{t('app.public.children_list.heading')}</h2>
<FabButton onClick={addChild}>
{t('app.public.children_list.add_child')}
</FabButton>
</header>
<div>
{children.map(child => (
<ChildItem key={child.id} child={child} onEdit={editChild} onDelete={deleteChild} />
))}
</div>
<ChildModal child={child} isOpen={isOpenChildModal} toggleModal={() => setIsOpenChildModal(false)} />
</section>
);
};
const ChildrenListWrapper: React.FC<ChildrenListProps> = (props) => {
return (
<Loader>
<ChildrenList {...props} />
</Loader>
);
};
Application.Components.component('childrenList', react2angular(ChildrenListWrapper, ['currentUser']));

View File

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

View File

@ -0,0 +1,16 @@
import { TDateISODate } 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
}

View File

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

View File

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

View File

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

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

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Child is a modal for a child of a user
class Child < ApplicationRecord
belongs_to :user
validates :first_name, presence: true
validates :last_name, presence: true
validate :validate_age
def validate_age
errors.add(:birthday, 'You should be over 18 years old.') if birthday.blank? && birthday < 18.years.ago
end
end

View File

@ -53,6 +53,9 @@ class User < ApplicationRecord
has_many :notifications, as: :receiver, dependent: :destroy
has_many :notification_preferences, dependent: :destroy
has_many :children, dependent: :destroy
accepts_nested_attributes_for :children, allow_destroy: true
# fix for create admin user
before_save do
email&.downcase!

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Check the access policies for API::ChildrenController
class ChildPolicy < ApplicationPolicy
# Defines the scope of the children index, depending on the current user
class Scope < Scope
def resolve
scope.where(user_id: user.id)
end
end
def index?
!user.organization?
end
def create?
!user.organization? && user.id == record.user_id
end
def show?
user.id == record.user_id
end
def update?
user.id == record.user_id
end
def destroy?
user.id == record.user_id
end
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.extract! child, :id, :first_name, :last_name, :email, :birthday, :phone, :user_id

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ en:
#dashboard sections
dashboard: "Dashboard"
my_profile: "My Profile"
my_children: "My Children"
my_settings: "My Settings"
my_supporting_documents_files: "My supporting documents"
my_projects: "My Projects"
@ -481,6 +482,8 @@ en:
member_select:
select_a_member: "Select a member"
start_typing: "Start typing..."
children_list:
heading: "My children"
tour:
conclusion:
title: "Thank you for your attention"

View File

@ -14,6 +14,7 @@ fr:
#dashboard sections
dashboard: "Tableau de bord"
my_profile: "Mon profil"
my_children: "Mes enfants"
my_settings: "Mes paramètres"
my_supporting_documents_files: "Mes justificatifs"
my_projects: "Mes projets"
@ -481,6 +482,21 @@ fr:
member_select:
select_a_member: "Sélectionnez un membre"
start_typing: "Commencez à écrire..."
children_list:
heading: "Mes enfants"
add_child: "Ajouter un enfant"
child_modal:
edit_child: "Modifier un enfant"
new_child: "Ajouter un enfant"
save: "Enregistrer"
child_form:
child_form_info: "Notez que vous ne pouvez ajouter que vos enfants de moins de 18 ans. Des pièces justificatives sont demandés par votre administrateur, elles lui seront utiles pour valider le compte de votre enfant et ainsi autoriser la réservation d'événements."
first_name: "Prénom"
last_name: "Nom"
child_item:
first_name: "Prénom de l'enfant"
last_name: "Nom de l'enfant"
birthday: "Date de naissance"
tour:
conclusion:
title: "Merci de votre attention"

View File

@ -185,6 +185,8 @@ Rails.application.routes.draw do
get 'withdrawal_instructions', on: :member
end
resources :children, only: %i[index show create update destroy]
# for admin
resources :trainings do
get :availabilities, on: :member

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# Child is a modal for a child of a user
class CreateChildren < ActiveRecord::Migration[5.2]
def change
create_table :children do |t|
t.belongs_to :user, foreign_key: true
t.string :first_name
t.string :last_name
t.date :birthday
t.string :phone
t.string :email
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[6.1].define(version: 2023_03_15_095054) do
ActiveRecord::Schema[6.1].define(version: 2023_03_31_132506) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -263,6 +263,18 @@ ActiveRecord::Schema[6.1].define(version: 2023_03_15_095054) do
t.index ["slug"], name: "index_categories_on_slug", unique: true
end
create_table "children", force: :cascade do |t|
t.bigint "user_id"
t.string "first_name"
t.string "last_name"
t.date "birthday"
t.string "phone"
t.string "email"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_children_on_user_id"
end
create_table "components", id: :serial, force: :cascade do |t|
t.string "name", null: false
end
@ -1332,8 +1344,8 @@ ActiveRecord::Schema[6.1].define(version: 2023_03_15_095054) do
t.boolean "is_allow_newsletter"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
t.string "mapped_from_sso"
t.datetime "validated_at"
t.string "mapped_from_sso"
t.index ["auth_token"], name: "index_users_on_auth_token"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
@ -1409,6 +1421,7 @@ ActiveRecord::Schema[6.1].define(version: 2023_03_15_095054) do
add_foreign_key "cart_item_reservations", "plans"
add_foreign_key "cart_item_subscriptions", "invoicing_profiles", column: "customer_profile_id"
add_foreign_key "cart_item_subscriptions", "plans"
add_foreign_key "children", "users"
add_foreign_key "event_price_categories", "events"
add_foreign_key "event_price_categories", "price_categories"
add_foreign_key "events", "categories"