From c3efad6e4302d5e00be192434dcfc4e5615845be Mon Sep 17 00:00:00 2001 From: Elhoucine Azayou Date: Thu, 11 Jul 2024 16:03:28 +0200 Subject: [PATCH] Feat: move admin pages to new panel * fix(#vil-503): villages monde * feat(#vil-504): admin users page * feat(#vil-513): admin add access page * feat(#vil-514): admin h5p page --- src/components/admin/AdminTile.tsx | 6 +- src/contexts/mediathequeContext.tsx | 2 +- src/pages/admin/newportal/create/index.tsx | 2 +- src/pages/admin/newportal/h5p/edit/[id].tsx | 70 ++++ src/pages/admin/newportal/h5p/index.tsx | 136 +++++++ src/pages/admin/newportal/h5p/new.tsx | 56 +++ .../admin/newportal/manage/access/index.tsx | 361 +++++++++++++++++- .../newportal/manage/users/edit/[id].tsx | 249 ++++++++++++ .../admin/newportal/manage/users/index.tsx | 254 +++++++++++- .../admin/newportal/manage/users/new.tsx | 244 ++++++++++++ .../newportal/manage/villages/edit/[id].tsx | 119 ++++++ .../admin/newportal/manage/villages/index.tsx | 181 ++++++++- .../admin/newportal/manage/villages/new.tsx | 90 +++++ 13 files changed, 1746 insertions(+), 24 deletions(-) create mode 100644 src/pages/admin/newportal/h5p/edit/[id].tsx create mode 100644 src/pages/admin/newportal/h5p/index.tsx create mode 100644 src/pages/admin/newportal/h5p/new.tsx create mode 100644 src/pages/admin/newportal/manage/users/edit/[id].tsx create mode 100644 src/pages/admin/newportal/manage/users/new.tsx create mode 100644 src/pages/admin/newportal/manage/villages/edit/[id].tsx create mode 100644 src/pages/admin/newportal/manage/villages/new.tsx diff --git a/src/components/admin/AdminTile.tsx b/src/components/admin/AdminTile.tsx index 3c670f269..47a407f93 100644 --- a/src/components/admin/AdminTile.tsx +++ b/src/components/admin/AdminTile.tsx @@ -19,7 +19,7 @@ export const AdminTile = ({ style = {}, }: React.PropsWithChildren) => { return ( - + theme.palette.secondary.main, @@ -27,9 +27,11 @@ export const AdminTile = ({ fontWeight: 'bold', minHeight: 'unset', padding: '8px 8px 8px 16px', + justifyContent: 'space-between', + flexWrap: 'wrap', }} > - + {title} {selectLanguage} {toolbarButton} diff --git a/src/contexts/mediathequeContext.tsx b/src/contexts/mediathequeContext.tsx index a70c72fef..e6417c628 100644 --- a/src/contexts/mediathequeContext.tsx +++ b/src/contexts/mediathequeContext.tsx @@ -55,7 +55,7 @@ export const MediathequeProvider: React.FC = ({ childr useEffect(() => { const activitiesMediaFinder = usersData - ?.filter(({ type }: { type: number }) => ![3, 5, 11].includes(type)) + ?.filter?.(({ type }: { type: number }) => ![3, 5, 11].includes(type)) .map(({ id, content, subType, type, villageId, userId, user, village, data }: UserData) => { const result: { id: number; diff --git a/src/pages/admin/newportal/create/index.tsx b/src/pages/admin/newportal/create/index.tsx index a822bb607..42521198c 100644 --- a/src/pages/admin/newportal/create/index.tsx +++ b/src/pages/admin/newportal/create/index.tsx @@ -62,7 +62,7 @@ const Creer = () => { const links: Link[] = [ { name: 'Créer du contenu libre', link: '/admin/newportal/contenulibre/1', action: handleNewActivity }, - { name: 'Créer une activité H5P', link: 'https://' }, + { name: 'Créer une activité H5P', link: '/admin/newportal/h5p' }, { name: 'Paramétrer l’hymne', link: 'https://' }, { name: 'Mixer l’hymne', link: 'https://' }, ]; diff --git a/src/pages/admin/newportal/h5p/edit/[id].tsx b/src/pages/admin/newportal/h5p/edit/[id].tsx new file mode 100644 index 000000000..afa113383 --- /dev/null +++ b/src/pages/admin/newportal/h5p/edit/[id].tsx @@ -0,0 +1,70 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { Breadcrumbs, Button } from '@mui/material'; +import MaterialLink from '@mui/material/Link'; + +import { useH5pContentList } from 'src/api/h5p/h5p-content.list'; +import { AdminTile } from 'src/components/admin/AdminTile'; +import { H5pEditor } from 'src/components/h5p'; +import { getQueryString } from 'src/utils'; + +const H5pEditContentPage = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { data: h5pContent } = useH5pContentList(); + const { enqueueSnackbar } = useSnackbar(); + + const contentId = React.useMemo(() => getQueryString(router.query.id), [router]); + const content = (h5pContent || []).find((h5p) => h5p.contentId === contentId); + + if (h5pContent && !content) { + router.push(`/admin/h5p`); + } + if (!content) { + return null; + } + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Contenu H5P

+
+ +

{content.title}

+
+ +
+ { + enqueueSnackbar('Contenu H5P modifié avec succès!', { + variant: 'success', + }); + queryClient.invalidateQueries('h5p'); + router.push(`/admin/h5p`); + }} + onError={(message) => { + enqueueSnackbar(message, { + variant: 'error', + }); + }} + > +
+
+ + + +
+ ); +}; + +export default H5pEditContentPage; diff --git a/src/pages/admin/newportal/h5p/index.tsx b/src/pages/admin/newportal/h5p/index.tsx new file mode 100644 index 000000000..5dc3f544c --- /dev/null +++ b/src/pages/admin/newportal/h5p/index.tsx @@ -0,0 +1,136 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; + +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { Button, NoSsr, IconButton, Tooltip } from '@mui/material'; + +import { useDeleteH5pContentMutation } from 'src/api/h5p/h5p-content.delete'; +import { useH5pContentList } from 'src/api/h5p/h5p-content.list'; +import { Modal } from 'src/components/Modal'; +import { AdminTable } from 'src/components/admin/AdminTable'; +import { AdminTile } from 'src/components/admin/AdminTile'; +import { defaultContainedButtonStyle } from 'src/styles/variables.const'; +import BackArrow from 'src/svg/back-arrow.svg'; + +const H5pList = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + const { data: h5pContent } = useH5pContentList(); + const [deleteIndex, setDeleteIndex] = React.useState(-1); + const { mutate: deleteH5pMutate, isLoading } = useDeleteH5pContentMutation(); + + const actions = (id: string) => ( + <> + + { + router.push(`/admin/newportal/h5p/edit/${id}`); + }} + > + + + + + { + setDeleteIndex((h5pContent || []).findIndex((h5p) => h5p.contentId === id)); + }} + > + + + + + ); + + return ( +
+ +
+ +

H5P

+
+ + + + + } + > + + {"Vous n'avez pas encore de contenu H5P ! "} + + En créer un ? + + + } + data={(h5pContent || []).map((h5p) => ({ ...h5p, id: h5p.contentId }))} + columns={[ + { key: 'title', label: 'Nom du contenu', sortable: true }, + { key: 'mainLibrary', label: 'Type' }, + ]} + actions={actions} + /> + + + { + setDeleteIndex(-1); + }} + onConfirm={() => { + deleteH5pMutate(h5pContent?.[deleteIndex]?.contentId || '', { + onSettled: () => { + queryClient.invalidateQueries('h5p'); + setDeleteIndex(-1); + }, + onError: () => { + enqueueSnackbar('Une erreur est survenue...', { + variant: 'error', + }); + }, + onSuccess: () => { + enqueueSnackbar('Contenu H5P supprimé avec succès!', { + variant: 'success', + }); + }, + }); + }} + loading={isLoading} + fullWidth + maxWidth="sm" + ariaLabelledBy="delete-h5p-id" + ariaDescribedBy="delete-h5p-desc" + error + > +
+ Voulez vous vraiment supprimer le contenu H5P {(h5pContent || [])[deleteIndex]?.title} ? +
+
+
+
+ ); +}; + +export default H5pList; diff --git a/src/pages/admin/newportal/h5p/new.tsx b/src/pages/admin/newportal/h5p/new.tsx new file mode 100644 index 000000000..16c884ead --- /dev/null +++ b/src/pages/admin/newportal/h5p/new.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { Breadcrumbs, Button } from '@mui/material'; +import MaterialLink from '@mui/material/Link'; + +import { AdminTile } from 'src/components/admin/AdminTile'; +import { H5pEditor } from 'src/components/h5p'; + +const H5pNewContentPage = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Contenu H5P

