diff --git a/.vscode/settings.json b/.vscode/settings.json index 97a3b8597633cbd435a43e56b66ab5749afe4054..d2f1afdc05168415c392035b4aef1bc1351e3e85 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,9 +27,9 @@ "editor.formatOnSave": true, "eslint.format.enable": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, - "source.fixAll": true, - "source.organizeImports": true + "source.fixAll.eslint": "explicit", + "source.fixAll": "explicit", + "source.organizeImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "peacock.color": "#2aa63d", @@ -48,7 +48,9 @@ "ecolyo", "enedis", "Enedis", + "firstname", "grdf", + "lastname", "luxon", "toastify", "wysiwyg", diff --git a/src/API.ts b/src/API.ts index e8e2df1f30029fe1cea997483e6ef2cce44ba3c4..876787995bf1fe1292eb9bc59836c35d1bb617eb 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import axios, { AxiosRequestConfig } from 'axios' import { useQuery } from 'react-query' import { toast } from 'react-toastify' import { User } from './models/user.model' @@ -24,3 +24,16 @@ export const useWhoAmI = () => { export const fetchLogout = async () => { return await axios.get('/Logout') } + +export const fetchEcogestureImages = async ( + axiosHeaders: AxiosRequestConfig +) => { + const { data: imageNames } = await axios.get<string[]>( + `/api/animator/imageNames`, + axiosHeaders + ) + if (imageNames && imageNames !== null) { + return imageNames.map(image => `/assets/ecogesture/${image}`) + } + return [] +} diff --git a/src/components/Consents/Consents.tsx b/src/components/Consents/Consents.tsx index 8b7a74491e18b96d95c977d075fdb9b9f067cd7c..7b9448042067662bfd628fc6fd1541a72a7cebdf 100644 --- a/src/components/Consents/Consents.tsx +++ b/src/components/Consents/Consents.tsx @@ -1,45 +1,49 @@ import { Button, TablePagination, TextField } from '@mui/material' import { - ColDef, - ColGroupDef, CsvExportParams, GridApi, GridReadyEvent, IRowNode, RowSelectedEvent, - ValueFormatterParams, } from 'ag-grid-community' import { AgGridReact } from 'ag-grid-react' import { DateTime } from 'luxon' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useWhoAmI } from '../../API' import { getAxiosXSRFHeader } from '../../axios.config' -import { IConsent } from '../../models/consent.model' -import { ConsentService } from '../../services/consent.service' +import { IGrdfConsent, grdfColumnDef } from '../../models/grdfConsent' +import { ISgeConsent, sgeColumnDefs } from '../../models/sgeConsent.model' +import { GrdfConsentService } from '../../services/grdfConsent.service' +import { SgeConsentService } from '../../services/sgeConsent.service' +import Loader from '../Loader/Loader' import DownloadModal from './DownloadModal' import './agGridOverrides.scss' import styles from './consents.module.scss' -import './muiPaginationOverrides.scss' -const Consents: React.FC = () => { +export const Consents: React.FC<{ type: 'sge' | 'grdf' }> = ({ type }) => { + const isGRDF = type === 'grdf' + + const [isLoading, setIsLoading] = useState(false) const [gridApi, setGridApi] = useState<GridApi | null>(null) const [search, setSearch] = useState<string>('') const [selectedNodes, setSelectedNodes] = useState<IRowNode[]>([]) const [isShowingSelection, setIsShowingSelection] = useState<boolean>(false) const [openDownloadModal, setOpenDownloadModal] = useState<boolean>(false) - const [consents, setConsents] = useState<IConsent[]>([]) + const [consents, setConsents] = useState<ISgeConsent[] | IGrdfConsent[]>([]) const [page, setPage] = useState<number>(0) const [rowsPerPage, setRowsPerPage] = useState<number>(50) const [totalRows, setTotalRows] = useState<number>(50) const { data: user } = useWhoAmI() - const consentService = useMemo(() => { - return new ConsentService() - }, []) const toggleOpenModal = useCallback(() => { setOpenDownloadModal(prev => !prev) }, []) + const consentService = useMemo( + () => (isGRDF ? new GrdfConsentService() : new SgeConsentService()), + [isGRDF] + ) + const defaultColDef = useMemo( () => ({ sortable: true, @@ -48,74 +52,10 @@ const Consents: React.FC = () => { [] ) - const dateFormatter = (data: ValueFormatterParams): string => { - return (data.value as DateTime).toLocaleString() - } - - const [columnDefs] = useState<(ColDef | ColGroupDef)[] | null>([ - { - field: 'ID', - hide: true, - }, - { - field: 'pointID', - headerName: 'N° PDL', - initialWidth: 180, - filter: true, - checkboxSelection: true, - }, - { - field: 'lastname', - headerName: 'Nom', - initialWidth: 180, - filter: true, - cellStyle: { textTransform: 'uppercase' }, - }, - { - field: 'firstname', - headerName: 'Prénom', - initialWidth: 180, - filter: true, - cellStyle: { textTransform: 'capitalize' }, - }, - { - field: 'address', - headerName: 'Adresse', - initialWidth: 300, - filter: true, - flex: 1, - }, - { - field: 'postalCode', - headerName: 'CP', - initialWidth: 80, - filter: true, - }, - { - field: 'city', - headerName: 'Ville', - }, - { - field: 'safetyOnBoarding', - headerName: 'Secours', - initialWidth: 100, - }, - { - field: 'startDate', - valueFormatter: dateFormatter, - headerName: 'Début du consentement', - initialWidth: 150, - filter: true, - sort: 'desc', - }, - { - field: 'endDate', - valueFormatter: dateFormatter, - headerName: 'Fin du consentement', - initialWidth: 150, - filter: true, - }, - ]) + const columnDefs = useMemo( + () => (isGRDF ? grdfColumnDef : sgeColumnDefs), + [isGRDF] + ) const handleChangePage = useCallback( ( @@ -138,13 +78,14 @@ const Consents: React.FC = () => { .filter(node => node.isSelected) .map(node => node.data.ID) - newNodes.forEach(node => { + newNodes?.forEach(node => { if (idsToCheck.includes(node.data.ID)) node.setSelected(true, false) }) } }, [gridApi, selectedNodes]) const searchConsents = async () => { + setIsLoading(true) if (user) { const consentPagination = await consentService.searchConsents( search, @@ -158,6 +99,7 @@ const Consents: React.FC = () => { setTotalRows(consentPagination.totalRows) } } + setIsLoading(false) } const handleSearchChange = (newSearch: string) => { @@ -242,23 +184,23 @@ const Consents: React.FC = () => { return () => { window.removeEventListener('resize', handleResize) } - }, [gridApi]) + }, [gridApi, isGRDF]) /** Trigger search when page loads or when admin changes input or pagination */ useEffect(() => { searchConsents() // /!\ Do not change dependencies or effect will not trigger when pagination changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rowsPerPage, page, search]) + }, [rowsPerPage, page, search, isGRDF]) return ( <> <div className="header"> - <h1>Gestion des consentements Enedis</h1> + <h1>Consentements {isGRDF ? 'GRDF' : 'Enedis'}</h1> </div> <div className={styles.content}> <TextField - placeholder="N°PDL (14 chiffres)" + placeholder={`N°${isGRDF ? 'PCE' : 'PDL'} (14 chiffres)`} label="Recherche" value={search} onChange={(e: React.ChangeEvent<HTMLInputElement>) => @@ -271,24 +213,27 @@ const Consents: React.FC = () => { className="ag-theme-alpine-dark" style={{ width: '100%', height: '75vh' }} > - <AgGridReact - onGridReady={onGridReady} - defaultColDef={defaultColDef} - rowHeight={35} - rowData={consents} - columnDefs={columnDefs} - animateRows={true} - rowSelection="multiple" - allowDragFromColumnsToolPanel={false} - onRowSelected={onRowSelected} - sortingOrder={['asc', 'desc']} - rowMultiSelectWithClick={true} - pagination={false} - suppressCellFocus={true} - rowClassRules={{ - expired: params => params.data.endDate < DateTime.now(), - }} - /> + {isLoading && <Loader />} + {!isLoading && ( + <AgGridReact + onGridReady={onGridReady} + defaultColDef={defaultColDef} + rowHeight={35} + rowData={consents} + columnDefs={columnDefs} + animateRows={true} + rowSelection="multiple" + allowDragFromColumnsToolPanel={false} + onRowSelected={onRowSelected} + sortingOrder={['asc', 'desc']} + rowMultiSelectWithClick={true} + pagination={false} + suppressCellFocus={true} + rowClassRules={{ + expired: params => params.data.endDate < DateTime.now(), + }} + /> + )} {!isShowingSelection && ( <TablePagination labelRowsPerPage="Consentements par page" @@ -332,4 +277,3 @@ const Consents: React.FC = () => { </> ) } -export default Consents diff --git a/src/components/Consents/muiPaginationOverrides.scss b/src/components/Consents/muiPaginationOverrides.scss deleted file mode 100644 index a759bd019b0e87beabe0086d6507b8ff39ca87c5..0000000000000000000000000000000000000000 --- a/src/components/Consents/muiPaginationOverrides.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Overrides MaterialUI Paginiation styles - -.MuiMenuItem-root { - color: black !important; -} diff --git a/src/components/ImagePicker/ImagePicker.tsx b/src/components/ImagePicker/ImagePicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6eeb73495a073af0b8b5c184de518b1dd7fc22c6 --- /dev/null +++ b/src/components/ImagePicker/ImagePicker.tsx @@ -0,0 +1,117 @@ +import { Button, Dialog, Pagination } from '@mui/material' +import React, { useState } from 'react' +import { useQuery } from 'react-query' +import { fetchEcogestureImages, useWhoAmI } from '../../API' +import { getAxiosXSRFHeader } from '../../axios.config' +import SingleImage from './SingleImage' + +const IMAGES_PER_PAGE = 15 + +interface ImagePickerProps { + imageURL: string + handleChange: (value: string, type: 'image') => void + defaultIcon?: string +} + +const ImagePicker: React.FC<ImagePickerProps> = ({ + imageURL, + handleChange, + defaultIcon, +}) => { + const { data: user } = useWhoAmI() + const { data: imagesNames } = useQuery({ + queryKey: ['imageNames'], + queryFn: () => + fetchEcogestureImages(getAxiosXSRFHeader(user?.xsrftoken ?? '')), + }) + + // Default icon must be the first of the list if it exists + if (defaultIcon && imagesNames) { + const indexOfDefault = imagesNames.indexOf(defaultIcon) + if (indexOfDefault > 0) { + imagesNames.splice(indexOfDefault, 1) + imagesNames.unshift(defaultIcon) + } + } + const pageCount = Math.ceil((imagesNames?.length ?? 1) / IMAGES_PER_PAGE) + const imageIndex = imagesNames?.indexOf(imageURL ?? '') ?? 1 + + const [selectedImageURL, setSelectedImageURL] = useState(imageURL ?? '') + const [openModal, setOpenModal] = useState(false) + const [currentPage, setCurrentPage] = useState( + Math.floor((imageIndex > 0 ? imageIndex : 1) / IMAGES_PER_PAGE + 1) + ) + const [preSelectImage, setPreSelectImage] = useState(imageURL ?? '') + + const toggleModal = () => { + setOpenModal(prev => !prev) + } + + const handleValidateImage = () => { + const newImage = + defaultIcon && preSelectImage === '' ? defaultIcon : preSelectImage + setPreSelectImage(newImage) + setSelectedImageURL(newImage) + handleChange(newImage, 'image') + setOpenModal(false) + } + + const startIndex = (currentPage - 1) * IMAGES_PER_PAGE + const stopIndex = startIndex + IMAGES_PER_PAGE + + return ( + <> + {selectedImageURL === '' || !selectedImageURL ? ( + <> + <p>Pas d'image sélectionnée</p> + <br /> + <Button onClick={toggleModal}>Choisir une image</Button> + </> + ) : ( + <> + <img + src={selectedImageURL} + width="120" + height="120" + className="ecogesture-image" + alt="selected" + /> + <Button onClick={toggleModal}>Modifier l'image</Button> + </> + )} + + <Dialog open={openModal} className="modal-large"> + <div className="image-picker"> + {imagesNames + ?.slice(startIndex, stopIndex) + ?.map(image => ( + <SingleImage + imageURL={image} + key={image} + selectedImage={preSelectImage} + setSelectedImageURL={setPreSelectImage} + /> + ))} + </div> + <Pagination + count={pageCount} + page={currentPage} + onChange={(_e, page) => setCurrentPage(page)} + style={{ + display: 'flex', + justifyContent: 'center', + marginTop: '1rem', + }} + /> + <div className="buttons"> + <Button variant="outlined" onClick={() => setOpenModal(false)}> + Annuler + </Button> + <Button onClick={handleValidateImage}>Valider</Button> + </div> + </Dialog> + </> + ) +} + +export default ImagePicker diff --git a/src/components/Newsletter/ImagePicker/SingleImage.tsx b/src/components/ImagePicker/SingleImage.tsx similarity index 86% rename from src/components/Newsletter/ImagePicker/SingleImage.tsx rename to src/components/ImagePicker/SingleImage.tsx index 9de3d93d39db87f17197b7887524086e27e352ec..1544bfb31d324f6ec71b99f993eaa33b336ab67d 100644 --- a/src/components/Newsletter/ImagePicker/SingleImage.tsx +++ b/src/components/ImagePicker/SingleImage.tsx @@ -12,7 +12,8 @@ const SingleImage: React.FC<SingleImageProps> = ({ setSelectedImageURL, }) => { const selectImage = (targetImage: string) => { - setSelectedImageURL(targetImage) + const newImageUrl = targetImage === selectedImage ? '' : targetImage + setSelectedImageURL(newImageUrl) } return ( <img diff --git a/src/components/Newsletter/ImagePicker/imagePicker.scss b/src/components/ImagePicker/imagePicker.scss similarity index 85% rename from src/components/Newsletter/ImagePicker/imagePicker.scss rename to src/components/ImagePicker/imagePicker.scss index 60c9acff8409e4889f0c08e7a31baddbdb26142a..278be86254f63bd4f86cb5aa91af031850874807 100644 --- a/src/components/Newsletter/ImagePicker/imagePicker.scss +++ b/src/components/ImagePicker/imagePicker.scss @@ -1,5 +1,5 @@ -@import '../../../styles/config/colors'; -@import '../../../styles/config/breakpoints'; +@import '../../styles/config/colors'; +@import '../../styles/config/breakpoints'; .image-picker { display: flex; diff --git a/src/components/Newsletter/ImagePicker/ImagePicker.tsx b/src/components/Newsletter/ImagePicker/ImagePicker.tsx deleted file mode 100644 index 98e236e37af1ae7dbc68875f9d254193d762e985..0000000000000000000000000000000000000000 --- a/src/components/Newsletter/ImagePicker/ImagePicker.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Button, Dialog, Pagination } from '@mui/material' -import React, { useEffect, useState } from 'react' -import { useWhoAmI } from '../../../API' -import { getAxiosXSRFHeader } from '../../../axios.config' -import { NewsletterService } from '../../../services/newsletter.service' -import { EditorType } from '../CustomEditor' -import SingleImage from './SingleImage' - -interface ImagePickerProps { - imageURL: string - handleChange: (value: string, type: EditorType) => void -} - -const ImagePicker: React.FC<ImagePickerProps> = ({ - imageURL, - handleChange, -}) => { - const { data: user } = useWhoAmI() - const [imageNames, setImageNames] = useState<string[][]>([]) - const [selectedImageURL, setSelectedImageURL] = useState<string>( - imageURL && imageURL !== null ? imageURL : '' - ) - const [openModal, setOpenModal] = useState<boolean>(false) - const [currentPage, setCurrentPage] = useState(1) - const [pageCount, setPageCount] = useState<number>(1) - const [preSelectImage, setPreSelectImage] = useState<string>('') - const imagePerPage = 10 - - const toggleModal = () => { - setOpenModal(prev => !prev) - } - const handleChangePage = (page: number) => { - setCurrentPage(page) - } - const handleValidateImage = () => { - setSelectedImageURL(preSelectImage) - handleChange(preSelectImage, 'image') - setOpenModal(false) - } - - useEffect(() => { - let subscribed = true - async function getImageNames() { - if (user) { - const newsletterService = new NewsletterService() - const images = await newsletterService.getEcogestureImages( - getAxiosXSRFHeader(user.xsrftoken) - ) - //Split array depending on page numbers - setPageCount(Math.ceil(images.length / imagePerPage)) - const arraySplitted = [] - while (images.length) { - arraySplitted.push(images.splice(0, imagePerPage)) - } - setImageNames(arraySplitted) - } - } - if (subscribed) { - getImageNames() - } - return () => { - subscribed = false - } - }, [user, imagePerPage]) - - return ( - <> - {selectedImageURL === '' || !selectedImageURL ? ( - <> - <p>Pas d'image sélectionnée</p> - <br /> - <Button onClick={toggleModal}>Choisir une image</Button> - </> - ) : ( - <> - <img - src={selectedImageURL} - width="120" - height="120" - className="ecogesture-image" - alt="selected" - /> - <Button onClick={toggleModal}>Modifier l'image</Button> - </> - )} - - <Dialog open={openModal} className="modal-large"> - <div className="image-picker"> - {imageNames && - imageNames.length !== 0 && - imageNames[currentPage - 1].length !== 0 && - imageNames[currentPage - 1].map(imageName => ( - <SingleImage - imageURL={imageName} - key={imageName} - selectedImage={preSelectImage} - setSelectedImageURL={setPreSelectImage} - /> - ))} - </div> - <Pagination - count={pageCount} - siblingCount={0} - onChange={(_e, page) => handleChangePage(page)} - style={{ - display: 'flex', - justifyContent: 'center', - marginTop: '1rem', - }} - /> - <div className="buttons"> - <Button variant="outlined" onClick={() => setOpenModal(false)}> - Annuler - </Button> - <Button onClick={handleValidateImage}>Valider</Button> - </div> - </Dialog> - </> - ) -} - -export default ImagePicker diff --git a/src/components/Newsletter/MonthlyInfo/MonthlyInfo.tsx b/src/components/Newsletter/MonthlyInfo/MonthlyInfo.tsx index 7b45e657f588ed13fb4cba92a766586433c4d484..384d443bc138d128960e00ac9f1799dba3621409 100644 --- a/src/components/Newsletter/MonthlyInfo/MonthlyInfo.tsx +++ b/src/components/Newsletter/MonthlyInfo/MonthlyInfo.tsx @@ -2,8 +2,8 @@ import { Button } from '@mui/material' import React from 'react' import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css' import { convertStringToEditorState } from '../../../utils/editorStateManagement' +import ImagePicker from '../../ImagePicker/ImagePicker' import CustomEditor, { EditorType } from '../CustomEditor' -import ImagePicker from '../ImagePicker/ImagePicker' import { ContentItems } from '../Newsletter' interface MonthlyInfoProps { diff --git a/src/components/Popups/Popups.tsx b/src/components/Popups/Popups.tsx index cc2cd407f4d765bf1b5ead46151da91b3fc45a39..1276dc2fc9ced044ed8c7eff25cd24de720f7064 100644 --- a/src/components/Popups/Popups.tsx +++ b/src/components/Popups/Popups.tsx @@ -18,11 +18,14 @@ import { Option, durationEnum, durationType, + mapDuration, } from '../../models/durationOptions.model' import { IPartnersInfo } from '../../models/partnersInfo.model' import { CustomPopupService } from '../../services/customPopup.service' import { PartnersInfoService } from '../../services/partnersInfo.service' import { convertStringToEditorState } from '../../utils/editorStateManagement' +import { getFilenameFromPath } from '../../utils/imagesUrlsGetter' +import ImagePicker from '../ImagePicker/ImagePicker' import Loader from '../Loader/Loader' import CustomEditor from '../Newsletter/CustomEditor' import './popups.scss' @@ -44,6 +47,7 @@ const OPTIONS: Option[] = [ const Popups: React.FC = () => { const { data: user } = useWhoAmI() + const defaultIcon = '/assets/ecogesture/bullhorn.png' const [refreshData, setRefreshData] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -54,15 +58,16 @@ const Popups: React.FC = () => { notification_activated: false, }) const [previousEndDate, setPreviousEndDate] = useState<string>() + const [popupDuration, setPopupDuration] = useState<PopupDuration>({ + type: durationEnum.infinite, + duration: 5, + }) const [customPopup, setCustomPopup] = useState<ICustomPopup>({ popupEnabled: false, title: '', + image: '', description: '', - endDate: DateTime.local().toISO(), - }) - const [popupDuration, setPopupDuration] = useState<PopupDuration>({ - type: durationEnum.hours, - duration: 0, + endDate: DateTime.local().plus({ days: 365 }).toISO(), }) const isPartnerNotificationOn = () => @@ -105,7 +110,10 @@ const Popups: React.FC = () => { } } - const handlePopupChange = (field: 'title' | 'description', value: string) => { + const handlePopupChange = ( + value: string, + field: 'title' | 'image' | 'description' + ) => { setCustomPopup(prev => ({ ...prev, [field]: value, @@ -135,19 +143,31 @@ const Popups: React.FC = () => { if (user) { const partnersInfoService = new PartnersInfoService() const customPopupService = new CustomPopupService() - const partnersInfoData = await partnersInfoService.getPartnersInfo() - const customPopupData = await customPopupService.getCustomPopupInfo() + const previousPartnersInfo = await partnersInfoService.getPartnersInfo() + const previousPopup = await customPopupService.getCustomPopupInfo() - if (partnersInfoData) { + if (previousPartnersInfo) { setPartnersInfo({ - ...partnersInfoData, + ...previousPartnersInfo, }) } - if (customPopupData) { + if (previousPopup) { + const isOutdated = isPopupOutdated(previousPopup.endDate) + /** If outdated, set value to false, otherwise, set it to its value */ + const isEnabled = isOutdated ? false : previousPopup.popupEnabled + const popupImage = + previousPopup.image === '' + ? defaultIcon + : `/assets/ecogesture/${previousPopup.image}.png` setCustomPopup({ - ...customPopupData, + ...previousPopup, + image: popupImage, + endDate: customPopup.endDate, + popupEnabled: isEnabled, }) - setPreviousEndDate(customPopupData.endDate || undefined) + setPreviousEndDate( + isOutdated ? customPopup.endDate : previousPopup.endDate + ) } } setIsLoading(false) @@ -159,6 +179,7 @@ const Popups: React.FC = () => { subscribed = false setRefreshData(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, refreshData, setPartnersInfo, setCustomPopup, resetFields]) const handleSave = async (): Promise<void> => { @@ -176,7 +197,10 @@ const Popups: React.FC = () => { getAxiosXSRFHeader(user.xsrftoken) ) await customPopupService.saveCustomPopup( - customPopup, + { + ...customPopup, + image: getFilenameFromPath(customPopup.image), + }, getAxiosXSRFHeader(user.xsrftoken) ) setPreviousEndDate(customPopup.endDate) @@ -190,9 +214,7 @@ const Popups: React.FC = () => { })) } - /** - * Handles duration change - */ + /** Handles duration change */ useEffect(() => { const now = DateTime.local() let newDate: DateTime @@ -200,7 +222,7 @@ const Popups: React.FC = () => { newDate = now.plus({ [popupDuration.type]: popupDuration.duration, }) - } else if (popupDuration.type === 'infinite') { + } else { newDate = now.plus({ years: 1, }) @@ -323,33 +345,8 @@ const Popups: React.FC = () => { getRemainingDuration(previousEndDate)} </FormGroup> - <div className="popupTitle"> - <TextField - type="text" - placeholder="Titre de la popup" - fullWidth - label="Titre" - value={customPopup.title} - onChange={event => - handlePopupChange('title', event.target.value) - } - /> - </div> - - <div className="popupDescription"> - <CustomEditor - baseState={convertStringToEditorState( - customPopup.description - )} - handleChange={value => - handlePopupChange('description', value) - } - type="custom_popup" - /> - </div> - <div className="popupEndDate"> - <label htmlFor="title">Nouvelle Durée</label> + <h4>Durée</h4> <div> <FormControl style={{ flexDirection: 'row', gap: '1rem' }}> <NativeSelect @@ -358,6 +355,7 @@ const Popups: React.FC = () => { id: 'uncontrolled-native', }} onChange={event => handleSelectChange(event)} + value={popupDuration.type} > {OPTIONS.map(option => ( <option key={option.value} value={option.value}> @@ -368,12 +366,14 @@ const Popups: React.FC = () => { {popupDuration.type !== 'infinite' && ( <TextField + style={{ width: '6rem' }} inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', }} id="outlined-number" type="number" + label={mapDuration[popupDuration.type]} InputLabelProps={{ shrink: true, }} @@ -389,6 +389,41 @@ const Popups: React.FC = () => { </FormControl> </div> </div> + + <h4>Image</h4> + <div> + <ImagePicker + imageURL={customPopup.image} + handleChange={handlePopupChange} + defaultIcon={defaultIcon} + /> + </div> + + <h4>Contenu</h4> + <div className="popupTitle"> + <TextField + type="text" + placeholder="Titre de la popup" + fullWidth + label="Titre" + value={customPopup.title} + onChange={event => + handlePopupChange(event.target.value, 'title') + } + /> + </div> + + <div className="popupDescription"> + <CustomEditor + baseState={convertStringToEditorState( + customPopup.description + )} + handleChange={value => + handlePopupChange(value, 'description') + } + type="custom_popup" + /> + </div> </div> <div className="buttons"> diff --git a/src/components/Popups/popups.scss b/src/components/Popups/popups.scss index 0071eb96daa5cfba37bc16abb470e22a596c33d0..f106a5253f22e3c517fca73cf93d8fd6b3d02790 100644 --- a/src/components/Popups/popups.scss +++ b/src/components/Popups/popups.scss @@ -16,11 +16,6 @@ flex-direction: column; gap: 0.5rem; - label { - text-transform: uppercase; - font-weight: 700; - } - .count { color: $text-dark; max-width: 600px; @@ -37,4 +32,9 @@ flex-direction: column; gap: 1rem; } + + h4 { + text-transform: uppercase; + margin-top: 1rem; + } } diff --git a/src/components/Routes/Router.tsx b/src/components/Routes/Router.tsx index 64c8f6cbfaea33ba15f2932a25f06e4ace6bd55f..85051f1b76835a79c69b0d2e68ab390160ac0bba 100644 --- a/src/components/Routes/Router.tsx +++ b/src/components/Routes/Router.tsx @@ -1,6 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useWhoAmI } from '../../API' -import Consents from '../Consents/Consents' +import { Consents } from '../Consents/Consents' import Loader from '../Loader/Loader' import Login from '../Login/Login' import Newsletter from '../Newsletter/Newsletter' @@ -24,9 +24,14 @@ export const links: Record< label: 'Prix', path: '/prices', }, - consents: { - label: 'Consentements', - path: '/consents', + sgeConsents: { + label: 'Consentements SGE', + path: '/consents/sge', + adminOnly: true, + }, + grdfConsents: { + label: 'Consentements GRDF', + path: '/consents/grdf', adminOnly: true, }, } @@ -52,7 +57,16 @@ const Router = () => { <Route path={links.prices.path} element={<Prices />} /> <Route path="/popups" element={<Popups />} /> {user.isAdmin && ( - <Route path={links.consents.path} element={<Consents />} /> + <> + <Route + path={links.sgeConsents.path} + element={<Consents type="sge" />} + /> + <Route + path={links.grdfConsents.path} + element={<Consents type="grdf" />} + /> + </> )} <Route path="/login" element={<Login />} /> <Route diff --git a/src/components/SideBar/sidebar.scss b/src/components/SideBar/sidebar.scss index 1b2189ed7cc9407f80bb4f597ec5c33660d2532d..35e62288761b63720788f8b0bf717724c72bd0ca 100644 --- a/src/components/SideBar/sidebar.scss +++ b/src/components/SideBar/sidebar.scss @@ -2,7 +2,7 @@ @import '../../styles/config/breakpoints'; .menu { - z-index: 1500; + z-index: 1000; display: flex; flex-direction: column; padding: 1.5rem; diff --git a/src/models/consent.model.ts b/src/models/consent.model.ts deleted file mode 100644 index 8c1714ae30c3b6af5970699bd0c9665758d6a9ba..0000000000000000000000000000000000000000 --- a/src/models/consent.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DateTime } from 'luxon' -export interface IConsent - extends Omit<ConsentEntity, 'CreatedAt' | 'endDate' | 'inseeCode'> { - startDate: DateTime - endDate: DateTime -} - -export interface ConsentEntity { - ID: number - CreatedAt: string - endDate: string - firstname: string - lastname: string - pointID: number - address: string - postalCode: string - inseeCode: string - city: string - safetyOnBoarding: boolean -} - -export interface IConsentPagination - extends Omit<ConsentPaginationEntity, 'rows'> { - rows: IConsent[] -} - -export interface ConsentPaginationEntity { - totalRows: number - totalPages: number - rows: ConsentEntity[] -} diff --git a/src/models/customPopup.model.ts b/src/models/customPopup.model.ts index 0241e3a1660e48af6e662391664b921d2df7aca6..74127e5c6d63f2eeea2a30c21e81e3086885dab7 100644 --- a/src/models/customPopup.model.ts +++ b/src/models/customPopup.model.ts @@ -2,6 +2,7 @@ import { durationType } from './durationOptions.model' export interface ICustomPopup { description: string + image: string popupEnabled: boolean endDate: string title: string diff --git a/src/models/durationOptions.model.ts b/src/models/durationOptions.model.ts index 8e71e9a2601000d9a6867cc21ffe889a31a8583b..38571d8afc42632d29ac7c3b76e4b41119f43686 100644 --- a/src/models/durationOptions.model.ts +++ b/src/models/durationOptions.model.ts @@ -6,6 +6,12 @@ export enum durationEnum { infinite = 'infinite', } +export enum mapDuration { + hours = 'Heures', + days = 'Jours', + infinite = 'Indéterminée', +} + export interface Option { value: durationType label: string diff --git a/src/models/grdfConsent.ts b/src/models/grdfConsent.ts new file mode 100644 index 0000000000000000000000000000000000000000..396b0d3f9a261029dc59c34f0eda9252ec355315 --- /dev/null +++ b/src/models/grdfConsent.ts @@ -0,0 +1,81 @@ +import { ColDef } from 'ag-grid-community' +import { DateTime } from 'luxon' +import { dateFormatter } from '../utils/dateFormatter' + +export interface IGrdfConsent + extends Omit<GrdfConsentEntity, 'CreatedAt' | 'endDate' | 'startDate'> { + startDate: DateTime + endDate: DateTime +} + +export interface GrdfConsentEntity { + ID: number + CreatedAt: string + startDate: string + endDate: string + firstname: string + lastname: string + pce: number + postalCode: string +} + +export interface IGrdfConsentPagination + extends Omit<GrdfConsentPaginationEntity, 'rows'> { + rows: IGrdfConsent[] +} + +export interface GrdfConsentPaginationEntity { + totalRows: number + totalPages: number + rows: GrdfConsentEntity[] +} + +export const grdfColumnDef: ColDef[] = [ + { + field: 'ID', + hide: true, + }, + { + field: 'pce', + headerName: 'N° PCE', + initialWidth: 180, + filter: true, + checkboxSelection: true, + }, + { + field: 'lastname', + headerName: 'Nom', + initialWidth: 180, + filter: true, + cellStyle: { textTransform: 'uppercase' }, + }, + { + field: 'firstname', + headerName: 'Prénom', + initialWidth: 180, + filter: true, + cellStyle: { textTransform: 'capitalize' }, + }, + { + field: 'postalCode', + headerName: 'CP', + initialWidth: 80, + filter: true, + }, + { + field: 'startDate', + valueFormatter: dateFormatter, + headerName: 'Début du consentement', + initialWidth: 150, + filter: true, + sortable: false, + }, + { + field: 'endDate', + valueFormatter: dateFormatter, + headerName: 'Fin du consentement', + initialWidth: 150, + filter: true, + sortable: false, + }, +] diff --git a/src/models/sgeConsent.model.ts b/src/models/sgeConsent.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..6746d21cecba68982044501dd2493c26441d6055 --- /dev/null +++ b/src/models/sgeConsent.model.ts @@ -0,0 +1,100 @@ +import { ColDef } from 'ag-grid-community' +import { DateTime } from 'luxon' +import { dateFormatter } from '../utils/dateFormatter' + +export interface ISgeConsent + extends Omit<SgeConsentEntity, 'CreatedAt' | 'endDate' | 'inseeCode'> { + startDate: DateTime + endDate: DateTime +} + +export interface SgeConsentEntity { + ID: number + CreatedAt: string + endDate: string + firstname: string + lastname: string + pointID: number + address: string + postalCode: string + inseeCode: string + city: string + safetyOnBoarding: boolean +} + +export interface ISgeConsentPagination + extends Omit<SgeConsentPaginationEntity, 'rows'> { + rows: ISgeConsent[] +} + +export interface SgeConsentPaginationEntity { + totalRows: number + totalPages: number + rows: SgeConsentEntity[] +} + +export const sgeColumnDefs: ColDef[] = [ + { + field: 'ID', + hide: true, + }, + { + field: 'pointID', + headerName: 'N° PDL', + initialWidth: 180, + filter: true, + checkboxSelection: true, + }, + { + field: 'lastname', + headerName: 'Nom', + initialWidth: 180, + filter: true, + cellStyle: { textTransform: 'uppercase' }, + }, + { + field: 'firstname', + headerName: 'Prénom', + initialWidth: 180, + filter: true, + cellStyle: { textTransform: 'capitalize' }, + }, + { + field: 'address', + headerName: 'Adresse', + initialWidth: 300, + filter: true, + flex: 1, + }, + { + field: 'postalCode', + headerName: 'CP', + initialWidth: 80, + filter: true, + }, + { + field: 'city', + headerName: 'Ville', + }, + { + field: 'safetyOnBoarding', + headerName: 'Secours', + initialWidth: 100, + }, + { + field: 'startDate', + valueFormatter: dateFormatter, + headerName: 'Début du consentement', + initialWidth: 150, + filter: true, + sortable: false, + }, + { + field: 'endDate', + valueFormatter: dateFormatter, + headerName: 'Fin du consentement', + initialWidth: 150, + filter: true, + sortable: false, + }, +] diff --git a/src/services/customPopup.service.ts b/src/services/customPopup.service.ts index 4379356c3e73a34e2ae68aabcd53ed1a2e4370fb..2f95978c387594a76708ed5e3177a5bc57a22ec2 100644 --- a/src/services/customPopup.service.ts +++ b/src/services/customPopup.service.ts @@ -32,8 +32,8 @@ export class CustomPopupService { */ public getCustomPopupInfo = async (): Promise<ICustomPopup | null> => { try { - const { data } = await axios.get(`/api/common/customPopup`) - return data as ICustomPopup + const { data } = await axios.get<ICustomPopup>(`/api/common/customPopup`) + return data } catch (e) { console.error('error', e) return null diff --git a/src/services/grdfConsent.service.ts b/src/services/grdfConsent.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcf60b4709b53955754426efcc1f3978da615144 --- /dev/null +++ b/src/services/grdfConsent.service.ts @@ -0,0 +1,83 @@ +import axios, { AxiosRequestConfig } from 'axios' +import { DateTime } from 'luxon' +import { toast } from 'react-toastify' +import { + GrdfConsentEntity, + GrdfConsentPaginationEntity, + IGrdfConsent, + IGrdfConsentPagination, +} from '../models/grdfConsent' + +export class GrdfConsentService { + /** + * Search for consents + * @param search + * @param limit + * @param page + * @param axiosHeaders + */ + public searchConsents = async ( + search: string, + limit: number, + page: number, + axiosHeaders: AxiosRequestConfig + ): Promise<IGrdfConsentPagination | null> => { + try { + const { data } = await axios.get<GrdfConsentPaginationEntity>( + `/api/admin/grdf/consent?search=${search}&limit=${limit}&page=${page}`, + axiosHeaders + ) + return this.parseConsentPagination(data) + } catch (e) { + if (e.response.status === 403) { + toast.error("Accès refusé : vous n'avez pas les droits nécessaires") + } else { + toast.error('Erreur lors de la récupération des consentements') + } + console.error(e) + return null + } + } + + /** + * Converts consent entity into consent + * @param consentEntity + */ + public parseConsent = (consentEntity: GrdfConsentEntity): IGrdfConsent => { + const startDate = DateTime.fromISO(consentEntity.startDate, { + zone: 'utc', + }).setLocale('fr-FR') + const endDate = DateTime.fromISO(consentEntity.endDate, { + zone: 'utc', + }).setLocale('fr-FR') + + return { + ID: consentEntity.ID, + startDate: startDate, + endDate: endDate, + firstname: consentEntity.firstname, + lastname: consentEntity.lastname, + pce: consentEntity.pce, + postalCode: consentEntity.postalCode, + } + } + + /** + * Converts consent pagination entity into consent pagination + * @param consentPaginationEntity + */ + public parseConsentPagination = ( + consentPaginationEntity: GrdfConsentPaginationEntity + ): IGrdfConsentPagination => { + const rows = consentPaginationEntity.rows.map(consent => + this.parseConsent(consent) + ) + + const consentPagination: IGrdfConsentPagination = { + rows: rows, + totalRows: consentPaginationEntity.totalRows, + totalPages: consentPaginationEntity.totalPages, + } + return consentPagination + } +} diff --git a/src/services/newsletter.service.ts b/src/services/newsletter.service.ts index 525ca8762ce9e4f758c78520d2774d9b92aa581c..fcedb6b50caf723ca7bd3243fb6e578ded0f4bbd 100644 --- a/src/services/newsletter.service.ts +++ b/src/services/newsletter.service.ts @@ -335,25 +335,4 @@ export class NewsletterService { console.error(e) } } - - /** - * Gets the ecogesture images URLs - */ - public getEcogestureImages = async ( - axiosHeaders: AxiosRequestConfig - ): Promise<string[]> => { - try { - const { data: imageNames } = await axios.get( - `/api/animator/imageNames`, - axiosHeaders - ) - if (imageNames && imageNames !== null) { - return imageNames.map((image: string) => `/assets/ecogesture/${image}`) - } - return [] - } catch (e) { - console.error('error', e) - return [] - } - } } diff --git a/src/services/consent.service.ts b/src/services/sgeConsent.service.ts similarity index 68% rename from src/services/consent.service.ts rename to src/services/sgeConsent.service.ts index 185b123064e38d2ce903f0fc177a80a9bd5cad1b..ad71837d63e862d752dff43c1075c5988545d74e 100644 --- a/src/services/consent.service.ts +++ b/src/services/sgeConsent.service.ts @@ -2,13 +2,13 @@ import axios, { AxiosRequestConfig } from 'axios' import { DateTime } from 'luxon' import { toast } from 'react-toastify' import { - ConsentEntity, - ConsentPaginationEntity, - IConsent, - IConsentPagination, -} from '../models/consent.model' + ISgeConsent, + ISgeConsentPagination, + SgeConsentEntity, + SgeConsentPaginationEntity, +} from '../models/sgeConsent.model' -export class ConsentService { +export class SgeConsentService { /** * Search for consents * @param search @@ -21,14 +21,13 @@ export class ConsentService { limit: number, page: number, axiosHeaders: AxiosRequestConfig - ): Promise<IConsentPagination | null> => { + ): Promise<ISgeConsentPagination | null> => { try { - const { data } = await axios.get( - `/api/admin/consent?search=${search}&limit=${limit}&page=${page}`, + const { data } = await axios.get<SgeConsentPaginationEntity>( + `/api/admin/sge/consent?search=${search}&limit=${limit}&page=${page}`, axiosHeaders ) - const consentPagination = data as ConsentPaginationEntity - return this.parseConsentPagination(consentPagination) + return this.parseConsentPagination(data) } catch (e) { if (e.response.status === 403) { toast.error("Accès refusé : vous n'avez pas les droits nécessaires") @@ -44,11 +43,11 @@ export class ConsentService { * Converts consent entity into consent * @param consentEntity */ - public parseConsent = (consentEntity: ConsentEntity): IConsent => { - const startDate: DateTime = DateTime.fromISO(consentEntity.CreatedAt, { + public parseConsent = (consentEntity: SgeConsentEntity): ISgeConsent => { + const startDate = DateTime.fromISO(consentEntity.CreatedAt, { zone: 'utc', }).setLocale('fr-FR') - const endDate: DateTime = DateTime.fromISO(consentEntity.endDate, { + const endDate = DateTime.fromISO(consentEntity.endDate, { zone: 'utc', }).setLocale('fr-FR') @@ -71,13 +70,13 @@ export class ConsentService { * @param consentPaginationEntity */ public parseConsentPagination = ( - consentPaginationEntity: ConsentPaginationEntity - ): IConsentPagination => { + consentPaginationEntity: SgeConsentPaginationEntity + ): ISgeConsentPagination => { const rows = consentPaginationEntity.rows.map(consent => this.parseConsent(consent) ) - const consentPagination: IConsentPagination = { + const consentPagination: ISgeConsentPagination = { rows: rows, totalRows: consentPaginationEntity.totalRows, totalPages: consentPaginationEntity.totalPages, diff --git a/src/utils/dateFormatter.ts b/src/utils/dateFormatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b4858f4d55cd35dd30b55a736a57dece3df61f4 --- /dev/null +++ b/src/utils/dateFormatter.ts @@ -0,0 +1,6 @@ +import { ValueFormatterParams } from 'ag-grid-community' +import { DateTime } from 'luxon' + +export const dateFormatter = (data: ValueFormatterParams): string => { + return (data.value as DateTime).toLocaleString() +} diff --git a/src/utils/imagesUrlsGetter.ts b/src/utils/imagesUrlsGetter.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bc24b330a482c5afbf98bab55a0e1dd738e9dc4 --- /dev/null +++ b/src/utils/imagesUrlsGetter.ts @@ -0,0 +1,35 @@ +import axios, { AxiosRequestConfig } from 'axios' + +/** + * Returns the filename from a path (without extension) + * Example: 'path/to/file.svg' or 'file.png' become 'file' + */ +export const getFilenameFromPath = (inputPath: string): string => { + // Use a regular expression to match the full path and capture the filename without extension + const regex = /(?:.*[\\/])?(.+?)(?:\.[^.]*$|$)/ + const match = RegExp(regex).exec(inputPath) + + // Check if there is a match, else case returns an empty string + return match?.[1] ?? '' +} + +/** + * Gets the ecogesture images URLs + */ +export const getEcogestureImages = async ( + axiosHeaders: AxiosRequestConfig +): Promise<string[]> => { + try { + const { data: imageNames } = await axios.get<string[]>( + `/api/animator/imageNames`, + axiosHeaders + ) + if (imageNames && imageNames !== null) { + return imageNames.map(image => `/assets/ecogesture/${image}`) + } + return [] + } catch (e) { + console.error('error', e) + return [] + } +}