diff --git a/.env b/.env index fc25010..871e4c5 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ REACT_APP_TAB_TITLE=Gepafin REACT_APP_API_EXECUTION_ADDRESS=https://api-dev-gepafin.memento.credit/v1 REACT_APP_API_ADDRESS=https://api-dev-gepafin.memento.credit +REACT_APP_API_ADDRESS_WS=https://api-dev-gepafin.memento.credit/wss REACT_APP_LOGO_FILENAME=gepafin-logo.svg REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs diff --git a/environments/dev/dev.env b/environments/dev/dev.env index 38e36ec..ae149fd 100644 --- a/environments/dev/dev.env +++ b/environments/dev/dev.env @@ -1,6 +1,7 @@ REACT_APP_TAB_TITLE=Gepafin REACT_APP_API_EXECUTION_ADDRESS=https://api-dev-gepafin.memento.credit/v1 REACT_APP_API_ADDRESS=https://api-dev-gepafin.memento.credit +REACT_APP_API_ADDRESS_WS=https://api-dev-gepafin.memento.credit/wss REACT_APP_LOGO_FILENAME=gepafin-logo.svg REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs diff --git a/environments/prod/prod.env b/environments/prod/prod.env index ecbe860..8e5fc08 100644 --- a/environments/prod/prod.env +++ b/environments/prod/prod.env @@ -1,6 +1,7 @@ REACT_APP_TAB_TITLE=Gepafin REACT_APP_API_EXECUTION_ADDRESS=https://bandi-api.gepafin.it/v1 REACT_APP_API_ADDRESS=https://bandi-api.gepafin.it +REACT_APP_API_ADDRESS_WS=https://bandi-api.gepafin.it/wss REACT_APP_LOGO_FILENAME=gepafin-logo.svg REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs diff --git a/package.json b/package.json index e58a0a4..0783782 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@emotion/styled": "11.13.0", "@number-flow/react": "0.4.2", "@sentry/browser": "^8.42.0", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-table": "^8.20.5", "@wordpress/i18n": "5.8.0", "@wordpress/react-i18n": "4.8.0", @@ -36,6 +37,7 @@ "react-hook-form": "7.53.0", "react-router-dom": "6.26.2", "react-scripts": "5.0.1", + "sockjs-client": "^1.6.1", "validate.js": "0.13.1", "zustand": "4.5.4", "zustand-x": "3.0.4" diff --git a/src/assets/scss/components/appPage.scss b/src/assets/scss/components/appPage.scss index 7d782b3..4ed00a3 100644 --- a/src/assets/scss/components/appPage.scss +++ b/src/assets/scss/components/appPage.scss @@ -218,6 +218,7 @@ .appPageSection__pMeta { margin-bottom: 1em; + break-inside: avoid; span:nth-of-type(1) { max-width: 30%; @@ -435,6 +436,15 @@ } } +.appPageSection__emailTemplate { + > div { + max-width: 100%!important; + > div { + max-width: 100%!important; + } + } +} + @media (max-width: 700px) { .appPageSection { &.columns { diff --git a/src/assets/scss/components/notificationsSidebar.scss b/src/assets/scss/components/notificationsSidebar.scss new file mode 100644 index 0000000..2bc2eb8 --- /dev/null +++ b/src/assets/scss/components/notificationsSidebar.scss @@ -0,0 +1,55 @@ +.notificationsIcon { + &:hover { + cursor: pointer; + } +} + +.notificationsSidebar { + max-width: 360px; + width: 100%; +} + +.notificationsSidebar__loading { + padding: 30px 0; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.notificationsSidebar__list { + display: flex; + flex-direction: column; + gap: 5px; + list-style: none; + padding: 0; +} + +.notificationsSidebar__listItem { + display: flex; + justify-content: space-between; + align-items: center; + gap: 5px; + padding: 15px 0; + border-bottom: 1px solid #e7e7e7; + + &:hover { + cursor: pointer; + color: var(--primary-text); + } +} + +.notificationsSidebar__listItemContent { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 14px; +} + +.notificationsSidebar__listItemChosen { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; +} diff --git a/src/assets/scss/components/statsBigBadges.scss b/src/assets/scss/components/statsBigBadges.scss index 7de0232..1788b0c 100644 --- a/src/assets/scss/components/statsBigBadges.scss +++ b/src/assets/scss/components/statsBigBadges.scss @@ -5,11 +5,15 @@ display: grid; align-items: stretch; /*grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));*/ - grid-template-columns: repeat(2, minmax(220px,1fr)); + grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 1rem; width: 100%; container-name: big-badges-grid; container-type: inline-size; + + &.grid-small { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } } .statsBigBadges__grid .statsBigBadges__gridItem span { diff --git a/src/assets/scss/theme.scss b/src/assets/scss/theme.scss index 4ad1ef5..2b48f72 100644 --- a/src/assets/scss/theme.scss +++ b/src/assets/scss/theme.scss @@ -44,4 +44,5 @@ @import "./components/error404.scss"; @import "./components/myTable.scss"; @import "./components/evaluation.scss"; -@import "./components/fieldsRepeater.scss"; \ No newline at end of file +@import "./components/fieldsRepeater.scss"; +@import "./components/notificationsSidebar.scss"; diff --git a/src/components/FlowBuilder/index.js b/src/components/FlowBuilder/index.js index fdb2d7f..3bf66de 100644 --- a/src/components/FlowBuilder/index.js +++ b/src/components/FlowBuilder/index.js @@ -8,7 +8,7 @@ import { isEmpty } from 'ramda'; import '@xyflow/react/dist/style.css'; // store -import { useStore, storeSet, storeGet } from '../../store'; +import { useStore, storeSet } from '../../store'; // nodes import NodeInitialForm from './components/NodeInitialForm'; diff --git a/src/components/FormField/components/DatepickerRange/index.js b/src/components/FormField/components/DatepickerRange/index.js index 00d5394..5c9fa75 100644 --- a/src/components/FormField/components/DatepickerRange/index.js +++ b/src/components/FormField/components/DatepickerRange/index.js @@ -1,6 +1,6 @@ import React from 'react'; import { classNames } from 'primereact/utils'; -import { isEmpty, isNil } from 'ramda'; +import { isNil } from 'ramda'; // components import { Controller } from 'react-hook-form'; diff --git a/src/components/NotificationsSidebar/components/NotificationItem/index.js b/src/components/NotificationsSidebar/components/NotificationItem/index.js new file mode 100644 index 0000000..3d2efc5 --- /dev/null +++ b/src/components/NotificationsSidebar/components/NotificationItem/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import getDateFromISOstring from '../../../../helpers/getDateFromISOstring'; + +const NotificationItem = ({ item, clickFn }) => { + const handleClick = () => { + clickFn(item.id); + } + + return ( +
  • +
    + {item.status === 'READ' + ?

    {item.title}

    + : {item.title}} + {getDateFromISOstring(item.createdDate)} +
    + +
  • + ) +} + +export default NotificationItem; diff --git a/src/components/NotificationsSidebar/components/NotificationItemChosen/index.js b/src/components/NotificationsSidebar/components/NotificationItemChosen/index.js new file mode 100644 index 0000000..4588de6 --- /dev/null +++ b/src/components/NotificationsSidebar/components/NotificationItemChosen/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { __ } from '@wordpress/i18n'; +import { Button } from 'primereact/button'; +import getDateFromISOstring from '../../../../helpers/getDateFromISOstring'; + +const NotificationItemChosen = ({ item, closeFn, markReadFn }) => { + return ( +
    +
    + ) +} + +export default NotificationItemChosen; diff --git a/src/components/NotificationsSidebar/index.js b/src/components/NotificationsSidebar/index.js new file mode 100644 index 0000000..f44a5e5 --- /dev/null +++ b/src/components/NotificationsSidebar/index.js @@ -0,0 +1,277 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { head, isEmpty, pathOr } from 'ramda'; +import SockJS from 'sockjs-client'; +import { Stomp } from '@stomp/stompjs'; + +// store +import { storeGet, useStore } from '../../store'; + +// api +import NotificationService from '../../service/notification-service'; + +// tools +import set404FromErrorResponse from '../../helpers/set404FromErrorResponse'; + +// components +import { Badge } from 'primereact/badge'; +import { Sidebar } from 'primereact/sidebar'; +import { TabPanel, TabView } from 'primereact/tabview'; +import NotificationItem from './components/NotificationItem'; +import NotificationItemChosen from './components/NotificationItemChosen'; + +const socketUrl = process.env.REACT_APP_API_ADDRESS_WS; + +const NotificationsSidebar = () => { + const chosenCompanyId = useStore().main.chosenCompanyId(); + const userData = useStore().main.userData(); + const [activeIndex, setActiveIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [notificationsVisible, setNotificationsVisible] = useState(false); + const [notifications, setNotifications] = useState([]); + const [notificationsRead, setNotificationsRead] = useState([]); + const [chosenMsg, setChosenMsg] = useState({}); + const socket = useRef(null); + const stomp = useRef(null); + const [currentSubscription, setCurrentSubscription] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + // Handle tab change + const handleTabChange = (e) => { + setActiveIndex(e.index); + fetchTabData(e.index); + }; + + const fetchTabData = (index) => { + setChosenMsg({}); + + if (0 === index) { + fetchMessages(); + } else { + fetchMessages('READ'); + } + } + + const chooseNotification = (id) => { + const properItems = activeIndex === 0 ? notifications : notificationsRead; + const chosen = head(properItems.filter(o => o.id === id)); + if (chosen) { + setChosenMsg(chosen); + } + } + + const closeChosenMsg = () => { + setChosenMsg({}); + } + + const fetchMessages = (status = 'UNREAD') => { + const chosenCompanyId = storeGet.main.chosenCompanyId(); + const userData = storeGet.main.userData(); + const role = pathOr('', ['role', 'roleType'], userData); + + if (currentSubscription) { + //console.log('UNsubscribed') + currentSubscription.unsubscribe(); + setCurrentSubscription(null); + } + + if (userData.id && chosenCompanyId !== 0 && role === 'ROLE_BENEFICIARY') { + setLoading(true); + NotificationService.getNotifications( + userData.id, + status === 'UNREAD' ? getNotifications : getNotificationsRead, + errGetNotifications, + [ + ['status', status], + ['companyId', chosenCompanyId] + ] + ); + if (isConnected && socket.current) { + subscribeTo(`/topic/notifications_user_${userData.id}_company_${chosenCompanyId}`) + } + } else if (userData.id && role !== 'ROLE_BENEFICIARY') { + setLoading(true); + NotificationService.getNotifications( + userData.id, + status === 'UNREAD' ? getNotifications : getNotificationsRead, + errGetNotifications, + [ + ['status', status] + ] + ); + if (isConnected && socket.current) { + subscribeTo(`/topic/notifications_user_${userData.id}`) + } + } + } + + const getNotifications = (resp) => { + if (resp.status === 'SUCCESS') { + setNotifications(resp.data); + } + set404FromErrorResponse(resp); + setLoading(false); + } + + const getNotificationsRead = (resp) => { + if (resp.status === 'SUCCESS') { + setNotificationsRead(resp.data); + } + set404FromErrorResponse(resp); + setLoading(false); + } + + const errGetNotifications = (resp) => { + set404FromErrorResponse(resp); + setLoading(false); + } + + const makeNotificationRead = (id) => { + NotificationService.notificationMakeRead(id, makeReadCallback, makeReadErrorCallback) + } + + const makeReadCallback = (resp) => { + if (resp.status === 'SUCCESS') { + if (0 === activeIndex) { + const msgs = notifications.map(o => o.id === resp.data.id ? resp.data : o); + setNotifications(msgs); + } else { + const msgs = notificationsRead.map(o => o.id === resp.data.id ? resp.data : o); + setNotificationsRead(msgs); + } + } + set404FromErrorResponse(resp); + } + + const makeReadErrorCallback = (resp) => { + set404FromErrorResponse(resp); + } + + const connectWebSocket = () => { + socket.current = new SockJS(socketUrl); + stomp.current = Stomp.over(socket.current); + + stomp.current.configure({ + debug: function(str) { + //console.log(str); + }, + reconnectDelay: 5000, + heartbeatIncoming: 20000, + heartbeatOutgoing: 20000 + }); + + stomp.current.connect( + {}, + () => { + //console.log('Websocket connected'); + setIsConnected(true); + }, + (error) => { + //console.error('WebSocket Connection Error:', error); + setIsConnected(false); + setTimeout(connectWebSocket, 5000); + } + ); + }; + + const subscribeTo = (topic) => { + const subscription = stomp.current.subscribe( + topic, + (message) => { + try { + const notification = JSON.parse(message.body); + setNotifications(prev => [notification, ...prev]); + } catch (error) { + console.error('Error parsing notification:', error); + } + } + ); + + setCurrentSubscription(subscription); + } + + useEffect(() => { + fetchMessages(); + }, [chosenCompanyId, userData.id, isConnected]); + + useEffect(() => { + connectWebSocket(); + + return () => { + if (currentSubscription) { + currentSubscription.unsubscribe(); + setCurrentSubscription(null); + } + + if (stomp.current) { + stomp.current.disconnect(() => { + //console.log('WebSocket Disconnected'); + }); + } + }; + }, []); + + return ( + <> + setNotificationsVisible(true)}> + o.status === 'UNREAD').length}> + + setNotificationsVisible(false)}> + + + {loading + ?
    + +
    + : !isEmpty(chosenMsg) + ? + : (notifications.length > 0 + ?
      + {notifications.map(o => )} +
    + :
    + + {__('Vuoto', 'gepafin')} +
    )} +
    + + {loading + ?
    + +
    + : !isEmpty(chosenMsg) + ? + : (notificationsRead.length > 0 + ?
      + {notificationsRead.map(o => )} +
    + : +
    + + {__('Vuoto', 'gepafin')} +
    )} +
    +
    +
    + + ) +} + +export default NotificationsSidebar; diff --git a/src/helpers/getStrippedHtmlBodyTags.js b/src/helpers/getStrippedHtmlBodyTags.js new file mode 100644 index 0000000..59c4315 --- /dev/null +++ b/src/helpers/getStrippedHtmlBodyTags.js @@ -0,0 +1,28 @@ +import parse from 'html-react-parser'; +import DOMPurify from 'dompurify'; + +const getEmailTemplateForSoccorso = (content = '', fallback = '') => { + const config = { + FORBID_TAGS: ['html', 'body'], + WHOLE_DOCUMENT: false, + RETURN_DOM: false, + RETURN_DOM_FRAGMENT: false, + RETURN_DOM_IMPORT: false, + FORCE_BODY: false, + ADD_TAGS: ['*'], + ADD_ATTR: ['*'] + }; + try { + const wrappedHtml = `
    ${content}
    `; + const cleaned = DOMPurify.sanitize(wrappedHtml, config); + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = cleaned; + return parse(tempDiv.innerHTML); + } catch (error) { + console.error('DOMPurify cleaning error:', error); + return fallback; + } +} + +export default getEmailTemplateForSoccorso; \ No newline at end of file diff --git a/src/helpers/set404FromErrorResponse.js b/src/helpers/set404FromErrorResponse.js index abcd70a..1830c19 100644 --- a/src/helpers/set404FromErrorResponse.js +++ b/src/helpers/set404FromErrorResponse.js @@ -1,4 +1,4 @@ -import { storeSet } from '../store'; +//import { storeSet } from '../store'; const set404FromErrorResponse = (data) => { if (data && data.status === 'NOT_FOUND') { diff --git a/src/layouts/DefaultLayout/components/AppTopbar/index.js b/src/layouts/DefaultLayout/components/AppTopbar/index.js index af1e9a8..8bb75b4 100644 --- a/src/layouts/DefaultLayout/components/AppTopbar/index.js +++ b/src/layouts/DefaultLayout/components/AppTopbar/index.js @@ -8,9 +8,9 @@ import LogoIcon from '../../../../icons/LogoIcon'; import { IconField } from 'primereact/iconfield'; import { InputIcon } from 'primereact/inputicon'; import { InputText } from 'primereact/inputtext'; -import { Badge } from 'primereact/badge'; import { Button } from 'primereact/button'; import TopBarProfileMenu from '../../../../components/TopBarProfileMenu'; +import NotificationsSidebar from '../../../../components/NotificationsSidebar'; const AppTopbar = () => { const menuLeft = useRef(null); @@ -24,14 +24,13 @@ const AppTopbar = () => { - - - + {/* */}