+
+ +

Nouveau

+
+ +
+ { + enqueueSnackbar('Contenu H5P créé avec succès!', { + variant: 'success', + }); + queryClient.invalidateQueries('h5p'); + router.push(`/admin/newportal/h5p`); + }} + onError={(message) => { + enqueueSnackbar(message, { + variant: 'error', + }); + }} + > +
+
+ + + +
+ ); +}; + +export default H5pNewContentPage; diff --git a/src/pages/admin/newportal/manage/access/index.tsx b/src/pages/admin/newportal/manage/access/index.tsx index f915f958b..db114a15a 100644 --- a/src/pages/admin/newportal/manage/access/index.tsx +++ b/src/pages/admin/newportal/manage/access/index.tsx @@ -1,28 +1,361 @@ import Link from 'next/link'; -import React from 'react'; +import { useSnackbar } from 'notistack'; +import React, { useState, useMemo } from 'react'; +import { useQueryClient } from 'react-query'; -import { UserContext } from 'src/contexts/userContext'; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Radio, + RadioGroup, + FormControlLabel, + Typography, + Grid, + Paper, + Box, + TableContainer, + InputLabel, + Select, + MenuItem, + FormControl, +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material/Select'; + +import { useFeatureFlags } from 'src/api/featureFlag/featureFlag.get'; +import { useUsers } from 'src/api/user/user.list'; import BackArrow from 'src/svg/back-arrow.svg'; -import { UserType } from 'types/user.type'; +import { axiosRequest } from 'src/utils/axiosRequest'; +import { FEATURE_FLAGS_NAMES } from 'types/featureFlag.constant'; +import type { User, UserType } from 'types/user.type'; +import { userTypeNames } from 'types/user.type'; + +const FeatureFlagsTest: React.FC = () => { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const { data: featureFlags } = useFeatureFlags(); + const { data: users } = useUsers(); + const [newFeatureFlag, setNewFeatureFlag] = useState({ name: '', isEnabled: false }); + const [userFilter, setUserFilter] = useState(''); + + const [addedUsers, setAddedUsers] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [usersPerPage] = useState(10); + const [userTypeFilter, setUserTypeFilter] = useState(''); + const [countryCodeFilter, setCountryCodeFilter] = useState(''); + + const filteredUsers = useMemo(() => { + const usersWithoutAccess = (users || []).filter((user) => !addedUsers?.find((addedUser) => addedUser.id === user.id)); + + let filtered = usersWithoutAccess; + + if (userFilter) { + filtered = filtered.filter((user) => user.email.toLowerCase().includes(userFilter.toLowerCase())); + } + + if (userTypeFilter !== '') { + filtered = filtered.filter((user) => user.type === userTypeFilter); + } + + if (countryCodeFilter) { + filtered = filtered.filter((user) => user.country?.isoCode === countryCodeFilter); + } + + return filtered; + }, [users, userFilter, addedUsers, userTypeFilter, countryCodeFilter]); + + const filteredAddedUsers = useMemo(() => { + let filtered = addedUsers; + + if (userFilter) { + filtered = filtered.filter((user) => user.email.toLowerCase().includes(userFilter.toLowerCase())); + } + + if (userTypeFilter !== '') { + filtered = filtered.filter((user) => user.type === userTypeFilter); + } + + if (countryCodeFilter) { + filtered = filtered.filter((user) => user.country?.isoCode === countryCodeFilter); + } + + return filtered; + }, [addedUsers, userFilter, userTypeFilter, countryCodeFilter]); + + const indexOfLastUser = currentPage * usersPerPage; + const indexOfFirstUser = indexOfLastUser - usersPerPage; + const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser); + const totalPages = Math.ceil(filteredUsers.length / usersPerPage); + + const handleNewFeatureFlagChange = async (event: SelectChangeEvent) => { + const featureFlagName = event.target.value; -const Access = () => { - const { user } = React.useContext(UserContext); - const hasAccess = user?.type === UserType.SUPER_ADMIN; + // Find the feature flag object from the featureFlags state using the selected feature flag name + const selectedFeatureFlag = (featureFlags || []).find((flag) => flag.name === featureFlagName); + + if (selectedFeatureFlag) { + // Update the newFeatureFlag and addedUsers states with the selected feature flag data + setNewFeatureFlag({ + name: selectedFeatureFlag.name, + isEnabled: selectedFeatureFlag.isEnabled, + }); + setAddedUsers(selectedFeatureFlag.users || []); + } else { + // Reset the newFeatureFlag and addedUsers states if the selected feature flag is not found + setNewFeatureFlag({ name: '', isEnabled: false }); + setAddedUsers([]); + } + }; + + const handleCheckboxChange = (event: React.ChangeEvent) => { + setNewFeatureFlag({ ...newFeatureFlag, isEnabled: event.target.value === 'true' }); + }; + + const handleUserFilterChange = (event: React.ChangeEvent) => { + setUserFilter(event.target.value); + }; + + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!newFeatureFlag.name) { + alert('Please select a feature flag.'); + return; + } + + const addedUserIds = addedUsers.map((user) => user.id); + + const response = await axiosRequest({ + method: 'POST', + url: '/featureFlags', + data: { + name: newFeatureFlag.name, + isEnabled: newFeatureFlag.isEnabled, + users: addedUserIds, + }, + }); + if (response.error) { + enqueueSnackbar('Erreur, impossible de mettre à jour le feature flag...', { + variant: 'error', + }); + const selectedFeatureFlag = (featureFlags || []).find((flag) => flag.name === newFeatureFlag.name); + if (selectedFeatureFlag) { + setNewFeatureFlag({ + name: selectedFeatureFlag.name, + isEnabled: selectedFeatureFlag.isEnabled, + }); + setAddedUsers(selectedFeatureFlag.users || []); + } + } else { + queryClient.invalidateQueries('feature-flags'); + enqueueSnackbar('Feature flag mis à jour !', { + variant: 'success', + }); + } + }; + + const handleAddUser = (userId: number) => { + const userToAdd = filteredUsers.find((user) => user.id === userId); + if (userToAdd) { + setAddedUsers((prevAddedUsers) => [...(prevAddedUsers || []), userToAdd]); + } + }; + + const handleRemoveUser = (userId: number) => { + setAddedUsers((prevAddedUsers) => prevAddedUsers.filter((user) => user.id !== userId)); + }; - if (!hasAccess) { - return

Vous n'avez pas accès à cette page, vous devez être super admin.

; - } return ( -
+ -
+

Droits d'accès

-

Il y a ici la liste complète des droits d'accès sur 1Village.

-
+ Gestion des feature flags (restrictions d'accès) + + Choisir une restriction + + +
+
+ + Feature Flag + + + +
+ + {newFeatureFlag.name && ( + <> + + État + + + } label="Actif pour tout le monde" /> + } label="Actif pour certains utilisateurs" /> + + + )} + + {newFeatureFlag.name && !newFeatureFlag.isEnabled && ( + + + + Utilisateurs + + + + + + + + Type d'utilisateur + + + + + ) => setCountryCodeFilter(event.target.value)} + /> + + + )} +
+ + {newFeatureFlag.name && !newFeatureFlag.isEnabled && ( + + + + Utilisateurs sans accès + + + + + + ID + Pseudo + Email + Country Code + Type + Action + + + + {currentUsers?.map((user) => ( + + {user.id} + {user.pseudo} + {user.email} + {user.country?.isoCode} + {user.type} + + + + + ))} + +
+
+ + + + + Page {currentPage} of {totalPages} + + + +
+ + + Utilisateurs avec accès{' '} + + + + + + ID + Pseudo + Email + Country Code + Action + + + + {filteredAddedUsers?.map((user) => ( + + {user.id} + {user.pseudo} + {user.email} + {user.country?.isoCode} + + + + + ))} + +
+
+
+
+ )} +
); }; -export default Access; +export default FeatureFlagsTest; diff --git a/src/pages/admin/newportal/manage/users/edit/[id].tsx b/src/pages/admin/newportal/manage/users/edit/[id].tsx new file mode 100644 index 000000000..7f499c43a --- /dev/null +++ b/src/pages/admin/newportal/manage/users/edit/[id].tsx @@ -0,0 +1,249 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import MaterialLink from '@mui/material/Link'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; + +import { AdminTile } from 'src/components/admin/AdminTile'; +import { CountrySelector } from 'src/components/selectors/CountrySelector'; +import { useUserRequests } from 'src/services/useUsers'; +import { useVillages } from 'src/services/useVillages'; +import { getQueryString } from 'src/utils'; +import { isPseudoValid, isEmailValid } from 'src/utils/accountChecks'; +import { axiosRequest } from 'src/utils/axiosRequest'; +import type { User } from 'types/user.type'; +import { UserType, userTypeNames } from 'types/user.type'; + +const Required = (label: string) => ( + <> + {label} + + * + + +); + +const EditUser = () => { + const router = useRouter(); + + const { villages } = useVillages(); + const { editUser } = useUserRequests(); + const { enqueueSnackbar } = useSnackbar(); + const userId = React.useMemo(() => parseInt(getQueryString(router.query.id), 10) || 0, [router]); + const [user, setUser] = React.useState(null); + const initialPseudo = React.useRef(''); + + const [errors, setErrors] = React.useState({ + email: false, + pseudo: false, + }); + + const getUser = React.useCallback(async () => { + const response = await axiosRequest({ + method: 'GET', + url: `/users/${userId}`, + }); + if (response.error) { + router.push('/admin/newportal/manage/users'); + } else { + setUser(response.data); + initialPseudo.current = response.data.pseudo || ''; + } + }, [router, userId]); + + React.useEffect(() => { + getUser().catch((e) => console.error(e)); + }, [getUser]); + + const checkEmailAndPseudo = async () => { + if (user === null) { + return; + } + const pseudoValid = await isPseudoValid(user.pseudo, initialPseudo.current); + setErrors((e) => ({ + ...e, + email: user.email !== undefined && !isEmailValid(user.email), + pseudo: user.pseudo !== undefined && !pseudoValid, + })); + }; + + if (user === null) { + return
; + } + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const requiredFields: Extract[] = ['email', 'pseudo', 'country']; + if (user.type === UserType.TEACHER) { + requiredFields.push('villageId'); + } + for (const field of requiredFields) { + if (!user[field]) { + enqueueSnackbar('Certain champs requis (*) sont non remplis!', { + variant: 'warning', + }); + return; + } + } + await checkEmailAndPseudo(); + if (errors.email || errors.pseudo) { + enqueueSnackbar('Email et/ou pseudo invalide', { + variant: 'warning', + }); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { position: _ignore, ...updatedValues } = user; + const result = await editUser({ ...updatedValues, villageId: user.villageId || null }); + if (result !== null) { + router.push('/admin/newportal/manage/users'); + } + }; + + const updateUserField = (field: Extract) => (event: React.ChangeEvent) => { + setUser((u) => (!u ? null : { ...u, [field]: event.target.value })); + }; + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Utilisateurs

+
+ +

{user.email}

+
+ +
+ + + + + + + + + Rôle + + + + {user.type === UserType.TEACHER ? Required('Village') : 'Village'} + + + { + setUser((u) => (!u ? null : { ...u, country: { isoCode: countryCode, name: '' } })); + }} + filterCountries={ + user.villageId ? villages.find((v) => v.id === user.villageId)?.countries?.map((c) => c.isoCode) || undefined : undefined + } + style={{ width: '100%', marginBottom: '1rem' }} + /> +
+ +
+ +
+
+ ); +}; + +export default EditUser; diff --git a/src/pages/admin/newportal/manage/users/index.tsx b/src/pages/admin/newportal/manage/users/index.tsx index 60bc41bc5..17d540bf4 100644 --- a/src/pages/admin/newportal/manage/users/index.tsx +++ b/src/pages/admin/newportal/manage/users/index.tsx @@ -1,18 +1,266 @@ import Link from 'next/link'; -import React from 'react'; +import { useRouter } from 'next/router'; +import React, { useCallback, useMemo, useState } from 'react'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import DeleteIcon from '@mui/icons-material/Delete'; +import DownloadIcon from '@mui/icons-material/Download'; +import EditIcon from '@mui/icons-material/Edit'; +import type { SelectChangeEvent } from '@mui/material'; +import { Box, FormControl, InputLabel, MenuItem, Select, TextField, Typography } from '@mui/material'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import NoSsr from '@mui/material/NoSsr'; +import Tooltip from '@mui/material/Tooltip'; + +import { useUsers } from 'src/api/user/user.list'; +import { Modal } from 'src/components/Modal'; +import { AdminTable } from 'src/components/admin/AdminTable'; +import { AdminTile } from 'src/components/admin/AdminTile'; +import { UserContext } from 'src/contexts/userContext'; +import { useUserRequests } from 'src/services/useUsers'; +import { useVillages } from 'src/services/useVillages'; +import { defaultContainedButtonStyle } from 'src/styles/variables.const'; import BackArrow from 'src/svg/back-arrow.svg'; +import { countryToFlag } from 'src/utils'; +import { exportJsonToCsv } from 'src/utils/csv-export'; +import { userTypeNames } from 'types/user.type'; +import type { Village } from 'types/village.type'; const Users = () => { + const router = useRouter(); + const { user } = React.useContext(UserContext); + const { data, isLoading } = useUsers(); + const users = React.useMemo(() => data || [], [data]); + const { villages } = useVillages(); + const villageMap = villages.reduce<{ [key: number]: Village }>((acc, village) => { + acc[village.id] = village; + return acc; + }, {}); + const { deleteUser } = useUserRequests(); + const [deleteIndex, setDeleteIndex] = React.useState(-1); + const [search, setSearch] = useState(''); + const [userTypeFilter, setUserTypeFilter] = useState(''); + const [countrySearch, setCountrySearch] = useState(''); + + const filteredUsers = useMemo( + () => + users.filter((u) => { + const searchMatch = [u.pseudo, u.email].some((field) => field?.toLowerCase().includes(search.toLowerCase())); + const countryMatch = u.country?.isoCode.toLowerCase().includes(countrySearch.toLowerCase()); + const userTypeMatch = userTypeFilter ? u.type === parseInt(userTypeFilter) : true; + + return searchMatch && userTypeMatch && countryMatch; + }), + [users, search, userTypeFilter, countrySearch], + ); + const tableData = useMemo( + () => + filteredUsers.map((u) => ({ + ...u, + country: u.country ? `${countryToFlag(u.country?.isoCode)} ${u.country?.name}` : Non renseignée, + village: u.villageId ? ( + villageMap[u.villageId]?.name || Non assigné + ) : ( + Non assigné + ), + type: , + })), + [filteredUsers, villageMap], + ); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setSearch(e.target.value); + }, []); + + const handleCountryChange = useCallback((e: React.ChangeEvent) => { + setCountrySearch(e.target.value); + }, []); + + const handleSelect = useCallback((e: SelectChangeEvent) => { + setUserTypeFilter(e.target.value); + }, []); + + const handleExportToCSV = () => { + if (filteredUsers.length === 0) { + return; + } + + const datasToExport = filteredUsers.map((user) => { + return { + firstname: user.firstname ? user.firstname : 'Non renseigné', + lastname: user.lastname ? user.lastname : 'Non renseigné', + email: user.email, + school: user.school ? user.school : 'Non renseignée', + village: user.villageId ? villageMap[user.villageId]?.name : 'Non renseigné', + country: user.country ? user.country.name : 'Non renseignée', + type: user.type ? userTypeNames[user.type] : 'Non renseigné', + }; + }); + + const headers = ['Prenom', 'Nom', 'Email', 'Ecole', 'Village', 'Pays', 'Rôle']; + + let userLabel = 'liste-utilisateurs-'; + + for (const [key, value] of Object.entries(userTypeNames)) { + if (key === userTypeFilter) { + userLabel = 'liste-' + value.toLowerCase().replaceAll(' ', '-') + 's-'; + } + } + const todayDate = new Date().toLocaleDateString('fr-FR').replaceAll('/', '-'); + const fileName = userLabel + todayDate; + + exportJsonToCsv(fileName, headers, datasToExport); + }; + + const actions = (id: number) => ( + <> + + { + router.push(`/admin/newportal/manage/users/edit/${id}`); + }} + > + + + + {user && user.id !== id && ( + + { + setDeleteIndex(users.findIndex((u) => u.id === id)); + }} + > + + + + )} + + ); + return (
-
+

Utilisateurs

-

Il y a ici la liste complète des utilisateurs sur 1Village.

+ + + + } + > +
+ + Filtres utilisateurs : + + + + + Filtrer par rôle + + + +
+ + + +
+ + { + setDeleteIndex(-1); + }} + onConfirm={async () => { + await deleteUser(users[deleteIndex]?.id || -1); + setDeleteIndex(-1); + }} + fullWidth + maxWidth="sm" + ariaLabelledBy="delete-village-id" + ariaDescribedBy="delete-village-desc" + error + > +
+ {"Voulez vous vraiment supprimer l'utilisateur "} + {users[deleteIndex]?.pseudo} ? +
+
+
); }; diff --git a/src/pages/admin/newportal/manage/users/new.tsx b/src/pages/admin/newportal/manage/users/new.tsx new file mode 100644 index 000000000..f08869360 --- /dev/null +++ b/src/pages/admin/newportal/manage/users/new.tsx @@ -0,0 +1,244 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import MaterialLink from '@mui/material/Link'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; + +import { AdminTile } from 'src/components/admin/AdminTile'; +import { CountrySelector } from 'src/components/selectors/CountrySelector'; +import { useUserRequests } from 'src/services/useUsers'; +import { useVillages } from 'src/services/useVillages'; +import { defaultOutlinedButtonStyle } from 'src/styles/variables.const'; +import { isPseudoValid, isEmailValid } from 'src/utils/accountChecks'; +import type { User } from 'types/user.type'; +import { UserType, userTypeNames } from 'types/user.type'; + +const Required = (label: string) => ( + <> + {label} + + * + + +); + +const NewUser = () => { + const router = useRouter(); + const { villages } = useVillages(); + const { addUser } = useUserRequests(); + const { enqueueSnackbar } = useSnackbar(); + const [newUser, setNewUser] = React.useState>({ + email: '', + pseudo: '', + city: '', + address: '', + postalCode: '', + school: '', + level: '', + type: UserType.TEACHER, + villageId: 0, + country: { + isoCode: '', + name: '', + }, + }); + const [errors, setErrors] = React.useState({ + email: false, + pseudo: false, + }); + + const checkEmailAndPseudo = async () => { + const pseudoValid = newUser.pseudo !== undefined && (await isPseudoValid(newUser.pseudo, '')); + setErrors((e) => ({ + ...e, + email: newUser.email !== undefined && !isEmailValid(newUser.email), + pseudo: newUser.pseudo !== undefined && !pseudoValid, + })); + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const requiredFields: Extract[] = ['email', 'pseudo', 'country']; + if (newUser.type === UserType.TEACHER) { + requiredFields.push('villageId'); + } + for (const field of requiredFields) { + if (!newUser[field]) { + enqueueSnackbar('Certain champs requis (*) sont non remplis!', { + variant: 'warning', + }); + return; + } + } + await checkEmailAndPseudo(); + if (errors.email || errors.pseudo) { + enqueueSnackbar('Email et/ou pseudo invalide', { + variant: 'warning', + }); + return; + } + const result = await addUser({ ...newUser, villageId: newUser.villageId || null }); + if (result !== null) { + router.push('/admin/newportal/manage/users'); + } + }; + + const updateUserField = (field: Extract) => (event: React.ChangeEvent) => { + setNewUser((u) => ({ ...u, [field]: event.target.value })); + }; + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Utilisateurs

+
+ +

Nouveau

+
+ +
+ + + + + + + + + Rôle + + + + {newUser.type === UserType.TEACHER ? Required('Village') : 'Village'} + + + { + setNewUser((u) => ({ ...u, country: { isoCode: countryCode, name: '' } })); + }} + filterCountries={ + newUser.villageId ? villages.find((v) => v.id === newUser.villageId)?.countries?.map((c) => c.isoCode) || undefined : undefined + } + style={{ width: '100%', marginBottom: '1rem' }} + /> +
+ +
+ +
+ + + +
+ ); +}; + +export default NewUser; diff --git a/src/pages/admin/newportal/manage/villages/edit/[id].tsx b/src/pages/admin/newportal/manage/villages/edit/[id].tsx new file mode 100644 index 000000000..eca5ec10c --- /dev/null +++ b/src/pages/admin/newportal/manage/villages/edit/[id].tsx @@ -0,0 +1,119 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { Button, TextField } from '@mui/material'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import MaterialLink from '@mui/material/Link'; + +import { AdminTile } from 'src/components/admin/AdminTile'; +import { CountrySelector } from 'src/components/selectors/CountrySelector'; +import { useVillageRequests } from 'src/services/useVillages'; +import { defaultOutlinedButtonStyle } from 'src/styles/variables.const'; +import { getQueryString } from 'src/utils'; +import { axiosRequest } from 'src/utils/axiosRequest'; +import type { Village } from 'types/village.type'; + +const EditVillage = () => { + const router = useRouter(); + + const { editVillage } = useVillageRequests(); + const villageId = React.useMemo(() => parseInt(getQueryString(router.query.id), 10) || 0, [router]); + const [village, setVillage] = React.useState(null); + + const getVillage = React.useCallback(async () => { + const response = await axiosRequest({ + method: 'GET', + url: `/villages/${villageId}`, + }); + if (response.error) { + router.push('/admin/villages'); + } else { + setVillage(response.data); + } + }, [router, villageId]); + + React.useEffect(() => { + getVillage().catch((e) => console.error(e)); + }, [getVillage]); + + if (village === null) { + return
; + } + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!village.name || !village.countries[0] || !village.countries[1]) { + return; + } + const result = await editVillage(village); + if (result !== null) { + router.push('/admin/newportal/manage/villages'); + } + }; + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Villages

+
+ +

{village.name}

+
+ +
+ { + setVillage((v) => (!v ? null : { ...v, name: event.target.value })); + }} + style={{ marginBottom: '1rem' }} + /> + { + setVillage((v) => (!v ? null : { ...v, countries: [{ isoCode: newValue, name: '' }, village.countries[1]] })); + }} + label="Pays 1" + style={{ width: '100%', marginBottom: '1rem' }} + /> + { + setVillage((v) => (!v ? null : { ...v, countries: [village.countries[0], { isoCode: newValue, name: '' }] })); + }} + label="Pays 2" + style={{ width: '100%', marginBottom: '1rem' }} + /> +
+ +
+ +
+ + + +
+ ); +}; + +export default EditVillage; diff --git a/src/pages/admin/newportal/manage/villages/index.tsx b/src/pages/admin/newportal/manage/villages/index.tsx index ca0e3abd4..2e3fdae6f 100644 --- a/src/pages/admin/newportal/manage/villages/index.tsx +++ b/src/pages/admin/newportal/manage/villages/index.tsx @@ -1,18 +1,193 @@ import Link from 'next/link'; -import React from 'react'; +import { useRouter } from 'next/router'; +import React, { useCallback, useMemo, useState } from 'react'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import GetAppIcon from '@mui/icons-material/GetApp'; +import { Box, Button, NoSsr, TextField, Typography } from '@mui/material'; +import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import { Modal } from 'src/components/Modal'; +import { AdminTable } from 'src/components/admin/AdminTable'; +import { AdminTile } from 'src/components/admin/AdminTile'; +import { useVillages, useVillageRequests } from 'src/services/useVillages'; +import { defaultContainedButtonStyle } from 'src/styles/variables.const'; import BackArrow from 'src/svg/back-arrow.svg'; +import { countryToFlag } from 'src/utils'; +import { SSO_HOSTNAME } from 'src/utils/sso'; +import type { Country } from 'types/country.type'; const Villages = () => { + const router = useRouter(); + const { villages } = useVillages(); + const { deleteVillage, importVillages } = useVillageRequests(); + const [isLoading, setIsLoading] = React.useState(false); + const [deleteIndex, setDeleteIndex] = React.useState(-1); + const [search, setSearch] = useState(''); + + const filteredVillages = useMemo( + () => + villages.filter((v) => { + const searchMatch = [v.name, ...v.countries.map((c) => c.name)].some((field) => field.toLowerCase().includes(search.toLowerCase())); + return searchMatch; + }), + [villages, search], + ); + + const countriesToText = (countries: Country[]) => { + return countries.map((c) => `${countryToFlag(c.isoCode)} ${c.name}`).join(' - '); + }; + + const onImportVillages = async () => { + setIsLoading(true); + await importVillages(); + setIsLoading(false); + }; + + const handleChange = useCallback((e: React.ChangeEvent) => { + setSearch(e.target.value); + }, []); + + const actions = (id: number) => ( + <> + + { + router.push(`/admin/newportal/manage/villages/edit/${id}`); + }} + > + + + + + { + setDeleteIndex(villages.findIndex((v) => v.id === id)); + }} + > + + + + + ); + return (
-
+

Villages-mondes

-

Il y a ici la liste complète des villages-mondes.

+ + + + + + + } + > + + + Filtres villages : + + + + + + {"Vous n'avez pas encore de villages ! "} + + En créer un ? + + + } + data={filteredVillages.map((v) => ({ + ...v, + countries: countriesToText(v.countries), + userCount: 0, + postCount: 0, + }))} + columns={[ + { key: 'name', label: 'Nom du village', sortable: true }, + { key: 'countries', label: 'Pays' }, + { key: 'userCount', label: 'Nombre de classes', sortable: true }, + { key: 'postCount', label: 'Nombre de posts', sortable: true }, + ]} + actions={actions} + /> + + + + { + setDeleteIndex(-1); + }} + onConfirm={async () => { + await deleteVillage(villages[deleteIndex]?.id || -1); + setDeleteIndex(-1); + }} + fullWidth + maxWidth="sm" + ariaLabelledBy="delete-village-id" + ariaDescribedBy="delete-village-desc" + error + > +
+ Voulez vous vraiment supprimer le village {villages[deleteIndex]?.name} ? +
+
+
+ + +
); }; diff --git a/src/pages/admin/newportal/manage/villages/new.tsx b/src/pages/admin/newportal/manage/villages/new.tsx new file mode 100644 index 000000000..2658f5911 --- /dev/null +++ b/src/pages/admin/newportal/manage/villages/new.tsx @@ -0,0 +1,90 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { Button, TextField } from '@mui/material'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import MaterialLink from '@mui/material/Link'; + +import { AdminTile } from 'src/components/admin/AdminTile'; +import { CountrySelector } from 'src/components/selectors/CountrySelector'; +import { useVillageRequests } from 'src/services/useVillages'; + +const NewVillage = () => { + const router = useRouter(); + const { addVillage } = useVillageRequests(); + + const [village, setVillage] = React.useState<{ name: string; countries: string[] }>({ + name: '', + countries: ['', ''], + }); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!village.name || !village.countries[0] || !village.countries[1]) { + return; + } + const result = await addVillage({ name: village.name, countries: village.countries.map((c) => ({ isoCode: c, name: c })) }); + if (result !== null) { + router.push('/admin/newportal/manage/villages'); + } + }; + + return ( +
+ } aria-label="breadcrumb" style={{ marginBottom: '1rem' }}> + + +

Villages

+
+ +

Nouveau

+
+ +
+ { + setVillage((v) => ({ ...v, name: event.target.value })); + }} + style={{ marginBottom: '1rem' }} + /> + { + setVillage((v) => ({ ...v, countries: [newValue, village.countries[1]] })); + }} + label="Pays 1" + style={{ width: '100%', marginBottom: '1rem' }} + /> + { + setVillage((v) => ({ ...v, countries: [village.countries[0], newValue] })); + }} + label="Pays 1" + style={{ width: '100%', marginBottom: '1rem' }} + /> +
+ +
+ +
+ + + +
+ ); +}; + +export default NewVillage;